diff --git a/packages/foundation/providers/src/__tests__/enabled-models.test.ts b/packages/foundation/providers/src/__tests__/enabled-models.test.ts index 00099e7fb..de97f2b9a 100644 --- a/packages/foundation/providers/src/__tests__/enabled-models.test.ts +++ b/packages/foundation/providers/src/__tests__/enabled-models.test.ts @@ -66,6 +66,31 @@ describe('enabledAccountModels', () => { expect(enabledAccountModels([sub], {}, 'claude-code')).toHaveLength(1); }); + it("narrows a picked model to the protocols it is known to answer, for the agent's actual binding", () => { + const gateway = account('acc_gw', { + service: 'linkcode-gateway', + credential: { type: 'auth-token', token: 'lc-test' }, + models: [ + { id: 'openai/gpt-5.6', protocols: ['openai-chat', 'openai-responses'] }, + { id: 'anthropic/claude-sonnet-5', protocols: ['openai-chat'] }, + // Probed before `protocols` existed, or never probed — absence must still offer it. + { id: 'openai/gpt-4.1' }, + ], + }); + // codex binds this account on openai-responses: only the model tagged for it survives, plus + // the one with no protocol data at all. + expect(enabledAccountModels([gateway], {}, 'codex').map(({ model }) => model.id)).toEqual([ + 'openai/gpt-5.6', + 'openai/gpt-4.1', + ]); + // opencode/pi accept any wire this account offers, so nothing here is narrowed. + expect(enabledAccountModels([gateway], {}, 'opencode').map(({ model }) => model.id)).toEqual([ + 'openai/gpt-5.6', + 'anthropic/claude-sonnet-5', + 'openai/gpt-4.1', + ]); + }); + it('reports an account with no picked model as offering nothing, not as unavailable', () => { expect(enabledAccountModels([account('acc_empty')], {}, 'opencode')).toEqual([]); expect(accountEnabledFor({}, 'opencode', 'acc_empty')).toBe(true); diff --git a/packages/foundation/providers/src/__tests__/resolve.test.ts b/packages/foundation/providers/src/__tests__/resolve.test.ts index 717db5133..c06c3c48d 100644 --- a/packages/foundation/providers/src/__tests__/resolve.test.ts +++ b/packages/foundation/providers/src/__tests__/resolve.test.ts @@ -191,7 +191,7 @@ describe('resolveBinding: variant chosen per agent', () => { ).toEqual({ tier: 'unavailable', reason: 'protocol-unsupported' }); }); - it('preserves LinkCode Gateway as an OpenAI Chat endpoint', () => { + it('serves LinkCode Gateway over both OpenAI wires', () => { const gateway = account({ service: 'linkcode-gateway', credential: { type: 'auth-token', token: 'lc-gateway-key' }, @@ -209,8 +209,9 @@ describe('resolveBinding: variant chosen per agent', () => { }); } expect(resolveBinding(gateway, 'codex')).toEqual({ - tier: 'unavailable', - reason: 'protocol-unsupported', + tier: 'native', + protocol: 'openai-responses', + baseUrl: 'https://gateway.linkcode.ai/v1', }); }); diff --git a/packages/foundation/providers/src/catalog.ts b/packages/foundation/providers/src/catalog.ts index d5d33b7b9..4a21e3b9d 100644 --- a/packages/foundation/providers/src/catalog.ts +++ b/packages/foundation/providers/src/catalog.ts @@ -21,6 +21,12 @@ export interface ServiceVariant { /** Ids for this endpoint in an agent's own provider catalog. Present means the agent already * carries the wire adapter and model metadata, so it needs only the key injected. */ knownProvider?: Partial>; + /** Overrides the service-level list for this variant only. Needed when one secret reaches + * a gateway whose ids differ by protocol — LinkCode Gateway's `openai-responses` variant + * lists only the models that speak Responses, a strict subset of the plain `/v1/models` + * every other variant (and the service-level fallback below) serves. Absent means this + * variant's list is the service-level one. */ + models?: ServiceModelList; /** `endpointParams` key → the env name that agent's own provider entry reads it under. Present * means the agent templates a per-model URL, so injecting one base URL would flatten routes that * differ per model — declare this instead of a base URL for such a provider. */ @@ -28,8 +34,10 @@ export interface ServiceVariant { } /** - * Where to read the ids this service serves. Service-level, not per variant: one secret reaches one - * model list, and the ids are the same whichever protocol shape an agent ends up using. + * Where to read the ids this service serves, by default. One secret can still reach several + * different lists — see `ServiceVariant.models` — but every service in this catalog except + * LinkCode Gateway serves the same ids regardless of which protocol shape an agent ends up + * using, so a per-variant override is the exception, not the rule this field describes. * * The URL is spelled out rather than derived from a variant's `baseUrl` + protocol, because * derivation is wrong for any service whose variants sit on different paths — DeepSeek's @@ -163,6 +171,15 @@ export const SERVICE_CATALOG: ServiceDescriptor[] = [ credentialType: 'auth-token', variants: { 'openai-chat': { baseUrl: 'https://gateway.linkcode.ai/v1' }, + // Lists only the models the gateway actually serves on this wire — a strict subset of + // the service-level list below, since not every provider behind it speaks Responses. + 'openai-responses': { + baseUrl: 'https://gateway.linkcode.ai/v1', + models: { + url: 'https://gateway.linkcode.ai/v1/models?protocol=openai-responses', + wire: 'openai', + }, + }, }, models: { url: 'https://gateway.linkcode.ai/v1/models', wire: 'openai' }, }, @@ -253,7 +270,24 @@ export function endpointServiceById(id: string | undefined): EndpointService | u return service?.kind === 'endpoint' ? service : undefined; } -/** Where to read this service's model ids, or undefined when it serves no list. */ +/** Where to read this service's model ids, or undefined when it serves no list. This is the + * service-level default — the one list shown by "does this service serve a list at all" checks, + * and correct for every service in this catalog except LinkCode Gateway. Use + * `modelListSourceForProtocol` when the caller actually has a protocol in hand and the two might + * differ. */ export function modelListSource(id: string | undefined): ServiceModelList | undefined { return endpointServiceById(id)?.models; } + +/** Where to read the ids this service serves *for one protocol* — a variant's own override if it + * has one, the service-level default otherwise. */ +export function modelListSourceForProtocol( + id: string | undefined, + protocol: AccountProtocol, +): ServiceModelList | undefined { + const service = endpointServiceById(id); + const variant = service?.variants[protocol]; + // A protocol the service does not serve at all must not fall through to the service-level + // list — that list is scoped to the variants that actually exist, not to every protocol name. + return variant && (variant.models ?? service.models); +} diff --git a/packages/foundation/providers/src/enabled-models.ts b/packages/foundation/providers/src/enabled-models.ts index cc54b1f7a..0bff32059 100644 --- a/packages/foundation/providers/src/enabled-models.ts +++ b/packages/foundation/providers/src/enabled-models.ts @@ -1,4 +1,12 @@ -import type { Account, AccountModel, Accounts, AgentKind, ProvidersConfig } from '@linkcode/schema'; +import type { + Account, + AccountModel, + AccountProtocol, + Accounts, + AgentKind, + ProvidersConfig, +} from '@linkcode/schema'; +import type { ResolvedBinding } from './resolve'; import { resolveBinding } from './resolve'; /** One model an agent may run on, paired with the account that serves it — the pair is the unit, @@ -20,9 +28,45 @@ export function accountEnabledFor( return enabled === undefined || enabled.includes(accountId); } +/** The protocol this agent would actually bind the account on, or undefined when the binding + * carries none — an oauth login, a pre-catalog bare key, or (after the caller's own tier check) + * an unavailable account. */ +function boundProtocol(binding: ResolvedBinding): AccountProtocol | undefined { + return binding.tier === 'unavailable' ? undefined : binding.protocol; +} + +/** Whether a picked model may be offered for a binding resolved to `protocol`. Unknown on either + * side means offer: a model probed before `protocols` existed (or never probed — a pre-catalog + * bare key, a hand-typed custom account) carries no set, and a binding with no protocol of its + * own (same two cases) has nothing to check against. Only an explicit set on both sides can + * narrow the result. */ +function modelReachable(model: AccountModel, protocol: AccountProtocol | undefined): boolean { + return ( + model.protocols === undefined || protocol === undefined || model.protocols.includes(protocol) + ); +} + +function resolvedAccounts( + accounts: Accounts, + providers: ProvidersConfig | undefined, + kind: AgentKind, +): Array<{ account: Account; binding: ResolvedBinding }> { + return accounts.reduce>( + (resolved, account) => { + const binding = resolveBinding(account, kind); + if (binding.tier !== 'unavailable' && accountEnabledFor(providers, kind, account.id)) { + resolved.push({ account, binding }); + } + return resolved; + }, + [], + ); +} + /** * Every model this agent may run on, in the order its pickers offer them: each enabled account in - * pool order, contributing its picked set in its own order. Availability still gates it — an enabled + * pool order, contributing its picked set in its own order, narrowed to the models reachable on the + * protocol this agent actually binds that account with. Availability still gates it — an enabled * account that cannot back this agent contributes nothing. * * **The first entry is the agent's default.** There is no stored default account or default model: @@ -35,9 +79,13 @@ export function enabledAccountModels( providers: ProvidersConfig | undefined, kind: AgentKind, ): EnabledAccountModel[] { - return enabledAccounts(accounts, providers, kind).flatMap((account) => - (account.models ?? []).map((model) => ({ account, model })), - ); + return resolvedAccounts(accounts, providers, kind).flatMap(({ account, binding }) => { + const protocol = boundProtocol(binding); + return (account.models ?? []).reduce((models, model) => { + if (modelReachable(model, protocol)) models.push({ account, model }); + return models; + }, []); + }); } /** The accounts this agent may resolve to, in pool order. An account with no picked model is still @@ -48,9 +96,5 @@ export function enabledAccounts( providers: ProvidersConfig | undefined, kind: AgentKind, ): Account[] { - return accounts.filter( - (account) => - resolveBinding(account, kind).tier !== 'unavailable' && - accountEnabledFor(providers, kind, account.id), - ); + return resolvedAccounts(accounts, providers, kind).map(({ account }) => account); } diff --git a/packages/foundation/providers/src/index.ts b/packages/foundation/providers/src/index.ts index d85bc5893..6204a511f 100644 --- a/packages/foundation/providers/src/index.ts +++ b/packages/foundation/providers/src/index.ts @@ -9,6 +9,7 @@ export { endpointServiceById, LINKCODE_GATEWAY_SERVICE_ID, modelListSource, + modelListSourceForProtocol, SERVICE_CATALOG, serviceById, } from './catalog'; diff --git a/packages/foundation/schema/src/model/account.ts b/packages/foundation/schema/src/model/account.ts index 920ad5c49..790d4b753 100644 --- a/packages/foundation/schema/src/model/account.ts +++ b/packages/foundation/schema/src/model/account.ts @@ -45,6 +45,11 @@ export type AccountEndpoint = z.infer; export const AccountModelSchema = z.object({ id: z.string().min(1), label: z.string().optional(), + /** Which protocols this model is known to answer, from probing every variant of the account's + * service. Absent means unknown, not "answers nothing" — a pre-catalog bare key, a custom + * account, or an account probed before this field existed all have no data here, and absence + * must keep offering the model everywhere it always did. Only an explicit set narrows it. */ + protocols: z.array(AccountProtocolSchema).optional(), }); export type AccountModel = z.infer; diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index 35c1b5ec4..3f9b6e6cf 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -9,7 +9,7 @@ import { WIRE_PAYLOAD_KINDS, WirePayloadSchema } from './payload'; */ /** Stamped on every frame this build sends; bump on any wire schema change. */ -export const WIRE_PROTOCOL_VERSION = 78 as const; +export const WIRE_PROTOCOL_VERSION = 79 as const; /** The oldest `v` this build still accepts. Bump only for a breaking change — a variant or field * removed, renamed, or given a new meaning; additive changes leave it alone. */ diff --git a/packages/foundation/schema/tests/contract/wire/config.test.ts b/packages/foundation/schema/tests/contract/wire/config.test.ts index 0d49cfc78..d3d09c781 100644 --- a/packages/foundation/schema/tests/contract/wire/config.test.ts +++ b/packages/foundation/schema/tests/contract/wire/config.test.ts @@ -6,6 +6,32 @@ function envelope(payload: unknown) { } describe('config wire schema — custom MCP servers', () => { + it('round-trips model protocol availability on config.get.result', () => { + const parsed = parseWireMessage( + envelope({ + kind: 'config.get.result', + replyTo: 'request-1', + providers: {}, + accounts: [ + { + id: 'account-1', + label: 'Gateway', + credential: { type: 'api-key', key: 'sk-test' }, + models: [ + { + id: 'gpt-5', + protocols: ['openai-chat', 'openai-responses'], + }, + ], + createdAt: 1, + }, + ], + customMcpServers: [], + }), + ); + expect(parsed.ok).toBe(true); + }); + it('round-trips a masked read projection on config.get.result', () => { const parsed = parseWireMessage( envelope({ diff --git a/packages/host/engine/src/__tests__/engine-model-probe.test.ts b/packages/host/engine/src/__tests__/engine-model-probe.test.ts index 1717e405a..a0623cbfe 100644 --- a/packages/host/engine/src/__tests__/engine-model-probe.test.ts +++ b/packages/host/engine/src/__tests__/engine-model-probe.test.ts @@ -1,10 +1,11 @@ import type { Server } from 'node:http'; import { Agent, createServer } from 'node:http'; import type { AddressInfo } from 'node:net'; +import type { EndpointService, ServiceModelList } from '@linkcode/providers'; import type { WirePayload } from '@linkcode/schema'; import { nullthrow } from 'foxts/guard'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { probeEndpointModels, requestPublicModelList } from '../agent/model-probe'; +import { probeServiceModels, requestPublicModelList } from '../agent/model-probe'; import { InMemoryProviderConfigStore } from '../agent/provider-config'; import { createSessionHarness } from './fixtures/session-harness'; @@ -37,13 +38,29 @@ function baseUrl(server: Server): string { let relay: Server | undefined; -/** Sends the catalog's own path at the local relay, so the relay records which path the service - * descriptor resolved to while the real HTTP round-trip stays under test. */ -const localModelProbe: typeof probeEndpointModels = (source, secret) => { - const resolved = new URL(source.url); +/** Rewrites every model-list URL a service carries — service-level and per-variant — to the + * local relay, so the relay records which path each one resolved to while the real HTTP + * round-trip stays under test. */ +function localize(list: ServiceModelList | undefined): ServiceModelList | undefined { + if (!list) return undefined; + const resolved = new URL(list.url); const local = `${baseUrl(nullthrow(relay, 'relay not started'))}${resolved.pathname}${resolved.search}`; + return { ...list, url: local }; +} + +const localModelProbe: typeof probeServiceModels = (service, secret) => { + const localized: EndpointService = { + ...service, + models: localize(service.models), + variants: Object.fromEntries( + Object.entries(service.variants).map(([protocol, variant]) => [ + protocol, + variant && { ...variant, models: localize(variant.models) }, + ]), + ), + }; // An unguarded agent: the relay is on loopback, which the probe policy exists to refuse. - return probeEndpointModels({ ...source, url: local }, secret, (url, headers) => + return probeServiceModels(localized, secret, (url, headers) => requestPublicModelList(url, headers, undefined, new Agent()), ); }; @@ -89,10 +106,15 @@ describe('config.probe-models', () => { credential: { type: 'inline', secret: { type: 'api-key', key: 'sk-test' } }, }); + // deepseek's three variants share one list (no per-variant override), so both ids are + // tagged with all three protocols from the single request `seen` below confirms. await expect(replyFor(h.sent, 'probe-1')).resolves.toEqual({ kind: 'config.probe-models.result', replyTo: 'probe-1', - models: [{ id: 'gpt-5' }, { id: 'gpt-5-mini' }], + models: [ + { id: 'gpt-5', protocols: ['anthropic', 'openai-chat', 'openai-responses'] }, + { id: 'gpt-5-mini', protocols: ['anthropic', 'openai-chat', 'openai-responses'] }, + ], }); // The service's own path, not one derived from a variant's baseUrl. expect(seen).toEqual(['/models']); @@ -166,11 +188,56 @@ describe('config.probe-models', () => { await expect(replyFor(h.sent, 'probe-4')).resolves.toEqual({ kind: 'config.probe-models.result', replyTo: 'probe-4', - models: [{ id: 'deepseek-v4-pro' }], + models: [ + { id: 'deepseek-v4-pro', protocols: ['anthropic', 'openai-chat', 'openai-responses'] }, + ], }); expect(seen).toEqual(['/models']); }); + it('probes every distinct list a service carries and merges the results', async () => { + const seen: string[] = []; + relay = await startRelay((url) => { + seen.push(url); + if (url === '/v1/models?protocol=openai-responses') { + return { status: 200, body: JSON.stringify({ data: [{ id: 'openai/gpt-5.6' }] }) }; + } + if (url === '/v1/models') { + return { + status: 200, + body: JSON.stringify({ + data: [{ id: 'openai/gpt-5.6' }, { id: 'anthropic/claude-sonnet-5' }], + }), + }; + } + return { status: 404, body: '{}' }; + }); + const h = createHarness(); + await h.engine.start(); + + await h.inject({ + kind: 'config.probe-models', + clientReqId: 'probe-7', + service: 'linkcode-gateway', + credential: { type: 'inline', secret: { type: 'auth-token', token: 'lc-test' } }, + }); + + const reply = await replyFor(h.sent, 'probe-7'); + if (reply.kind !== 'config.probe-models.result') throw new Error('no result for probe-7'); + expect(reply.models).toEqual( + expect.arrayContaining([ + { + id: 'openai/gpt-5.6', + // Returned by both lists: tagged with every protocol whose list named it. + protocols: ['openai-chat', 'openai-responses'], + }, + { id: 'anthropic/claude-sonnet-5', protocols: ['openai-chat'] }, + ]), + ); + expect(reply.models).toHaveLength(2); + expect(seen.sort()).toEqual(['/v1/models', '/v1/models?protocol=openai-responses']); + }); + it('refuses to send an account secret to a service it does not belong to', async () => { const reached: string[] = []; relay = await startRelay((url) => { diff --git a/packages/host/engine/src/agent/model-probe.ts b/packages/host/engine/src/agent/model-probe.ts index 1d2df3a33..423738150 100644 --- a/packages/host/engine/src/agent/model-probe.ts +++ b/packages/host/engine/src/agent/model-probe.ts @@ -2,8 +2,14 @@ import type { Agent as HttpAgent } from 'node:http'; import { request as httpRequest } from 'node:http'; import type { Agent as HttpsAgent } from 'node:https'; import { request as httpsRequest } from 'node:https'; -import type { ServiceModelList } from '@linkcode/providers'; -import type { AccountEndpoint, AccountModel, AccountSecret } from '@linkcode/schema'; +import type { EndpointService, ServiceModelList } from '@linkcode/providers'; +import type { + AccountEndpoint, + AccountModel, + AccountProtocol, + AccountSecret, +} from '@linkcode/schema'; +import { AccountProtocolSchema } from '@linkcode/schema'; import { AntiSSRFError, AntiSSRFPolicy, @@ -78,7 +84,7 @@ export type ModelListRequest = ( headers: Record, signal?: AbortSignal, ) => Promise; -export type ModelProbe = typeof probeEndpointModels; +export type ModelProbe = typeof probeServiceModels; /** A custom account names its own endpoint, so its list path can only be guessed from the protocol. * Catalog services never come through here — they carry an explicit URL (`@linkcode/providers`). */ @@ -234,3 +240,49 @@ export async function probeEndpointModels( } return [...byId.values()]; } + +/** + * Every model a service serves, across every variant reachable with this one secret, each tagged + * with the protocols whose list actually returned it. Most services serve one list for every + * variant (`ServiceVariant.models` absent everywhere), so this makes exactly one request; a + * service like LinkCode Gateway, whose `openai-responses` variant lists a strict subset of the + * service-level list, makes one request per distinct list and merges the results — a model + * returned by more than one list is tagged with every protocol whose list named it, not just the + * first. + */ +export async function probeServiceModels( + service: EndpointService, + secret: AccountSecret, + request: ModelListRequest = requestPublicModelList, +): Promise { + const byUrl = new Map(); + for (const protocol of AccountProtocolSchema.options) { + // A protocol this service does not actually serve (no variant at all) must not fall through + // to the service-level list — that would tag every model with a protocol the service never + // offered. + const variant = service.variants[protocol]; + if (!variant) continue; + const source = variant.models ?? service.models; + if (!source) continue; + const existing = byUrl.get(source.url); + if (existing) existing.protocols.push(protocol); + else byUrl.set(source.url, { source, protocols: [protocol] }); + } + if (byUrl.size === 0) throw new Error(`${service.id} serves no model list`); + + const merged = new Map(); + const lists = await Promise.all( + [...byUrl.values()].map(async ({ source, protocols }) => ({ + models: await probeEndpointModels(source, secret, request), + protocols, + })), + ); + for (const { models, protocols } of lists) { + for (const model of models) { + const known = merged.get(model.id); + const combined = [...new Set([...(known?.protocols ?? []), ...protocols])]; + merged.set(model.id, { ...model, protocols: combined }); + } + } + return [...merged.values()]; +} diff --git a/packages/host/engine/src/agent/request-handler.ts b/packages/host/engine/src/agent/request-handler.ts index 7f652b739..fb3e93c7f 100644 --- a/packages/host/engine/src/agent/request-handler.ts +++ b/packages/host/engine/src/agent/request-handler.ts @@ -1,5 +1,5 @@ import type { AdapterFactory } from '@linkcode/agent-adapter'; -import { modelListSource } from '@linkcode/providers'; +import { endpointServiceById } from '@linkcode/providers'; import type { AccountSecret, WirePayload } from '@linkcode/schema'; import type { Transport } from '@linkcode/transport'; import { createWireMessage } from '@linkcode/transport'; @@ -10,7 +10,7 @@ import type { WireResponder } from '../wire/responder'; import type { CustomMcpServerService } from './custom-mcp-service'; import type { AgentLoginService } from './login-service'; import type { ModelProbe } from './model-probe'; -import { probeEndpointModels } from './model-probe'; +import { probeServiceModels } from './model-probe'; import type { ProviderConfigStore } from './provider-config'; import { applyProviderDefaults } from './provider-config'; import type { AgentRuntimeService } from './runtime-service'; @@ -40,7 +40,7 @@ export class AgentRequestHandler { private readonly logins: AgentLoginService | undefined, private readonly responder: WireResponder, private readonly factory: AdapterFactory, - private readonly probeModels: ModelProbe = probeEndpointModels, + private readonly probeModels: ModelProbe = probeServiceModels, ) {} handle(payload: AgentRequest): Effect.Effect { @@ -141,12 +141,12 @@ export class AgentRequestHandler { payload.clientReqId, Effect.tryPromise({ try: async () => { - const source = modelListSource(payload.service); - if (!source) { + const service = endpointServiceById(payload.service); + if (!service) { throw new Error(`${payload.service} serves no model list`); } const models = await this.probeModels( - source, + service, this.probeSecret(payload.service, payload.credential), ); this.transport.send(