Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/derive-model-capabilities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": minor
---

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.
23 changes: 22 additions & 1 deletion packages/agent-core/src/services/modelCatalog/modelCatalog.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getModelCapability, isUnknownCapability } from '@pymodel/kosong';
import { createDecorator } from '../../di';
import type { PythinkerConfig, ModelAlias, ProviderConfig } from '../../config';
import type {
Expand Down Expand Up @@ -43,18 +44,38 @@ export class ModelNotFoundError extends Error {
export function toProtocolModel(
modelId: string,
alias: ModelAlias,
provider?: ProviderConfig,
): ModelCatalogItem {
return {
provider: alias.provider,
model: modelId,
display_name: alias.displayName ?? alias.model,
max_context_size: alias.maxContextSize,
capabilities: alias.capabilities,
capabilities: alias.capabilities ?? derivedCapabilities(alias, provider),
support_efforts: alias.supportEfforts,
adaptive_thinking: alias.adaptiveThinking,
};
}

function derivedCapabilities(
alias: ModelAlias,
provider: ProviderConfig | undefined,
): string[] | undefined {
if (provider === undefined) return undefined;
try {
const capability = getModelCapability(provider.type, alias.model);
if (isUnknownCapability(capability)) return undefined;
return Object.entries(capability)
.filter(
([key, value]) =>
value === true && key !== 'max_context_tokens' && key !== 'cost',
)
.map(([key]) => key);
} catch {
return undefined;
}
}

export interface ProviderCredentialState {
readonly hasApiKey: boolean;
readonly hasOAuthToken: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export class ModelCatalogService
async listModels(): Promise<readonly ModelCatalogItem[]> {
const config = await this._readConfig();
return Object.entries(config.models ?? {}).map(([modelId, alias]) =>
toProtocolModel(modelId, alias),
toProtocolModel(modelId, alias, config.providers[alias.provider]),
);
}

Expand Down Expand Up @@ -63,7 +63,7 @@ export class ModelCatalogService
const updatedAlias = updated.models?.[modelId] ?? alias;
return {
default_model: modelId,
model: toProtocolModel(modelId, updatedAlias),
model: toProtocolModel(modelId, updatedAlias, updated.providers[updatedAlias.provider]),
};
}

Expand Down
75 changes: 75 additions & 0 deletions packages/agent-core/test/services/model-catalog-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getModelCapability } from '@pymodel/kosong';

import type {
CoreRPC,
Expand Down Expand Up @@ -116,6 +117,80 @@ function catalogConfig(): PythinkerConfig {
}

describe('model catalog adapters', () => {
it('derives capabilities from the configured provider wire type', async () => {
const config = catalogConfig();
const alias = {
provider: 'openai',
model: 'gpt-5.4',
maxContextSize: 200000,
};
config.models = { gpt54: alias };
const { core } = makeCore({ current: config });

const [model] = await new ModelCatalogService(core).listModels();
const detected = getModelCapability('openai', alias.model);
expect(model?.capabilities).toEqual(
Object.entries(detected)
.filter(([, value]) => value === true)
.map(([key]) => key),
);
});

it('keeps an explicit capability list exactly', () => {
const config = catalogConfig();
const alias = {
...config.models!['gpt4o']!,
capabilities: ['custom_capability', 'always_thinking'],
};

expect(toProtocolModel('gpt4o', alias, config.providers['openai']).capabilities).toEqual(
alias.capabilities,
);
});

it('emits only true capability flags, excluding context and cost metadata', () => {
const config = catalogConfig();
const alias = config.models!['gpt4o']!;
const capabilities = toProtocolModel('gpt4o', alias, config.providers['openai']).capabilities;

expect(capabilities).toEqual(['image_in', 'tool_use']);
expect(capabilities).not.toContain('video_in');
expect(capabilities).not.toContain('audio_in');
expect(capabilities).not.toContain('thinking');
expect(capabilities).not.toContain('max_context_tokens');
expect(capabilities).not.toContain('cost');
});

it('omits capabilities when the provider reports unknown capability data', () => {
const config = catalogConfig();
const alias = { ...config.models!['turbo']!, capabilities: undefined };

expect(toProtocolModel('turbo', alias, config.providers['pythinker']).capabilities).toBeUndefined();
});

it('keeps a model entry when its provider cannot be resolved', async () => {
const config: PythinkerConfig = {
providers: {},
models: {
orphan: {
provider: 'missing',
model: 'gpt-4o',
maxContextSize: 128000,
},
},
};
const { core } = makeCore({ current: config });

await expect(new ModelCatalogService(core).listModels()).resolves.toMatchObject([
{
provider: 'missing',
model: 'orphan',
max_context_size: 128000,
capabilities: undefined,
},
]);
});

it('maps model aliases to selectable wire ids', () => {
const alias = catalogConfig().models!['k2']!;
expect(toProtocolModel('k2', alias)).toEqual({
Expand Down
4 changes: 4 additions & 0 deletions packages/server/test/model-catalog.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ describe('model/provider catalog routes', () => {
model: 'gpt4o',
display_name: 'gpt-4o',
max_context_size: 128000,
// Declares no capabilities in config, so they are derived from the
// model itself. `k2` above keeps the list its config states, and
// `turbo` omits the field because the pythinker wire reports unknown.
capabilities: ['image_in', 'tool_use'],
},
]);
});
Expand Down
Loading