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
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { Accounts, AgentRuntimes, ProvidersConfig } from '@linkcode/schema';
import type { AccountModel, Accounts, AgentRuntimes, ProvidersConfig } from '@linkcode/schema';
import { describe, expect, it } from 'vitest';
import { updateAccountFromDraft } from '../add-flow';
import {
accountConfigSnippet,
boundAgentKinds,
maskSecret,
providerAccountDetailViewModel,
providerAccountListViewModel,
withAccountEnabled,
withoutAccount,
Expand Down Expand Up @@ -153,6 +154,40 @@ describe('view helpers', () => {
});
});

it('tells each agent row how much of the picked set it can actually run', () => {
const claudeOnly: AccountModel = {
id: 'anthropic/claude-sonnet-5',
protocols: ['openai-chat', 'anthropic'],
};
const responsesToo: AccountModel = {
id: 'openai/gpt-5.6',
protocols: ['openai-chat', 'openai-responses'],
};
const gateway: Accounts[number] = {
id: 'acc_gw',
label: 'LinkCode Gateway',
createdAt: 0,
service: 'linkcode-gateway',
credential: { type: 'auth-token', token: 'lc-test' },
models: [responsesToo, claudeOnly],
};
const codexRow = (account: Accounts[number]) =>
providerAccountDetailViewModel(account, undefined, undefined).agents.find(
({ kind }) => kind === 'codex',
);

expect(codexRow(gateway)?.status).toEqual({ kind: 'model-shortfall', picked: 2, reachable: 1 });

// Pick only what codex cannot reach and the row has to say why its picker is empty — as a
// sentence, not as a "0 of 1" ratio saying the same thing twice.
const empty = codexRow({ ...gateway, models: [claudeOnly] });
expect(empty?.enabled).toBe(true);
expect(empty?.status).toEqual({ kind: 'no-reachable-model' });

// Nothing to report when the agent can run everything that was picked.
expect(codexRow({ ...gateway, models: [responsesToo] })?.status).toBeUndefined();
});

