diff --git a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts index bbeb01544..1a855c799 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts +++ b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts @@ -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, @@ -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', diff --git a/packages/client/workbench/src/settings/providers/view.ts b/packages/client/workbench/src/settings/providers/view.ts index 7bb01a4b4..84812f695 100644 --- a/packages/client/workbench/src/settings/providers/view.ts +++ b/packages/client/workbench/src/settings/providers/view.ts @@ -1,5 +1,7 @@ +import type { AccountModelReach } from '@linkcode/providers'; import { accountEnabledFor, + accountModelReach, pinnedEndpoint, resolveBinding, serviceById, @@ -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, diff --git a/packages/foundation/providers/AGENTS.md b/packages/foundation/providers/AGENTS.md index 21b772e5a..f98d34785 100644 --- a/packages/foundation/providers/AGENTS.md +++ b/packages/foundation/providers/AGENTS.md @@ -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 diff --git a/packages/foundation/providers/src/__tests__/enabled-models.test.ts b/packages/foundation/providers/src/__tests__/enabled-models.test.ts index 9f6cc6335..f2ae7b66c 100644 --- a/packages/foundation/providers/src/__tests__/enabled-models.test.ts +++ b/packages/foundation/providers/src/__tests__/enabled-models.test.ts @@ -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 { return { @@ -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); diff --git a/packages/foundation/providers/src/enabled-models.ts b/packages/foundation/providers/src/enabled-models.ts index 0bff32059..5c968c2a1 100644 --- a/packages/foundation/providers/src/enabled-models.ts +++ b/packages/foundation/providers/src/enabled-models.ts @@ -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, diff --git a/packages/foundation/providers/src/index.ts b/packages/foundation/providers/src/index.ts index 6204a511f..ee1e6b6da 100644 --- a/packages/foundation/providers/src/index.ts +++ b/packages/foundation/providers/src/index.ts @@ -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'; diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 02a181740..44ab86677 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -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', configPreview: 'config.json snippet · what this account writes', configPreviewEmpty: '// not connected to any agent yet', remove: 'Remove account', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 68d11d116..2356dc5d1 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -1074,6 +1074,8 @@ export const zhCN = { unavailableOauth: '仅可接入 {agent}', unavailableProtocol: '端点协议与此智能体不兼容', unavailableEndpointIncomplete: '端点信息不完整,请补全账号设置', + noReachableModel: '选中的模型都不支持此智能体使用的协议', + modelsReachable: '选中的 {picked} 个模型中有 {reachable} 个可用于此智能体', configPreview: 'config.json 片段 · 此账号写入的内容', configPreviewEmpty: '// 尚未接入任何智能体', remove: '移除账号', diff --git a/packages/presentation/ui/src/shell/__tests__/account-detail.test.tsx b/packages/presentation/ui/src/shell/__tests__/account-detail.test.tsx new file mode 100644 index 000000000..b7500d93b --- /dev/null +++ b/packages/presentation/ui/src/shell/__tests__/account-detail.test.tsx @@ -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 { + 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((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( + , + ); +} + +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(); + }); +}); diff --git a/packages/presentation/ui/src/shell/providers/account-detail.tsx b/packages/presentation/ui/src/shell/providers/account-detail.tsx index aa7f08dde..ae68544e8 100644 --- a/packages/presentation/ui/src/shell/providers/account-detail.tsx +++ b/packages/presentation/ui/src/shell/providers/account-detail.tsx @@ -34,6 +34,8 @@ export type ProviderAgentStatus = | { kind: 'unavailable-oauth'; agent: AgentKind } | { kind: 'unavailable-endpoint-incomplete' } | { kind: 'unavailable-protocol' } + | { kind: 'no-reachable-model' } + | { 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. @@ -41,7 +43,10 @@ export type ProviderAgentStatus = 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; } @@ -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: