Skip to content
Merged
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: 25 additions & 0 deletions packages/foundation/providers/src/__tests__/enabled-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 4 additions & 3 deletions packages/foundation/providers/src/__tests__/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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',
});
});

Expand Down
40 changes: 37 additions & 3 deletions packages/foundation/providers/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,23 @@ 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<Record<AgentKind, string>>;
/** 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. */
endpointEnv?: Partial<Record<AgentKind, Record<string, string>>>;
}

/**
* 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
Expand Down Expand Up @@ -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' },
},
Expand Down Expand Up @@ -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(
Comment thread
xiaoland marked this conversation as resolved.
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);
}
64 changes: 54 additions & 10 deletions packages/foundation/providers/src/enabled-models.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<Array<{ account: Account; binding: ResolvedBinding }>>(
(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:
Expand All @@ -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 }) => {
Comment thread
xiaoland marked this conversation as resolved.
const protocol = boundProtocol(binding);
return (account.models ?? []).reduce<EnabledAccountModel[]>((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
Expand All @@ -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);
}
1 change: 1 addition & 0 deletions packages/foundation/providers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
endpointServiceById,
LINKCODE_GATEWAY_SERVICE_ID,
modelListSource,
modelListSourceForProtocol,
SERVICE_CATALOG,
serviceById,
} from './catalog';
Expand Down
5 changes: 5 additions & 0 deletions packages/foundation/schema/src/model/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ export type AccountEndpoint = z.infer<typeof AccountEndpointSchema>;
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(),
Comment thread
pullfrog[bot] marked this conversation as resolved.
});
export type AccountModel = z.infer<typeof AccountModelSchema>;

Expand Down
2 changes: 1 addition & 1 deletion packages/foundation/schema/src/wire/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
26 changes: 26 additions & 0 deletions packages/foundation/schema/tests/contract/wire/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
xiaoland marked this conversation as resolved.
});

it('round-trips a masked read projection on config.get.result', () => {
const parsed = parseWireMessage(
envelope({
Expand Down
83 changes: 75 additions & 8 deletions packages/host/engine/src/__tests__/engine-model-probe.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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()),
);
};
Expand Down Expand Up @@ -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']);
Expand Down Expand Up @@ -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) => {
Expand Down
Loading
Loading