it('updates editable account fields without replacing its identity or hidden fields', () => {
const account: Accounts[number] = {
id: 'acc_a',
Expand Down
20 changes: 19 additions & 1 deletion packages/client/workbench/src/settings/providers/view.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { AccountModelReach } from '@linkcode/providers';
import {
accountEnabledFor,
accountModelReach,
pinnedEndpoint,
resolveBinding,
serviceById,
Expand Down Expand Up @@ -114,13 +116,29 @@ function agentStatus(
}
// Enabled is the whole state, and the switch already shows it — only a reason to be off earns text.
const enabled = accountEnabledFor(providers, kind, account.id);
const status = offerStatus(enabled, accountModelReach(account, kind));
return {
tier: availability.tier,
enabled,
...(!enabled && { status: { kind: 'disabled' } }),
...(status !== undefined && { status }),
};
}

/**
* What this row has to say for itself, most operative first: an off switch outranks anything about
* the models behind it, and an empty picker is a sentence rather than a "0 of 3" ratio. Returning
* one of them — rather than setting a field per fact — is what keeps the row from rendering two
* answers to the same question.
*/
function offerStatus(enabled: boolean, models: AccountModelReach): ProviderAgentStatus | undefined {
if (!enabled) return { kind: 'disabled' };
if (models.picked === 0 || models.reachable === models.picked) return undefined;
// An enabled agent whose picker comes up empty reads as an enablement bug; the picked set is the
// real reason, so the row names it instead of leaving the switch to imply otherwise.
if (models.reachable === 0) return { kind: 'no-reachable-model' };
return { kind: 'model-shortfall', ...models };
}

/** Selected account plus precomputed binding rows; UI owns only rendering and local interaction. */
export function providerAccountDetailViewModel(
account: Account,
Expand Down
15 changes: 12 additions & 3 deletions packages/foundation/providers/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,23 @@ Pure data plus pure functions: no hooks, no browser APIs, no I/O. Its only depen
resolver about the same account — showing a pinned endpoint for one that resolves per agent.
Display, edit-form prefill, and resolution have to answer the question identically.
- **`models` is service-level and spelled out, never derived.** One secret reaches one model list,
and the ids are identical whichever protocol shape an agent resolves to — so the list belongs to
the service, not the variant, and one fetch serves every agent bound to the account. The URL is
written out because deriving it from a variant's `baseUrl` + protocol is wrong wherever variants
and for every service but one the ids are identical whichever protocol shape an agent resolves to
— so the list belongs to the service, not the variant, and one fetch serves every agent bound to
the account. The URL is written out because deriving it from a variant's `baseUrl` + protocol is
wrong wherever variants
sit on different paths: DeepSeek's `/anthropic` variant would give `/anthropic/v1/models` and
Vercel's bare-origin one a root `/models`, neither of which exists. `wire` picks the auth header
and response shape only. Absent means the service serves no list, and the account is freeform-only
— true for both Cloudflare entries, whose `/compat` route has no model-list path (docs + verified
live). Anthropic's list defaults to `limit=20`, so the full list must be asked for.
- **`ServiceVariant.models` overrides it for a service whose ids differ by wire.** LinkCode
Gateway is the one: it serves every model on Chat Completions but only a subset on Responses and
on Messages, and answers `GET /v1/models?protocol=…` with exactly that subset. The daemon's
probe fetches each distinct list once and tags every model with the protocols whose list named
it (`AccountModel.protocols`, `packages/host/engine` `model-probe.ts`), which is what lets
`enabledAccountModels` keep a model out of an agent's picker instead of letting the agent's
first request 404. A model with no tags predates the probe and stays offered everywhere — only
an explicit set narrows anything, so re-detecting an old account is what tags it.
- **A missing variant is a claim about the vendor, so verify it.** Omitting `openai-responses`
refuses codex outright, and an unverified assumption that "that endpoint doesn't serve it anyway"
once shipped exactly that gap for xAI, OpenRouter and Vercel — all three do serve
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Account, Accounts } from '@linkcode/schema';
import { describe, expect, it } from 'vitest';
import { accountEnabledFor, enabledAccountModels } from '../enabled-models';
import { accountEnabledFor, accountModelReach, enabledAccountModels } from '../enabled-models';

function account(id: string, overrides: Partial<Account> = {}): Account {
return {
Expand Down Expand Up @@ -91,6 +91,29 @@ describe('enabledAccountModels', () => {
]);
});

it('counts the reachable share of a picked set per agent, untagged models included', () => {
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: 'openai/gpt-4.1', protocols: ['openai-chat', 'openai-responses'] },
{ id: 'anthropic/claude-sonnet-5', protocols: ['openai-chat', 'anthropic'] },
],
});
// The same picked set is a different share per agent: codex binds responses, claude-code the
// gateway's Anthropic wire.
expect(accountModelReach(gateway, 'codex')).toEqual({ picked: 3, reachable: 2 });
expect(accountModelReach(gateway, 'claude-code')).toEqual({ picked: 3, reachable: 1 });
// An untagged set predates the protocol probe, so it counts as fully reachable rather than
// reading as a shortfall the user cannot act on.
expect(accountModelReach(account('acc_old', { models: [{ id: 'x-1' }] }), 'codex')).toEqual({
picked: 1,
reachable: 1,
});
expect(accountModelReach(account('acc_empty'), 'codex')).toEqual({ picked: 0, reachable: 0 });
});

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
29 changes: 29 additions & 0 deletions packages/foundation/providers/src/enabled-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,35 @@ function modelReachable(model: AccountModel, protocol: AccountProtocol | undefin
);
}

/** How much of an account's picked set one agent can run: `picked` is the stored set, `reachable`
* the part the protocol this agent binds answers. */
export interface AccountModelReach {
picked: number;
reachable: number;
}

