diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDev.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDev.ts index 82b964bf09..407f7d731d 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/modelsDev.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDev.ts @@ -254,7 +254,12 @@ function inferDeclaredWireType(entry: ModelsDevProviderEntry): ProviderType | un if (isWireType(entry.type)) return entry.type; const npm = (entry.npm ?? '').toLowerCase(); const id = (entry.id ?? '').toLowerCase(); - if (npm.includes('anthropic') || id.includes('anthropic') || id.includes('claude')) { + if ( + npm.includes('anthropic') || + id.includes('anthropic') || + id.includes('claude') || + hasAnthropicEndpointPath(entry.api) + ) { return 'anthropic'; } if (id.includes('vertex')) return 'vertexai'; @@ -265,6 +270,24 @@ function inferDeclaredWireType(entry: ModelsDevProviderEntry): ProviderType | un return undefined; } +/** + * Detects the Anthropic wire from the declared endpoint when neither `type` + * nor `npm`/`id` names it: vendors that speak OpenAI by default expose their + * Anthropic-compatible surface under a dedicated `/anthropic` path segment + * (e.g. `https://host/anthropic`). Without this the endpoint falls through to + * the OpenAI-compatible fallback and its Anthropic URL is persisted under the + * OpenAI wire — an incompatible protocol/base-URL pair. The match is on the + * URL path only, so a host such as `api.anthropic.com` (whose path is `/v1`) + * is left to the `id`/`npm` checks and never triggers here. + */ +function hasAnthropicEndpointPath(api: string | undefined): boolean { + if (typeof api !== 'string' || api.length === 0) return false; + // Drop the scheme + authority so only the path is inspected; a bare + // `anthropic` path segment is the endpoint convention for this wire. + const path = api.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i, ''); + return /(?:^|\/)anthropic(?:\/|$)/i.test(path); +} + /** * Resolves the base URL to store for a models.dev provider, adapting the * directory's `api` to the wire's SDK convention. diff --git a/packages/agent-core-v2/test/app/kosongConfig/modelsDevImport.test.ts b/packages/agent-core-v2/test/app/kosongConfig/modelsDevImport.test.ts index 138e972dea..8106c7ce46 100644 --- a/packages/agent-core-v2/test/app/kosongConfig/modelsDevImport.test.ts +++ b/packages/agent-core-v2/test/app/kosongConfig/modelsDevImport.test.ts @@ -80,6 +80,20 @@ const CATALOG = { }, }, }, + 'anthropic-gateway': { + id: 'anthropic-gateway', + name: 'Anthropic Gateway', + // No `type` and no anthropic token in id/npm: the wire is declared solely + // by the `/anthropic` endpoint path. + api: 'https://api.example.test/anthropic', + models: { + 'gw-anthropic': { + id: 'gw-anthropic', + limit: { context: 200000 }, + modalities: { input: ['text'], output: ['text'] }, + }, + }, + }, } as const; const REGISTRY_URL = 'https://internal.example/api.json'; @@ -232,6 +246,22 @@ describe('IModelsDevImportService', () => { expect(config.get('defaultModel')).toBe('k2'); }); + it('imports an entry whose /anthropic endpoint path declares it as an anthropic provider', async () => { + setModelsDevUpstreamForTest({ fetchImpl: fetchJson(CATALOG) }); + const { config, imports } = createHost({ providers: {}, models: {} }); + + const item = await imports.getModelsDevProvider('anthropic-gateway'); + expect(item).toMatchObject({ wire_type: 'anthropic', guessed: false, needs_base_url: false }); + + await imports.importModelsDevProvider({ catalogId: 'anthropic-gateway', apiKey: 'sk-test' }); + const providers = config.inspect(PROVIDERS_SECTION).userValue ?? {}; + expect(providers['anthropic-gateway']).toMatchObject({ + type: 'anthropic', + baseUrl: 'https://api.example.test/anthropic', + apiKey: 'sk-test', + }); + }); + it('seeds default_model from the first imported model only when none is configured', async () => { setModelsDevUpstreamForTest({ fetchImpl: fetchJson(CATALOG) }); const { config, imports } = createHost({ providers: {}, models: {} }); diff --git a/packages/kosong/src/catalog.ts b/packages/kosong/src/catalog.ts index 6c7c611c81..9e90b73172 100644 --- a/packages/kosong/src/catalog.ts +++ b/packages/kosong/src/catalog.ts @@ -244,7 +244,12 @@ function inferDeclaredWireType(entry: CatalogProviderEntry): ProviderType | unde if (isWireType(entry.type)) return entry.type; const npm = (entry.npm ?? '').toLowerCase(); const id = (entry.id ?? '').toLowerCase(); - if (npm.includes('anthropic') || id.includes('anthropic') || id.includes('claude')) { + if ( + npm.includes('anthropic') || + id.includes('anthropic') || + id.includes('claude') || + hasAnthropicEndpointPath(entry.api) + ) { return 'anthropic'; } if (id.includes('vertex')) return 'vertexai'; @@ -255,6 +260,24 @@ function inferDeclaredWireType(entry: CatalogProviderEntry): ProviderType | unde return undefined; } +/** + * Detects the Anthropic wire from the declared endpoint when neither `type` + * nor `npm`/`id` names it: vendors that speak OpenAI by default expose their + * Anthropic-compatible surface under a dedicated `/anthropic` path segment + * (e.g. `https://host/anthropic`). Without this the endpoint falls through to + * the OpenAI-compatible fallback and its Anthropic URL is persisted under the + * OpenAI wire — an incompatible protocol/base-URL pair. The match is on the + * URL path only, so a host such as `api.anthropic.com` (whose path is `/v1`) + * is left to the `id`/`npm` checks and never triggers here. + */ +function hasAnthropicEndpointPath(api: string | undefined): boolean { + if (typeof api !== 'string' || api.length === 0) return false; + // Drop the scheme + authority so only the path is inspected; a bare + // `anthropic` path segment is the endpoint convention for this wire. + const path = api.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i, ''); + return /(?:^|\/)anthropic(?:\/|$)/i.test(path); +} + /** * Resolves the base URL to store for a catalog provider, adapting the catalog's * `api` to the wire's SDK convention. diff --git a/packages/kosong/test/catalog.test.ts b/packages/kosong/test/catalog.test.ts index ace6298362..ee236fe327 100644 --- a/packages/kosong/test/catalog.test.ts +++ b/packages/kosong/test/catalog.test.ts @@ -110,6 +110,30 @@ describe('resolveCatalogImport — wire resolution', () => { reason: 'proprietary-sdk', }); }); + + it('infers anthropic from a declared /anthropic endpoint path when no type or npm/id names it', () => { + // A vendor that speaks OpenAI by default but exposes its Anthropic-compatible + // surface under a `/anthropic` path: without endpoint inference this fell + // through to the OpenAI fallback and the Anthropic URL was persisted under + // the OpenAI wire — an incompatible protocol/base-URL pair. + expect( + resolveCatalogImport({ id: 'gateway', api: 'https://api.example.test/anthropic' }), + ).toEqual({ + kind: 'ok', + wire: 'anthropic', + guessed: false, + baseUrl: 'https://api.example.test/anthropic', + }); + // The regional twin on a different host resolves the same way. + expect( + resolveCatalogImport({ id: 'gateway', api: 'https://api.example.com/anthropic' }), + ).toMatchObject({ kind: 'ok', wire: 'anthropic', guessed: false }); + // A host that merely contains "anthropic" but serves the OpenAI path is + // left to the fallback — the match is on the path, not the host. + expect( + resolveCatalogImport({ id: 'gateway', api: 'https://anthropic.example.test/v1' }), + ).toMatchObject({ kind: 'ok', wire: 'openai', guessed: true }); + }); }); describe('resolveCatalogImport — endpoint resolution', () => {