Skip to content
Open
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
25 changes: 24 additions & 1 deletion packages/agent-core-v2/src/app/kosongConfig/modelsDev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,20 @@ const CATALOG = {
},
},
},
'anthropic-gateway': {
id: 'anthropic-gateway',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use a neutral id in the endpoint-path fixture

This new fixture still contains anthropic in the provider id, so the existing id.includes('anthropic') heuristic makes getModelsDevProvider() and the import assertions pass even if the new /anthropic endpoint-path detection in the v2 resolver is removed or broken. Since this test is meant to guard the agent-core-v2 lockstep copy, rename the catalog key/id to a neutral value while keeping the /anthropic API path.

Useful? React with 👍 / 👎.

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';
Expand Down Expand Up @@ -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<ProvidersSection>(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: {} });
Expand Down
25 changes: 24 additions & 1 deletion packages/kosong/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions packages/kosong/test/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down