/**
* The counted form of `enabledAccountModels`' own narrowing, for the Settings row that names a
* shortfall next to the agent's switch. A set with no `protocols` at all counts as fully reachable,
* matching that filter, so an account probed before tagging existed stays silent instead of
* alarming.
*
* **Only meaningful for a binding that resolves.** An unavailable agent has no protocol to count
* against and reads as fully reachable — ask `resolveBinding` first, as `agentStatus` does, and
* report the unavailability itself rather than this count.
*/
export function accountModelReach(account: Account, kind: AgentKind): AccountModelReach {
const models = account.models ?? [];
const protocol = boundProtocol(resolveBinding(account, kind));
return {
picked: models.length,
reachable: models.reduce(
(count, model) => (modelReachable(model, protocol) ? count + 1 : count),
0,
),
};
}

function resolvedAccounts(
accounts: Accounts,
providers: ProvidersConfig | undefined,
Expand Down
9 changes: 7 additions & 2 deletions packages/foundation/providers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,13 @@ export {
export { CURATED_AGENT_MODELS } from './curated-models';
export type { DetectedLogin } from './detected-logins';
export { detectedLogins } from './detected-logins';
export type { EnabledAccountModel } from './enabled-models';
export { accountEnabledFor, enabledAccountModels, enabledAccounts } from './enabled-models';
export type { AccountModelReach, EnabledAccountModel } from './enabled-models';
export {
accountEnabledFor,
accountModelReach,
enabledAccountModels,
enabledAccounts,
} from './enabled-models';
export type { BindingTier, BindingUnavailableReason, ResolvedBinding } from './resolve';
export { pinnedEndpoint, resolveBinding, serviceProtocols } from './resolve';
export { fillTemplate, isTemplateFilled, templatePlaceholders } from './template';
2 changes: 2 additions & 0 deletions packages/presentation/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1102,6 +1102,8 @@ export const en = {
unavailableOauth: 'Only connects to {agent}',
unavailableProtocol: 'The endpoint protocol is incompatible with this agent',
unavailableEndpointIncomplete: 'Endpoint details are incomplete — finish the account setup',
noReachableModel: 'None of the selected models speaks the protocol this agent uses',
modelsReachable: '{reachable} of {picked} selected models work with this agent',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The display gate is reachable > 0 && reachable < picked, so reachable === 1 is the common shortfall and the string renders "1 of 2 selected models work with this agent". This file already uses ICU plurals in ~18 places, so the agreement is worth fixing; rewording sidesteps the plural form entirely, or keep the count-first phrasing with {reachable, plural, one {works} other {work}}.

Suggested change
modelsReachable: '{reachable} of {picked} selected models work with this agent',
modelsReachable: 'Works with {reachable} of {picked} selected models',

configPreview: 'config.json snippet · what this account writes',
configPreviewEmpty: '// not connected to any agent yet',
remove: 'Remove account',
Expand Down
2 changes: 2 additions & 0 deletions packages/presentation/i18n/src/locales/zh-cn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1074,6 +1074,8 @@ export const zhCN = {
unavailableOauth: '仅可接入 {agent}',
unavailableProtocol: '端点协议与此智能体不兼容',
unavailableEndpointIncomplete: '端点信息不完整,请补全账号设置',
noReachableModel: '选中的模型都不支持此智能体使用的协议',
modelsReachable: '选中的 {picked} 个模型中有 {reachable} 个可用于此智能体',
configPreview: 'config.json 片段 · 此账号写入的内容',
configPreviewEmpty: '// 尚未接入任何智能体',
remove: '移除账号',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// @vitest-environment jsdom

import type { AgentKind } from '@linkcode/schema';
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
ProviderAccountDetailViewModel,
ProviderAgentViewModel,
} from '../providers/account-detail';
import { AccountDetail } from '../providers/account-detail';

/** Names each value, so an assertion says which number it expected where. */
function passthrough(key: string, values?: Record<string, unknown>): string {
if (!values) return key;
const named = Object.entries(values).map(([name, value]) => `${name}=${String(value)}`);
return `${key}:${named.join(',')}`;
}

vi.mock('use-intl', () => ({ useTranslations: () => passthrough }));

afterEach(cleanup);

const REACHABLE_PATTERN = /modelsReachable/;

function detail(agents: ProviderAgentViewModel[]): ProviderAccountDetailViewModel {
const bound = agents.reduce<AgentKind[]>((kinds, agent) => {
if (agent.enabled) kinds.push(agent.kind);
return kinds;
}, []);
return {
id: 'acc_gw',
label: 'LinkCode Gateway',
credential: { kind: 'secret', type: 'auth-token', value: 'lc-secret', maskedValue: 'lc-…ret' },
agents,
boundAgents: bound,
enabledAgentCount: bound.length,
availableAgentCount: agents.filter(({ tier }) => tier !== 'unavailable').length,
};
}

function renderDetail(agent: ProviderAgentViewModel): void {
render(
<AccountDetail
account={detail([agent])}
busy={false}
onSetAccountEnabled={vi.fn()}
onEdit={vi.fn()}
onRemove={vi.fn()}
/>,
);
}

describe('AccountDetail agent rows', () => {
it('names the share it was handed, each number under its own placeholder', () => {
renderDetail({
kind: 'codex',
tier: 'native',
enabled: true,
status: { kind: 'model-shortfall', picked: 3, reachable: 2 },
});
expect(screen.getByText('modelsReachable:picked=3,reachable=2')).toBeTruthy();
});

// Which one of these a row gets is the view model's call (see the workbench view tests); the
// row's own rule is only that an absent status says nothing at all.
it('says nothing when it was handed no status', () => {
renderDetail({ kind: 'codex', tier: 'native', enabled: true });
expect(screen.queryByText(REACHABLE_PATTERN)).toBeNull();
});

it('says why an enabled agent offers nothing instead of leaving the switch to imply it', () => {
renderDetail({
kind: 'codex',
tier: 'native',
enabled: true,
status: { kind: 'no-reachable-model' },
});
expect(screen.getByText('noReachableModel')).toBeTruthy();
// Zero reachable is the status' story; a "0 of 3" ratio beside it would say it twice.
expect(screen.queryByText(REACHABLE_PATTERN)).toBeNull();
});
});
11 changes: 10 additions & 1 deletion packages/presentation/ui/src/shell/providers/account-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,19 @@ export type ProviderAgentStatus =
| { kind: 'unavailable-oauth'; agent: AgentKind }
| { kind: 'unavailable-endpoint-incomplete' }
| { kind: 'unavailable-protocol' }
| { kind: 'no-reachable-model' }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This variant falsifies the doc comment on status three lines below: "Only a reason the row cannot be, or is not, on. Absent means enabled and available." no-reachable-model is only ever set on a row that is on and is available, so status now means "a reason this row offers nothing", not "a reason it is off". Worth widening that sentence in the same commit that widens the union — this PR is already correcting one stale claim in AGENTS.md.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The rewrite in 3bf358b fixes the false "Absent means enabled and available" claim and adds the shortfall, but its enumeration ("a reason it cannot be, or is not, on, or a picked set it can run only part of") still omits no-reachable-model — a row that is on, available, and can run none of the set — so leaving this open.

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

| { kind: 'model-shortfall'; picked: number; reachable: number }
| { kind: 'disabled' };

/** One agent row in an account's dialog: whether this account's models are offered to that agent.
* That is the whole state — nothing here is a default, and the switch says it without help. */
export interface ProviderAgentViewModel {
kind: AgentKind;
tier: 'native' | 'translate' | 'unavailable';
/** Only a reason the row cannot be, or is not, on. Absent means enabled and available. */
/** The one thing worth saying about this row — a reason it cannot be, or is not, on, or a picked
* set it can run only part of. Absent means nothing to say. One field rather than several,
* because these never stack: the view model picks which one applies, so the row cannot render
* "off" and "2 of 3 models" as if both were the news. */
status?: ProviderAgentStatus;
enabled: boolean;
}
Expand Down Expand Up @@ -314,6 +319,10 @@ function agentStatusLabel(
return t('unavailableEndpointIncomplete');
case 'unavailable-protocol':
return t('unavailableProtocol');
case 'no-reachable-model':
return t('noReachableModel');
case 'model-shortfall':
return t('modelsReachable', { picked: status.picked, reachable: status.reachable });
case 'disabled':
return t('accountDisabled');
default:
Expand Down
Loading