Skip to content
This repository was archived by the owner on Oct 21, 2026. It is now read-only.

Commit a79b917

Browse files
authored
feat(providers): resolve LinkCode Gateway as a Codex-native endpoint (#477)
* feat(providers): resolve LinkCode Gateway as a Codex-native endpoint LinkCode Cloud now serves POST /v1/responses at the same base URL, so Codex binds natively instead of reporting an incompatible protocol. * feat(providers,schema): per-protocol model lists, and filter models an agent can't reach Corresponds to CODE-603's client-side half: the gateway now serves a different model roster per protocol (GET /v1/models?protocol=), so the client needs to learn and act on that instead of showing every account's full catalog to every agent regardless of which wire it actually binds on. ServiceVariant gains an optional models override, so one service can serve a different list per variant while every other catalog entry (which serves the same ids on every variant) keeps using the service-level default unchanged. LinkCode Gateway's openai-responses variant now points at the protocol-filtered list. modelListSourceForProtocol resolves the effective list for one protocol; the existing modelListSource (service-level, used only as an existence check today) is untouched, so its test and the add-flow "can this be probed" gate are unaffected. AccountModel gains optional protocols: AccountProtocol[]. Absence must mean "offer it" — a pre-catalog bare key, a hand-typed custom account, or a model probed before this field existed all carry no data, and a stored account must not go dark on upgrade. enabledAccountModels narrows a picked model to the protocol the agent actually binds that account with (reusing the ResolvedBinding already computed for the enabled/unavailable check, rather than resolving twice), and treats an unknown protocol on either side — the model's or the binding's — as "offer", not "hide". * feat(engine): probe every variant of a service, tag ids by protocol config.probe-models now resolves the EndpointService and calls the new probeServiceModels instead of a single service-level modelListSource lookup. Most services still make exactly one request (no variant overrides an otherwise-shared list), but a service like LinkCode Gateway — whose openai-responses variant lists a strict subset of the plain list — makes one request per distinct list and merges the results, tagging each returned id with every protocol whose list actually named it. A protocol the service has no variant for at all must not fall through to the service-level list by accident of `??` — fixed before it shipped, caught by a new test asserting the merge across two distinct lists rather than just the single-list case every other service exercises. * fix(schema): bump wire version for model protocols
1 parent 5373769 commit a79b917

11 files changed

Lines changed: 289 additions & 34 deletions

File tree

‎packages/foundation/providers/src/__tests__/enabled-models.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,31 @@ describe('enabledAccountModels', () => {
6666
expect(enabledAccountModels([sub], {}, 'claude-code')).toHaveLength(1);
6767
});
6868

69+
it("narrows a picked model to the protocols it is known to answer, for the agent's actual binding", () => {
70+
const gateway = account('acc_gw', {
71+
service: 'linkcode-gateway',
72+
credential: { type: 'auth-token', token: 'lc-test' },
73+
models: [
74+
{ id: 'openai/gpt-5.6', protocols: ['openai-chat', 'openai-responses'] },
75+
{ id: 'anthropic/claude-sonnet-5', protocols: ['openai-chat'] },
76+
// Probed before `protocols` existed, or never probed — absence must still offer it.
77+
{ id: 'openai/gpt-4.1' },
78+
],
79+
});
80+
// codex binds this account on openai-responses: only the model tagged for it survives, plus
81+
// the one with no protocol data at all.
82+
expect(enabledAccountModels([gateway], {}, 'codex').map(({ model }) => model.id)).toEqual([
83+
'openai/gpt-5.6',
84+
'openai/gpt-4.1',
85+
]);
86+
// opencode/pi accept any wire this account offers, so nothing here is narrowed.
87+
expect(enabledAccountModels([gateway], {}, 'opencode').map(({ model }) => model.id)).toEqual([
88+
'openai/gpt-5.6',
89+
'anthropic/claude-sonnet-5',
90+
'openai/gpt-4.1',
91+
]);
92+
});
93+
6994
it('reports an account with no picked model as offering nothing, not as unavailable', () => {
7095
expect(enabledAccountModels([account('acc_empty')], {}, 'opencode')).toEqual([]);
7196
expect(accountEnabledFor({}, 'opencode', 'acc_empty')).toBe(true);

‎packages/foundation/providers/src/__tests__/resolve.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ describe('resolveBinding: variant chosen per agent', () => {
191191
).toEqual({ tier: 'unavailable', reason: 'protocol-unsupported' });
192192
});
193193

194-
it('preserves LinkCode Gateway as an OpenAI Chat endpoint', () => {
194+
it('serves LinkCode Gateway over both OpenAI wires', () => {
195195
const gateway = account({
196196
service: 'linkcode-gateway',
197197
credential: { type: 'auth-token', token: 'lc-gateway-key' },
@@ -209,8 +209,9 @@ describe('resolveBinding: variant chosen per agent', () => {
209209
});
210210
}
211211
expect(resolveBinding(gateway, 'codex')).toEqual({
212-
tier: 'unavailable',
213-
reason: 'protocol-unsupported',
212+
tier: 'native',
213+
protocol: 'openai-responses',
214+
baseUrl: 'https://gateway.linkcode.ai/v1',
214215
});
215216
});
216217

‎packages/foundation/providers/src/catalog.ts‎

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,23 @@ export interface ServiceVariant {
2121
/** Ids for this endpoint in an agent's own provider catalog. Present means the agent already
2222
* carries the wire adapter and model metadata, so it needs only the key injected. */
2323
knownProvider?: Partial<Record<AgentKind, string>>;
24+
/** Overrides the service-level list for this variant only. Needed when one secret reaches
25+
* a gateway whose ids differ by protocol — LinkCode Gateway's `openai-responses` variant
26+
* lists only the models that speak Responses, a strict subset of the plain `/v1/models`
27+
* every other variant (and the service-level fallback below) serves. Absent means this
28+
* variant's list is the service-level one. */
29+
models?: ServiceModelList;
2430
/** `endpointParams` key → the env name that agent's own provider entry reads it under. Present
2531
* means the agent templates a per-model URL, so injecting one base URL would flatten routes that
2632
* differ per model — declare this instead of a base URL for such a provider. */
2733
endpointEnv?: Partial<Record<AgentKind, Record<string, string>>>;
2834
}
2935

3036
/**
31-
* Where to read the ids this service serves. Service-level, not per variant: one secret reaches one
32-
* model list, and the ids are the same whichever protocol shape an agent ends up using.
37+
* Where to read the ids this service serves, by default. One secret can still reach several
38+
* different lists — see `ServiceVariant.models` — but every service in this catalog except
39+
* LinkCode Gateway serves the same ids regardless of which protocol shape an agent ends up
40+
* using, so a per-variant override is the exception, not the rule this field describes.
3341
*
3442
* The URL is spelled out rather than derived from a variant's `baseUrl` + protocol, because
3543
* derivation is wrong for any service whose variants sit on different paths — DeepSeek's
@@ -163,6 +171,15 @@ export const SERVICE_CATALOG: ServiceDescriptor[] = [
163171
credentialType: 'auth-token',
164172
variants: {
165173
'openai-chat': { baseUrl: 'https://gateway.linkcode.ai/v1' },
174+
// Lists only the models the gateway actually serves on this wire — a strict subset of
175+
// the service-level list below, since not every provider behind it speaks Responses.
176+
'openai-responses': {
177+
baseUrl: 'https://gateway.linkcode.ai/v1',
178+
models: {
179+
url: 'https://gateway.linkcode.ai/v1/models?protocol=openai-responses',
180+
wire: 'openai',
181+
},
182+
},
166183
},
167184
models: { url: 'https://gateway.linkcode.ai/v1/models', wire: 'openai' },
168185
},
@@ -253,7 +270,24 @@ export function endpointServiceById(id: string | undefined): EndpointService | u
253270
return service?.kind === 'endpoint' ? service : undefined;
254271
}
255272

256-
/** Where to read this service's model ids, or undefined when it serves no list. */
273+
/** Where to read this service's model ids, or undefined when it serves no list. This is the
274+
* service-level default — the one list shown by "does this service serve a list at all" checks,
275+
* and correct for every service in this catalog except LinkCode Gateway. Use
276+
* `modelListSourceForProtocol` when the caller actually has a protocol in hand and the two might
277+
* differ. */
257278
export function modelListSource(id: string | undefined): ServiceModelList | undefined {
258279
return endpointServiceById(id)?.models;
259280
}
281+
282+
/** Where to read the ids this service serves *for one protocol* — a variant's own override if it
283+
* has one, the service-level default otherwise. */
284+
export function modelListSourceForProtocol(
285+
id: string | undefined,
286+
protocol: AccountProtocol,
287+
): ServiceModelList | undefined {
288+
const service = endpointServiceById(id);
289+
const variant = service?.variants[protocol];
290+
// A protocol the service does not serve at all must not fall through to the service-level
291+
// list — that list is scoped to the variants that actually exist, not to every protocol name.
292+
return variant && (variant.models ?? service.models);
293+
}

‎packages/foundation/providers/src/enabled-models.ts‎

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import type { Account, AccountModel, Accounts, AgentKind, ProvidersConfig } from '@linkcode/schema';
1+
import type {
2+
Account,
3+
AccountModel,
4+
AccountProtocol,
5+
Accounts,
6+
AgentKind,
7+
ProvidersConfig,
8+
} from '@linkcode/schema';
9+
import type { ResolvedBinding } from './resolve';
210
import { resolveBinding } from './resolve';
311

412
/** 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(
2028
return enabled === undefined || enabled.includes(accountId);
2129
}
2230

31+
/** The protocol this agent would actually bind the account on, or undefined when the binding
32+
* carries none — an oauth login, a pre-catalog bare key, or (after the caller's own tier check)
33+
* an unavailable account. */
34+
function boundProtocol(binding: ResolvedBinding): AccountProtocol | undefined {
35+
return binding.tier === 'unavailable' ? undefined : binding.protocol;
36+
}
37+
38+
/** Whether a picked model may be offered for a binding resolved to `protocol`. Unknown on either
39+
* side means offer: a model probed before `protocols` existed (or never probed — a pre-catalog
40+
* bare key, a hand-typed custom account) carries no set, and a binding with no protocol of its
41+
* own (same two cases) has nothing to check against. Only an explicit set on both sides can
42+
* narrow the result. */
43+
function modelReachable(model: AccountModel, protocol: AccountProtocol | undefined): boolean {
44+
return (
45+
model.protocols === undefined || protocol === undefined || model.protocols.includes(protocol)
46+
);
47+
}
48+
49+
function resolvedAccounts(
50+
accounts: Accounts,
51+
providers: ProvidersConfig | undefined,
52+
kind: AgentKind,
53+
): Array<{ account: Account; binding: ResolvedBinding }> {
54+
return accounts.reduce<Array<{ account: Account; binding: ResolvedBinding }>>(
55+
(resolved, account) => {
56+
const binding = resolveBinding(account, kind);
57+
if (binding.tier !== 'unavailable' && accountEnabledFor(providers, kind, account.id)) {
58+
resolved.push({ account, binding });
59+
}
60+
return resolved;
61+
},
62+
[],
63+
);
64+
}
65+
2366
/**
2467
* Every model this agent may run on, in the order its pickers offer them: each enabled account in
25-
* pool order, contributing its picked set in its own order. Availability still gates it — an enabled
68+
* pool order, contributing its picked set in its own order, narrowed to the models reachable on the
69+
* protocol this agent actually binds that account with. Availability still gates it — an enabled
2670
* account that cannot back this agent contributes nothing.
2771
*
2872
* **The first entry is the agent's default.** There is no stored default account or default model:
@@ -35,9 +79,13 @@ export function enabledAccountModels(
3579
providers: ProvidersConfig | undefined,
3680
kind: AgentKind,
3781
): EnabledAccountModel[] {
38-
return enabledAccounts(accounts, providers, kind).flatMap((account) =>
39-
(account.models ?? []).map((model) => ({ account, model })),
40-
);
82+
return resolvedAccounts(accounts, providers, kind).flatMap(({ account, binding }) => {
83+
const protocol = boundProtocol(binding);
84+
return (account.models ?? []).reduce<EnabledAccountModel[]>((models, model) => {
85+
if (modelReachable(model, protocol)) models.push({ account, model });
86+
return models;
87+
}, []);
88+
});
4189
}
4290

4391
/** 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(
4896
providers: ProvidersConfig | undefined,
4997
kind: AgentKind,
5098
): Account[] {
51-
return accounts.filter(
52-
(account) =>
53-
resolveBinding(account, kind).tier !== 'unavailable' &&
54-
accountEnabledFor(providers, kind, account.id),
55-
);
99+
return resolvedAccounts(accounts, providers, kind).map(({ account }) => account);
56100
}

‎packages/foundation/providers/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export {
99
endpointServiceById,
1010
LINKCODE_GATEWAY_SERVICE_ID,
1111
modelListSource,
12+
modelListSourceForProtocol,
1213
SERVICE_CATALOG,
1314
serviceById,
1415
} from './catalog';

‎packages/foundation/schema/src/model/account.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ export type AccountEndpoint = z.infer<typeof AccountEndpointSchema>;
4545
export const AccountModelSchema = z.object({
4646
id: z.string().min(1),
4747
label: z.string().optional(),
48+
/** Which protocols this model is known to answer, from probing every variant of the account's
49+
* service. Absent means unknown, not "answers nothing" — a pre-catalog bare key, a custom
50+
* account, or an account probed before this field existed all have no data here, and absence
51+
* must keep offering the model everywhere it always did. Only an explicit set narrows it. */
52+
protocols: z.array(AccountProtocolSchema).optional(),
4853
});
4954
export type AccountModel = z.infer<typeof AccountModelSchema>;
5055

‎packages/foundation/schema/src/wire/message.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { WIRE_PAYLOAD_KINDS, WirePayloadSchema } from './payload';
99
*/
1010

1111
/** Stamped on every frame this build sends; bump on any wire schema change. */
12-
export const WIRE_PROTOCOL_VERSION = 78 as const;
12+
export const WIRE_PROTOCOL_VERSION = 79 as const;
1313

1414
/** The oldest `v` this build still accepts. Bump only for a breaking change — a variant or field
1515
* removed, renamed, or given a new meaning; additive changes leave it alone. */

‎packages/foundation/schema/tests/contract/wire/config.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,32 @@ function envelope(payload: unknown) {
66
}
77

88
describe('config wire schema — custom MCP servers', () => {
9+
it('round-trips model protocol availability on config.get.result', () => {
10+
const parsed = parseWireMessage(
11+
envelope({
12+
kind: 'config.get.result',
13+
replyTo: 'request-1',
14+
providers: {},
15+
accounts: [
16+
{
17+
id: 'account-1',
18+
label: 'Gateway',
19+
credential: { type: 'api-key', key: 'sk-test' },
20+
models: [
21+
{
22+
id: 'gpt-5',
23+
protocols: ['openai-chat', 'openai-responses'],
24+
},
25+
],
26+
createdAt: 1,
27+
},
28+
],
29+
customMcpServers: [],
30+
}),
31+
);
32+
expect(parsed.ok).toBe(true);
33+
});
34+
935
it('round-trips a masked read projection on config.get.result', () => {
1036
const parsed = parseWireMessage(
1137
envelope({

‎packages/host/engine/src/__tests__/engine-model-probe.test.ts‎

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { Server } from 'node:http';
22
import { Agent, createServer } from 'node:http';
33
import type { AddressInfo } from 'node:net';
4+
import type { EndpointService, ServiceModelList } from '@linkcode/providers';
45
import type { WirePayload } from '@linkcode/schema';
56
import { nullthrow } from 'foxts/guard';
67
import { afterEach, describe, expect, it, vi } from 'vitest';
7-
import { probeEndpointModels, requestPublicModelList } from '../agent/model-probe';
8+
import { probeServiceModels, requestPublicModelList } from '../agent/model-probe';
89
import { InMemoryProviderConfigStore } from '../agent/provider-config';
910
import { createSessionHarness } from './fixtures/session-harness';
1011

@@ -37,13 +38,29 @@ function baseUrl(server: Server): string {
3738

3839
let relay: Server | undefined;
3940

40-
/** Sends the catalog's own path at the local relay, so the relay records which path the service
41-
* descriptor resolved to while the real HTTP round-trip stays under test. */
42-
const localModelProbe: typeof probeEndpointModels = (source, secret) => {
43-
const resolved = new URL(source.url);
41+
/** Rewrites every model-list URL a service carries — service-level and per-variant — to the
42+
* local relay, so the relay records which path each one resolved to while the real HTTP
43+
* round-trip stays under test. */
44+
function localize(list: ServiceModelList | undefined): ServiceModelList | undefined {
45+
if (!list) return undefined;
46+
const resolved = new URL(list.url);
4447
const local = `${baseUrl(nullthrow(relay, 'relay not started'))}${resolved.pathname}${resolved.search}`;
48+
return { ...list, url: local };
49+
}
50+
51+
const localModelProbe: typeof probeServiceModels = (service, secret) => {
52+
const localized: EndpointService = {
53+
...service,
54+
models: localize(service.models),
55+
variants: Object.fromEntries(
56+
Object.entries(service.variants).map(([protocol, variant]) => [
57+
protocol,
58+
variant && { ...variant, models: localize(variant.models) },
59+
]),
60+
),
61+
};
4562
// An unguarded agent: the relay is on loopback, which the probe policy exists to refuse.
46-
return probeEndpointModels({ ...source, url: local }, secret, (url, headers) =>
63+
return probeServiceModels(localized, secret, (url, headers) =>
4764
requestPublicModelList(url, headers, undefined, new Agent()),
4865
);
4966
};
@@ -89,10 +106,15 @@ describe('config.probe-models', () => {
89106
credential: { type: 'inline', secret: { type: 'api-key', key: 'sk-test' } },
90107
});
91108

109+
// deepseek's three variants share one list (no per-variant override), so both ids are
110+
// tagged with all three protocols from the single request `seen` below confirms.
92111
await expect(replyFor(h.sent, 'probe-1')).resolves.toEqual({
93112
kind: 'config.probe-models.result',
94113
replyTo: 'probe-1',
95-
models: [{ id: 'gpt-5' }, { id: 'gpt-5-mini' }],
114+
models: [
115+
{ id: 'gpt-5', protocols: ['anthropic', 'openai-chat', 'openai-responses'] },
116+
{ id: 'gpt-5-mini', protocols: ['anthropic', 'openai-chat', 'openai-responses'] },
117+
],
96118
});
97119
// The service's own path, not one derived from a variant's baseUrl.
98120
expect(seen).toEqual(['/models']);
@@ -166,11 +188,56 @@ describe('config.probe-models', () => {
166188
await expect(replyFor(h.sent, 'probe-4')).resolves.toEqual({
167189
kind: 'config.probe-models.result',
168190
replyTo: 'probe-4',
169-
models: [{ id: 'deepseek-v4-pro' }],
191+
models: [
192+
{ id: 'deepseek-v4-pro', protocols: ['anthropic', 'openai-chat', 'openai-responses'] },
193+
],
170194
});
171195
expect(seen).toEqual(['/models']);
172196
});
173197

198+
it('probes every distinct list a service carries and merges the results', async () => {
199+
const seen: string[] = [];
200+
relay = await startRelay((url) => {
201+
seen.push(url);
202+
if (url === '/v1/models?protocol=openai-responses') {
203+
return { status: 200, body: JSON.stringify({ data: [{ id: 'openai/gpt-5.6' }] }) };
204+
}
205+
if (url === '/v1/models') {
206+
return {
207+
status: 200,
208+
body: JSON.stringify({
209+
data: [{ id: 'openai/gpt-5.6' }, { id: 'anthropic/claude-sonnet-5' }],
210+
}),
211+
};
212+
}
213+
return { status: 404, body: '{}' };
214+
});
215+
const h = createHarness();
216+
await h.engine.start();
217+
218+
await h.inject({
219+
kind: 'config.probe-models',
220+
clientReqId: 'probe-7',
221+
service: 'linkcode-gateway',
222+
credential: { type: 'inline', secret: { type: 'auth-token', token: 'lc-test' } },
223+
});
224+
225+
const reply = await replyFor(h.sent, 'probe-7');
226+
if (reply.kind !== 'config.probe-models.result') throw new Error('no result for probe-7');
227+
expect(reply.models).toEqual(
228+
expect.arrayContaining([
229+
{
230+
id: 'openai/gpt-5.6',
231+
// Returned by both lists: tagged with every protocol whose list named it.
232+
protocols: ['openai-chat', 'openai-responses'],
233+
},
234+
{ id: 'anthropic/claude-sonnet-5', protocols: ['openai-chat'] },
235+
]),
236+
);
237+
expect(reply.models).toHaveLength(2);
238+
expect(seen.sort()).toEqual(['/v1/models', '/v1/models?protocol=openai-responses']);
239+
});
240+
174241
it('refuses to send an account secret to a service it does not belong to', async () => {
175242
const reached: string[] = [];
176243
relay = await startRelay((url) => {

0 commit comments

Comments
 (0)