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
5 changes: 4 additions & 1 deletion src/adapters/agents/kilo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
ambientUnset,
anthropicOptions,
collapsedTierWarning,
compatIgnoredWarning,
sessionUnavailableWarning,
extendedContextWarning,
modelRef,
Expand Down Expand Up @@ -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<string, unknown> = {
Expand All @@ -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 }
},
Expand Down
5 changes: 4 additions & 1 deletion src/adapters/agents/opencode/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
ambientUnset,
anthropicOptions,
collapsedTierWarning,
compatIgnoredWarning,
sessionUnavailableWarning,
extendedContextWarning,
modelRef,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 }
},
Expand Down
29 changes: 29 additions & 0 deletions src/adapters/agents/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean | undefined> | 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
Expand Down
32 changes: 32 additions & 0 deletions test/adapters/agents/kilo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
})
53 changes: 41 additions & 12 deletions web/src/routes/Accounts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<PageHeader
Expand All @@ -250,7 +255,20 @@ export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Prom
<select
className={selectStyle}
value={String(draft.provider ?? '')}
onChange={(e) => put('provider', e.target.value)}
onChange={(e) => {
const nextId = e.target.value
const next = data.providers.find((p) => p.id === nextId)
const nextSessionCapable = Boolean(next && next.baseUrl === null && !next.askBaseUrl)
setDraft((d) => ({
...d,
provider: nextId,
// A session login belongs to the first-party Anthropic
// endpoint; switching to a gateway must not leave a stale
// configDir that would send the ~/.claude token to a foreign
// host. Drop it, reverting the account to key mode.
...(nextSessionCapable ? {} : { configDir: '' }),
}))
}}
>
{data.providers.map((p) => (
<option key={p.id} value={p.id}>
Expand Down Expand Up @@ -292,16 +310,27 @@ export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Prom
naming nothing.
*/}
<SectionLabel id={MODE_LABEL_ID}>How this account authenticates</SectionLabel>
<SegmentedControl
labelledBy={MODE_LABEL_ID}
options={CREDENTIAL_MODES}
value={mode}
onChange={(id) => put('configDir', id === 'session' ? '~/.claude' : '')}
/>
<Note>
A key, or a login the agent already performed. Never both — the server refuses that
rather than picking one.
</Note>
{sessionCapable ? (
<>
<SegmentedControl
labelledBy={MODE_LABEL_ID}
options={CREDENTIAL_MODES}
value={mode}
onChange={(id) => put('configDir', id === 'session' ? '~/.claude' : '')}
/>
<Note>
A key, or a login the agent already performed. Never both — the server refuses
that rather than picking one.
</Note>
</>
) : (
// Session mode is a subscription login held in ~/.claude, which
// only the first-party Anthropic endpoint reads. A gateway or a
// custom endpoint authenticates with a key, so the choice is not
// offered — it could only build an account that ships the wrong
// token to the wrong host.
<Note>{provider?.label ?? 'This provider'} authenticates with an API key.</Note>
)}
</Stack>

{/*
Expand Down
Loading