diff --git a/src/adapters/agents/kilo/index.ts b/src/adapters/agents/kilo/index.ts index 44c4960..8cdf2b7 100644 --- a/src/adapters/agents/kilo/index.ts +++ b/src/adapters/agents/kilo/index.ts @@ -19,6 +19,7 @@ import { ambientUnset, anthropicOptions, collapsedTierWarning, + compatIgnoredWarning, sessionUnavailableWarning, extendedContextWarning, modelRef, @@ -57,7 +58,7 @@ export const kilo = { }, binary, translate(input: TranslateInput): Translation { - const { intent, passthrough } = input + const { intent, passthrough, profile } = input const primary = intent.models.opus const config: Record = { @@ -82,6 +83,8 @@ export const kilo = { if (collapse) warnings.push(collapse) const ext = extendedContextWarning(intent, primary, 'Kilo') if (ext) warnings.push(ext) + const compat = compatIgnoredWarning(profile.compat, 'Kilo') + if (compat) warnings.push(compat) return { plan: { set, unset: ambientUnset(intent) }, args: [...passthrough], warnings } }, diff --git a/src/adapters/agents/opencode/index.ts b/src/adapters/agents/opencode/index.ts index 17336f0..0b9976b 100644 --- a/src/adapters/agents/opencode/index.ts +++ b/src/adapters/agents/opencode/index.ts @@ -16,6 +16,7 @@ import { ambientUnset, anthropicOptions, collapsedTierWarning, + compatIgnoredWarning, sessionUnavailableWarning, extendedContextWarning, modelRef, @@ -56,7 +57,7 @@ export const opencode = { }, binary, translate(input: TranslateInput): Translation { - const { intent, passthrough } = input + const { intent, passthrough, profile } = input const primary = intent.models.opus const small = intent.models.haiku @@ -87,6 +88,8 @@ export const opencode = { if (collapse) warnings.push(collapse) const ext = extendedContextWarning(intent, primary, 'OpenCode') if (ext) warnings.push(ext) + const compat = compatIgnoredWarning(profile.compat, 'OpenCode') + if (compat) warnings.push(compat) return { plan: { set, unset: ambientUnset(intent) }, args, warnings } }, diff --git a/src/adapters/agents/shared.ts b/src/adapters/agents/shared.ts index a41da85..593dffb 100644 --- a/src/adapters/agents/shared.ts +++ b/src/adapters/agents/shared.ts @@ -103,6 +103,35 @@ export function collapsedTierWarning( } } +/** + * Warn when a profile carries gateway compatibility flags that this agent does + * not consume. + * + * The compat mechanism is Claude-Code-only (capabilities.compatFlags). A flag + * toggled on for a Kilo or OpenCode setup changes nothing and, until this + * existed, said so NOWHERE — not at edit, not at launch, not in the doctor. + * That is the one capability mismatch with no net, and the + * capability-gap-never-silently-dropped rule the tier and session warnings + * already follow says it should have one. The flags are kept, not stripped, so + * the setup still works when it runs Claude Code again. + */ +export function compatIgnoredWarning( + compat: Record | undefined, + agentLabel: string, +): EnvWarning | null { + const active = Object.entries(compat ?? {}) + .filter(([, on]) => on) + .map(([id]) => id) + if (active.length === 0) return null + return { + severity: 'medium', + code: 'compat-ignored', + message: + `${agentLabel} does not use gateway compatibility flags; these are set but ignored: ` + + `${active.join(', ')}.`, + } +} + /** * Warn when a 1M-capable provider is reached without Claude Code's `[1m]` * signal, which only Claude Code sends. The model still runs; its window is diff --git a/test/adapters/agents/kilo.test.ts b/test/adapters/agents/kilo.test.ts index 1ddba9a..5e9eb9f 100644 --- a/test/adapters/agents/kilo.test.ts +++ b/test/adapters/agents/kilo.test.ts @@ -68,3 +68,35 @@ test('capabilities describe a single model slot', () => { assert.equal(kilo.binary.name, 'kilo') assert.equal(kilo.binary.overrideEnv, 'SWISSCODE_KILO_BIN') }) + +test('a gateway compat flag on a Kilo profile is flagged as ignored — the safety net', () => { + // The UI now hides the compat section for Kilo, but a hand-edited or + // agent-switched config can still carry a flag. Compat is Claude-Code-only, + // so Kilo warns rather than silently doing nothing — the one mismatch that + // previously had no net at edit, launch, or doctor. + const input: TranslateInput = { + intent: intent(), + profile: makeProfile({ provider: 'zai', compat: { disableAdaptiveThinking: true } }), + provider: null, + passthrough: [], + ambient: {}, + } + const w = kilo.translate(input).warnings.find((x) => x.code === 'compat-ignored') + assert.ok(w, 'Kilo should warn that a compat flag is ignored') + assert.match(w.message, /disableAdaptiveThinking/) + assert.match(w.message, /Kilo/) +}) + +test('a Kilo profile with no compat flags emits no compat-ignored warning', () => { + const input: TranslateInput = { + intent: intent(), + profile: makeProfile({ provider: 'zai' }), + provider: null, + passthrough: [], + ambient: {}, + } + assert.equal( + kilo.translate(input).warnings.find((x) => x.code === 'compat-ignored'), + undefined, + ) +}) diff --git a/web/src/routes/Accounts.tsx b/web/src/routes/Accounts.tsx index 1c2b39d..791de27 100644 --- a/web/src/routes/Accounts.tsx +++ b/web/src/routes/Accounts.tsx @@ -223,7 +223,12 @@ export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Prom const isNew = !data.state.providerAccounts?.[editing] const provider = data.providers.find((p) => p.id === draft.provider) const stored = data.state.providerAccounts?.[editing] - const mode: 'key' | 'session' = draft.configDir ? 'session' : 'key' + // Session mode belongs to the first-party Anthropic endpoint: a provider + // with no baseUrl of its own that is not the custom "ask me" one. A gateway + // (baseUrl set) or a custom endpoint cannot read a ~/.claude login. + const sessionCapable = Boolean(provider && provider.baseUrl === null && !provider.askBaseUrl) + const mode: 'key' | 'session' = + draft.configDir && sessionCapable ? 'session' : 'key' return ( <> Prom setBrowseProviderId(e.target.value)} + > + {data.providers.map((p) => ( + + ))} + + + {/* The label is not wrapped around its input the way `Field` wraps one, because the two live in different grid columns — hence the explicit htmlFor, which buys back the click target. */}
- {data.tiers.map((tier) => ( + {visibleSlots.map(({ tier, label }) => ( ))}
- {!provider ? ( - - No profile uses this setup yet, so there is no provider to browse a catalog from. - Type ids by hand, or attach it to a profile first. - + {provider && !browsable ? ( + {provider.label} publishes no browsable catalog — type ids by hand. ) : null} @@ -258,29 +331,49 @@ export function AgentProfiles({ data, reload }: { data: Bootstrap; reload: () => put('skipPermissions', v)} + // Neutral wording: every agent supports skipping prompts, but each + // lowers it differently (Claude Code's --dangerously-skip-permissions, + // OpenCode's --auto, Kilo's config permission block), so the concrete + // flag is shown only for the agent that actually receives it. label={ - <> - Skip permission prompts --dangerously-skip-permissions - + agentId === 'claude-code' ? ( + <> + Skip permission prompts --dangerously-skip-permissions + + ) : ( + <>Skip permission prompts + ) } /> - - Gateway compatibility - {/* A flag that trades something away says what it costs, here as - well as on stderr — as the checkbox's note, so the price reads - as belonging to that flag rather than competing with its name. */} - {data.compatFlags.map((flag) => ( - put('compat', { ...compat, [flag.id]: v })} - label={{flag.id}} - note={flag.consequence ? `costs: ${flag.consequence}` : undefined} - noteTone="warn" - /> - ))} - + {/* Gateway compat is a Claude-Code-only mechanism (capabilities.compatFlags). + For an agent that does not consume it, a checked box would be a + pure silent no-op — nothing catches it at launch or in the doctor — + so the section is hidden, and a note explains any flags a previous + Claude Code setup left behind rather than dropping them. */} + {supportsCompat ? ( + + Gateway compatibility + {/* A flag that trades something away says what it costs, here as + well as on stderr — as the checkbox's note, so the price reads + as belonging to that flag rather than competing with its name. */} + {data.compatFlags.map((flag) => ( + put('compat', { ...compat, [flag.id]: v })} + label={{flag.id}} + note={flag.consequence ? `costs: ${flag.consequence}` : undefined} + noteTone="warn" + /> + ))} + + ) : Object.values(compat).some(Boolean) ? ( + + {agentInfo?.label ?? 'This agent'} does not use gateway compatibility flags; the ones + set here are kept but ignored until this setup runs Claude Code again. + + ) : null} @@ -320,7 +413,21 @@ export function AgentProfiles({ data, reload }: { data: Bootstrap; reload: () => {agentProfiles.map(([name, ap]) => { const users = usersOf(name) - const pinned = data.tiers.filter((t) => ap.models?.[t]).length + // Count only the slots this agent actually reads — a Kilo setup + // with an opus id is "1 model", not "1 of 4 tiers". + const shape = data.agents.find((a) => a.id === (ap.agent ?? 'claude-code'))?.capabilities + .models + const slots = + shape === 'single' ? ['opus'] : shape === 'primary+small' ? ['opus', 'haiku'] : data.tiers + const pinned = slots.filter((t) => ap.models?.[t]).length + const modelsLabel = + shape === 'single' + ? pinned > 0 + ? '1 model' + : 'provider default' + : pinned > 0 + ? `${pinned} of ${slots.length} ${shape === 'primary+small' ? 'models' : 'tiers'} pinned` + : 'provider defaults' return ( {ap.agent ?? 'claude-code'} - - {pinned > 0 - ? `${pinned} of ${data.tiers.length} tiers pinned` - : 'provider defaults'} - + {modelsLabel} 0 ? 'default' : 'neutral'}> {users.length > 0 ? users.join(', ') : 'nothing yet'} diff --git a/web/src/routes/Profiles.tsx b/web/src/routes/Profiles.tsx index 5f27a8a..bc4f697 100644 --- a/web/src/routes/Profiles.tsx +++ b/web/src/routes/Profiles.tsx @@ -26,6 +26,7 @@ import { selectStyle, } from '../ui' import { EmptyState } from '../Brand' +import { credentialSource } from '../../../src/core/account' /** * Profiles — the pairing, and the only screen that expresses MULTIPLE accounts. @@ -229,7 +230,16 @@ export function Profiles({ data, reload }: { data: Bootstrap; reload: () => Prom {a.provider} - {a.hasKey || a.apiKeyFromEnv ? null : no key} + {/* A session account is authenticated by its login, not a + key, so "no key" is only a warning for an account that + carries no credential of any kind. `credentialSource` + reads the redacted `hasKey`, so the browser and the + server agree on what counts. */} + {credentialSource(a) === 'none' ? ( + no credential + ) : credentialSource(a) === 'conflict' ? ( + key + login + ) : null} } />