Skip to content

Commit 68ca5f9

Browse files
committed
Merge branch 'feat/derive-model-capabilities'
2 parents d4bf350 + cf1773d commit 68ca5f9

5 files changed

Lines changed: 108 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": minor
3+
---
4+
5+
Report each model's real capabilities in the catalog. Until now `capabilities` carried only what a user had typed into their config file by hand, so for almost every model it was empty. It is now derived from the model itself when the config says nothing, while an explicit list in the config still wins. A provider whose capabilities are genuinely unknown keeps omitting the field rather than claiming the model can do nothing.

packages/agent-core/src/services/modelCatalog/modelCatalog.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getModelCapability, isUnknownCapability } from '@pymodel/kosong';
12
import { createDecorator } from '../../di';
23
import type { PythinkerConfig, ModelAlias, ProviderConfig } from '../../config';
34
import type {
@@ -44,18 +45,38 @@ export class ModelNotFoundError extends Error {
4445
export function toProtocolModel(
4546
modelId: string,
4647
alias: ModelAlias,
48+
provider?: ProviderConfig,
4749
): ModelCatalogItem {
4850
return {
4951
provider: alias.provider,
5052
model: modelId,
5153
display_name: alias.displayName ?? alias.model,
5254
max_context_size: alias.maxContextSize,
53-
capabilities: alias.capabilities,
55+
capabilities: alias.capabilities ?? derivedCapabilities(alias, provider),
5456
support_efforts: alias.supportEfforts,
5557
adaptive_thinking: alias.adaptiveThinking,
5658
};
5759
}
5860

61+
function derivedCapabilities(
62+
alias: ModelAlias,
63+
provider: ProviderConfig | undefined,
64+
): string[] | undefined {
65+
if (provider === undefined) return undefined;
66+
try {
67+
const capability = getModelCapability(provider.type, alias.model);
68+
if (isUnknownCapability(capability)) return undefined;
69+
return Object.entries(capability)
70+
.filter(
71+
([key, value]) =>
72+
value === true && key !== 'max_context_tokens' && key !== 'cost',
73+
)
74+
.map(([key]) => key);
75+
} catch {
76+
return undefined;
77+
}
78+
}
79+
5980
export interface ProviderCredentialState {
6081
readonly hasApiKey: boolean;
6182
readonly hasOAuthToken: boolean;

packages/agent-core/src/services/modelCatalog/modelCatalogService.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export class ModelCatalogService
3030
async listModels(): Promise<readonly ModelCatalogItem[]> {
3131
const config = await this._readConfig();
3232
return Object.entries(config.models ?? {}).map(([modelId, alias]) =>
33-
toProtocolModel(modelId, alias),
33+
toProtocolModel(modelId, alias, config.providers[alias.provider]),
3434
);
3535
}
3636

@@ -71,7 +71,7 @@ export class ModelCatalogService
7171
const updatedAlias = updated.models?.[modelId] ?? alias;
7272
return {
7373
default_model: modelId,
74-
model: toProtocolModel(modelId, updatedAlias),
74+
model: toProtocolModel(modelId, updatedAlias, updated.providers[updatedAlias.provider]),
7575
};
7676
}
7777

packages/agent-core/test/services/model-catalog-service.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import { getModelCapability } from '@pymodel/kosong';
23

34
import type {
45
CoreRPC,
@@ -116,6 +117,80 @@ function catalogConfig(): PythinkerConfig {
116117
}
117118

118119
describe('model catalog adapters', () => {
120+
it('derives capabilities from the configured provider wire type', async () => {
121+
const config = catalogConfig();
122+
const alias = {
123+
provider: 'openai',
124+
model: 'gpt-5.4',
125+
maxContextSize: 200000,
126+
};
127+
config.models = { gpt54: alias };
128+
const { core } = makeCore({ current: config });
129+
130+
const [model] = await new ModelCatalogService(core).listModels();
131+
const detected = getModelCapability('openai', alias.model);
132+
expect(model?.capabilities).toEqual(
133+
Object.entries(detected)
134+
.filter(([, value]) => value === true)
135+
.map(([key]) => key),
136+
);
137+
});
138+
139+
it('keeps an explicit capability list exactly', () => {
140+
const config = catalogConfig();
141+
const alias = {
142+
...config.models!['gpt4o']!,
143+
capabilities: ['custom_capability', 'always_thinking'],
144+
};
145+
146+
expect(toProtocolModel('gpt4o', alias, config.providers['openai']).capabilities).toEqual(
147+
alias.capabilities,
148+
);
149+
});
150+
151+
it('emits only true capability flags, excluding context and cost metadata', () => {
152+
const config = catalogConfig();
153+
const alias = config.models!['gpt4o']!;
154+
const capabilities = toProtocolModel('gpt4o', alias, config.providers['openai']).capabilities;
155+
156+
expect(capabilities).toEqual(['image_in', 'tool_use']);
157+
expect(capabilities).not.toContain('video_in');
158+
expect(capabilities).not.toContain('audio_in');
159+
expect(capabilities).not.toContain('thinking');
160+
expect(capabilities).not.toContain('max_context_tokens');
161+
expect(capabilities).not.toContain('cost');
162+
});
163+
164+
it('omits capabilities when the provider reports unknown capability data', () => {
165+
const config = catalogConfig();
166+
const alias = { ...config.models!['turbo']!, capabilities: undefined };
167+
168+
expect(toProtocolModel('turbo', alias, config.providers['pythinker']).capabilities).toBeUndefined();
169+
});
170+
171+
it('keeps a model entry when its provider cannot be resolved', async () => {
172+
const config: PythinkerConfig = {
173+
providers: {},
174+
models: {
175+
orphan: {
176+
provider: 'missing',
177+
model: 'gpt-4o',
178+
maxContextSize: 128000,
179+
},
180+
},
181+
};
182+
const { core } = makeCore({ current: config });
183+
184+
await expect(new ModelCatalogService(core).listModels()).resolves.toMatchObject([
185+
{
186+
provider: 'missing',
187+
model: 'orphan',
188+
max_context_size: 128000,
189+
capabilities: undefined,
190+
},
191+
]);
192+
});
193+
119194
it('maps model aliases to selectable wire ids', () => {
120195
const alias = catalogConfig().models!['k2']!;
121196
expect(toProtocolModel('k2', alias)).toEqual({

packages/server/test/model-catalog.e2e.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,10 @@ describe('model/provider catalog routes', () => {
177177
model: 'gpt4o',
178178
display_name: 'gpt-4o',
179179
max_context_size: 128000,
180+
// Declares no capabilities in config, so they are derived from the
181+
// model itself. `k2` above keeps the list its config states, and
182+
// `turbo` omits the field because the pythinker wire reports unknown.
183+
capabilities: ['image_in', 'tool_use'],
180184
},
181185
]);
182186
});

0 commit comments

Comments
 (0)