62 Commits
Author SHA1 Message Date
ZacharyZhang-NY ff0fb56c67 release: v0.1.8
Release / build (aarch64-apple-darwin) (push) Waiting to run
Release / build (x86_64-apple-darwin) (push) Waiting to run
Release / build (aarch64-unknown-linux-gnu) (push) Waiting to run
Release / build (x86_64-pc-windows-msvc) (push) Waiting to run
Release / publish GitHub Release (push) Blocked by required conditions
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 8s
Since v0.1.7 — chat-completions dialect correctness (Pi-referenced):
- fix(chat): BYOK/custom endpoints default to Passthrough (vanilla
  OpenAI semantics; Kimi body mutations no longer leak to third-party
  servers); house/Kimi coding endpoints keep the Kimi dialect via
  base-url detection
- fix(chat): dedicated Mistral dialect — exactly-nine-alphanumeric
  tool-call id normalizer (deterministic, call/result-symmetric)
- fix(chat): tool-result images relocate to a batched user message
  (tool messages are text-only on the OpenAI chat wire)
- docs: AGENTS.md records the dialect-selection contract
2026-07-23 10:39:18 -04:00
ZacharyZhang-NY 2524c33a5b fix(chat): relocate tool-result images to a batched user message
C3 (Pi openai-completions relocation): tool messages are text-only on
the OpenAI chat wire — image parts inside role:tool content 400 on
strict validators. Images from a consecutive tool-result run now batch
into ONE synthetic user message appended after the run (a user message
may not interrupt tool responses answering the same assistant's
tool_calls); an image-only result carries a '(see attached image)'
pointer placeholder. Applied at the typed conversion layer, so every
chat dialect gets the universally-valid shape.

Verified: models+sampling-types+sampler+chat-state+shell all green,
clippy clean.
2026-07-23 10:38:37 -04:00
ZacharyZhang-NY 301eb61de4 fix(chat): BYOK dialect defaults to Passthrough; dedicated Mistral dialect
C1 (decision): custom/BYOK ChatCompletions entries defaulted to the Kimi
dialect, leaking Kimi-specific body mutations (thinking:{…} control,
replayed reasoning_content, schema rewrites) to arbitrary third-party
OpenAI-compatible servers. The default is now Passthrough (vanilla
OpenAI semantics), with ONE exception mirroring Pi's base-url quirk
sniffing: entries pointed at the house/Kimi coding endpoint keep the
Kimi dialect. Registry platforms are unaffected (all declare
explicitly).

C2 (Pi mistral-conversations normalizer): Mistral requires tool-call ids
of EXACTLY nine [a-zA-Z0-9] chars; even same-session synthesized UUIDs
violate it. New ChatCompat::Mistral = StrictOpenAi behavior + the
normalizer — strip non-alphanumerics, keep exact-9 ids, else FNV-1a →
base36 (build-stable, deterministic across requests for prefix-cache
stability) with collision retry; ONE map covers tool_calls[].id and
tool_call_id so pairing survives. The mistral registry row and the
persisted 'mistral' serde value both resolve to it (pre-rename Mistral
sessions gain the contract automatically); Cerebras/NVIDIA stay on
StrictOpenAi untouched.

Verified: models+sampling-types+sampler+chat-state+shell all green,
clippy clean.
2026-07-23 10:31:45 -04:00
ZacharyZhang-NY d3c9380307 release: v0.1.7
Release / build (aarch64-apple-darwin) (push) Waiting to run
Release / build (x86_64-apple-darwin) (push) Waiting to run
Release / build (aarch64-unknown-linux-gnu) (push) Waiting to run
Release / build (x86_64-pc-windows-msvc) (push) Waiting to run
Release / publish GitHub Release (push) Blocked by required conditions
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 7s
Since v0.1.6 — the cross-provider replay audit (Pi transform-messages
policy: every wire builder emits only items valid for its target):
- fix(responses): reasoning items without a native rs_* id dropped (the
  GPT/codex 400 Invalid 'input[N].id'); provenance gate drops foreign
  reasoning and demotes foreign backend tool calls on model switches
- fix(codex): bare rs_* reasoning references dropped (stateless backend)
- fix(wire): shared ASCII tool-call id sanitizer, symmetric call+result
  on both the Messages and Responses legs
- fix(messages): image-source whitelist (raster base64 only, http(s)
  urls only) and empty-user-content guard
- fix(conversation): char-boundary-safe code preview (CJK/emoji code
  panicked every request build)
- docs: AGENTS.md records the replay policy
2026-07-23 01:16:55 -04:00
ZacharyZhang-NY c950f8087c fix(messages): whitelist image sources and guard empty user content
Cross-provider audit M4/M5/M6 (Anthropic Messages builder):
- Non-base64 data: URIs rode as ImageSource::Url — url sources are
  http(s) only → 400; and the two image paths parsed data URIs
  differently (user path split on first comma, tool-result path on
  ';base64,').
- No media-type whitelist: image/svg+xml and param-carrying headers
  ('image/webp;name=x') reached the wire → 400.
- Empty user content shipped empty arrays/text blocks → 400.

One shared parse_base64_image_data_uri (raster whitelist: jpeg/png/gif/
webp) now serves both paths; rejected images degrade to a SHORT
'[unsupported image]' placeholder (never the multi-megabyte payload);
empty user turns get '[empty message]' (mirrors the assistant guard).

Part 6 of the cross-provider replay audit.

Verified: sampling-types 292 + downstream green, clippy clean.
2026-07-23 01:16:03 -04:00
ZacharyZhang-NY 40c71a8343 fix(responses): provenance-gate foreign turns (Pi transform-messages)
Cross-provider audit R5 + R2-residual: BackendToolCall items round-trip
as their typed shapes with provider-issued ids (grok x_search
CustomToolCall, web_search/code_interpreter calls) and Reasoning items
carry model-bound encrypted payloads — replaying either to a DIFFERENT
Responses target names undeclared tools / undecryptable material → 400.

New transform_items_for_responses pre-pass: each [Reasoning|
BackendToolCall]* Assistant run carries provenance in
AssistantItem.model_id; on confirmed mismatch with the request's target
model, Reasoning siblings are dropped and BackendToolCall demoted to the
same text summary the Messages/ChatCompletions builders already emit.
Same-model and unknown-provenance turns stay byte-verbatim (KV-cache
prefix stability preserved). The legacy-upgrade round-trip test now
models the same-model continuation it always described.

Part 5 of the cross-provider replay audit.

Verified: sampling-types 290 + downstream 5794 green, clippy clean.
2026-07-23 01:10:51 -04:00
ZacharyZhang-NY a3e3973453 docs(tests): restore doc comments displaced by the summary-test insertion
The new truncation test's doc landed between backend_tool_call_position_
stable's doc and its #[test] attribute (clippy: duplicated attribute).
Each test owns its own doc block again.
2026-07-23 01:02:19 -04:00
ZacharyZhang-NY 9cdc0ccfa3 fix(wire): shared ASCII tool-call id sanitizer, symmetric on both legs
Cross-provider audit R4+M3: the Responses leg passed tool-call ids
verbatim (call_id on both function_call and function_call_output) while
the Messages leg sanitized — and its closure used Unicode
is_alphanumeric, letting CJK ids through to Anthropic's ASCII-only
contract, with no empty-id fallback. Both providers enforce
[A-Za-z0-9_-]+ (the codex 400's own words). One module-scope
sanitize_tool_call_id now serves both builders, ASCII-only, empty → "_",
applied identically on call+result so pairing survives.

Part 4 of the cross-provider replay audit.

Verified: 6081 tests green across the four crates, clippy clean.
2026-07-23 01:00:56 -04:00
ZacharyZhang-NY 6f9f550308 fix(codex): drop bare rs_* reasoning references — stateless backend
Cross-provider audit R3: reasoning captured on stateful api.openai.com
sessions (no include requested) carries a server-issued rs_* id but NO
encrypted_content; replaying that bare reference to the stateless
(store:false) codex backend points at server state chatgpt.com does not
have. adapt_body_for_codex_backend step 3 drops such items (encrypted
ones pass through verbatim). Capture-side include for the API-key path
is deferred: the typed CreateResponse is shared with the xai Responses
leg and changing its bytes needs separate validation.

Part 3 of the cross-provider replay audit.

Verified: sampling-types+sampler+chat-state+shell all green.
2026-07-23 00:55:23 -04:00
ZacharyZhang-NY 2b43f54669 test(conversation): fix code-preview fixture to actually exceed the char cap
The multibyte-truncation test used 80 chars — below the 100-char cap, so
the truncation assertion failed (the previous commit's suite count was
misread; the FIX itself was correct and the panic repro held). 120 chars
now exercises both the boundary safety and the truncation.
2026-07-23 00:51:04 -04:00
ZacharyZhang-NY 6979407f22 fix(conversation): char-boundary-safe code preview in text_summary
The code-interpreter preview truncated at BYTE 100 — a guaranteed panic
on any CJK/emoji boundary in interpreted code. One poisoned history item
then crashed every subsequent request build on every backend (the
summary feeds both the Messages and ChatCompletions builders). Truncate
at 100 chars via char_indices instead. Panic pinned by test.

Part 2 of the cross-provider replay audit.

Verified: sampling-types 287 tests green.
2026-07-23 00:50:29 -04:00
ZacharyZhang-NY 1fa87566d9 fix(responses): drop reasoning items without a native rs_* id
Root cause of 400 Invalid 'input[N].id': '' on chatgpt.com/backend-api/
codex/responses: the Responses input builder replayed every stored
Reasoning item verbatim, and rs::ReasoningItem.id serializes
unconditionally — so foreign items (Messages-captured Anthropic
signatures, chat-completions-synthesized reasoning, stream-delta
fallbacks, legacy upgrades — all id '') reached the wire with an empty
id the API rejects. This also self-poisoned pure codex sessions whose
reasoning arrived only as deltas.

A native item always carries a server-issued rs_* id: empty id = foreign
= unusable by any Responses provider = dropped at the builder — the
exact mirror of the Messages builder's prune_replayed_thinking. The
encrypted-only fixture that pinned the poison shape now uses a native id
(the pass-through case it always meant to cover).

Part 1 of the cross-provider replay audit (Pi transform-messages
policy: builders emit only items valid for their target).

Verified: sampling-types 286 + sampler/chat-state/shell 5791 green.
2026-07-23 00:49:18 -04:00
ZacharyZhang-NY d6e49bcc7d release: v0.1.6
Release / build (aarch64-apple-darwin) (push) Waiting to run
Release / build (x86_64-apple-darwin) (push) Waiting to run
Release / build (aarch64-unknown-linux-gnu) (push) Waiting to run
Release / build (x86_64-pc-windows-msvc) (push) Waiting to run
Release / publish GitHub Release (push) Blocked by required conditions
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 8s
Since v0.1.5:
- fix(messages): Claude models no longer 400 with 'Invalid signature in
  thinking block' — thinking is replayed only for the active tool loop
  and only when genuinely signed (cross-backend/cross-model histories
  are stripped)
- fix(codex): ChatGPT Codex no longer 400s with 'System messages are
  not allowed' — system prompts ride the instructions field and
  encrypted reasoning is requested for stateless replay
- docs: AGENTS.md records both wire contracts
2026-07-22 23:36:22 -04:00
ZacharyZhang-NY d2358b037c fix(codex): adapt the Responses body to the ChatGPT/Codex backend contract
Root cause of 400 {"detail":"System messages are not allowed"} at
chatgpt.com/backend-api/codex/responses (both platforms): the codex
adaptation covered only IDENTITY HEADERS (originator/OpenAI-Beta/UA/
chatgpt-account-id) — the BODY still carried the system prompt as
role:system input items, which the codex backend rejects outright. Its
system channel is the top-level  field, and stateless
(store:false) reasoning replay requires
include:["reasoning.encrypted_content"] — both per the same reference
the headers were ported from (official Codex CLI + Pi's
api/openai-codex-responses.ts).

New adapt_body_for_codex_backend (kigi-sampling-types): hoists every
system input item into  (order preserved, appended to any
existing instructions; string and parts content shapes) and requests
encrypted reasoning. Idempotent. Applied at both Responses send paths,
openai_codex-GATED — the API-key  path stays byte-identical
(pinned by a control wire test).

Tests: adapter unit tests (hoist+include, no-system no-op, idempotence)
plus two mock-server wire tests (codex body has no system role,
instructions + include present; plain Responses body unchanged).

Verified: sampling-types + sampler + chat-state + shell 6076 tests
green, clippy clean.
2026-07-22 23:35:31 -04:00
ZacharyZhang-NY 6ba24db019 fix(messages): replay thinking blocks only for the active tool loop
Root cause of 'Invalid signature in thinking block' (400 at
messages.1.content.0, Claude models): build_messages_request replayed
EVERY stored Reasoning item as a thinking block with no origin check —
history synthesized by other backends (encrypted_content: None → the
mandatory signature field serialized as ""), Responses-API tco_* blobs
(signature bytes, no text), and blocks signed by a DIFFERENT model after
a mid-session /model switch. Anthropic validates every replayed
signature (model-bound), so such histories 400 deterministically. The
platform split was circumstantial: Windows sessions started on the
default model and switched to Claude; macOS sessions were Claude-native
from turn 1. Adversarially verified — no platform-divergent byte path
exists in capture, storage, or replay.

New prune_replayed_thinking pass (Pi/Claude Code replay policy): keep
exactly the final assistant message's thinking when its tool loop is
still open (request ends on the tool results — an open loop can never
span a model switch) and the block is genuinely signed; strip every
other thinking block (the API ignores valid prior-turn thinking and
rejects invalid). Assistant messages emptied by the strip (thinking-only
aborted turns) are removed — empty content arrays are rejected too.

Tests: three unit tests pin strip-outside-loop (unsigned, tco_*, stale
signed), keep-in-active-loop (verbatim text+signature at content.0), and
emptied-message removal; the legacy-upgrade integration test now proves
both wire fidelity in the active loop AND stripping once the loop
closes.

Verified: sampling-types + sampler + chat-state + shell 6072 tests
green, clippy clean.
2026-07-22 23:26:49 -04:00
ZacharyZhang-NY 10149f50dd install: stop persisting KIGI_GRAPH — the binary default is the product default
The README always shipped graph engineering enabled; the enablement was
delegated to installer env plumbing that diverged per platform:
install.sh exported KIGI_GRAPH=1 into shell rc (worked), install.ps1
wrote the User registry variable — which running Windows terminals (and
new tabs of an open Windows Terminal) never pick up, so /graph was
'missing on Windows' despite a successful install.

With resolve_graph() defaulting true in the binary (e53a66d), the env
writes are redundant complexity: drop them from both installers, keep
KIGI_GRAPH=0 as the documented opt-out (env still beats the default),
and correct the flag comment to tell this story instead of a
'gray release' one. Both scripts syntax-checked (sh -n / pwsh parser).

Installers are served from main (raw.githubusercontent), so this takes
effect for all new installs immediately — no retag needed; the running
v0.1.5 build already carries the binary-default fix.
2026-07-22 21:22:16 -04:00
ZacharyZhang-NY e53a66d113 feat(graph): /graph ships on by default — end the KIGI_GRAPH gray release
Release / build (aarch64-apple-darwin) (push) Waiting to run
Release / build (x86_64-apple-darwin) (push) Waiting to run
Release / build (aarch64-unknown-linux-gnu) (push) Waiting to run
Release / build (x86_64-pc-windows-msvc) (push) Waiting to run
Release / publish GitHub Release (push) Blocked by required conditions
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 7s
resolve_graph() read only the KIGI_GRAPH env var with default(false)
(plan.md G0 gate), so /graph existed solely on machines whose environment
exported the dev flag — which presented as '/graph is missing on
Windows'. There was never any platform-conditional code: the Mac worked
because the dev env var was set there.

Default is now true (matching /goal's shipped state); KIGI_GRAPH=0
remains the off-switch, and availability still requires the goal harness
(BuiltinGate::Graph). AGENTS.md updated; new test pins fresh-install-on
plus env-zero-off.

Verified: kigi-shell 5262 + kigi-tui 6874 tests green, clippy clean.
2026-07-22 21:16:49 -04:00
ZacharyZhang-NY f6eaafa3da build(kigi-shell): retry transient ripgrep download failures with backoff
A single HTTP 502 from the GitHub release CDN killed the entire v0.1.5
tag build (one job of five, ~50 minutes wasted). The build-script
download now retries twice with backoff on 5xx/429/network errors;
genuine failures (404, offline) still fail fast with the
KIGI_SHELL_BUNDLE_RG_PATH hint, and every retry prints a cargo:warning
so flakiness stays observable.
2026-07-22 20:48:13 -04:00
ZacharyZhang-NY 13ccab980c ci: warm a main-branch build cache so release tag builds aren't cold
GitHub Actions cache isolation lets a run restore caches only from its own
ref or the default branch. Releases run on tag refs and nothing ever ran
on main, so release.yml's rust-cache never restored anything — every
release compiled all 62 crates cold on five targets (~50 min, gated by
Windows at ~49.5 min).

warm-cache.yml builds the same release-dist profile with the same
rust-cache key on main (dep-affecting pushes — including each release's
version-bump commit — plus a weekly refresh against 7-day eviction and
manual dispatch). Tag builds then restore a warm default-branch cache;
the remaining cost is workspace-crate compilation + the codegen-units=1
thin-LTO link, which is the deliberate release-hardening tradeoff.
2026-07-22 19:59:00 -04:00
ZacharyZhang-NY b9b7e6c989 release: v0.1.5
Since v0.1.4:
- fix(fs): Windows-safe atomic replace everywhere (util::fs::replace_file)
  — model+effort switches now persist on Windows; models cache and session
  state writes no longer fail silently under AV/indexer file locks
- fix(tui): a default-model persist failure keeps the live session's model
  instead of reverting the pick
- docs: AGENTS.md records the replace_file contract
2026-07-22 19:55:30 -04:00
ZacharyZhang-NY f403c38a94 fix(tui): a default-model persist failure no longer reverts the live model
Persist-failure ≠ switch-failure. The default_model rollback arm re-ran
set_default_model_inner(prev) AND issued a reverse SwitchModel whenever
the config.toml write failed — the only mechanism in the codebase that
deliberately re-shows the ORIGINAL model after a successful pick. On
Windows, where AV/indexer file locks routinely failed that write (until
27d009c), every /model selection appeared to not take.

The session switch succeeds independently and reports its own failures
via handle_switch_model_complete; a disk-persist failure now keeps the
live model, logs, and surfaces only the 'Could not save' toast — the
same policy PersistPreferredModel already ships ('still active for this
session').

Verified: kigi-tui 6874 tests green, clippy clean.
2026-07-22 19:55:02 -04:00
ZacharyZhang-NY 27d009cb6e fix(fs): Windows-safe atomic replace everywhere — model switch now sticks
Root cause of 'model+effort switch works on Mac, not on Windows': the
switch APPLIES in-session (the dispatch/apply chain is platform-identical,
verified adversarially) but its persistence never sticks on Windows.
Every tmp+rename atomic write except auth/storage.rs committed with a
bare fs::rename, and Windows MoveFileExW(REPLACE_EXISTING) fails with a
sharing violation whenever AV/search-indexer/cloud-sync transiently holds
the destination open. Consequences: [models].default never persisted
(next launch = original model), the session summary's current model never
persisted (resume = original model), and the models cache went silently
stale (all its write errors were swallowed).

- New kigi_shell_base::util::fs::replace_file — THE commit step for
  tmp+rename: plain rename on Unix; on Windows delete-first + two short
  backoffs (the pattern auth/storage.rs shipped first), tmp cleaned on
  failure, error always returned. Windows branch type-checked against
  x86_64-pc-windows-msvc.
- Adopted at every replace site: config.toml (save_config /
  atomic_write_string / mcp saves), models cache (plus unique tmp
  suffixes and tracing::warn on failure — writes were fully silent),
  session storage (summary/current-model, jsonl, plan/signals/
  announcement/goal/graph state), auth.json, active-sessions registry,
  prompt history, claude/kimi import, campaigns state, goal artifacts.
  Directory-move renames (worktree pool, corrupt-file backups) keep
  plain rename — their destinations don't pre-exist.

Verified: kigi-shell + kigi-shell-base 5318 tests green, clippy clean,
msvc-target check of the new cfg(windows) code clean.
2026-07-22 19:42:13 -04:00
ZacharyZhang-NY 815bd99356 docs(agents): stop embedding the workspace version literal
'(0.1.0)' had rotted three releases behind. Name the source of truth
(workspace.package.version) instead of snapshotting its value.
2026-07-22 17:54:51 -04:00
ZacharyZhang-NY 5189bc5e28 release: v0.1.4
Release / build (x86_64-apple-darwin) (push) Waiting to run
Release / build (aarch64-apple-darwin) (push) Waiting to run
Release / build (aarch64-unknown-linux-gnu) (push) Waiting to run
Release / build (x86_64-pc-windows-msvc) (push) Waiting to run
Release / publish GitHub Release (push) Blocked by required conditions
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 7s
Since v0.1.3:
- refactor(auth): CredentialAuthority — one chokepoint for inference
  credential routing; SessionCredential is unforgeable outside it
- fix(tui): login picker scrolls in a capped viewport; moon never clips
- fix(tui): each OAuth picker row routes to its own provider (LoginWith),
  no more every-row-opens-kimi.com
- feat(login): /login opens the provider picker with green connected
  badges; mid-session picker cancels back to the session
- fix(models): stored subscription-OAuth sessions are catalog fetch
  sources — a claude-only login gets claude models and a claude default,
  not the bundled Kimi table with an 'unknown' model
- feat(tui): /model + Enter opens the model picker (required-args
  commands block-and-reopen); rows name their connected provider
2026-07-22 17:51:12 -04:00
ZacharyZhang-NY 0e4356c34e docs(agents): stored subscription-OAuth sessions are catalog fetch sources 2026-07-22 17:38:44 -04:00
ZacharyZhang-NY 63ff114f71 feat(model-picker): each row names its connected provider
The shell stamps meta.provider (platform display name via
parse_managed_model_key) on every managed {platform}/{model} catalog
entry in to_acp_model_info; user-defined [model.*] entries stay
provider-less. The /model dropdown surfaces it in the description column
(a model's own description still wins), so the picker reads as 'choose a
model from your connected providers'.

Verified: kigi-shell 5261 + kigi-tui 6873 tests green, clippy clean.
2026-07-22 17:38:05 -04:00
ZacharyZhang-NY 66d5194ee3 feat(tui): /model + Enter opens the model picker instead of erroring
A required-args slash command submitted bare (/model, or /m) ran straight
into its usage error. The completeness contract already existed —
is_command_complete's documented 'Blocks' row — but had zero non-test
callers; both dispatch paths ran the command unconditionally.

Both the session prompt path and the dashboard dispatch box now consult
is_command_complete before running: an incomplete required-args command
re-opens the input as '/{command} ' with the cursor in the args phase, so
the existing suggestion dropdown lists the choices (for /model: the
catalog — by construction the connected providers' models, after
1bb10ef). Generalizes to every future required-args command; no new UI.

Verified: kigi-tui 6872 tests green, clippy clean.
2026-07-22 17:28:58 -04:00
ZacharyZhang-NY 1bb10ef7f1 fix(models): subscription-OAuth sessions are a fetch source for the catalog
Root cause of 'connected Claude, still shows Kimi / unknown model': every
fetch-plan decision consulted only the primary (Kimi) session and API keys
— never the stored subscription-OAuth sessions:

- on_auth_changed's wipe guard: a claude-pro-max-only login satisfied
  'no session, no keys' → catalog wiped, early return BEFORE the fetch
  and BEFORE notify_models_updated. The TUI kept an empty picker and the
  prompt bar rendered 'unknown'. Guard decision extracted into the pure
  should_wipe_catalog_on_auth_change (matrix-tested); a stored OAuth
  session now vetoes the wipe, so the fetch runs, the first real catalog
  reselects the default model (first entry = the connected provider's),
  and kigi/models/update reaches the client.
- Startup prefetch: the arming gate ignored stored OAuth sessions and the
  prefetch thread passed an empty token map — a claude-only user booted
  onto the bundled Kimi table until a later refresh. The gate now takes
  has_stored_oauth and the thread resolves each stored session's bearer
  (refresh-on-expiry) via a current-thread runtime.
- Cache origins: from_config's startup cache load and cache_origin()
  computed the fetch-plan origin with an empty token map, so a
  claude-inclusive cached catalog never matched at startup. Both now use
  presence-only stubs (stored_oauth_token_stubs — names only, no bearers)
  proven equal to the real-token origin by test.

New probes in models_fetch: stored_oauth_platforms / stored_oauth_token_
stubs (sync auth.json scope scan; no AuthManager, no secrets).

Verified: kigi-shell 5260 tests green, clippy clean.
2026-07-22 17:16:38 -04:00
ZacharyZhang-NY 0e352c9914 docs(agents): record per-row OAuth routing, /login picker, and _meta.connected contract 2026-07-22 16:16:59 -04:00
ZacharyZhang-NY fc7c2a1b9b feat(login): /login opens the provider picker with green connected badges
/login previously fired the resolved method's flow immediately — there was
no way to see providers or their status. It now lands on the provider
picker; already-connected providers show a green 'connected' badge in the
key column.

Shell: initialize() probes stored credentials once (primary session flag,
auth.json oauth/<provider> scopes, resolved platform keys env>auth.json>
config) and stamps _meta.connected on each advertised method
(connected_method_ids + stamp_connected_meta, pure and unit-tested).
Display state only — never an authorization input.

TUI:
- PendingMenuItem::connected() reads the badge from method meta; the
  picker renders it green (accent_success), replacing the shortcut hint.
- New Action::OpenLoginPicker: /login shows the picker; mid-session it
  stashes the view like dispatch_login, and starts no flow by itself.
- Mid-session picker: last row reads 'Cancel' and dispatches CancelLogin
  (clicking it must not exit the app); Esc also returns to the session.
- After a successful login, the just-authenticated method is stamped
  connected in the TUI's advertised-methods copy (auth_in_flight_method →
  AuthComplete), so a later /login shows the badge without re-initialize.

Verified: kigi-shell 5256 + kigi-tui 6870 tests green, clippy clean.
2026-07-22 16:14:42 -04:00
ZacharyZhang-NY 1e57cd9225 fix(tui): route each OAuth picker row to its own provider, not kimi.com
Root cause: PendingMenuItem::Login carried only a label, so every provider
row collapsed to the id-less Action::Login, and dispatch_login resolved
the FIRST advertised interactive method — the Kimi device flow. Selecting
Grok/Claude/Copilot/Codex all opened kimi.com. (The shell side was already
correct: authenticate() dispatches each method id to its own OAuth flow.)

- PendingMenuItem::Login now carries the advertised method id (None only
  on the no-interactive-method fallback row).
- New Action::LoginWith(AuthMethodId); the picker dispatches it with the
  row's own id. Action::Login keeps its meaning (resolved/default method)
  for /login, auto-login, and re-auth.
- dispatch_login_with resolves the id against the advertised methods and
  FAILS CLOSED on an unknown id — no silent first-method fallback — then
  adopts the method's label and start mode.

Tests: picker rows pinned to their method ids; LoginWith(claude-pro-max)
must authenticate with claude-pro-max even when kimi-code was previously
resolved; unknown ids surface an error and start nothing.

Verified: kigi-tui 6867 tests green, clippy clean.
2026-07-22 15:51:46 -04:00
ZacharyZhang-NY c2d1067f0d fix(tui): login picker scrolls in a capped viewport; the moon never clips
Root cause (issues: unscrollable list + half-blocked moon): the stacked
welcome layout gave the menu one Length row per item. With ~30 advertised
providers the vertical layout overflowed and the constraint solver
squeezed every Length — clipping the logo — while rows past the fold were
silently unreachable.

- WelcomeLayout::compute_inner caps the menu to the rows genuinely left
  over after logo/prompt/version, so the chrome never gets squeezed.
- render_menu is now a minimal-scroll viewport: it scrolls only when the
  selection exits it (stable under hover — no re-centering feedback
  loop), draws the picker-style scrollbar, and returns index-aligned hit
  rects (zero Rect for off-screen rows) plus the offset, which AppView
  feeds back next frame.
- Mouse wheel moves the login-picker selection (clamped, not wrapping).

Test: pending_menu_scrolls_and_never_clips_the_moon renders the full
method list at 40 rows and asserts the whole moon (10 braille rows), the
version badge, and that selecting Quit scrolls it into view.

Verified: kigi-tui 6864 tests green, clippy clean.
2026-07-22 15:31:18 -04:00
ZacharyZhang-NY 48d89c7830 refactor(auth): centralize inference-credential routing in CredentialAuthority
One authority answers 'which credential may ride this request':
credential_class / manager_for / credential_for / bearer_resolver_for,
keyed by (platform, base_url). SessionCredential is an opaque type with
no production constructor, so a new call site cannot re-introduce the
session-bearer leak. Platform-scoped tests extended across all bearer
channels (session, aux, summary, subagent override).

Verified: cargo check --workspace --all-targets clean; kigi-shell and
kigi-tui suites green (6611+ tests).
2026-07-22 15:12:00 -04:00
ZacharyZhang-NY 2d00a4e6e6 update 2026-07-22 11:08:54 -04:00
ZacharyZhang-NY 422e241e13 feat(providers): add ChatGPT/Codex subscription OAuth (PKCE + account-id, hardcoded catalog)
29th platform `openai-codex` (uses_oauth, Responses wire). PKCE-localhost login at
auth.openai.com (client app_EMoamEEZ73f0CkXaXp7hrann, redirect localhost:1455/auth/
callback, form token exchange, fresh-random state) reusing the claude-pro-max flow;
OAuthFlow::PkceLocalhost gained a redirect_path and OAuthConfig an authorize_extra
(empty elsewhere, so claude/xai/copilot authorize URLs stay byte-identical).

Codex-specific: the access token is a JWT carrying chatgpt_account_id, which becomes
the `chatgpt-account-id` inference header. It is derived STATELESSLY from whichever
bearer rides each request (so a rotated token needs no persisted field), and BOTH
login and refresh fail fast when the claim is absent — gated on the explicit
OAuthConfig.requires_chatgpt_account_id fact, never inferred from the token-body
encoding (a plain form endpoint is the OAuth norm and must not inherit this).

Inference rides the existing Responses wire at chatgpt.com/backend-api/codex →
/responses, with codex headers (chatgpt-account-id, originator, OpenAI-Beta
responses=experimental, codex UA) gated on SamplerConfig.openai_codex so API-key
`openai` stays byte-identical; store:false was already the global Responses default.

Catalog is HARDCODED (no live endpoint exists for this backend; read from the
official Codex CLI's model cache): gpt-5.6-sol/terra/luna + gpt-5.5, ctx 272000,
each with its real reasoning levels (low..ultra — ReasoningEffort gained Ultra).
Excluded: gpt-5.3-codex-spark (supported_in_api=false), gpt-5.4/-mini and
codex-auto-review (hidden) — they would list but fail at inference. The fetch
short-circuits before any HTTP; Kigi never shells out to the codex CLI or reads
~/.codex.

Security review fixes: redact any `account-id` header from request logs (it was
reaching debug logs), strict 3-segment JWT check (fail closed), refresh no longer
fails open on a missing claim. Inherits leak-safe pooled routing (scope
oauth/openai-codex) — never the Kimi token. Full gate green (234 suites, 0 warnings).
2026-07-22 08:28:03 -04:00
ZacharyZhang-NY 8179438278 feat(providers): add GitHub Copilot subscription OAuth (device flow + copilot-token re-mint)
28th platform `github-copilot` (uses_oauth, ChatCompletions wire). Two-stage auth:
RFC-8628 GitHub device flow (client Iv1.b507a08c87ecfe98, scope read:user, errors
in a 200 body) mints the DURABLE github token; a GET api.github.com/copilot_internal/
v2/token exchange re-mints the SHORT-LIVED copilot token. Persisted as key=copilot
token, refresh_token=github token, expires_at=copilot expiry; the "refresh" is a
copilot-token re-mint (not a refresh_token grant), dispatched via
OAuthTokenBody::GithubCopilotExchange in the generic refresher.

VS Code editor-identity headers on /models + /chat/completions, gated on
SamplerConfig.github_copilot / PlatformId::sends_copilot_editor_headers() so every
other ChatCompletions provider stays byte-identical. Live /models filtered
(parse_github_copilot_listing) to the openai-completions-served models: keep iff
model_picker_enabled && policy.state!="disabled" && tool_calls!=false AND not a
claude-4.x/5.x (messages) or gpt-5/oswe/mai- (responses-only) id — those need
per-model wire routing (documented debt), excluded rather than mis-routed.

Inherits the leak-safe pooled routing (scope oauth/github-copilot); its bearer/
refresh/api_key never touch the Kimi token (regression test added). Fail-fast on
an out-of-range copilot expires_at (would otherwise silently 401 mid-session).
Adversarial security review: GO, no CRITICAL/HIGH. Known limitation: Pi's
per-model policy-enablement POST is not ported (documented in AGENTS.md).
2026-07-22 04:21:02 -04:00
ZacharyZhang-NY 5a9183b08b feat(providers): add Claude Pro/Max subscription OAuth (PKCE-localhost)
27th registry variant, 2nd subscription-OAuth provider. Log in with a Claude
Pro/Max subscription via PKCE authorization-code + S256 (loopback callback on
127.0.0.1:53692, with a manual code-paste fallback), then use it against
api.anthropic.com — reusing the existing Anthropic Messages wire + Anthropic
listing + the multi-provider OAuth foundation (dbce6bf). Sourced from Pi
(earendil-works/pi auth/oauth/anthropic.ts): client 9d1c250a..., authorize
claude.ai/oauth/authorize, token platform.claude.com/v1/oauth/token, scope
'…user:inference user:sessions:claude_code…'.

New machinery (foundation handles token routing — claude-pro-max is a
uses_oauth platform so its bearer/refresh/api_key already route to its own
pooled manager, never Kimi):
- OAuthConfig gains flow{DeviceCode|PkceLocalhost} + token_host + token_body
  {Form|JSON}; xai/kimi rows unchanged (DeviceCode/Form).
- auth/oauth_pkce.rs: PKCE S256 wire — loopback listener with STRICT state
  validation (CSRF, fail-closed), manual-paste fallback, JSON code→token
  exchange + rotating-refresh. Never logs code/verifier/tokens.
- Messages OAuth adaptation gated on SamplerConfig.anthropic_oauth (true only
  for a claude-pro-max managed key): Authorization: Bearer + anthropic-beta
  oauth + user-agent claude-cli + x-app cli, and the required 'You are Claude
  Code' system prefix. API-key anthropic/minimax Messages requests are
  BYTE-IDENTICAL (regression-guarded).
- Live /models under the OAuth Bearer + oauth-beta headers (Anthropic listing,
  enriched from models.dev anthropic); persistent 401 → 0 models + WARN, NO
  hardcoded fallback list (honest failure).

Adversarial review: no blocking findings (secret handling, CSRF/state, the
anthropic_oauth gate, token routing, non-regression all CONFIRMED). Full gate
green. Registry at 27; picker updated. Residual (unverifiable without a real
Claude Pro/Max account): whether GET /v1/models accepts the OAuth bearer, and
the real endpoint's acceptance of the OAuth Messages request.
2026-07-22 02:53:25 -04:00
ZacharyZhang-NY dbce6bf305 feat(providers): add xAI Grok subscription OAuth (device-code) + per-provider session auth
First subscription-OAuth provider beyond Kimi Code (26th registry variant).
Log in with a Grok/SuperGrok/X subscription via RFC-8628 device-code OAuth
(auth.x.ai), then use it against api.x.ai/v1 — reusing the existing xai wire
(ChatCompletions + OpenAI listing + Passthrough + restrict + models_dev_id
xai). Sourced from Pi (earendil-works/pi auth/oauth/xai.ts): client
b1a00492..., scope 'openid profile email offline_access grok-cli:access
api:access', standard Bearer (no x-xai-token-auth).

Foundation (generalizes Kigi's Kimi-singleton OAuth to per-provider, root
cause, not a patch):
- Registry: OAuthConfig on PlatformSpec (client_id/host/device+token
  paths/scope/scope_key); XAI_OAUTH_CONFIG + XAI_GROK_SPEC (uses_oauth, method
  id 'xai-grok', an interactive login after kimi-code).
- Generic device-code wire (auth/oauth_device.rs) + GenericDeviceRefresher,
  sharing the RFC-8628 core with Kimi; Kimi's bespoke flow is byte-identical
  (X-Msh headers, KIMI_CODE_OAUTH_SCOPE, keyring gating unchanged).
- Per-provider AuthManager via a process-global pool (auth/oauth_registry.rs):
  build-on-demand with start_proactive_refresh, keyed by scope. The session
  resolves the AuthManager for the ACTIVE model's platform for bearer/refresh/
  401-recovery/api_key — an oauth-platform model always uses its OWN token,
  never the primary.
- Live /models under OAuth; base routes oauth().is_some() -> platform.base_url()
  (kimi-code stays on proxy_url).

Security: adversarial review + a systematic token-leak audit found and closed
FIVE channels where the primary Kimi token could reach api.x.ai (bearer
resolver, api_key stamping, aux summary/classifier/image-describe models, and
subagent model-override). Each fix routes through the platform-aware resolver
(the oauth model's pooled token or None, NEVER the primary) and is revert-to-red
verified. No access/refresh token is ever logged.

Registry at 26; picker updated (xai-grok interactive login row); TUI
context-window already auto-updates per model. Full gate green (234 suites,
fmt, clippy -D warnings, deny). GPT/Claude/Grok officially permit third-party
subscription use.
2026-07-22 01:36:29 -04:00
ZacharyZhang-NY 8a26460251 docs(readme): reframe intro + list all 25 supported providers
Kigi started as an unofficial Kimi Code CLI community build; by request it
now works with 25 providers. Replace the stale three-platform table with the
full supported list (Kimi Code OAuth + 24 API-key providers, each with its
platform id and env var). Unsupported/not-yet-built providers are omitted.
2026-07-21 20:30:53 -04:00
ZacharyZhang-NY bc4e76db96 feat(providers): add MiniMax (global + China) via Anthropic Messages
Providers 21-22 (24th & 25th registry variants), sourced from Pi
(earendil-works/pi). Pi drives MiniMax through its Anthropic-COMPATIBLE
surface (baseUrl .../anthropic), so Kigi reuses the existing Anthropic
Messages machinery (wire_api=Messages, listing=Anthropic, key_header=XApiKey
x-api-key+anthropic-version) rather than the OpenAI path. Global:
api.minimax.io/anthropic, MINIMAX_API_KEY, models.dev minimax. China:
api.minimaxi.com/anthropic, MINIMAX_CN_API_KEY, models.dev minimax-cn.

The base carries the /v1 suffix (.../anthropic/v1) since Kigi appends bare
paths → listing .../anthropic/v1/models?limit=1000, inference
.../anthropic/v1/messages (matches the live-probed x-api-key-gated endpoint).
restrict_to_enriched=FALSE: the 7 MiniMax-M* models are clean (no pollution)
and restrict would drop launch-day models not yet in models.dev.

Also HARDENS parse_anthropic_listing to tolerate a bare array in addition to
the {data:[...]} envelope (mirrors parse_openai_listing's sniff that Together
taught us) — so MiniMax's Anthropic-compatible /models can't silently empty
the catalog if it serves a bare array. A bare object without data still errors.

Review found no defects (5 areas CONFIRMED incl. the /v1 non-doubling, the
additive parser change, restrict=false rationale). Residual (logged): the live
200 body / anthropic-version acceptance is unverifiable without a key; the
bare-array tolerance + restrict=false hedge most shapes.

Tests: e2e mocks the x-api-key-gated Anthropic listing (proves the auth header
+ enrichment + keying under minimax/); both validation tests reject 401 with
the per-variant console host; new parser test covers envelope + bare array +
bare-object-errors. Registry at 25; picker 26 rows.
2026-07-21 20:21:26 -04:00
ZacharyZhang-NY 347311bfe5 feat(providers): add Xiaomi MiMo (global + China token plan)
Providers 19-20 (22nd & 23rd registry variants), sourced from Pi
(earendil-works/pi). Pi's xiaomi.ts / xiaomi-token-plan-cn.ts use plain
openAICompletionsApi (no thinking dialect) → Kigi Passthrough. Global: MiMo
at api.xiaomimimo.com/v1, XIAOMI_API_KEY, models.dev xiaomi. China token plan:
token-plan-cn.xiaomimimo.com/v1, XIAOMI_TOKEN_PLAN_CN_API_KEY, models.dev
xiaomi-token-plan-cn. Both Bearer + OpenAI listing + ChatCompletions +
restrict_to_enriched; /models auth-gated → validator.

id-match proven vs Pi's static ids (mimo-v2.5-pro, mimo-v2-flash, ...). The
CN token plan lists 4 mimo TTS models (tool_call=false); restrict's tool_call
cut drops them, keeping only the 3 chat models — the e2e proves this
non-vacuously with the real mimo-v2-tts. Review found no defects.

Registry at 23; picker 24 rows (welcome picker verified rendering all rows at
a taller viewport).
2026-07-21 19:55:51 -04:00
ZacharyZhang-NY 010f6c3be3 feat(providers): add Z.AI coding plan (global + China)
Providers 17-18 (20th & 21st registry variants), sourced authoritatively
from Pi (earendil-works/pi), the open-source agent whose provider list is
being mirrored. Pi's zai.ts / zai-coding-cn.ts use plain openAICompletionsApi
(NO special thinking dialect — overturns the matrix's 'thinking:{type} → new
dialect' concern), so Kigi maps them to Passthrough. Global: api.z.ai/api/
coding/paas/v4, ZAI_API_KEY, models.dev zai-coding-plan. China (Zhipu
BigModel): open.bigmodel.cn/api/coding/paas/v4, ZAI_CODING_CN_API_KEY,
models.dev zhipuai-coding-plan. Both Bearer + OpenAI listing + ChatCompletions
+ restrict_to_enriched; /models is auth-gated → validator.

id-match is PROVEN (not just assumed like Qwen): Pi's static model ids
[glm-4.5-air, glm-4.7, glm-5-turbo, glm-5.1, glm-5.2, glm-5v-turbo] are
byte-identical to the models.dev zai-coding-plan keys, all tool_call=true, so
restrict keeps every model with no silent-empty risk. Review found no defects.
GLM thinking is not lost (reasoning_content is parsed regardless of dialect).

Tests: e2e proves enrichment-supplied context + non-vacuous restrict (a
non-enriched wire model is dropped) + Passthrough; both variants' validation
tests hit /models and assert the per-variant console host (z.ai vs
open.bigmodel.cn). Registry at 21; picker 22 rows.

Also fixes the welcome login-picker test for the now-taller menu (renders at
a taller viewport to verify content coverage) and logs the real menu-overflow
UX debt: the picker clips rows past the fold with no scroll (q/l shortcuts
still work; only shown when unauthenticated) — deferred to its own cycle.
2026-07-21 19:32:58 -04:00
ZacharyZhang-NY c02b4b1ed7 feat(providers): add Kimi For Coding via static KIMI_API_KEY
Provider 16 (19th registry variant). Same endpoint + models + Kimi dialect
as the existing OAuth kimi-code platform (api.kimi.com/coding/v1 via the
KIGI_CODE_BASE_URL override), but authenticated with a static KIMI_API_KEY
instead of the device flow — for users who have a Kimi For Coding key rather
than an OAuth subscription. Bearer, OpenAI listing, ChatCompletions,
ChatCompat::Kimi, wire_serves_metadata=true (Kimi /models self-serves
context/thinking), restrict_to_enriched=false (clean 3-model catalog).
/coding/v1/models is auth-gated (401) so it doubles as the validator.

No collision: KIMI_API_KEY was previously unused (grep-verified), and the
house BYOK reads only KIGI_API_KEY/XAI_API_KEY/legacy. kimi-code (OAuth) and
kimi-coding (static key) are independently gated (OAuth-token vs key) and
their models get distinct managed keys (kimi-code/k3 vs kimi-coding/k3) — a
user with both simply sees each Kimi model twice; no dedup collision, no crash.

Review (6 areas): no blocking defects; confirmed the spec correctly mirrors
KIMI_CODE_SPEC (differing only in uses_oauth/api_key_envs/console_host/labels)
and the coexistence is benign. Strengthened the e2e's dialect assertion (Kimi
is the default ChatCompat, so it did not discriminate a parse failure) by
also asserting parse_managed_model_key attributes the key to KimiCoding.

Tests: e2e proves wire-served context (1_048_576 from the wire) with the
models.dev fetch SKIPPED (all-wire-metadata provider, .expect(0)), bare-id
round-trip under kimi-coding/, Kimi dialect; validation test rejects a 401
from /models. Registry at 19; picker 20 rows; snapshot already bundles
kimi-for-coding.
2026-07-21 18:10:23 -04:00
ZacharyZhang-NY 8245deb373 feat(providers): add Qwen Token Plan (global + China)
Providers 14-15 (17th & 18th registry variants), Alibaba DashScope
compatible-mode. Global: token-plan.ap-southeast-1.maas.aliyuncs.com,
QWEN_TOKEN_PLAN_API_KEY, models.dev alibaba-token-plan. China:
token-plan.cn-beijing.maas.aliyuncs.com, QWEN_TOKEN_PLAN_CN_API_KEY,
alibaba-token-plan-cn. Both Bearer + OpenAI listing + ChatCompletions +
Passthrough (stream_options.include_usage is documented-supported).

/models is auth-gated (401 without a key) so it doubles as the validator;
metadata from models.dev enrichment. The token plan is a multi-vendor
catalog (deepseek/kimi/minimax/glm/qwen); restrict_to_enriched keeps the 15
tool-calling chat models and drops the 4 qwen-image/wan image generators.

Review downgraded two flagged concerns: the enable_thinking non-streaming
400 cannot occur (Kigi never issues non-streaming ChatCompletions in
production — all inference streams), and Qwen thinking is NOT invisible
(reasoning_content is parsed regardless of the Passthrough dialect). No
defects. Residual (logged): restrict does an exact id-match of live /models
ids vs the models.dev keys; a mismatch fails safe (0 models) — verify with a
real key. Snapshot already bundles both providers (regenerated in ebf1105).

Tests: global e2e proves enrichment-supplied context (wire carries none),
non-vacuous tool_call restriction (qwen-image dropped), bare-id round-trip,
Passthrough; both variants' validation tests hit /models (401 reject) and
assert the correct per-variant console host. Registry at 18; picker 19 rows.
2026-07-21 16:54:35 -04:00
ZacharyZhang-NY ebf11057f8 feat(providers): add xAI (Grok) + migrate house BYOK env to KIGI_API_KEY
13th provider (16th registry variant). Also reconciles a naming collision
the matrix flagged: this fork is house-branded "xai" (cf. xai.dev metadata,
KIGI_CODE_XAI_API_KEY legacy env), so XAI_API_KEY + method xai.api_key were
the GENERIC house BYOK, not x.ai/Grok. The provider table wants xai/XAI_API_KEY
for Grok.

Resolution (user-approved): XAI_API_KEY now keys the x.ai/Grok provider; the
house BYOK primary env moves to KIGI_API_KEY, keeping XAI_API_KEY and
KIGI_CODE_XAI_API_KEY as back-compat fallbacks (read_xai_api_key_env checks
KIGI_API_KEY first). The xai.api_key method id is unchanged (persisted-session
compat); the platform method id is the bare "xai", distinct from it.

xAI spec: api.x.ai/v1, Bearer, OpenAI listing + ChatCompletions, Passthrough
(docs confirm stream_options.include_usage accepted). /v1/models is minimal
(ids only) and requires auth, so it doubles as the key validator (401 on bad
key, no override) and metadata comes from models.dev enrichment. Live ids
match the models.dev "xai" keys byte-for-byte, so restrict_to_enriched keeps
the 5 tool-calling chat models (grok-4.5/4.3/4.20-0309-*/build-0.1) and drops
the grok-imagine-* generators + the non-tool multi-agent model. Snapshot
regenerated to include the xai provider (was stale; gen script already listed
it in TARGETS).

Env migration is comprehensive to avoid keying the xai platform (which would
trigger a live api.x.ai fetch) or leaving house-key reads stranded: routed
the trace CLI resolver + acp_agent/auth.json bridge + paste-key ext handler
through the new primary; moved all leader/pager/e2e harness setters to
KIGI_API_KEY; made every house-key isolation test unset KIGI_API_KEY too;
updated user-facing hints to name KIGI_API_KEY.

Tests: e2e proves enrichment-supplied context (wire carries none), non-vacuous
tool_call restriction, bare-id round-trip under xai/, Passthrough; validation
tests hit /models (401 reject, 200 accept); house_env_var_takes_precedence_over_xai
pins the new precedence. Registry at 16; picker 17 rows; 4 auth arrays + xai.

Review (16 findings, all fixed): caught a missed else-branch env clear in the
paste-key handler (would leak the house key past a clear) and a non-hermetic
credential-priority test; both fixed.
2026-07-21 16:23:08 -04:00
ZacharyZhang-NY 193d16f6d5 feat(providers): add Vercel AI Gateway (vercel-ai-gateway)
12th provider. API-key via AI_GATEWAY_API_KEY, Bearer, OpenAI listing +
ChatCompletions, Passthrough dialect. Second wire-metadata provider but
takes the enrichment path instead: Vercel serves context under
context_window, which WireModel ignores (reads context_length), so
wire_serves_metadata=false + restrict_to_enriched pulls context/limits
from the models.dev "vercel" snapshot (302/306 live ids match snapshot
keys byte-for-byte, so restrict keeps essentially the whole catalog).

/models is public (200 for any key), so login validation targets
/credits (key_validation_path) which 401s on a bad bearer — avoids
false-accepting invalid keys against the public listing.

Tests: e2e proves enrichment-wins (wire context_window=999 distinct from
enrichment context=400000, asserts 400000) and non-vacuous tool_call
restriction; validation test proves /credits (not /models) is hit.
Registry at 15 (ordinal/VARIANT_COUNT/ALL), 4 auth arrays + 16-row picker.
2026-07-21 15:03:43 -04:00
ZacharyZhang-NY 9ee40b13d0 Add NVIDIA NIM platform (provider 11)
The 14th registry row: id "nvidia", NVIDIA_API_KEY > auth.json "nvidia"
scope, https://integrate.api.nvidia.com/v1 with KIGI_NVIDIA_BASE_URL
override, Bearer, ChatCompletions, enrichment-backed metadata
(models_dev_id nvidia — the quirk matrix's earlier 'absent' claim was
wrong; models.dev has 84 nvidia models), restrict_to_enriched=true (the
NIM listing mixes chat/embedding/rerank/vision/image; keep the 45
tool-calling chat models). Slashed org/model ids
(nvidia/meta/llama-3.3-70b-instruct) round-trip via the first-slash split;
the native id rides the wire.

NIM exposes raw vLLM behavior and stream_options support varies per model
(some strict vLLM backends 4xx on it), so chat_compat=StrictOpenAi strips
stream_options — streaming works across the whole fleet, usage falls back
to estimation. Snapshot reasoning models carry no effort menus, so kigi
sends no reasoning_effort (which an unsupported strict validator would
400 on). Review: no defects. Logged note: a key lacking the org 'Public
API Endpoints' permission passes /models validation but 403s on chat
(user-fixable edge case).
2026-07-21 14:31:25 -04:00
ZacharyZhang-NY f825132983 Add Cerebras platform + generalize StrictOpenAi dialect (provider 10)
The 13th registry row: id "cerebras", CEREBRAS_API_KEY > auth.json
"cerebras" scope, https://api.cerebras.ai/v1 with KIGI_CEREBRAS_BASE_URL
override, Bearer, ChatCompletions, enrichment-backed metadata
(models_dev_id cerebras).

Cerebras' catalog is all chat LLMs (no embedding/tts pollution) and its
/models is minimal (ids only), so restrict_to_enriched=FALSE: keep every
live model, enrich the known ones (context + effort menus low/medium/high),
unknown ones keep the default context. The e2e pins this enrich-without-
restrict path (new — prior enrichment providers all used restrict=true).

Review caught a likely-DOA defect: Cerebras uses strict
additionalProperties:false validation (confirmed 400-rejecting store,
maxTokens, thinking, nested reasoning_content), and stream_options is not
in its schema — so Passthrough (which keeps the stream_options.include_usage
kigi injects on every streaming request) would very likely 400 all
streaming. Generalized ChatCompat::Mistral -> ChatCompat::StrictOpenAi
(serde alias "mistral" keeps pre-rename persisted sessions loading), which
strips stream_options + private fields for any strict OpenAI-compat
validator; both Mistral and Cerebras now map to it. Future strict-validator
candidates (NVIDIA/Azure/Xiaomi/OpenCode) noted for the same check.

reasoning_effort (incl. "none") passes through; /v1/models requires auth
so key validation works; console cloud.cerebras.ai.
2026-07-21 14:04:34 -04:00
ZacharyZhang-NY 0a147bd8a5 Add Together AI platform + bare-array listing tolerance (provider 9)
The 12th registry row: id "together", TOGETHER_API_KEY > auth.json
"together" scope, https://api.together.xyz/v1 with KIGI_TOGETHER_BASE_URL
override, Bearer, ChatCompletions + Passthrough, enrichment-backed
metadata (models_dev_id togetherai) with the tool-calling listing
restriction (Together's listing mixes chat/embedding/rerank/image types).

Together's GET /v1/models returns a BARE JSON ARRAY, not the OpenAI
{object:list,data:[]} envelope. New shared parser parse_openai_listing
tolerates both shapes (sniffs the top-level [ vs { for accurate
diagnostics); every OpenAI-listing provider now routes through it, with
byte-equivalent envelope behavior (verified: Groq/Google/OpenRouter
unchanged) and non-silent errors. Together's org/Model ids match the
togetherai snapshot keys exactly (verified), so restrict_to_enriched keeps
the ~27 tool-calling models without the id-shape trap.

Review: ship-ready, shared-parser change proven strictly-additive and safe,
registry/e2e/dialect all correct. Backlog logged: kigi ignores the wire
per-model  field, so a live Together chat model absent from
models.dev is dropped until indexed — a future wire- filter would
unlock the fresh full catalog.
2026-07-21 13:16:20 -04:00
ZacharyZhang-NY e7dbb98207 Add OpenRouter platform: wire-served metadata (provider 8)
The 11th registry row and the first THIRD-PARTY wire_serves_metadata=true
provider: id "openrouter", OPENROUTER_API_KEY > auth.json "openrouter"
scope, https://openrouter.ai/api/v1 (note /api/v1) with
KIGI_OPENROUTER_BASE_URL override, Bearer, OpenAI listing +
ChatCompletions + Passthrough.

OpenRouter's public /models serves context_length for every model
(verified live: 340/340), so it needs NO enrichment: models_dev_id=None,
wire_serves_metadata=true, restrict_to_enriched=false. An OpenRouter-only
user makes zero models.dev calls; context comes straight from the listing.
Slashed ids (anthropic/claude-opus-4.8) round-trip through the managed key
via the first-slash split; the native id rides the wire. The e2e pins all
of this with the models.dev refresh disabled.

Review-confirmed defect fixed (and independently re-verified with live
curls): OpenRouter's /models is PUBLIC — GET /models returns 200 for ANY
key — so login key-validation would false-accept a bad key, deferring the
failure to the first chat 401. New spec field key_validation_path lets a
public-listing platform validate against an auth-requiring endpoint;
OpenRouter uses /key (401s for bad keys). Reusable for Vercel (also
public). Tests pin the /key validation and no regression to the default
/models path.

Gate caught a fixture regression: the kimi_import test used openrouter.ai
to represent a CUSTOM provider, which now correctly dedupes to the builtin
OpenRouter — moved the fixture to a reserved llm.example.test host that no
future platform can shadow.
2026-07-21 12:41:14 -04:00
ZacharyZhang-NY 7a2cd8a726 Add Google Gemini platform via OpenAI-compat endpoint (provider 7)
The 10th registry row: id "google", GEMINI_API_KEY > auth.json "google"
scope, https://generativelanguage.googleapis.com/v1beta/openai (Gemini's
OpenAI-compatibility shim) with KIGI_GOOGLE_BASE_URL override, Bearer,
OpenAI listing + ChatCompletions + Passthrough, enrichment-backed
metadata (models_dev_id google), tool-calling listing restriction.

Review caught a ship-blocking defect: Gemini's compat /models returns
-PREFIXED ids (confirmed via Google's own cookbook), but the
models.dev snapshot keys and the chat endpoint use the BARE id. Without
normalization the enrichment lookup misses and restrict_to_enriched
silently empties the Gemini catalog (login works, zero models
selectable). A doc WebFetch had hidden this — the compat docs show bare
INPUT ids to retrieve/chat but never print the list OUTPUT.

Fix: new spec field strip_listing_id_prefix, applied in the fetch before
filter/enrich/keying. Google sets Some("models/") (defensive: a no-op if
an id is already bare, so correct regardless of the live shape); all other
rows None. The e2e now feeds the REAL prefixed listing ids and asserts
they survive as the bare managed key google/gemini-2.5-pro with the bare
id on the wire (chat rejects the prefix); a registry test pins the config.

Note: models.dev models Gemini reasoning as budget_tokens (not
effort-type), so no auto effort-menu — models still fully work; reasoning
is dynamic. Gemini compat applies default safety filters (no BLOCK_NONE).
2026-07-21 11:47:47 -04:00
ZacharyZhang-NY e373ff8b29 Add Fireworks AI platform (provider 6)
The 9th registry row, pure Groq pattern: id "fireworks",
FIREWORKS_API_KEY > auth.json "fireworks" scope,
https://api.fireworks.ai/inference/v1 (note /inference/v1) with
KIGI_FIREWORKS_BASE_URL override, OpenAI listing + ChatCompletions +
Passthrough dialect, enrichment-backed metadata (models_dev_id
fireworks-ai) with the tool-calling listing restriction.

Fireworks native ids are deeply slashed (accounts/fireworks/models/glm-5p2);
the e2e pins the full round-trip: the managed key
(fireworks/accounts/fireworks/models/glm-5p2) parses back on the first
slash, and — the 404-risk property — the NATIVE id rides the inference
wire (entry.model and the resolved SamplerConfig.model) while the
fireworks/ prefix stays internal routing only.

Review (models.dev provider.toml + Fireworks docs): row facts confirmed,
registry integrity at 9, counts complete, e2e strong on all axes, zero
defects. One tradeoff logged as debt: restrict_to_enriched drops
fine-tuned/account-scoped deployed models (a headline Fireworks feature)
that can never be in models.dev.
2026-07-21 11:03:50 -04:00
ZacharyZhang-NY 9953a26b8d Add Mistral platform + Mistral dialect + array-content handling (provider 5)
The 8th registry row: id "mistral", MISTRAL_API_KEY > auth.json
"mistral" scope, https://api.mistral.ai/v1 with KIGI_MISTRAL_BASE_URL
override, enrichment-backed metadata with the tool-calling listing
restriction (embed/moderation/OCR noise).

Mistral is NOT a pure-pattern provider — an adversarial review found two
doc-confirmed blockers that no test exercises (no e2e covers a chat POST),
so the gate-green registry row alone would have shipped it DOA. A research
workflow pinned the exact wire shapes against the mistralai/client-python
SDK source (adversarially verified), then both were fixed:

1. stream_options 422: Mistral's strict Pydantic validator rejects the
   stream_options.include_usage field kigi injects on every streaming
   request (the SDK's request model has no such field). New
   ChatCompat::Mistral dialect strips it (plus the kigi-private message
   fields, like Passthrough). Streaming usage falls back to token
   estimation.
2. Reasoning content arrays: Mistral reasoning models return content as
   Union[str, List[ContentChunk]] on both streaming and non-streaming,
   which the flat Option<String> path could not decode -> aborted turn.
   A UNIVERSAL lenient deserializer (#[serde(from = "Raw..")] on
   ChatResponseMessage + ChatChunkDelta) accepts string-or-array, routing
   {type:text} chunks to the answer and the nested text of {type:thinking}
   chunks to reasoning_content, tolerant of the OPEN chunk union (unknown
   types ignored, never fatal). String content stays byte-identical for
   every other provider (kimi/deepseek/groq/BYOK).

Review refuted all seven attack lines (no regression, no crash, exhaustive)
and flagged one coverage gap, now closed: a stream-consumer integration
test drives a full thinking -> transition -> answer chunk sequence and
proves it yields the same reasoning-sibling + assistant-answer result as
the reasoning_content string path.

Also folds a verified quirk matrix for all 23 remaining API providers into
providers-plan.md, tiered by real difficulty (self-enriching OpenRouter/
Vercel; bare-array Together listing; Messages-dialect MiniMax reusing the
Anthropic machinery; non-Bearer Azure/Bedrock; router wildcards; the OAuth
block).
2026-07-21 10:36:33 -04:00
ZacharyZhang-NY 49e6414c29 Add Groq platform (provider 4)
The 7th registry row and the first pure-pattern cycle: id "groq",
GROQ_API_KEY > auth.json "groq" scope, https://api.groq.com/openai/v1
with KIGI_GROQ_BASE_URL override, OpenAI listing + ChatCompletions +
Passthrough dialect (Groq accepts the OpenAI-style reasoning_effort
scalar verbatim), enrichment-backed metadata with the tool-calling
restriction (the listing carries whisper/tts/guard noise — 8 of 15
enrichment entries are non-chat).

Review verdict: faithful pattern repeat, zero blocking findings — row
facts verified against live Groq docs, registry integrity at 7, e2e
proven strong on both axes (restriction + dialect mapping). Added the
recommended pin: managed keys split on the FIRST slash, so Groq's
provider-native slashed ids (openai/gpt-oss-120b and 10 more) round-trip
as groq/openai/gpt-oss-120b → (Groq, openai/gpt-oss-120b).

Also fixes a pre-existing test race this cycle surfaced: the enterprise-
endpoints test asserted moonshot's fixed base while non-serial, racing
serial tests that legitimately point KIGI_MOONSHOT_CN_BASE_URL at
wiremock; now serial + env-unset like its documented siblings. Two
display-only advisories logged as tracked debt (Groq's delta.reasoning
field invisible in the TUI; platform-generic rate-limit copy).
2026-07-21 08:08:43 -04:00
ZacharyZhang-NY 7efb4b07cc Add DeepSeek platform + ChatCompletions dialect system (provider 3)
The 6th registry row: id "deepseek", DEEPSEEK_API_KEY > auth.json
"deepseek" scope, base https://api.deepseek.com (chat rides
{base}/chat/completions per official docs) with KIGI_DEEPSEEK_BASE_URL
override, enrichment-backed metadata (1M context, 384k output cap,
high/max effort menu).

Structural fix the cycle exposed: kigi's Kimi-specific body adaptation
ran UNCONDITIONALLY on every ChatCompletions request. New ChatCompat
dialect, declared per platform row and threaded through SamplerConfig,
ClientDefaults, and the session-persisted SamplingConfig (serde-default
Kimi keeps restored pre-field sessions and BYOK endpoints byte-identical;
production persist seams copy it; subagents inherit it):
- Kimi: full legacy pipeline (dispatch ≡ legacy pinned)
- DeepSeek: thinking:{type, reasoning_effort} per api-docs.deepseek.com
  (server maps low/medium→high, xhigh→max itself; none disables; absent
  leaves the server default)
- Passthrough: OpenAI-style reasoning_effort scalar untouched (unblocks
  Groq and the rest of the OpenAI-compatible list)

Review-confirmed release blocker fixed: kigi replays Kimi's
reasoning_content (and its private model_id) on input assistant
messages — Kimi consumes these, but DeepSeek documents input
reasoning_content as prefix-mode-only (historically a 400) and other
providers don't know either field. The DeepSeek and Passthrough arms now
strip both; Kimi's own pipeline is untouched. Pinned on both message
shapes.
2026-07-21 07:21:05 -04:00
ZacharyZhang-NY b86722f508 Add Anthropic platform: wire-served metadata via listing dialect (provider 2)
The 5th registry row: id "anthropic", ANTHROPIC_API_KEY > auth.json
"anthropic" scope, api.anthropic.com/v1 with KIGI_ANTHROPIC_BASE_URL
override, Messages dialect. Two new spec dimensions most future rows
reuse: ListingDialect (Anthropic's /v1/models wants x-api-key +
anthropic-version headers, ?limit=1000, and its own response shape) and
PlatformKeyHeader (Bearer vs x-api-key across listing/validation/
inference, with auth_scheme stamped onto entries).

The 2026 Anthropic listing serves real metadata: the adapter maps
max_input_tokens, per-level effort capabilities (low..max as the menu,
xhigh/max distinct), thinking/image flags — and enrichment fills only
genuine wire gaps (e2e pins wire-1M beating enrichment, and a zero
context filled to 200k).

Two review-confirmed defects fixed red-green:
- Output caps were dropped at three layers, so every sub-128K-output
  model (64k Haiku, legacy models) would 400 on EVERY request against
  the sampler's 128K max_tokens default. Wire max_tokens and enrichment
  limit.output now flow to entry.max_completion_tokens.
- An explicit wire effort-decline was indistinguishable from wire
  silence, letting enrichment inject effort menus pre-4.6 models reject
  (adaptive thinking 400). The adapter now emits a decline sentinel
  (support:false) that enrichment respects — proven end to end.

Also: the Messages client now sends anthropic-version (previously never
sent — real api.anthropic.com rejects such requests; pinned across all
three scheme/backend quadrants), key validation builds per-key-header
requests, missing listing data fails fast, empty-id ghosts drop with a
warning, kimi-import recognizes api.anthropic.com as built-in
automatically.
2026-07-21 06:12:53 -04:00
ZacharyZhang-NY 23e94939c0 Add OpenAI platform: live model fetching with enrichment (provider 1)
The 4th registry row: id "openai", OPENAI_API_KEY env > auth.json
"openai" scope (login picker/paste/validation all registry-generic —
zero TUI changes needed, pinned by the picker test), base
https://api.openai.com/v1 with KIGI_OPENAI_BASE_URL override, Responses
dialect via the new PlatformWireApi spec field, enrichment-backed
metadata (wire_serves_metadata=false).

OpenAI's GET /v1/models returns bare ids and is polluted with
tts/whisper/embeddings entries: the listing is restricted to
enrichment-known TOOL-CALLING models (review caught that membership
alone admitted models.dev-known embeddings models, which would 400 on
every agentic request; dropped ids are debug-logged for launch-day
diagnosability). Context windows, effort menus, display names, and
thinking capability come from the enrichment pipeline — wiremock e2e
pins the full contract: polluted live listing + models.dev →
one Responses-backed chat model with a 400k documented context window.

Responses max-effort wiring (closes the P0c-1 debt): canonical effort
rides a CreateResponseWrapper sidecar and patch_reasoning_effort writes
it onto the serialized body at both send sites (all seven levels pinned,
xhigh/max distinct, summary preserved); normalize_effort_echo drops
echoes async-openai's typed enum cannot represent at both the non-stream
and SSE parse seams; the dead typed to_responses_api converter is
deleted. Kimi/moonshot stay byte-identical (ChatCompletions untouched,
wire_api maps to the same default; kimi wire tests green).

kimi-import now recognizes ANY registry platform host as built-in
(was hardcoded moonshot), covering openai and future rows.
2026-07-21 05:04:55 -04:00
ZacharyZhang-NY fdf9b956f5 Add models.dev metadata-enrichment pipeline (providers P0c-2)
Provider /models listings that return bare ids (OpenAI-style) get context
windows, thinking levels, image support, and display names from models.dev:
kigi-models owns the transform (parse_api_json — ONE field interpretation
for the bundled snapshot AND runtime refreshes), enrich_wire_model fills
gaps with wire values always winning and model availability strictly
wire-truth. Spec rows gained models_dev_id + wire_serves_metadata; all
three current platforms are wire-served, so this pipeline is provably
inert for them (byte-identical catalogs, zero egress, zero ~/.kigi writes
— adversarially verified).

Shell side: enrichment_fetch with a 24h disk cache guarded by binary
version + keep-set + future-stamp sanity (a registry change or downgrade
refetches instead of serving a catalog missing new providers), refresh of
https://models.dev/api.json filtered to registry ids, KIGI_MODELS_DEV_URL
override with case/whitespace-tolerant kill switch, fallback chain
fresh-cache > refresh > stale-cache > bundled (each step logged). The
fast path returns an empty catalog without forcing the bundled parse.

From the review: blast-radius-confined parsing (one drifted provider on
models.dev warn-skips instead of failing the whole refresh), registry-
coverage and field-coverage tests guarding script/parser drift, a path-
injectable core with 8 state-machine tests (one of which caught a guard
patch that had failed to apply), _meta provenance stamp in the snapshot,
and models.dev (MIT) attribution in NOTICE. Snapshot: 29 providers, 1124
models, 246KB, regenerated by scripts/gen_enrichment_snapshot.py (pure
filter, no transform).
2026-07-21 03:36:51 -04:00
ZacharyZhang-NY 83e6935189 Split canonical ReasoningEffort::Max out of Xhigh (providers P0c-1)
OpenAI (Responses) and Anthropic (Messages) treat xhigh and max as
DISTINCT effort levels in 2026, and the Kimi K3 wire's top tier is max —
the old parse alias (max→Xhigh) conflated them. Canonical Max now exists:
parse/as_str/serde split, Messages mapping sends xhigh and max as their
own tokens (was Xhigh→"max"), and the K3 menu token max carries
canonical Max end to end.

Kimi wire is byte-identical in all four flows (menu pick, restored
legacy xhigh session, --reasoning-effort flag, /effort command) —
adversarially traced and pinned: kimi_compat's string-level xhigh→max
rename covers legacy tokens, max passes through verbatim.

From the review:
- Rollback safety: persisted reasoning_effort (session summaries, chat
  history) deserializes leniently — unknown future tokens degrade to
  None with a warning instead of hiding sessions or failing resume.
- Restore migration: a pre-split xhigh override onto a model whose menu
  offers max but not xhigh (K3) migrates once, healing display/active-row
  drift and re-persisting the live vocabulary.
- /effort max now rejects (with the offered list) on models whose menu
  lacks a max row instead of silently applying xhigh; deliberate, tested.
- The interim Responses-backend Max→xhigh downgrade (async-openai has no
  Max variant through 0.41) warns loudly; real max wiring lands with the
  OpenAI provider cycle via post-serialize body patch.
- Two rusted ignored-e2e wire pins asserted the pre-adapt reasoning_effort
  key (deleted by the body adapter since ea0ce9d); they now pin the real
  thinking.effort=max shape.
2026-07-21 02:36:37 -04:00
ZacharyZhang-NY c5ddaec71e Add per-provider auth.json keys; make auth methods registry-generic (P0b)
Platform API keys now live in auth.json under the platform-id scope (the
per-provider auth.json key contract), resolved env > auth.json > legacy
[platforms.*] config.toml (read-only fallback). The TUI login picker,
paste box, auth-method advertising, and authenticate handler are all
registry-generic: a new PlatformSpec row appears in the login UI and
authenticates with zero UI changes. Spec rows gained vendor/console_host/
login_label display fields (moonshot strings byte-identical, pinned by
tests).

Adversarial review caught that auth.json keys were validated at login but
never stamped onto catalog entries (completions would 401; restart lost
eager auth). Fixed red-green: resolve_model_list/resolve_model_catalog now
take a resolved PlatformApiKeys snapshot consumed by the credential-
stamping layer (auth.json beats stale config.toml, matching the login
validator), with production callers resolving fresh per catalog build.
Also from review: the new auth.json writer takes the manager's cross-
process flock (bounded retry — an unlocked RMW racing a token refresh
could revert a rotated refresh token); the oauth-401 wiremock test is
hermetic (KIGI_SHARE_DIR tempdir; it could read a dev's real auth.json
and hit live moonshot); cli_models resolves real keys; auth.json is read
once per registry sweep; caller-less lock_config_writes deleted; catalog
resolvers tightened to pub(crate); stale config.toml doc comments and the
no-credentials error copy updated.
2026-07-21 01:18:46 -04:00
ZacharyZhang-NY 99d99fb47a Refactor platform registry onto spec rows (providers P0a)
Replace PlatformId's per-method match arms with static PlatformSpec rows
read through a single spec() accessor. Zero behavior change (adversarially
reviewed: ids, display names, base URLs + env override pairing, oauth flag,
prefix filters, and api-key env precedence order are byte-identical; all
public signatures unchanged). Groundwork for the multi-provider expansion:
adding a platform becomes variant + ALL entry + spec() arm + row.

Guards added for the row-addition workflow: spec-id uniqueness test (parse
scans rows) and a compile-forced ALL-completeness test (a variant missing
from ALL would otherwise be silently unparseable and skipped by model
sync). Future-proofed two assertions that hardcoded 'openai' as an
unknown-platform example.
2026-07-21 00:03:46 -04:00
ZacharyZhang-NY c4fe0750e2 Enable graph engineering by default in installers; feature it in the README
README leads with the claim under the title — the world's first CLI with
built-in graph engineering — plus a dedicated section covering the /graph
command set, the closed loop it runs (plan → parallel worktree workers →
adversarial verify → 3-way merge-back → replan → optimize → terminal
whole-objective verification), the repo-following .kigi/graph.jsonl state,
copy-paste disable commands for macOS/Linux and Windows, and the tuning
knobs.

install.sh: the rc-file resolution and persist_line helper are hoisted out
of the not-on-PATH branch and generalized (guard + label), so PATH
persistence is unchanged while KIGI_GRAPH=1 is persisted unconditionally
and idempotently for zsh/bash/fish/POSIX profiles — an existing
KIGI_GRAPH line (e.g. a user's =0 opt-out) is left untouched on
reinstall. install.ps1 mirrors it with a User-scope environment variable,
also only when unset. No binary change — installers and README ship from
main, so this needs no rebuild or release.
2026-07-20 22:22:22 -04:00
136 changed files with 20381 additions and 1321 deletions
+85
View File
@@ -0,0 +1,85 @@
name: Warm build cache
# Release builds run on TAG refs, and GitHub Actions cache isolation only
# lets a run restore caches created on its OWN ref or the DEFAULT branch.
# No workflow ran on `main`, so every release compiled the whole workspace
# cold on all five targets (~50 min wall clock, gated by Windows). This
# workflow builds the same `release-dist` profile on `main` so tag builds
# restore a warm default-branch cache.
#
# Triggers: dependency-affecting pushes to main (each release's version-bump
# commit warms the cache for the NEXT release), a weekly refresh so the
# cache never hits GitHub's 7-day unused-eviction, and manual dispatch.
#
# The setup steps mirror release.yml's build job (toolchain, target,
# dotslash/protoc, rust-cache key) — keep them in lockstep, or the cache
# key won't match and releases go back to cold builds.
on:
push:
branches: ["main"]
paths:
- "Cargo.lock"
- "Cargo.toml"
- "rust-toolchain.toml"
- ".github/workflows/warm-cache.yml"
schedule:
- cron: "17 5 * * 1"
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
concurrency:
group: warm-cache
cancel-in-progress: true
jobs:
warm:
name: warm (${{ matrix.target }})
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
os: macos-14
- target: x86_64-apple-darwin
os: macos-14
- target: x86_64-unknown-linux-gnu
os: ubuntu-24.04
- target: aarch64-unknown-linux-gnu
os: ubuntu-24.04-arm
- target: x86_64-pc-windows-msvc
os: windows-2022
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install toolchain (rust-toolchain.toml)
run: rustup show
- name: Add build target
run: rustup target add ${{ matrix.target }}
- name: Install dotslash (protoc launcher)
run: cargo install dotslash --locked
- name: Install protoc (Windows PATH fallback)
if: runner.os == 'Windows'
shell: pwsh
run: |
$url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.3/protoc-29.3-win64.zip"
Invoke-WebRequest -Uri $url -OutFile protoc.zip
Expand-Archive protoc.zip -DestinationPath "$env:USERPROFILE\protoc"
Add-Content $env:GITHUB_PATH "$env:USERPROFILE\protoc\bin"
# Same key as release.yml so tag builds restore this cache verbatim.
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Build kigi (release-dist)
run: cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
+293 -6
View File
@@ -18,9 +18,12 @@ import) or any `KIMI_*` env var.
- **Zero egress**: outbound connections are limited to
`auth.kimi.com`, `api.kimi.com`, `api.moonshot.cn`, `api.moonshot.ai`,
GitHub Releases domains, and user-configured MCP servers. No telemetry,
no analytics, ever. `crates/codegen/kigi-env` is the single home of
first-party endpoints.
GitHub Releases domains, user-configured MCP servers, the endpoints of
provider platforms the user has credentialed, and `models.dev` (model
metadata refresh — reached ONLY when an enabled platform's `/models` wire
lacks metadata, `wire_serves_metadata=false`; Kimi/Moonshot never trigger
it; `KIGI_MODELS_DEV_URL=0` disables). No telemetry, no analytics, ever.
`crates/codegen/kigi-env` is the single home of first-party endpoints.
- **Toolchain**: Rust 1.97.0 (rust-toolchain.toml), edition 2024.
- **Gates** (all must stay green):
`cargo check --workspace --all-targets`,
@@ -29,9 +32,19 @@ import) or any `KIMI_*` env var.
- **Observability is local**: `kigi-log` (unified session log, `--debug`
firehose, subsystem file logs, opt-in instrumentation) writes under
`~/.kigi` only. Its zero-network property is a contract.
- **Atomic file replace goes through `util::fs::replace_file`** (tmp+rename
commit step; async callers wrap in `spawn_blocking`). Never inline a bare
`fs::rename` replace: Windows `MoveFileExW(REPLACE_EXISTING)` fails with a
sharing violation while AV/indexer/cloud-sync holds the destination open —
the "persists on macOS, silently doesn't on Windows" class (a /model
switch that never stuck). Plain rename stays correct only for true moves
whose destination doesn't pre-exist (worktree-pool markers, corrupt-file
backups). Write failures must at least `warn!` — never `let _ =`.
- The root `Cargo.toml` is hand-maintained (upstream's generator is not in
this repo). Members sorted; versions inherited from
`workspace.package.version` (0.1.0).
`workspace.package.version` — the single source of truth for the release
version (`kigi_version::VERSION` derives from it; the release workflow
gates the `v*` tag against it).
## Layout
@@ -76,8 +89,9 @@ node as one ordinary goal — the agentic loop lives INSIDE the node; the
edges stay deterministic Rust. The harness appends a terminal
`gn-final` verification node depending on every planner node.
- Feature flag `KIGI_GRAPH=1` (default off); availability additionally
requires the goal harness (`BuiltinGate::Graph`).
- Enabled by default (`KIGI_GRAPH=0` is the off-switch; the G0 gray
release is over); availability additionally requires the goal harness
(`BuiltinGate::Graph`).
- Key modules (kigi-shell): `session/graph_tracker.rs` (pure state
machine; reuses `GoalStatus`/`GoalPhase`/`GoalPauseReason`),
`session/graph_plan.rs` (planner-JSON contract + validation + fnv id
@@ -150,6 +164,279 @@ edges stay deterministic Rust. The harness appends a terminal
the replan cap; `{"ops": []}` is a respected free no-op; failures
degrade.
## Provider registry & API-key auth (post-0.1.3 expansion)
- The platform registry is compiled-in spec rows in `kigi-models`
(`PlatformSpec`; adding a platform = enum variant + `ALL` entry + `spec()`
arm + row; registry tests enforce completeness/uniqueness/row shape).
- API-key resolution precedence, per platform: platform env var(s) >
`auth.json` scope named by the platform id (`moonshot-cn`, …) >
legacy `[platforms.<id>]` in config.toml (read-only fallback).
- The TUI login picker persists pasted keys to `auth.json` (platform-id
scope, `api_key` mode) — never to config.toml. The keyring holds ONLY the
OAuth session scope; platform keys are file-only.
- Auth method ids over ACP equal the platform ids; interactive picker rows
are built generically from advertised methods (`AuthMethodKind::
ApiKeyPlatform`), so new registry rows appear in the picker with no TUI
changes.
- Every OAuth picker row carries ITS OWN method id (`PendingMenuItem::Login
{ method_id }` → `Action::LoginWith`); an unknown id fails closed. The
id-less `Action::Login` (auto-login, 401 re-auth) resolves the first
interactive method. `/login` opens the picker (`Action::OpenLoginPicker`),
never a flow directly; mid-session its last row is Cancel, not Quit.
- `_meta.connected` on an advertised method is DISPLAY state (green badge on
the picker): stamped at `initialize()` from stored credentials
(`connected_method_ids` + `stamp_connected_meta`), kept fresh TUI-side
after in-session logins (`auth_in_flight_method` → `AuthComplete`). It is
never an authorization input.
- Refreshable-OAuth providers beyond Kimi Code use a GENERIC path, NOT Kimi's
bespoke wire. A `uses_oauth` platform carrying `oauth: Some(&OAuthConfig)`
(client id / auth host / start+token paths / `token_host` / `scope` /
`scope_key` / optional extra device field / `flow` / `token_body`) drives a
scope-keyed `AuthManager::new_oauth_provider` +
`refresh::GenericDeviceRefresher` (selected by `build_refresher` via
`oauth_config_for_scope_key`; the refresher dispatches the refresh body by
`token_body`: form → `auth::oauth_device`, JSON → `auth::oauth_pkce`,
`GithubCopilotExchange` → `auth::github_copilot` copilot-token re-mint). Kimi
Code keeps `oauth: None` and its bespoke path unchanged. The interactive
login is dispatched by `OAuthConfig.flow` (in `run_oauth_provider_flow`):
- `OAuthFlow::DeviceCode` → `auth::oauth_device` (RFC-8628 device-code, plain
kigi UA, no X-Msh headers). Provider: `xai-grok` (`scope_key oauth/xai`,
base `api.x.ai/v1`, form token body, same wire as the API-key `xai` row).
- `OAuthFlow::PkceLocalhost { redirect_port, redirect_path }` →
`auth::oauth_pkce` (authorization-code + PKCE S256,
`127.0.0.1:redirect_port{redirect_path}` loopback with STRICT `state`
validation + manual-paste fallback, authorize host possibly ≠ token host).
The `token_body` selects the login dialect: `Json` = claude (`state ==
verifier`, JSON exchange carrying `state`), `Form` = codex (fresh-random
`state`, FORM exchange without `state` via `exchange_code_form`).
`OAuthConfig.authorize_extra` appends provider-only authorize params (empty
for all but codex). Providers:
- `claude-pro-max` (`scope_key oauth/claude-pro-max`, port 53692
`/callback`, JSON body, base `api.anthropic.com/v1`, Anthropic Messages +
listing wire reached with an OAuth `sk-ant-oat…` Bearer). Its Messages
requests take the OAuth adaptation — `anthropic-beta claude-code-…,oauth-…`
+ `claude-cli` UA + `x-app cli` + the required "You are Claude Code…"
system prefix — gated on `SamplerConfig.anthropic_oauth` (claude-pro-max
only), so API-key `anthropic`/`minimax` Messages requests stay
byte-identical. Its `/v1/models` listing rides the same Bearer +
oauth-beta headers. THINKING REPLAY (`prune_replayed_thinking`,
all Messages requests): Anthropic validates every replayed
`thinking` block (signature model-bound, non-empty required), so
only the final assistant message's signed thinking is replayed and
only while its tool loop is open (request ends on the tool
results); everything else — unsigned cross-backend history, `tco_*`
Responses blobs, stale-model blocks — is stripped, or the request
400s "Invalid `signature` in `thinking` block".
- CROSS-PROVIDER REPLAY POLICY (Pi `transform-messages` pattern): the
conversation history is provider-agnostic and sessions switch
models/backends mid-history, so EACH wire builder owns emitting only
items valid for its target — never patch downstream except in the
per-backend body adapters. Concretely: the Responses input drops
Reasoning items without a native `rs_*` id (foreign capture is id "")
and provenance-gates whole turns via `transform_items_for_responses`
(`AssistantItem.model_id` vs the request model: foreign Reasoning
dropped, foreign BackendToolCall demoted to its `text_summary`);
the codex adapter additionally drops bare `rs_*` references (stateless
backend); tool-call ids pass through ONE shared ASCII
`sanitize_tool_call_id` symmetrically on call+result on BOTH the
Messages and Responses legs; Messages image sources go through
`parse_base64_image_data_uri` (raster whitelist, no `data:` url
sources) and empty user turns get a placeholder. Dangling tool calls
are already repaired item-level by `repair_dangling_tool_calls` on the
actor's build path. When a provider wire bug surfaces, fix the CLASS
across all three builders in the same pass — three sequential
single-provider fixes (thinking signature → codex system role → codex
reasoning id) motivated this policy.
- ChatCompletions dialect selection: registry platforms declare
`chat_compat` explicitly; BYOK/custom entries default to `Passthrough`
(vanilla OpenAI semantics) EXCEPT entries pointed at the house/Kimi
coding endpoint, which keep the `Kimi` dialect (base-url detection —
Pi-style quirk sniffing). `ChatCompat::Mistral` = StrictOpenAi plus the
exactly-nine-`[a-zA-Z0-9]` tool-call id normalizer
(`normalize_mistral_tool_call_ids`, deterministic FNV-1a→base36, one
map for call+result; persisted `mistral` values resolve here). Chat
tool messages are TEXT-ONLY: tool-result images batch into one
synthetic user message after the consecutive tool-result run
(`conversation_to_chat_messages`).
- `openai-codex` (ChatGPT Plus/Pro, `scope_key oauth/openai-codex`, port
1455 `/auth/callback`, FORM body, authorize+token host `auth.openai.com`,
client `app_EMoam…`, scope `openid profile email offline_access`, the 3
authorize-extra params `id_token_add_organizations`/
`codex_cli_simplified_flow`/`originator=codex_cli_rs`). Refresh is a plain
`refresh_token` FORM grant (the generic refresher's `Form` path →
`auth::oauth_device`). The minted `access_token` is a JWT; login FAILS
FAST unless it carries the `["https://api.openai.com/auth"]
["chatgpt_account_id"]` claim (`chatgpt_account_id_from_jwt`) — that
account id is NOT persisted but re-derived STATELESSLY from the current
bearer at every request. INFERENCE reuses the EXISTING Responses wire
against base `chatgpt.com/backend-api/codex` (→ `{base}/responses`) with
a Codex-gated adaptation (`SamplerConfig.openai_codex` /
`PlatformId::sends_codex_responses_headers()`): headers
`chatgpt-account-id` (per-request from the JWT), `originator codex_cli_rs`,
`OpenAI-Beta responses=experimental`, a codex `User-Agent`; `store:false`
is the shared Responses default. BODY adaptation
(`adapt_body_for_codex_backend`, same gate): the backend 400s
`role:system` input ("System messages are not allowed") — system items
are hoisted into the top-level `instructions` field — and stateless
reasoning replay requires `include:["reasoning.encrypted_content"]`.
API-key `openai` Responses requests carry NONE of this
(byte-identical, pinned by a control wire test). `reasoning.effort`
carries the thinking level (incl. the codex-only `ultra`). NO
websocket, NO base_instructions.
CATALOG is HARDCODED (`PlatformId::hardcoded_catalog` →
`openai_codex_wire_models`, mapped through the SAME
`platform_wire_model_to_entry` output): exactly the 4 `visibility=list` &&
`supported_in_api=true` models (`gpt-5.6-sol/terra/luna`, `gpt-5.5`, ctx
272000, per-model efforts) — NO live `/models` fetch, NO codex-CLI /
`~/.codex` dependency; `gpt-5.3-codex-spark` (api=false) and
`gpt-5.4`/`gpt-5.4-mini`/`codex-auto-review` (hidden) are EXCLUDED.
- `OAuthFlow::GithubDeviceCopilot` → `auth::github_copilot` (TWO-STAGE).
Provider: `github-copilot` (`scope_key oauth/github-copilot`, base
`api.individual.githubcopilot.com`, ChatCompletions wire). Stage 1 is an
RFC-8628 device flow on `github.com` (client `Iv1.b507a08c87ecfe98`, scope
`read:user`) whose errors ride a `200` body (not `4xx`) — it mints the
DURABLE github token. Stage 2 (`GET api.github.com/copilot_internal/v2/token`
with `copilot_exchange` + editor headers) re-mints the SHORT-LIVED copilot
token. Persisted as `KimiAuth.key = copilot token`, `refresh_token = github
token`, `expires_at = copilot expiry`; the "refresh" is a copilot-token
RE-MINT (GET, not a `refresh_token` grant). Every `/models` listing AND
`/chat/completions` request carries the VS Code editor-identity headers
(`User-Agent GitHubCopilotChat/…`, `Editor-Version`, `Editor-Plugin-Version`,
`Copilot-Integration-Id`; `+X-GitHub-Api-Version` on `/models`, `+X-Initiator
user` on inference) — gated on `SamplerConfig.github_copilot` /
`PlatformId::sends_copilot_editor_headers()` so every other ChatCompletions
provider stays byte-identical. WIRE-COMPAT SCOPE: Kigi is one-wire-per-
platform, so the catalog is FILTERED (`parse_github_copilot_listing`) to the
openai-completions-served models — keep iff `model_picker_enabled` &&
`policy.state != "disabled"` && `tool_calls != false` AND the id is NOT a
`claude-(haiku|sonnet|opus)-[45]` (anthropic-messages) or `gpt-5/oswe/mai-`
(responses-only) model. Those excluded models need per-model wire routing
(deferred, documented debt), NOT included lest they fail at inference.
KNOWN LIMITATION: Kigi does NOT port Pi's per-model policy-acceptance step
(`POST {base}/models/{id}/policy {state:"enabled"}`). A kept model whose
Copilot policy is unconfigured can list yet `403` at inference until the user
enables it once in GitHub's UI — a deliberate omission (it mutates account
state and is unverifiable without a live Copilot account), not a silent gap.
These are INTERACTIVE login rows advertised right after `kimi-code`
(`AuthMethodKind::OAuthPlatform`, in `PlatformId::ALL` order: `xai-grok`,
`claude-pro-max`, `github-copilot`, `openai-codex`). The catalog fetch resolves each such platform's OWN session
token (`resolve_generic_oauth_tokens`, refreshed on expiry) and routes
`platform.oauth().is_some()` → `platform.base_url()` (kimi-code alone →
`proxy_url()`). Tokens/codes/verifiers are NEVER logged.
- A stored subscription-OAuth session IS a catalog fetch source. Every
fetch-PLAN decision that cannot afford real token resolution — the startup
prefetch arming gate, `on_auth_changed`'s wipe guard
(`should_wipe_catalog_on_auth_change`), and cache-origin computation — uses
the sync presence probes `models_fetch::stored_oauth_platforms` /
`stored_oauth_token_stubs` (auth.json scope scan; names only, no bearers).
All three must see the SAME enabled-platform set the real fetch enables, or
a claude-pro-max-only user boots onto the bundled Kimi table with an empty
picker. Managed catalog entries stamp `meta.provider` (platform display
name) for the client's `/model` picker; the picker itself lists the fetched
catalog, which is by construction the credentialed providers' models.
- INFERENCE-AUTH CHOKEPOINT (security): `auth::credential_authority::CredentialAuthority`
is the ONE authority answering "WHICH credential — if any — may ride this
request?". It holds the session's effective `EndpointsConfig` and
its primary `AuthManager` PRIVATELY and answers only
`(platform, base_url)` questions: `credential_class` → `CredentialClass::{Pooled,
Primary, None}` (the outer term of `auth_method::session_token_auth_gate`),
`manager_for` (the governing manager
for refresh / 401 recovery), `credential_for` (the request's `api_key`) and
`bearer_resolver_for` (the aux/summary/session resolver). Do NOT re-derive this
rule anywhere else — three rounds of leaks came from exactly that.
The rule: a subscription-OAuth platform rides ITS OWN pooled `AuthManager` (so
it keeps a live `bearer_resolver` and mid-session refresh despite a
non-first-party base URL) and ONLY at its own `platform.base_url()` — a
`[model."claude-pro-max/x"]` override keeps `info.id` but can point `base_url`
anywhere. `kimi-code` and a platform-less model (a bare slug / `[model.*]`
entry) ride the PRIMARY session, and ONLY at the session's own effective coding
endpoint: `EndpointsConfig::proxy_url()` (which prefers `[endpoints]
coding_api_base_url` from **config.toml** — what the managed-config sync writes
— over `KIGI_CODE_BASE_URL`), `models_base_url`, loopback, or the compiled
production endpoint. Never a blanket allow: BYOK is `has_own_credentials()`,
which probes `std::env::var` at call time, so a `[model.*]` block with an unset
`env_key` classifies `NotByok`. Every API-key registry platform rides NOTHING.
STRUCTURAL ENFORCEMENT: the authority is the only producer of
`SessionCredential`, an opaque type with no production constructor, and every
API that stamps a session bearer onto a request (`resolve_credentials`,
`resolve_aux_model_sampling_config`, `try_resolve_model_credentials`,
`resolve_chat_state_auth_type`) takes
`Option<&SessionCredential>` rather than `Option<&str>` — a new call site
cannot express the leak. `stamp_session_local_sampler_fields` likewise takes the
aux `bearer_resolver` explicitly instead of copying the session's and relying on
the caller to re-point it, and `sampler_turn::aux_bearer_resolver_for` is the
ONE definition of the aux/summary resolver rule (the session actor and
`MvpAgent::build_summary_client` both call it; a private second copy is how the
summary client stayed ungated after M3). NEVER HAND-CARRY A CREDENTIAL TO A
GUARD (C1): "may **a** session credential ride here" is `true` for
a subscription-OAuth platform at its own host — where the credential that may
ride is that platform's POOLED token, never the primary. Ask
`credential_for(platform, base_url)` and stamp what it returns, so the question
and the credential are the same object; where you cannot (a credential the
authority does not own), MATCH on `credential_class` rather than reach for a
boolean. There is deliberately no second, similarly-named predicate to pick
wrongly. The shared `sampling_config.api_key`
(the subagent baseline and the unresolved-model fallback) has exactly TWO
production writers, both guarded by the authority:
`MvpAgent::stamp_session_credential` (the `cached_token` / `kimi.com/oidc`
login handlers and the `new_session` / `load_session` seed), which asks
`credential_for`; and the `xai.api_key` handler in `acp_agent.rs`, which stamps
the house `KIGI_API_KEY` read from the environment — a credential the authority
does not own — only when `credential_class` is `Primary`, the class the house
key rides and the one every OAuth platform's own host is NOT.
- MODEL→PLATFORM LOOKUP (security): `SamplingConfig::model` is the BARE routing
slug, and duplicate slugs across platforms are BY DESIGN — an API-key platform
and its subscription-OAuth twin list identical ids (`xai`/`xai-grok`,
`anthropic`/`claude-pro-max`, `openai`/`openai-codex`), with the API-key
platform FIRST in `PlatformId::ALL`. The auth layer therefore resolves the
platform from the catalog KEY the picker selected, held PER SESSION in
`SessionActor::selected_catalog_key` (seeded at spawn by
`agent::models::selected_catalog_key_for_spawn`, rewritten by `SetSessionModel`,
CLEARED by `OverrideModelName` when the rename makes it stale), via
`agent::models::entry_for_slug`/`platform_for_slug`.
NEVER `ModelsManager::current_model_id()`: that cell is process-global,
last-writer-wins across concurrent sessions, and Leader mode never writes it at
all (`agent/handlers/model_switch.rs`). The shared `MvpAgent::sampling_config`
is BUILT from that cell — but ONCE, at startup, and never rebuilt, while the
cell moves on every non-Leader switch. Its guards therefore read
`MvpAgent::sampling_config_platform`, the platform captured WITH the config by
the same `ModelsManager::sampling_config()` call, never a fresh lookup against
the live cell: once the two drift, re-resolving the config's bare slug falls
through to `resolve_catalog_key`'s `.rev()` scan, answers the API-key twin, and
a post-expiry `kigi login` silently leaves the EXPIRED bearer in the config
that seeds every subagent (H-a). REFUSE RATHER THAN GUESS (H-b):
when the per-session key does not name the slug, `platform_for_slug` returns
`None` for a slug that collides across platforms rather than trusting
`resolve_catalog_key`'s `.rev()` last match — which is the subscription-OAuth
twin, so the guess hands an API-key session the pooled bearer that REPLACES its
own key on the wire. `None` then routes purely by the ENDPOINT, which for an
OAuth host means no credential, no resolver and no adaptation.
Anything else (aux models, subagent
overrides) falls back to the picker's own `resolve_catalog_key`, and
`config::find_model_by_id`'s slug scan takes the LAST match so the two can
never disagree. Resolving the wrong twin costs the OAuth platform its live
`bearer_resolver` (unrecoverable 401 ~1h in), its Messages adaptation and its
Copilot/Codex identity headers — and hands the API-key twin's session a pooled
OAuth bearer stamped over the user's own key.
- CATALOG VISIBILITY: `platform_wire_model_to_entry` stamps
`supported_in_api = platform != KimiCode`. `ModelInfo::visible_for_auth`
reads only the PRIMARY manager's auth mode, so gating the other OAuth
platforms on it would hide every model from a user who signed in with ONLY a
Claude Pro/Max, ChatGPT, Copilot, or Grok subscription. Only `kimi-code`
rides the primary session, so only it may be gated on it.
- Model metadata (context window, thinking levels) comes from the provider
wire when served; metadata-poor listings are enriched from models.dev
(`kigi-models/src/enrichment.rs` — bundled raw snapshot regenerated by
`scripts/gen_enrichment_snapshot.py`, single Rust transform
`parse_api_json` for bundled + runtime refresh; 24h cache
`~/.kigi/models_dev_cache.json`). Wire values always win; enrichment
never invents model availability. Canonical reasoning efforts:
none/minimal/low/medium/high/xhigh/max/ultra (`max` split from `xhigh`
2026-07; `ultra` is codex-only, above `max`, surfaced only via a model's
server-declared effort menu; Kimi wire spells its top tier `max`, kimi_compat
renames).
## Milestones (PRD §8.3)
- M0 (done): rename, deletions (voice/telemetry/announcements/marketplace/
Generated
+64 -62
View File
@@ -5442,7 +5442,7 @@ dependencies = [
[[package]]
name = "kigi-acp-lib"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5456,7 +5456,7 @@ dependencies = [
[[package]]
name = "kigi-agent"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -5486,7 +5486,7 @@ dependencies = [
[[package]]
name = "kigi-agent-lifecycle"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"async-trait",
"tokio",
@@ -5495,7 +5495,7 @@ dependencies = [
[[package]]
name = "kigi-auth"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"async-trait",
"http 1.4.2",
@@ -5508,7 +5508,7 @@ dependencies = [
[[package]]
name = "kigi-bin"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"anyhow",
"clap",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]]
name = "kigi-chat-state"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"indexmap",
"kigi-compaction",
@@ -5560,7 +5560,7 @@ dependencies = [
[[package]]
name = "kigi-codebase-graph"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"ahash",
"clap",
@@ -5596,7 +5596,7 @@ dependencies = [
[[package]]
name = "kigi-compaction"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"anyhow",
"async-trait",
@@ -5609,7 +5609,7 @@ dependencies = [
[[package]]
name = "kigi-config"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"base64",
"blake3",
@@ -5632,7 +5632,7 @@ dependencies = [
[[package]]
name = "kigi-config-types"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"indexmap",
@@ -5646,7 +5646,7 @@ dependencies = [
[[package]]
name = "kigi-crash-handler"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"backtrace",
"libc",
@@ -5657,7 +5657,7 @@ dependencies = [
[[package]]
name = "kigi-env"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"tracing",
"url",
@@ -5665,7 +5665,7 @@ dependencies = [
[[package]]
name = "kigi-fast-worktree"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"anyhow",
"bytes",
@@ -5697,7 +5697,7 @@ dependencies = [
[[package]]
name = "kigi-file-utils"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"anyhow",
"aws-config",
@@ -5721,7 +5721,7 @@ dependencies = [
[[package]]
name = "kigi-fsnotify"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"criterion",
"dunce",
@@ -5742,7 +5742,7 @@ dependencies = [
[[package]]
name = "kigi-gix-status"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"gix",
"kigi-test-utils",
@@ -5752,7 +5752,7 @@ dependencies = [
[[package]]
name = "kigi-hooks"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"fastrand",
"kigi-config",
@@ -5771,7 +5771,7 @@ dependencies = [
[[package]]
name = "kigi-hooks-plugins-types"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"serde",
"serde_json",
@@ -5779,7 +5779,7 @@ dependencies = [
[[package]]
name = "kigi-http"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"kigi-auth",
"kigi-log",
@@ -5794,7 +5794,7 @@ dependencies = [
[[package]]
name = "kigi-hunk-tracker"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"chrono",
"dunce",
@@ -5815,14 +5815,14 @@ dependencies = [
[[package]]
name = "kigi-interjection-core"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"serde",
]
[[package]]
name = "kigi-log"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"anyhow",
"chrono",
@@ -5840,7 +5840,7 @@ dependencies = [
[[package]]
name = "kigi-markdown"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"anstyle",
"anstyle-lossy",
@@ -5864,14 +5864,14 @@ dependencies = [
[[package]]
name = "kigi-markdown-core"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "kigi-mcp"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5908,7 +5908,7 @@ dependencies = [
[[package]]
name = "kigi-memory"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"anyhow",
"arc-swap",
@@ -5942,7 +5942,7 @@ dependencies = [
[[package]]
name = "kigi-mermaid"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"fontdb",
"image",
@@ -5960,16 +5960,17 @@ dependencies = [
[[package]]
name = "kigi-models"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"kigi-env",
"serde",
"serde_json",
"tracing",
]
[[package]]
name = "kigi-pager-minimal"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"chrono",
"crossterm",
@@ -5986,7 +5987,7 @@ dependencies = [
[[package]]
name = "kigi-pager-pty-harness"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -6011,7 +6012,7 @@ dependencies = [
[[package]]
name = "kigi-pager-render"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"anstyle",
@@ -6063,7 +6064,7 @@ dependencies = [
[[package]]
name = "kigi-paths"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"camino",
"serde",
@@ -6073,7 +6074,7 @@ dependencies = [
[[package]]
name = "kigi-prompt-queue"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"serde",
"serde_json",
@@ -6081,7 +6082,7 @@ dependencies = [
[[package]]
name = "kigi-proto-build"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"anyhow",
"pbjson-build",
@@ -6092,7 +6093,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-inline"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"ansi-width",
"anstyle-parse 0.2.7",
@@ -6109,7 +6110,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-textarea"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"arboard",
"chrono",
@@ -6130,7 +6131,7 @@ dependencies = [
[[package]]
name = "kigi-sampler"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"async-openai",
"async-stream",
@@ -6153,10 +6154,11 @@ dependencies = [
[[package]]
name = "kigi-sampling-types"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"assert_matches",
"async-openai",
"base64",
"indexmap",
"kigi-compaction",
"kigi-tools",
@@ -6169,7 +6171,7 @@ dependencies = [
[[package]]
name = "kigi-sandbox"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"anyhow",
"chrono",
@@ -6190,7 +6192,7 @@ dependencies = [
[[package]]
name = "kigi-secrets"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"regex",
"serde_json",
@@ -6228,7 +6230,7 @@ dependencies = [
[[package]]
name = "kigi-shell"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6365,7 +6367,7 @@ dependencies = [
[[package]]
name = "kigi-shell-base"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"anyhow",
"chrono",
@@ -6390,7 +6392,7 @@ dependencies = [
[[package]]
name = "kigi-sqlite-journal"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"libc",
"rusqlite",
@@ -6401,7 +6403,7 @@ dependencies = [
[[package]]
name = "kigi-subagent-resolution"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"kigi-sampling-types",
"kigi-tool-types",
@@ -6416,7 +6418,7 @@ dependencies = [
[[package]]
name = "kigi-system-power"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"windows-sys 0.59.0",
"zbus",
@@ -6424,7 +6426,7 @@ dependencies = [
[[package]]
name = "kigi-test-support"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6446,7 +6448,7 @@ dependencies = [
[[package]]
name = "kigi-test-utils"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"runfiles",
"tracing",
@@ -6455,11 +6457,11 @@ dependencies = [
[[package]]
name = "kigi-token-estimation"
version = "0.1.3"
version = "0.1.8"
[[package]]
name = "kigi-tool-protocol"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"kigi-tool-types",
"serde",
@@ -6470,7 +6472,7 @@ dependencies = [
[[package]]
name = "kigi-tool-runtime"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"anyhow",
"async-trait",
@@ -6488,7 +6490,7 @@ dependencies = [
[[package]]
name = "kigi-tool-types"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"minijinja",
"schemars 1.2.1",
@@ -6498,7 +6500,7 @@ dependencies = [
[[package]]
name = "kigi-tools"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"anyhow",
"arc-swap",
@@ -6575,7 +6577,7 @@ dependencies = [
[[package]]
name = "kigi-tools-api"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"kigi-proto-build",
"kigi-tool-protocol",
@@ -6588,11 +6590,11 @@ dependencies = [
[[package]]
name = "kigi-tracing-macros"
version = "0.1.3"
version = "0.1.8"
[[package]]
name = "kigi-tty-utils"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"libc",
"nix 0.30.1",
@@ -6602,7 +6604,7 @@ dependencies = [
[[package]]
name = "kigi-tui"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"ansi-to-tui",
@@ -6689,7 +6691,7 @@ dependencies = [
[[package]]
name = "kigi-update"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"anyhow",
"dunce",
@@ -6718,14 +6720,14 @@ dependencies = [
[[package]]
name = "kigi-version"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"semver",
]
[[package]]
name = "kigi-workspace"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6804,7 +6806,7 @@ dependencies = [
[[package]]
name = "kigi-workspace-types"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"base64",
"chrono",
@@ -8838,7 +8840,7 @@ dependencies = [
[[package]]
name = "ptyctl"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -8856,7 +8858,7 @@ dependencies = [
[[package]]
name = "ptyctl-cli"
version = "0.1.3"
version = "0.1.8"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -76,7 +76,7 @@ members = [
]
[workspace.package]
version = "0.1.3"
version = "0.1.8"
edition = "2024"
license = "Apache-2.0"
+5
View File
@@ -19,3 +19,8 @@ Modifications relative to the upstream work (Apache License 2.0 §4(b) notice):
This product includes code ported from openai/codex and sst/opencode; see
crates/codegen/kigi-tools/THIRD_PARTY_NOTICES.md for the license terms and
the per-file change notices.
This product bundles model metadata derived from models.dev
(https://github.com/sst/models.dev, MIT License) as
crates/codegen/kigi-models/enrichment_snapshot.json, and may refresh it
from https://models.dev/api.json at runtime.
+113 -61
View File
@@ -2,17 +2,23 @@
<h1>Kigi (<code>kigi</code>) 🌘</h1>
**Kigi** is an unofficial Kimi Code CLI community build — a terminal-based
AI coding agent re-targeted at the Kimi Code subscription API and the
Moonshot open platform, built on the Apache-2.0 sources of
[xai-org/grok-build](https://github.com/xai-org/grok-build).
<h3>🕸️ The world's first CLI with built-in <em>Graph Engineering</em></h3>
It runs as a full-screen TUI that understands your codebase, edits files,
executes shell commands, searches the web, and manages long-running tasks —
interactively, headlessly for scripting/CI, or embedded in editors via the
Agent Client Protocol (ACP).
<p><code>/graph</code> turns one objective into a dependency graph of
autonomous, self-verifying agent loops — planned, parallelized,
adversarially verified, and merged back, end to end.</p>
**Kigi** is a coding agent that lives in your terminal. It reads the repo,
writes the patch, runs the tests, and keeps going while you do something else.
Full-screen, headless in CI with `-p`, or docked in your editor over ACP.
**Already paying for Claude Pro/Max, ChatGPT Plus/Pro, GitHub Copilot, or
Grok? Sign in and use it.** No API key, no second bill. Rather bring your own
key? OpenAI, Anthropic, Google, DeepSeek, Groq, Moonshot and
[two dozen more](#providers-and-api-keys) are wired in.
[Installation](#installation) ·
[Graph engineering](#graph-engineering) ·
[Providers and API keys](#providers-and-api-keys) ·
[Building from source](#building-from-source) ·
[Coexistence with the official CLI](#coexistence-with-the-official-kimi-cli) ·
@@ -28,10 +34,6 @@ Agent Client Protocol (ACP).
## Installation
Prebuilt single-file binaries for macOS (arm64/x86_64), Linux (arm64/x86_64),
and Windows (x86_64) are published on
[GitHub Releases](https://github.com/ZacharyZhang-NY/Kigi-CLI/releases):
```sh
# macOS / Linux
curl -fsSL https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/install.sh | bash
@@ -43,61 +45,115 @@ irm https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/install.ps1
```
```sh
kigi --version # kigi 0.1.1 … unofficial Kimi Code CLI community build
kigi login # sign in with your Kimi Code subscription (device-code flow)
kigi # start the TUI
kigi login # pick a provider, sign in
kigi # go
```
The installer verifies every download against the release's `SHA256SUMS`,
installs into `~/.kigi/bin/kigi` (`%USERPROFILE%\.kigi\bin\kigi.exe` on
Windows), and prints the PATH line to add. Later releases arrive through the
built-in self-updater (`kigi update`, gated by `KIGI_AUTO_UPDATE`), which
pulls from the same GitHub Releases feed.
Single file, no runtime. macOS and Linux on arm64/x86_64, Windows on x86_64,
checksummed against the release's `SHA256SUMS`. `kigi update` handles upgrades.
## Graph engineering
Every other agent runs a loop: think, act, repeat — one thread, one thing at a
time. `/graph` runs a dependency graph instead. Work that doesn't block other
work happens at the same time, in separate worktrees, and nothing merges until
something else has tried to tear it apart.
```
/graph <objective> [--budget <tokens>] # decompose + run fully autonomously
/graph status # node tree, budget, current work
/graph show # box-drawing DAG view
/graph pause | resume [--budget <n>] # halt / continue (budget top-up)
/graph clear # abandon the graph
```
One command runs the whole thing, start to finish:
- A planner breaks your objective into a dependency DAG, then validates it.
- Independent nodes fan out as parallel workers, each in its own git worktree.
- Every node has to get past an adversarial verifier before it merges back.
- Find something out of scope? Say `DISCOVERED:` and the graph replans —
append-only, so nothing already agreed on gets rewritten.
- Between passes, a topology optimizer drops dependencies that were never real.
- A final node re-checks the *whole* objective before the graph is allowed to
call itself done.
State lives in `.kigi/graph.jsonl`, next to your code. Close the laptop, come
back tomorrow, `/graph resume`. A teammate can pick it up from the same file.
On by default. `KIGI_GRAPH=0` turns it off; `KIGI_GRAPH_CONCURRENCY` (default
3) controls how many nodes run at once.
## Providers and API keys
Kigi talks to a fixed three-platform registry:
29 platforms ship compiled in: 5 you sign into, 24 you hand a key. Nothing is
registered at runtime — if it's not in this list, it's not there.
| Platform id | Base URL | Auth |
| ------------- | -------------------------------- | --------------------------- |
| `kimi-code` | `https://api.kimi.com/coding/v1` | Kimi Code subscription OAuth (`kigi login`) |
| `moonshot-cn` | `https://api.moonshot.cn/v1` | Moonshot open-platform API key |
| `moonshot-ai` | `https://api.moonshot.ai/v1` | Moonshot open-platform API key |
**Sign in with a subscription you already pay for.** Run `kigi login` and pick.
Each provider's token is stored under its own key, and one provider's
credentials are never sent to another.
Moonshot API keys come from the environment or `~/.kigi/config.toml`
(environment wins; values are never logged):
| Platform id | Provider | Sign-in |
| ---------------- | ------------------------- | ------------------------------------------- |
| `kimi-code` | Kimi Code (original target)| Subscription OAuth (device code) |
| `claude-pro-max` | Claude Pro/Max | Subscription OAuth (browser, PKCE) |
| `openai-codex` | ChatGPT Plus/Pro (Codex) | Subscription OAuth (browser, PKCE) |
| `github-copilot` | GitHub Copilot | Subscription OAuth (device code) |
| `xai-grok` | xAI Grok | Subscription OAuth (device code) |
You get whatever models your plan actually serves — the list is fetched at
sign-in, not hardcoded. (ChatGPT/Codex is the exception: its backend publishes
no model endpoint, so those four are compiled in.)
**API-key providers.** Export the env var, or drop the key in
`~/.kigi/config.toml`. Keys are never logged.
| Provider | Platform id | API key env |
| ------------------------- | ---------------------- | ----------------------------------------------- |
| Moonshot (moonshot.cn) | `moonshot-cn` | `KIGI_MOONSHOT_CN_API_KEY` (or `KIGI_MOONSHOT_API_KEY`) |
| Moonshot (moonshot.ai) | `moonshot-ai` | `KIGI_MOONSHOT_AI_API_KEY` (or `KIGI_MOONSHOT_API_KEY`) |
| OpenAI | `openai` | `OPENAI_API_KEY` |
| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` |
| DeepSeek | `deepseek` | `DEEPSEEK_API_KEY` |
| Groq | `groq` | `GROQ_API_KEY` |
| Mistral | `mistral` | `MISTRAL_API_KEY` |
| Fireworks AI | `fireworks` | `FIREWORKS_API_KEY` |
| Google Gemini | `google` | `GEMINI_API_KEY` |
| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` |
| Together AI | `together` | `TOGETHER_API_KEY` |
| Cerebras | `cerebras` | `CEREBRAS_API_KEY` |
| NVIDIA NIM | `nvidia` | `NVIDIA_API_KEY` |
| Vercel AI Gateway | `vercel-ai-gateway` | `AI_GATEWAY_API_KEY` |
| xAI (Grok) | `xai` | `XAI_API_KEY` |
| Qwen Token Plan | `qwen-token-plan` | `QWEN_TOKEN_PLAN_API_KEY` |
| Qwen Token Plan (China) | `qwen-token-plan-cn` | `QWEN_TOKEN_PLAN_CN_API_KEY` |
| Kimi For Coding | `kimi-coding` | `KIMI_API_KEY` |
| Z.AI | `zai` | `ZAI_API_KEY` |
| Z.AI Coding (China) | `zai-coding-cn` | `ZAI_CODING_CN_API_KEY` |
| Xiaomi MiMo | `xiaomi` | `XIAOMI_API_KEY` |
| Xiaomi Token Plan (China) | `xiaomi-token-plan-cn` | `XIAOMI_TOKEN_PLAN_CN_API_KEY` |
| MiniMax | `minimax` | `MINIMAX_API_KEY` |
| MiniMax (China) | `minimax-cn` | `MINIMAX_CN_API_KEY` |
```sh
export KIGI_MOONSHOT_API_KEY=sk-... # applies to both open platforms
export KIGI_MOONSHOT_CN_API_KEY=sk-... # platform-scoped, beats the generic name
export KIGI_MOONSHOT_AI_API_KEY=sk-...
export OPENAI_API_KEY=sk-...
export XAI_API_KEY=xai-...
```
```toml
# ~/.kigi/config.toml
[platforms.moonshot-cn]
[platforms.openai]
api_key = "sk-..."
[platforms.moonshot-ai]
api_key = "sk-..."
[platforms.xai]
api_key = "xai-..."
```
On login and on startup Kigi syncs each configured platform's model list
from `GET {base}/models` and shows the merged catalog in the model picker
(catalog keys are `{platform_id}/{model_id}`). Models that advertise
selectable thinking levels (e.g. K3's `low`/`high`/`max`) expose them in
`/model` and `/effort`. If the sync fails, the last cached catalog is used;
with no cache, a small built-in fallback list applies. Model selection
resolves as `--model` CLI flag > `KIGI_DEFAULT_MODEL` > `[models] default`
in config.toml > server-delivered list > built-in fallback.
Model lists sync on startup. Pick one with `/model`, set its thinking level
with `/effort`.
`KIGI_CODE_BASE_URL` re-points the subscription platform (useful for
testing); `KIGI_MOONSHOT_CN_BASE_URL` / `KIGI_MOONSHOT_AI_BASE_URL` are the
equivalent dev/test overrides for the open platforms.
The web `search`/`fetch` tools ride the Kimi Code subscription services and
are present only on OAuth sessions — API-key-only sessions run without
them, matching the official client.
Web `search`/`fetch` need a Kimi Code subscription; API-key sessions run
without them, same as the official client.
## Building from source
@@ -113,19 +169,15 @@ launcher at `bin/protoc`; install dotslash (`brew install dotslash` or
## Coexistence with the official Kimi CLI
Kigi is not affiliated with Moonshot AI or xAI, and it coexists with the
official `kimi` CLI on the same machine: independent binary name,
independent config directory (`~/.kigi`), independent keyring credentials
(service `kigi`), and a `KIGI_*` environment-variable namespace. Nothing
the official client installs or stores is ever read at runtime or written.
On first launch Kigi offers a **one-time, strictly read-only** import of
your existing `~/.kimi` configuration (MCP servers, custom providers,
default model) via `kigi import-kimi` — file contents and mtimes under
`~/.kimi` are left untouched, verified by tests.
Kigi started as an unofficial Kimi Code CLI — a community fork of
[xai-org/grok-build](https://github.com/xai-org/grok-build), not affiliated
with Moonshot AI or xAI. It keeps its own binary, its own `~/.kigi`, its own
keyring entry, and its own `KIGI_*` env vars, and never touches what the
official `kimi` CLI installed. `kigi import-kimi` copies your old config over
once, read-only.
Kigi is **zero-telemetry**: the only outbound connections are the
inference/auth APIs you configure, GitHub Releases for updates, and MCP
servers you add.
**Zero telemetry.** It talks to the APIs you configured, GitHub Releases, and
your own MCP servers. Nothing else.
## License
@@ -272,6 +272,7 @@ mod tests {
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: std::num::NonZeroU64::new(128_000).unwrap(),
reasoning_effort: None,
@@ -23,6 +23,7 @@ fn test_config_with_window(context_window: u64) -> SamplingConfig {
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: NonZeroU64::new(context_window)
.expect("test context_window must be non-zero"),
@@ -913,6 +914,7 @@ async fn update_sampling_config_is_queryable() {
temperature: Some(0.5),
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: NonZeroU64::new(200_000).unwrap(),
reasoning_effort: None,
@@ -1298,6 +1300,7 @@ async fn build_request_uses_sampling_config() {
temperature: Some(0.7),
top_p: Some(0.9),
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: NonZeroU64::new(128_000).unwrap(),
reasoning_effort: None,
@@ -3401,6 +3404,7 @@ async fn sampling_config_survives_compaction_replacement() {
temperature: Some(0.7),
top_p: Some(0.95),
api_backend: ApiBackend::Responses,
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: NonZeroU64::new(500_000).unwrap(),
reasoning_effort: None,
@@ -3481,6 +3485,7 @@ async fn model_metadata_lost_after_compaction_then_recovered_on_next_turn() {
temperature: Some(0.7),
top_p: Some(0.95),
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: NonZeroU64::new(500_000).unwrap(),
reasoning_effort: None,
@@ -3569,6 +3574,7 @@ async fn context_window_downgrade_triggers_auto_compact() {
temperature: Some(0.7),
top_p: Some(0.95),
api_backend: ApiBackend::Responses,
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: NonZeroU64::new(500_000).unwrap(),
reasoning_effort: None,
@@ -370,6 +370,7 @@ mod tests {
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: std::num::NonZeroU64::new(128_000).unwrap(),
reasoning_effort: None,
@@ -178,6 +178,7 @@ mod tests {
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: NonZeroU64::new(128_000).unwrap(),
reasoning_effort: None,
@@ -221,6 +222,7 @@ mod tests {
temperature: Some(0.7),
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: NonZeroU64::new(128_000).unwrap(),
reasoning_effort: None,
+1
View File
@@ -9,6 +9,7 @@ description = "Kimi platform registry, /models wire contract, capability derivat
kigi-env = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tracing = { workspace = true }
[lints]
workspace = true
File diff suppressed because one or more lines are too long
@@ -0,0 +1,388 @@
//! models.dev metadata enrichment (multi-provider expansion).
//!
//! Most provider `/models` listings return bare ids — no context window, no
//! thinking levels. This module carries per-model metadata keyed by
//! models.dev provider id, sourced from the bundled snapshot
//! (`enrichment_snapshot.json`, regenerated by
//! `scripts/gen_enrichment_snapshot.py`) or a runtime refresh fetched by the
//! shell. It NEVER invents model availability: the live listing is the only
//! source of which models exist — enrichment only fills metadata gaps, and
//! wire-served values always win (see [`enrich_wire_model`]).
use std::collections::BTreeMap;
use std::sync::LazyLock;
/// One model's enrichment metadata. All fields optional-by-default so the
/// snapshot stays minimal.
#[derive(Debug, Clone, Default, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct EnrichmentModel {
/// Max context window in tokens (`limit.context`).
#[serde(default, skip_serializing_if = "is_zero")]
pub context: u64,
/// Max output tokens (`limit.output`); fills a wire-unserved output cap
/// (Anthropic 400s when `max_tokens` exceeds the model's limit).
#[serde(default, skip_serializing_if = "is_zero")]
pub output: u64,
/// Model supports reasoning/thinking.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub reasoning: bool,
/// Selectable effort levels (canonical tokens, e.g. ["low","high","max"]).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub efforts: Vec<String>,
/// Accepts image input.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub image_in: bool,
/// Supports tool calling (used later to filter non-agentic listings).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub tool_call: bool,
/// Human display name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
fn is_zero(v: &u64) -> bool {
*v == 0
}
/// `models.dev provider id -> model id -> metadata`.
pub type EnrichmentCatalog = BTreeMap<String, BTreeMap<String, EnrichmentModel>>;
/// The bundled snapshot, embedded at compile time — RAW models.dev shape,
/// filtered to the providers kigi references (pure-filter script:
/// `scripts/gen_enrichment_snapshot.py`). OFFLINE FALLBACK for the runtime
/// refresh; both parse through [`parse_api_json`] so there is exactly one
/// field interpretation.
pub const ENRICHMENT_SNAPSHOT_JSON: &str = include_str!("../enrichment_snapshot.json");
static BUNDLED: LazyLock<EnrichmentCatalog> = LazyLock::new(|| {
// Baked-in JSON — a mismatch here is a developer error, not a runtime
// condition (same policy as default_models.json).
parse_api_json(ENRICHMENT_SNAPSHOT_JSON, None)
.expect("enrichment_snapshot.json: invalid JSON (regenerate via script)")
});
// ── Raw models.dev api.json shape (parse-only) ──────────────────────────────
#[derive(serde::Deserialize)]
struct RawProvider {
#[serde(default)]
models: BTreeMap<String, RawModel>,
}
#[derive(serde::Deserialize, Default)]
#[serde(default)]
struct RawModel {
name: Option<String>,
reasoning: bool,
reasoning_options: Vec<RawReasoningOption>,
limit: RawLimit,
modalities: RawModalities,
tool_call: bool,
}
#[derive(serde::Deserialize, Default)]
#[serde(default)]
struct RawReasoningOption {
r#type: String,
values: Vec<String>,
}
#[derive(serde::Deserialize, Default)]
#[serde(default)]
struct RawLimit {
context: u64,
output: u64,
}
#[derive(serde::Deserialize, Default)]
#[serde(default)]
struct RawModalities {
input: Vec<String>,
}
/// Parse a models.dev `api.json` document (full download or the bundled
/// filtered snapshot) into the in-memory catalog. `keep`: restrict to these
/// provider ids (runtime refresh filters the full 3MB download to the
/// registry's providers); `None` keeps everything present.
///
/// Blast-radius confinement: providers are filtered on RAW keys first and
/// typed-parsed individually — a schema drift in one of the ~70 providers
/// kigi never keeps cannot fail the whole refresh, and a malformed KEPT
/// provider is warn-skipped (its models fall to defaults) rather than
/// killing the others. Only a document that isn't a JSON object errors
/// (the caller falls back to cache/bundled — never a silently empty
/// catalog). Keys starting with `_` (provenance stamps) are skipped.
pub fn parse_api_json(
json: &str,
keep: Option<&std::collections::BTreeSet<&str>>,
) -> Result<EnrichmentCatalog, serde_json::Error> {
let raw: BTreeMap<String, serde_json::Value> = serde_json::from_str(json)?;
let mut catalog = EnrichmentCatalog::new();
for (pid, value) in raw {
if pid.starts_with('_') {
continue;
}
if let Some(keep) = keep
&& !keep.contains(pid.as_str())
{
continue;
}
let provider: RawProvider = match serde_json::from_value(value) {
Ok(p) => p,
Err(e) => {
tracing::warn!(provider = %pid, error = %e,
"models.dev provider entry malformed; skipping (models fall to defaults)");
continue;
}
};
let models = provider
.models
.into_iter()
.map(|(mid, m)| {
let efforts = m
.reasoning_options
.into_iter()
.find(|o| o.r#type == "effort" && !o.values.is_empty())
.map(|o| o.values)
.unwrap_or_default();
let meta = EnrichmentModel {
context: m.limit.context,
output: m.limit.output,
reasoning: m.reasoning,
efforts,
image_in: m.modalities.input.iter().any(|s| s == "image"),
tool_call: m.tool_call,
name: m.name,
};
(mid, meta)
})
.collect();
catalog.insert(pid, models);
}
Ok(catalog)
}
/// The compiled-in enrichment catalog.
pub fn bundled_enrichment() -> &'static EnrichmentCatalog {
&BUNDLED
}
/// Look up one model's metadata. `None` when the provider or model is
/// unknown to the catalog (callers fall through to defaults).
pub fn lookup<'a>(
catalog: &'a EnrichmentCatalog,
models_dev_id: &str,
model_id: &str,
) -> Option<&'a EnrichmentModel> {
catalog.get(models_dev_id)?.get(model_id)
}
/// Fill metadata gaps on a live listing entry. WIRE WINS: a field the
/// provider served is never overwritten — enrichment only supplies what the
/// wire left absent/zero. Never changes the model id (availability stays
/// wire-truth).
pub fn enrich_wire_model(wire: &mut crate::WireModel, meta: &EnrichmentModel) {
if wire.context_length == 0 && meta.context > 0 {
wire.context_length = meta.context;
}
if wire.max_output_tokens == 0 && meta.output > 0 {
wire.max_output_tokens = meta.output;
}
if meta.reasoning {
wire.supports_reasoning = true;
}
if meta.image_in {
wire.supports_image_in = true;
}
if wire.display_name.is_none() {
wire.display_name = meta.name.clone();
}
if wire.think_efforts.is_none() && !meta.efforts.is_empty() {
wire.think_efforts = Some(crate::WireThinkEfforts {
support: true,
valid_efforts: meta.efforts.clone(),
// The provider's implicit default applies when the user doesn't
// pick a level; models.dev doesn't record one.
default_effort: None,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The bundled snapshot parses and covers the providers this expansion
/// references; kimi entries cross-check against the live wire values the
/// registry already knows (guards against a corrupted regeneration).
#[test]
fn bundled_snapshot_parses_and_cross_checks_kimi() {
let catalog = bundled_enrichment();
assert!(
catalog.len() >= 25,
"snapshot lost providers: {}",
catalog.len()
);
let k3 = lookup(catalog, "kimi-for-coding", "k3").expect("k3 present");
assert_eq!(k3.context, 1_048_576, "k3 context must match the live wire");
assert_eq!(k3.efforts, ["low", "high", "max"]);
assert!(k3.reasoning);
let opus = lookup(catalog, "anthropic", "claude-opus-4-8").expect("opus present");
assert_eq!(opus.context, 1_000_000);
assert_eq!(opus.efforts, ["low", "medium", "high", "xhigh", "max"]);
}
/// Wire-served fields are never overwritten; absent fields are filled.
#[test]
fn enrich_fills_gaps_and_never_overwrites_wire() {
let meta = EnrichmentModel {
context: 400_000,
output: 64_000,
reasoning: true,
efforts: vec!["low".into(), "high".into()],
image_in: true,
name: Some("GPT Test".into()),
..Default::default()
};
// Bare listing entry (OpenAI-style: id only).
let mut bare: crate::WireModel =
serde_json::from_value(serde_json::json!({ "id": "gpt-test" })).unwrap();
enrich_wire_model(&mut bare, &meta);
assert_eq!(bare.context_length, 400_000);
assert_eq!(bare.max_output_tokens, 64_000, "output cap filled");
assert!(bare.supports_reasoning);
assert!(bare.supports_image_in);
assert_eq!(bare.display_name.as_deref(), Some("GPT Test"));
let efforts = bare.think_efforts.expect("efforts filled");
assert!(efforts.support);
assert_eq!(efforts.valid_efforts, ["low", "high"]);
assert_eq!(efforts.default_effort, None);
// Wire-served entry: nothing may change.
let mut served: crate::WireModel = serde_json::from_value(serde_json::json!({
"id": "gpt-test",
"context_length": 123,
"max_output_tokens": 77,
"display_name": "Wire Name",
"think_efforts": { "support": true, "valid_efforts": ["max"] }
}))
.unwrap();
enrich_wire_model(&mut served, &meta);
assert_eq!(served.context_length, 123, "wire context wins");
assert_eq!(served.max_output_tokens, 77, "wire output cap wins");
assert_eq!(served.display_name.as_deref(), Some("Wire Name"));
assert_eq!(
served.think_efforts.unwrap().valid_efforts,
["max"],
"wire efforts win"
);
}
/// The runtime refresh parses the FULL api.json (extra fields like cost/
/// env/doc present) and filters to the registry's provider ids.
#[test]
fn parse_api_json_filters_and_tolerates_unknown_fields() {
let full = serde_json::json!({
"openai": {
"id": "openai", "env": ["OPENAI_API_KEY"], "doc": "https://x",
"models": {
"gpt-test": {
"name": "GPT Test",
"reasoning": true,
"reasoning_options": [
{"type": "effort", "values": ["low", "high", "max"]}
],
"limit": {"context": 400000, "output": 128000},
"modalities": {"input": ["text", "image"], "output": ["text"]},
"tool_call": true,
"cost": {"input": 1.25, "output": 10}
}
}
},
"unwanted-provider": { "models": { "m": {} } }
})
.to_string();
let keep: std::collections::BTreeSet<&str> = ["openai"].into();
let catalog = parse_api_json(&full, Some(&keep)).unwrap();
assert!(!catalog.contains_key("unwanted-provider"));
let m = lookup(&catalog, "openai", "gpt-test").unwrap();
assert_eq!(m.context, 400_000);
assert_eq!(m.output, 128_000);
assert_eq!(m.efforts, ["low", "high", "max"]);
assert!(m.reasoning && m.image_in && m.tool_call);
assert_eq!(m.name.as_deref(), Some("GPT Test"));
// Malformed document errors — callers must fall back loudly, never
// proceed with a silently empty catalog.
assert!(parse_api_json("not json", None).is_err());
}
/// Every registry `models_dev_id` must be covered by the bundled
/// snapshot — a registry row added without updating the script's
/// TARGETS would silently diverge bundled-vs-refresh behavior.
#[test]
fn bundled_snapshot_covers_every_registry_models_dev_id() {
let catalog = bundled_enrichment();
for platform in crate::PlatformId::ALL {
if let Some(dev_id) = platform.models_dev_id() {
assert!(
catalog.contains_key(dev_id),
"{}: models_dev_id {dev_id:?} missing from the bundled \
snapshot — add it to scripts/gen_enrichment_snapshot.py \
TARGETS and regenerate",
platform.as_str(),
);
}
}
}
/// Field-coverage guard against script/parser drift: for every field
/// `RawModel` reads, at least one bundled model must carry a non-default
/// value. A regeneration that dropped a MODEL_KEYS entry (e.g.
/// `modalities`) would zero that field across the whole snapshot and
/// fail here, instead of silently diverging from runtime refreshes.
#[test]
fn bundled_snapshot_carries_every_parsed_field() {
let all: Vec<&EnrichmentModel> = bundled_enrichment()
.values()
.flat_map(|models| models.values())
.collect();
assert!(all.iter().any(|m| m.context > 0), "no context anywhere");
assert!(all.iter().any(|m| m.output > 0), "no output anywhere");
assert!(all.iter().any(|m| m.reasoning), "no reasoning anywhere");
assert!(
all.iter().any(|m| !m.efforts.is_empty()),
"no efforts anywhere"
);
assert!(all.iter().any(|m| m.image_in), "no image_in anywhere");
assert!(all.iter().any(|m| m.tool_call), "no tool_call anywhere");
assert!(all.iter().any(|m| m.name.is_some()), "no names anywhere");
}
/// One malformed provider (schema drift on models.dev) must not kill
/// the whole refresh — kept siblings still parse; only a non-object
/// document errors.
#[test]
fn malformed_provider_is_skipped_not_fatal() {
let doc = serde_json::json!({
"good": { "models": { "m": { "limit": {"context": 7} } } },
"drifted": { "models": "this is not an object" },
"_meta": { "source": "stamp, must be ignored" }
})
.to_string();
let catalog = parse_api_json(&doc, None).expect("document parses");
assert_eq!(
lookup(&catalog, "good", "m").map(|m| m.context),
Some(7),
"sibling providers must survive one drifted provider"
);
assert!(!catalog.contains_key("drifted"));
assert!(!catalog.contains_key("_meta"));
}
#[test]
fn lookup_misses_are_none() {
let catalog = bundled_enrichment();
assert!(lookup(catalog, "no-such-provider", "x").is_none());
assert!(lookup(catalog, "openai", "no-such-model").is_none());
}
}
File diff suppressed because it is too large Load Diff
@@ -97,7 +97,7 @@ impl ContentController {
// pins them to the mock so no PTY test can reach a live endpoint.
("KIGI_MOONSHOT_CN_BASE_URL".into(), self.url()),
("KIGI_MOONSHOT_AI_BASE_URL".into(), self.url()),
("XAI_API_KEY".into(), "test-key-for-ci".into()),
("KIGI_API_KEY".into(), "test-key-for-ci".into()),
("KIGI_TELEMETRY_ENABLED".into(), "false".into()),
("KIGI_FEEDBACK_ENABLED".into(), "false".into()),
("KIGI_TRACE_UPLOAD".into(), "false".into()),
@@ -287,7 +287,7 @@ mod tests {
assert_eq!(get("KIGI_API_BASE_URL"), Some(content.url()));
assert_eq!(get("KIGI_MOONSHOT_CN_BASE_URL"), Some(content.url()));
assert_eq!(get("KIGI_MOONSHOT_AI_BASE_URL"), Some(content.url()));
assert_eq!(get("XAI_API_KEY").as_deref(), Some("test-key-for-ci"));
assert_eq!(get("KIGI_API_KEY").as_deref(), Some("test-key-for-ci"));
assert_eq!(get("KIGI_TELEMETRY_ENABLED").as_deref(), Some("false"));
assert_eq!(get("KIGI_FEEDBACK_ENABLED").as_deref(), Some("false"));
assert_eq!(get("KIGI_TRACE_UPLOAD").as_deref(), Some("false"));
@@ -96,11 +96,12 @@ pub fn seed_fake_oauth(content: &ContentController, user: &str) {
.expect("seed fake oauth auth.json");
}
/// [`ContentController::env_for_pager`] minus `XAI_API_KEY`, so the entry
/// written by [`seed_fake_oauth`] is the active credential.
/// [`ContentController::env_for_pager`] minus the house BYOK key
/// (`KIGI_API_KEY`), so the entry written by [`seed_fake_oauth`] is the active
/// credential.
pub fn oauth_env_for_pager(content: &ContentController) -> Vec<(String, String)> {
let mut env = content.env_for_pager();
env.retain(|(k, _)| k != "XAI_API_KEY");
env.retain(|(k, _)| k != "KIGI_API_KEY");
env
}
@@ -89,6 +89,10 @@ mod tests {
top_p: None,
api_backend: ApiBackend::ChatCompletions,
auth_scheme: Default::default(),
anthropic_oauth: false,
github_copilot: false,
openai_codex: false,
chat_compat: Default::default(),
extra_headers: IndexMap::new(),
context_window: 8192,
force_http1: false,
+529 -9
View File
@@ -41,6 +41,35 @@ pub use kigi_sampling_types::ApiBackend;
const AGENT_PRODUCT: &str = "kigi";
const ANTHROPIC_DEFAULT_MAX_TOKENS: u32 = 128_000;
/// Prepend the required Claude-Code system block to a Messages request's
/// `system` field (Claude Pro/Max OAuth path). Anthropic inspects the FIRST
/// system block, so the prefix is inserted as a distinct leading `text` block
/// while preserving any caller-supplied prompt (string or block form).
/// Idempotent: a leading block already equal to the prefix is not re-added.
fn prepend_claude_code_system_prefix(system: &mut Option<messages::SystemParam>) {
use messages::{SystemParam, TextBlock};
let text_block = |text: String| TextBlock {
r#type: "text".to_string(),
text,
cache_control: None,
};
let mut blocks = match system.take() {
None => Vec::new(),
Some(SystemParam::Text(text)) => vec![text_block(text)],
Some(SystemParam::Blocks(blocks)) => blocks,
};
let already_present = blocks
.first()
.is_some_and(|b| b.text == kigi_sampling_types::CLAUDE_CODE_SYSTEM_PREFIX);
if !already_present {
blocks.insert(
0,
text_block(kigi_sampling_types::CLAUDE_CODE_SYSTEM_PREFIX.to_string()),
);
}
*system = Some(SystemParam::Blocks(blocks));
}
/// Parse the `Retry-After` response header as delta-seconds.
/// Our inference backends only emit integer seconds (never HTTP-date),
/// so we only handle that form. HTTP-dates silently return `None` and
@@ -70,6 +99,9 @@ fn deserialize_response_event(data: &str) -> Result<rs::ResponseStreamEvent> {
Err(first_err) => {
// Try sanitizing: parse as Value, strip unknown tools, retry.
if let Ok(mut value) = serde_json::from_str::<serde_json::Value>(data) {
// A `max` reasoning-effort echo is unrepresentable in the
// typed enum; drop it so the event parses.
kigi_sampling_types::normalize_effort_echo(&mut value);
// Strip tools that async_openai's rs::Tool can't deserialize
// (e.g., xAI-specific "x_search"). Instead of maintaining a
// hardcoded allowlist, try deserializing each tool entry —
@@ -267,8 +299,14 @@ struct ClientDefaults {
top_p: Option<f32>,
api_backend: ApiBackend,
auth_scheme: AuthScheme,
chat_compat: kigi_sampling_types::ChatCompat,
stream_tool_calls: bool,
doom_loop_recovery: Option<kigi_sampling_types::DoomLoopRecoveryPolicy>,
/// Claude Pro/Max OAuth Messages adaptation (see [`SamplerConfig`]).
anthropic_oauth: bool,
/// ChatGPT/Codex Responses adaptation (see [`SamplerConfig`]). Gates the
/// per-request `chatgpt-account-id` header derived from the bearer JWT.
openai_codex: bool,
}
// =============================================================================
@@ -368,6 +406,14 @@ impl SamplingClient {
)
})?;
headers.insert(HeaderName::from_static("x-api-key"), header_value);
if config.api_backend == kigi_sampling_types::ApiBackend::Messages {
// The real Anthropic Messages wire rejects requests
// without this; compatible endpoints ignore it.
headers.insert(
HeaderName::from_static("anthropic-version"),
HeaderValue::from_static(kigi_sampling_types::ANTHROPIC_VERSION),
);
}
}
AuthScheme::Bearer => {
let bearer = format!("Bearer {}", api_key);
@@ -386,6 +432,74 @@ impl SamplingClient {
}
}
// Claude Pro/Max OAuth identity headers (claude-pro-max only). The
// OAuth `sk-ant-oat…` bearer is Claude-Code-scoped, so Anthropic
// rejects the Messages request without the oauth beta + claude-cli
// identity. Gated on `anthropic_oauth` so API-key anthropic/minimax
// requests carry none of this and stay byte-identical. `Accept` is set
// per-request (text/event-stream for streams), so it is NOT added here.
if config.anthropic_oauth {
headers.insert(
HeaderName::from_static("anthropic-version"),
HeaderValue::from_static(kigi_sampling_types::ANTHROPIC_VERSION),
);
headers.insert(
HeaderName::from_static("anthropic-beta"),
HeaderValue::from_static(kigi_sampling_types::ANTHROPIC_OAUTH_BETA),
);
headers.insert(
HeaderName::from_static("x-app"),
HeaderValue::from_static("cli"),
);
headers.insert(
HeaderName::from_static("anthropic-dangerous-direct-browser-access"),
HeaderValue::from_static("true"),
);
}
// GitHub Copilot editor-identity headers (github-copilot only). Copilot's
// proxy validates the VS Code editor identity, so the ChatCompletions
// request MUST carry it. `User-Agent` is set in the UA block below (it
// would otherwise be overwritten); here we add the other three editor
// headers plus `X-Initiator: user`. Gated on `github_copilot` so every
// other ChatCompletions provider (groq, …) stays byte-identical.
if config.github_copilot {
headers.insert(
HeaderName::from_static("editor-version"),
HeaderValue::from_static(kigi_sampling_types::COPILOT_EDITOR_VERSION),
);
headers.insert(
HeaderName::from_static("editor-plugin-version"),
HeaderValue::from_static(kigi_sampling_types::COPILOT_EDITOR_PLUGIN_VERSION),
);
headers.insert(
HeaderName::from_static("copilot-integration-id"),
HeaderValue::from_static(kigi_sampling_types::COPILOT_INTEGRATION_ID),
);
headers.insert(
HeaderName::from_static("x-initiator"),
HeaderValue::from_static(kigi_sampling_types::COPILOT_INITIATOR),
);
}
// ChatGPT/Codex Responses identity headers (openai-codex only). The
// Codex backend authorizes the OAuth bearer AND validates the Codex
// client identity. The static pieces (`originator`, `OpenAI-Beta`) ride
// every request; `chatgpt-account-id` is dynamic (derived per-request
// from the bearer JWT in `post()`), and `User-Agent` is set in the UA
// block below. Gated on `openai_codex` so API-key `openai` Responses
// requests stay byte-identical (`store: false` is the shared default).
if config.openai_codex {
headers.insert(
HeaderName::from_static("originator"),
HeaderValue::from_static(kigi_sampling_types::CODEX_ORIGINATOR),
);
headers.insert(
HeaderName::from_static("openai-beta"),
HeaderValue::from_static(kigi_sampling_types::CODEX_OPENAI_BETA),
);
}
// Apply all extra headers verbatim. This is the single
// injection point for proxy-auth headers and any other URL- or
// environment-specific headers the session decides to set.
@@ -403,12 +517,23 @@ impl SamplingClient {
// (PRD F3: auth is a plain bearer; kimi-cli sends only User-Agent
// plus the OAuth device headers, src/kimi_cli/llm.py:317-323).
{
let ua_string = match config.origin_client.as_ref() {
Some(origin) => user_agent_string_for(origin),
None => user_agent_string_for(&OriginClientInfo {
product: AGENT_PRODUCT.to_string(),
version: Some(agent_version()),
}),
// Claude Pro/Max OAuth presents the claude-cli identity; GitHub
// Copilot presents the VS Code Copilot Chat identity; every other
// path keeps the kigi User-Agent.
let ua_string = if config.anthropic_oauth {
kigi_sampling_types::CLAUDE_CODE_USER_AGENT.to_string()
} else if config.github_copilot {
kigi_sampling_types::COPILOT_USER_AGENT.to_string()
} else if config.openai_codex {
kigi_sampling_types::CODEX_USER_AGENT.to_string()
} else {
match config.origin_client.as_ref() {
Some(origin) => user_agent_string_for(origin),
None => user_agent_string_for(&OriginClientInfo {
product: AGENT_PRODUCT.to_string(),
version: Some(agent_version()),
}),
}
};
if let Ok(v) = HeaderValue::from_str(&ua_string) {
headers.insert(USER_AGENT, v);
@@ -445,8 +570,11 @@ impl SamplingClient {
top_p: config.top_p,
api_backend: config.api_backend,
auth_scheme: config.auth_scheme,
chat_compat: config.chat_compat,
stream_tool_calls: config.stream_tool_calls,
doom_loop_recovery: config.doom_loop_recovery,
anthropic_oauth: config.anthropic_oauth,
openai_codex: config.openai_codex,
};
Ok(Self {
@@ -486,6 +614,23 @@ impl SamplingClient {
}
}
}
// ChatGPT/Codex `chatgpt-account-id` (openai-codex only): derive it
// STATELESSLY from the bearer that will actually ride this request (the
// resolver-fresh one just set, or the construction-time bearer in
// `default_headers`) by decoding its JWT claim. A refreshed token still
// carries the claim, so there is no persisted account-id field.
// openai-codex-gated → API-key `openai` never gets this header.
// SECURITY: the bearer and account id are never logged here.
if self.defaults.openai_codex
&& let Some(bearer) = headers
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
&& let Some(account_id) = kigi_sampling_types::chatgpt_account_id_from_jwt(bearer)
&& let Ok(v) = HeaderValue::from_str(&account_id)
{
headers.insert(HeaderName::from_static("chatgpt-account-id"), v);
}
{
let auth_prefix = headers
.get(AUTHORIZATION)
@@ -595,6 +740,9 @@ impl SamplingClient {
|| lower.contains("apikey")
|| lower.contains("token")
|| lower.contains("secret")
// `chatgpt-account-id` (Codex) and any other account identifier: a
// stable per-user id that must never reach a log.
|| lower.contains("account-id")
}
/// Format a single header for error messages, redacting sensitive values.
@@ -764,7 +912,10 @@ impl SamplingClient {
tracing::error!("Failed to serialize chat/completions request: {}", e);
SamplingError::Serialization(e)
})?;
crate::kimi_compat::adapt_chat_completions_body(&mut request_body);
crate::kimi_compat::adapt_chat_completions_body_for(
self.defaults.chat_compat,
&mut request_body,
);
let http_request = self
.post(self.endpoint("chat/completions"))
@@ -818,7 +969,10 @@ impl SamplingClient {
tracing::error!("Failed to serialize chat/completions request: {}", e);
SamplingError::Serialization(e)
})?;
crate::kimi_compat::adapt_chat_completions_body(&mut request_body);
crate::kimi_compat::adapt_chat_completions_body_for(
self.defaults.chat_compat,
&mut request_body,
);
let http_request = self
.post(self.endpoint("chat/completions"))
@@ -1028,6 +1182,10 @@ impl SamplingClient {
// it in post-serialize. This is the last surviving piece of the
// old raw_output machinery.
kigi_sampling_types::patch_reasoning_text_types(&mut request_body);
kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort);
if self.defaults.openai_codex {
kigi_sampling_types::adapt_body_for_codex_backend(&mut request_body);
}
let http_request = self.post(self.endpoint("responses")).json(&request_body);
let response = http_request.send().await.map_err(|e| {
@@ -1074,7 +1232,16 @@ impl SamplingClient {
});
}
let response_obj = serde_json::from_slice::<rs::Response>(&bytes).map_err(|e| {
let mut response_value =
serde_json::from_slice::<serde_json::Value>(&bytes).map_err(|e| {
let raw_body = String::from_utf8_lossy(&bytes);
tracing::error!(error = %e, raw_body = %raw_body, "Response body is not JSON");
SamplingError::Serialization(e)
})?;
// A `max` effort echo is unrepresentable in the typed enum — drop it
// rather than failing the whole response.
kigi_sampling_types::normalize_effort_echo(&mut response_value);
let response_obj = serde_json::from_value::<rs::Response>(response_value).map_err(|e| {
let raw_body = String::from_utf8_lossy(&bytes);
tracing::error!(
error = %e,
@@ -1156,6 +1323,10 @@ impl SamplingClient {
}
}
kigi_sampling_types::patch_reasoning_text_types(&mut request_body);
kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort);
if self.defaults.openai_codex {
kigi_sampling_types::adapt_body_for_codex_backend(&mut request_body);
}
// Fresh per attempt so signals never leak across retries; `None`
// (check disabled) sends no header and does no peek work per event.
let doom_loop = self
@@ -1314,6 +1485,15 @@ impl SamplingClient {
/// Apply default configuration to a Messages API request.
fn apply_message_defaults(&self, request: &mut MessagesRequestWrapper) -> Result<()> {
// Claude Pro/Max OAuth adaptation (claude-pro-max only): the OAuth
// token is Claude-Code-scoped, so the request MUST lead with the exact
// "You are Claude Code…" system block or Anthropic rejects it. Prepend
// it as a distinct first system block, preserving any caller prompt.
// Gated on `anthropic_oauth` so API-key anthropic/minimax are untouched.
if self.defaults.anthropic_oauth {
prepend_claude_code_system_prefix(&mut request.inner.system);
}
// Apply model default if not specified
if request.inner.model.is_empty() {
request.inner.model = self.defaults.model.clone();
@@ -1687,6 +1867,7 @@ impl SamplingClient {
let responses_request: rs::CreateResponse = (&request).into();
let mut wrapper = CreateResponseWrapper::new(responses_request);
wrapper.reasoning_effort = request.reasoning_effort;
wrapper.x_kigi_conv_id = x_kigi_conv_id;
wrapper.x_kigi_req_id = x_kigi_req_id;
wrapper.x_kigi_session_id = x_kigi_session_id;
@@ -1720,6 +1901,7 @@ impl SamplingClient {
let responses_request: rs::CreateResponse = (&request).into();
let mut wrapper = CreateResponseWrapper::new(responses_request);
wrapper.reasoning_effort = request.reasoning_effort;
wrapper.x_kigi_conv_id = x_kigi_conv_id;
wrapper.x_kigi_req_id = x_kigi_req_id;
wrapper.x_kigi_session_id = x_kigi_session_id;
@@ -1856,6 +2038,10 @@ mod tests {
top_p: None,
api_backend: ApiBackend::ChatCompletions,
auth_scheme: AuthScheme::Bearer,
anthropic_oauth: false,
github_copilot: false,
openai_codex: false,
chat_compat: Default::default(),
extra_headers: IndexMap::new(),
context_window: 8192,
force_http1: false,
@@ -1874,6 +2060,337 @@ mod tests {
}
}
/// The real Anthropic Messages wire rejects requests without
/// `anthropic-version`; the XApiKey+Messages client must carry it in its
/// default headers, and Bearer/ChatCompletions clients must NOT.
#[test]
fn x_api_key_messages_client_sends_anthropic_version() {
let mut config = minimal_config();
config.auth_scheme = AuthScheme::XApiKey;
config.api_backend = ApiBackend::Messages;
let client = SamplingClient::new(config).expect("client builds");
assert_eq!(
client
.default_headers
.get("anthropic-version")
.and_then(|v| v.to_str().ok()),
Some(kigi_sampling_types::ANTHROPIC_VERSION)
);
assert!(client.default_headers.get("x-api-key").is_some());
let bearer = SamplingClient::new(minimal_config()).expect("client builds");
assert!(
bearer.default_headers.get("anthropic-version").is_none(),
"non-anthropic clients must not grow the header"
);
let mut x_api_chat = minimal_config();
x_api_chat.auth_scheme = AuthScheme::XApiKey;
let x_api_chat = SamplingClient::new(x_api_chat).expect("client builds");
assert!(
x_api_chat
.default_headers
.get("anthropic-version")
.is_none(),
"XApiKey without the Messages backend must not grow the header"
);
}
/// Claude Pro/Max OAuth Messages client (`anthropic_oauth = true`, Bearer,
/// Messages) carries the full OAuth identity: Bearer auth, anthropic-version,
/// the oauth `anthropic-beta`, `x-app: cli`, the claude-cli User-Agent, and
/// the direct-browser-access header.
#[test]
fn anthropic_oauth_messages_client_sends_oauth_identity_headers() {
let mut config = minimal_config();
config.api_key = Some("sk-ant-oat-secret".to_string());
config.auth_scheme = AuthScheme::Bearer;
config.api_backend = ApiBackend::Messages;
config.anthropic_oauth = true;
let client = SamplingClient::new(config).expect("client builds");
let h = &client.default_headers;
assert_eq!(
h.get(AUTHORIZATION).and_then(|v| v.to_str().ok()),
Some("Bearer sk-ant-oat-secret"),
"OAuth path rides Authorization: Bearer, never x-api-key"
);
assert!(
h.get("x-api-key").is_none(),
"OAuth path must not send x-api-key"
);
assert_eq!(
h.get("anthropic-version").and_then(|v| v.to_str().ok()),
Some(kigi_sampling_types::ANTHROPIC_VERSION)
);
assert_eq!(
h.get("anthropic-beta").and_then(|v| v.to_str().ok()),
Some(kigi_sampling_types::ANTHROPIC_OAUTH_BETA)
);
assert_eq!(h.get("x-app").and_then(|v| v.to_str().ok()), Some("cli"));
assert_eq!(
h.get(USER_AGENT).and_then(|v| v.to_str().ok()),
Some(kigi_sampling_types::CLAUDE_CODE_USER_AGENT)
);
assert_eq!(
h.get("anthropic-dangerous-direct-browser-access")
.and_then(|v| v.to_str().ok()),
Some("true")
);
}
/// REGRESSION: an API-key Anthropic Messages client (XApiKey, NOT oauth)
/// carries NONE of the OAuth identity — no anthropic-beta, no x-app, and
/// the kigi User-Agent — so the API-key path stays byte-identical.
#[test]
fn api_key_anthropic_messages_client_has_no_oauth_identity() {
let mut config = minimal_config();
config.auth_scheme = AuthScheme::XApiKey;
config.api_backend = ApiBackend::Messages;
// anthropic_oauth stays false.
let client = SamplingClient::new(config).expect("client builds");
let h = &client.default_headers;
assert!(
h.get("anthropic-beta").is_none(),
"API-key anthropic must NOT send the oauth beta"
);
assert!(
h.get("x-app").is_none(),
"API-key anthropic must NOT send x-app"
);
assert!(h.get("anthropic-dangerous-direct-browser-access").is_none());
assert!(
h.get(USER_AGENT)
.and_then(|v| v.to_str().ok())
.is_some_and(|ua| ua.starts_with("kigi/")),
"API-key anthropic keeps the kigi User-Agent"
);
}
/// GitHub Copilot ChatCompletions client (`github_copilot = true`) carries
/// the VS Code Copilot editor-identity headers + `X-Initiator: user` and
/// presents the Copilot User-Agent (overriding the kigi UA).
#[test]
fn github_copilot_client_sends_editor_identity_headers() {
let mut config = minimal_config();
config.github_copilot = true;
let client = SamplingClient::new(config).expect("client builds");
let h = &client.default_headers;
assert_eq!(
h.get(USER_AGENT).and_then(|v| v.to_str().ok()),
Some(kigi_sampling_types::COPILOT_USER_AGENT),
"Copilot presents the VS Code Copilot User-Agent"
);
assert_eq!(
h.get("editor-version").and_then(|v| v.to_str().ok()),
Some(kigi_sampling_types::COPILOT_EDITOR_VERSION)
);
assert_eq!(
h.get("editor-plugin-version").and_then(|v| v.to_str().ok()),
Some(kigi_sampling_types::COPILOT_EDITOR_PLUGIN_VERSION)
);
assert_eq!(
h.get("copilot-integration-id")
.and_then(|v| v.to_str().ok()),
Some(kigi_sampling_types::COPILOT_INTEGRATION_ID)
);
assert_eq!(
h.get("x-initiator").and_then(|v| v.to_str().ok()),
Some("user"),
"inference carries X-Initiator: user"
);
}
/// SECURITY REGRESSION: `chatgpt-account-id` (the Codex per-user account
/// identifier) must be redacted by the header loggers. It is not a token, so
/// the substring list has to name it explicitly — without this, one debug
/// request log would write the user's stable ChatGPT account id to disk.
#[test]
fn account_id_headers_are_redacted_from_logs() {
assert!(
SamplingClient::is_sensitive_header("chatgpt-account-id"),
"chatgpt-account-id must be treated as sensitive"
);
assert!(
SamplingClient::is_sensitive_header("ChatGPT-Account-Id"),
"redaction is case-insensitive"
);
let rendered = SamplingClient::format_header("chatgpt-account-id", "acct-abc-123");
assert!(
rendered.contains("[REDACTED]") && !rendered.contains("acct-abc-123"),
"the account id value must never appear verbatim, got {rendered:?}"
);
// Sanity: an ordinary header is still shown (redaction stays targeted).
assert!(!SamplingClient::is_sensitive_header("content-type"));
}
/// REGRESSION: a plain ChatCompletions client (github-copilot OFF, standing
/// in for groq) carries NONE of the Copilot editor headers and keeps the
/// kigi User-Agent — every other ChatCompletions provider stays untouched.
#[test]
fn plain_chat_completions_client_has_no_copilot_editor_headers() {
// github_copilot stays false (as it is for groq and every other
// ChatCompletions platform).
let client = SamplingClient::new(minimal_config()).expect("client builds");
let h = &client.default_headers;
assert!(
h.get("editor-version").is_none(),
"groq must NOT send Editor-Version"
);
assert!(
h.get("editor-plugin-version").is_none(),
"groq must NOT send Editor-Plugin-Version"
);
assert!(
h.get("copilot-integration-id").is_none(),
"groq must NOT send Copilot-Integration-Id"
);
assert!(
h.get("x-initiator").is_none(),
"groq must NOT send X-Initiator"
);
assert!(
h.get(USER_AGENT)
.and_then(|v| v.to_str().ok())
.is_some_and(|ua| ua.starts_with("kigi/")),
"groq keeps the kigi User-Agent"
);
}
// A JWT whose payload carries `["https://api.openai.com/auth"]
// ["chatgpt_account_id"] = "acct-test-42"` (alg=none; signature is cosmetic
// — the extractor only base64url-decodes the payload segment).
const CODEX_TEST_JWT: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjdC10ZXN0LTQyIn0sInN1YiI6InVzZXItMSJ9.sig";
/// openai-codex Responses client (`openai_codex = true`): the STATIC codex
/// identity headers (`originator`, `OpenAI-Beta`, codex `User-Agent`) ride
/// `default_headers`, and a built `post()` request derives `chatgpt-account-
/// id` from the bearer JWT. The Responses body default keeps `store: false`.
#[test]
fn openai_codex_client_sends_codex_identity_headers() {
let mut config = minimal_config();
config.api_backend = ApiBackend::Responses;
config.openai_codex = true;
config.api_key = Some(CODEX_TEST_JWT.to_string());
let client = SamplingClient::new(config).expect("client builds");
let h = &client.default_headers;
assert_eq!(
h.get("originator").and_then(|v| v.to_str().ok()),
Some(kigi_sampling_types::CODEX_ORIGINATOR),
"codex must send originator: codex_cli_rs"
);
assert_eq!(
h.get("openai-beta").and_then(|v| v.to_str().ok()),
Some(kigi_sampling_types::CODEX_OPENAI_BETA),
"codex must opt into OpenAI-Beta: responses=experimental"
);
assert_eq!(
h.get(USER_AGENT).and_then(|v| v.to_str().ok()),
Some(kigi_sampling_types::CODEX_USER_AGENT),
"codex presents the codex User-Agent"
);
// The account id is derived PER REQUEST from the bearer JWT in post().
let req = client
.post("https://chatgpt.com/backend-api/codex/responses")
.build()
.expect("build request");
assert_eq!(
req.headers()
.get("chatgpt-account-id")
.and_then(|v| v.to_str().ok()),
Some("acct-test-42"),
"chatgpt-account-id must be decoded from the bearer JWT claim"
);
}
/// REGRESSION: an API-key `openai` Responses client (`openai_codex = false`)
/// carries NONE of the Codex identity headers — not the static ones and not
/// the per-request `chatgpt-account-id` — so its request stays byte-identical.
#[test]
fn api_key_openai_responses_client_has_no_codex_headers() {
let mut config = minimal_config();
config.api_backend = ApiBackend::Responses;
// openai_codex stays false (as it is for API-key openai). Even with a
// JWT-shaped key, no account-id header is derived.
config.api_key = Some(CODEX_TEST_JWT.to_string());
let client = SamplingClient::new(config).expect("client builds");
let h = &client.default_headers;
assert!(
h.get("originator").is_none(),
"openai must NOT send originator"
);
assert!(
h.get("openai-beta").is_none(),
"openai must NOT send the codex OpenAI-Beta"
);
assert!(
h.get(USER_AGENT)
.and_then(|v| v.to_str().ok())
.is_some_and(|ua| ua.starts_with("kigi/")),
"API-key openai keeps the kigi User-Agent"
);
let req = client
.post("https://api.openai.com/v1/responses")
.build()
.expect("build request");
assert!(
req.headers().get("chatgpt-account-id").is_none(),
"API-key openai must NOT send chatgpt-account-id (regression)"
);
}
/// The system-prompt prefix is prepended as a distinct leading `text`
/// block for each `system` shape (absent / string / blocks), preserving the
/// caller's prompt, and is idempotent (not stamped twice).
#[test]
fn claude_code_system_prefix_prepends_and_is_idempotent() {
use messages::{SystemParam, TextBlock};
let prefix = kigi_sampling_types::CLAUDE_CODE_SYSTEM_PREFIX;
// Absent system → a single prefix block.
let mut none = None;
prepend_claude_code_system_prefix(&mut none);
match none {
Some(SystemParam::Blocks(b)) => {
assert_eq!(b.len(), 1);
assert_eq!(b[0].text, prefix);
}
other => panic!("expected one prefix block, got {other:?}"),
}
// String system → [prefix, original].
let mut text = Some(SystemParam::Text("do the thing".into()));
prepend_claude_code_system_prefix(&mut text);
match text {
Some(SystemParam::Blocks(b)) => {
assert_eq!(b.len(), 2);
assert_eq!(b[0].text, prefix);
assert_eq!(b[1].text, "do the thing");
}
other => panic!("expected two blocks, got {other:?}"),
}
// Idempotent: a leading prefix block is not re-added.
let mut already = Some(SystemParam::Blocks(vec![
TextBlock {
r#type: "text".into(),
text: prefix.to_string(),
cache_control: None,
},
TextBlock {
r#type: "text".into(),
text: "tail".into(),
cache_control: None,
},
]));
prepend_claude_code_system_prefix(&mut already);
match already {
Some(SystemParam::Blocks(b)) => {
assert_eq!(b.len(), 2, "prefix must not be duplicated");
assert_eq!(b[0].text, prefix);
}
other => panic!("expected unchanged blocks, got {other:?}"),
}
}
/// Verify the serialized shape of StreamingChatRequest matches the
/// expected wire format: all ChatCompletionRequest fields flattened at
/// top level, plus `stream: true` and `stream_options.include_usage: true`.
@@ -2017,6 +2534,7 @@ mod tests {
api_key: Some("bearer-key-abc123".to_string()),
api_backend: ApiBackend::Messages,
auth_scheme: AuthScheme::Bearer,
chat_compat: Default::default(),
..minimal_config()
};
let client = SamplingClient::new(cfg).expect("client should build");
@@ -2183,6 +2701,7 @@ mod tests {
api_key: Some("stale-bearer".to_string()),
api_backend: ApiBackend::Messages,
auth_scheme: AuthScheme::Bearer,
chat_compat: Default::default(),
bearer_resolver: Some(std::sync::Arc::new(StaticBearerResolver("fresh-bearer"))),
..minimal_config()
};
@@ -2211,6 +2730,7 @@ mod tests {
api_key: Some("stale-bearer".to_string()),
api_backend: ApiBackend::Responses,
auth_scheme: AuthScheme::Bearer,
chat_compat: Default::default(),
bearer_resolver: Some(std::sync::Arc::new(StaticBearerResolver("fresh-bearer"))),
..minimal_config()
};
+33 -1
View File
@@ -33,7 +33,7 @@ pub enum AuthScheme {
///
/// `SamplerConfig` is the single source of truth for sampler
/// configuration. The shell builds it directly (see
/// `agent::config::resolve_model_to_sampling_config` and
/// `agent::config::sampling_config_for_model` and
/// `session::acp_session::SessionActor::reconstruct_full_config`) by
/// composing chat-state's `kigi_sampling_types::SamplingConfig`
/// with `Credentials` (api key, client version).
@@ -55,6 +55,29 @@ pub struct SamplerConfig {
pub api_backend: ApiBackend,
#[serde(default)]
pub auth_scheme: AuthScheme,
/// Claude Pro/Max OAuth adaptation (claude-pro-max only). When true the
/// Messages request carries the OAuth identity headers (`anthropic-beta`
/// oauth, `claude-cli` User-Agent, `x-app: cli`) and its system prompt is
/// prefixed with the required "You are Claude Code…" line. Gated so the
/// API-key `anthropic` + `minimax` Messages requests stay byte-identical.
#[serde(default)]
pub anthropic_oauth: bool,
/// GitHub Copilot ChatCompletions adaptation (github-copilot only). When
/// true the request carries the VS Code Copilot editor-identity headers
/// (User-Agent `GitHubCopilotChat/…`, `Editor-Version`,
/// `Editor-Plugin-Version`, `Copilot-Integration-Id`) plus `X-Initiator:
/// user`. Gated so every other ChatCompletions provider (groq, …) stays
/// byte-identical.
#[serde(default)]
pub github_copilot: bool,
/// ChatGPT/Codex Responses adaptation (openai-codex only). When true the
/// `/codex/responses` request carries the Codex identity headers
/// (`chatgpt-account-id` derived per-request from the bearer JWT,
/// `originator: codex_cli_rs`, `OpenAI-Beta: responses=experimental`, a codex
/// `User-Agent`). Gated so the API-key `openai` Responses requests stay
/// byte-identical (`store: false` is already the shared Responses default).
#[serde(default)]
pub openai_codex: bool,
/// Extra request headers applied verbatim. The sampler never inspects
/// the URL to derive headers; callers (the session) inject proxy auth
/// and other access headers here before constructing the config.
@@ -70,6 +93,11 @@ pub struct SamplerConfig {
// Reasoning effort
pub reasoning_effort: Option<ReasoningEffort>,
/// ChatCompletions body-adaptation dialect (per-platform; BYOK/custom
/// endpoints default to the historical Kimi behavior; lenient default on
/// deserialize so persisted configs from before the field parse).
#[serde(default)]
pub chat_compat: kigi_sampling_types::ChatCompat,
/// Client identity for the User-Agent header (`kigi/{version}` plus an
/// optional origin product). The old xAI proxy's identity headers
@@ -135,9 +163,13 @@ impl Default for SamplerConfig {
model: String::new(),
max_completion_tokens: None,
temperature: None,
chat_compat: kigi_sampling_types::ChatCompat::default(),
top_p: None,
api_backend: ApiBackend::default(),
auth_scheme: AuthScheme::default(),
anthropic_oauth: false,
github_copilot: false,
openai_codex: false,
extra_headers: IndexMap::new(),
context_window: 0,
force_http1: false,
+349 -2
View File
@@ -29,6 +29,174 @@ pub(crate) fn adapt_chat_completions_body(body: &mut Value) {
adapt_tool_schemas(body);
}
/// Dialect-dispatched body adaptation. Kimi keeps the full historical
/// pipeline (thinking + message hygiene + schema normalization — all built
/// for the Kimi wire's strictness); DeepSeek differs ONLY in how thinking
/// rides the body; Passthrough providers take OpenAI-style bodies verbatim
/// (their `reasoning_effort` scalar is already the wire form).
pub(crate) fn adapt_chat_completions_body_for(
compat: kigi_sampling_types::ChatCompat,
body: &mut Value,
) {
match compat {
kigi_sampling_types::ChatCompat::Kimi => adapt_chat_completions_body(body),
kigi_sampling_types::ChatCompat::DeepSeek => {
adapt_thinking_deepseek(body);
strip_kigi_private_message_fields(body);
}
kigi_sampling_types::ChatCompat::Passthrough => {
strip_kigi_private_message_fields(body);
}
kigi_sampling_types::ChatCompat::StrictOpenAi => {
strip_kigi_private_message_fields(body);
strip_stream_options(body);
}
kigi_sampling_types::ChatCompat::Mistral => {
strip_kigi_private_message_fields(body);
strip_stream_options(body);
normalize_mistral_tool_call_ids(body);
}
}
}
/// Mistral's validator requires tool-call ids of EXACTLY nine
/// `[a-zA-Z0-9]` characters. Foreign backends mint arbitrary ids
/// (OpenAI `call_…`, UUIDs, Anthropic `toolu_…`), so non-conforming ids
/// are remapped deterministically — ported from Pi's
/// `mistral-conversations.ts` normalizer: strip non-alphanumerics, keep
/// the id when the result is already exactly nine chars, otherwise hash
/// (FNV-1a → base36) down to nine, retrying with an attempt suffix on
/// collision. ONE map serves `tool_calls[].id` and `tool_call_id` alike,
/// so call/result pairing survives.
fn normalize_mistral_tool_call_ids(body: &mut Value) {
const LEN: usize = 9;
fn derive(id: &str, attempt: u32) -> String {
let normalized: String = id.chars().filter(char::is_ascii_alphanumeric).collect();
if attempt == 0 && normalized.len() == LEN {
return normalized;
}
let seed_base = if normalized.is_empty() {
id
} else {
&normalized
};
let seed = if attempt == 0 {
seed_base.to_string()
} else {
format!("{seed_base}:{attempt}")
};
// FNV-1a (stable across builds, unlike std's DefaultHasher) → base36.
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for b in seed.bytes() {
hash ^= u64::from(b);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
let mut out = String::with_capacity(LEN);
let digits = b"0123456789abcdefghijklmnopqrstuvwxyz";
let mut h = hash;
while out.len() < LEN {
out.push(digits[(h % 36) as usize] as char);
h = h / 36 + 1; // +1 keeps the stream from collapsing to zeros
}
out
}
let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) else {
return;
};
let mut forward: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let mut taken: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut normalize = |id: &str| -> String {
if let Some(mapped) = forward.get(id) {
return mapped.clone();
}
let mut attempt = 0;
loop {
let candidate = derive(id, attempt);
if taken.insert(candidate.clone()) {
forward.insert(id.to_string(), candidate.clone());
return candidate;
}
attempt += 1;
}
};
for message in messages.iter_mut() {
if let Some(tool_calls) = message.get_mut("tool_calls").and_then(|t| t.as_array_mut()) {
for tc in tool_calls {
if let Some(id) = tc.get("id").and_then(|v| v.as_str()).map(str::to_owned) {
tc["id"] = Value::String(normalize(&id));
}
}
}
if let Some(id) = message
.get("tool_call_id")
.and_then(|v| v.as_str())
.map(str::to_owned)
{
message["tool_call_id"] = Value::String(normalize(&id));
}
}
}
/// Mistral's strict Pydantic validator 422-rejects `stream_options`
/// (`extra_forbidden` on `stream_options.include_usage`; its request model
/// has no such field). kigi injects `stream_options.include_usage` on every
/// streaming request for the other providers, so strip the whole object for
/// Mistral. Streaming usage falls back to token estimation (as for any
/// provider that omits streaming usage).
fn strip_stream_options(body: &mut Value) {
if let Some(obj) = body.as_object_mut() {
obj.remove("stream_options");
}
}
/// Remove kigi-internal history artifacts from input messages before they
/// reach a non-Kimi wire. `reasoning_content` is Kimi's replayed-thinking
/// field (Kimi consumes it; DeepSeek documents it as prefix-mode-only and
/// historically 400s on it; other providers don't know it) and `model_id`
/// is kigi's private per-message provenance. Kimi's own pipeline handles
/// these in `adapt_messages`.
fn strip_kigi_private_message_fields(body: &mut Value) {
let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) else {
return;
};
for message in messages {
if let Some(obj) = message.as_object_mut() {
obj.remove("reasoning_content");
obj.remove("model_id");
}
}
}
/// DeepSeek spells the thinking control `thinking:{type, reasoning_effort}`
/// (api-docs.deepseek.com create-chat-completion; the server maps
/// low/medium→high and xhigh→max itself, so the canonical level passes
/// through verbatim). `none` disables thinking; absent leaves the server
/// default (enabled).
fn adapt_thinking_deepseek(body: &mut Value) {
let Some(obj) = body.as_object_mut() else {
return;
};
let Some(effort) = obj.remove("reasoning_effort") else {
return;
};
let Some(level) = effort.as_str().map(str::to_owned) else {
return;
};
if level == "none" {
obj.insert(
"thinking".to_string(),
serde_json::json!({ "type": "disabled" }),
);
} else {
obj.insert(
"thinking".to_string(),
serde_json::json!({ "type": "enabled", "reasoning_effort": level }),
);
}
}
/// Map the OpenAI-style `reasoning_effort` knob onto Kimi's `thinking`
/// request field and drop `reasoning_effort` from the wire.
///
@@ -280,6 +448,123 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn deepseek_dialect_spells_thinking_reasoning_effort() {
use kigi_sampling_types::ChatCompat;
// Official docs: thinking:{type, reasoning_effort}; server maps
// low/medium→high, xhigh→max itself — levels pass through verbatim.
let mut body = json!({ "model": "deepseek-v4-pro", "reasoning_effort": "high" });
adapt_chat_completions_body_for(ChatCompat::DeepSeek, &mut body);
assert_eq!(body.get("reasoning_effort"), None);
assert_eq!(
body["thinking"],
json!({ "type": "enabled", "reasoning_effort": "high" })
);
let mut body = json!({ "model": "deepseek-v4-flash", "reasoning_effort": "max" });
adapt_chat_completions_body_for(ChatCompat::DeepSeek, &mut body);
assert_eq!(
body["thinking"],
json!({ "type": "enabled", "reasoning_effort": "max" })
);
// none disables; absent leaves the server default (no thinking key).
let mut body = json!({ "reasoning_effort": "none" });
adapt_chat_completions_body_for(ChatCompat::DeepSeek, &mut body);
assert_eq!(body["thinking"], json!({ "type": "disabled" }));
let mut body = json!({ "model": "deepseek-chat" });
adapt_chat_completions_body_for(ChatCompat::DeepSeek, &mut body);
assert_eq!(body.get("thinking"), None);
// DeepSeek does NOT get kimi's message/tool-schema rewrites (empty
// assistant tool-call content survives — DeepSeek's documented
// function-calling round-trip uses that shape), but kigi-private
// fields are stripped: replayed reasoning_content is prefix-mode-only
// on the DeepSeek wire (historically a 400 in input messages).
let mut body = json!({
"reasoning_effort": "high",
"messages": [
{ "role": "assistant", "content": "", "tool_calls": [{}],
"reasoning_content": "replayed thinking", "model_id": "kigi/x" }
]
});
adapt_chat_completions_body_for(ChatCompat::DeepSeek, &mut body);
assert_eq!(body["messages"][0]["content"], json!(""));
assert_eq!(body["messages"][0].get("reasoning_content"), None);
assert_eq!(body["messages"][0].get("model_id"), None);
}
#[test]
fn strict_openai_dialect_strips_stream_options_and_private_fields() {
use kigi_sampling_types::ChatCompat;
// Mistral 422s on stream_options (extra_forbidden) and doesn't know
// kigi's private message fields; OpenAI-style reasoning_effort stays.
let mut body = json!({
"model": "mistral-medium-latest",
"reasoning_effort": "high",
"stream": true,
"stream_options": { "include_usage": true },
"messages": [
{ "role": "assistant", "content": "hi",
"reasoning_content": "internal", "model_id": "kigi/x" }
]
});
adapt_chat_completions_body_for(ChatCompat::StrictOpenAi, &mut body);
assert_eq!(
body.get("stream_options"),
None,
"stream_options must be stripped"
);
assert_eq!(body["stream"], json!(true), "stream flag stays");
assert_eq!(
body["reasoning_effort"],
json!("high"),
"OpenAI-style effort passes through (Mistral accepts it natively)"
);
assert_eq!(body["messages"][0].get("reasoning_content"), None);
assert_eq!(body["messages"][0].get("model_id"), None);
assert_eq!(body["messages"][0]["content"], json!("hi"));
}
#[test]
fn passthrough_dialect_leaves_openai_body_verbatim() {
use kigi_sampling_types::ChatCompat;
// Verbatim EXCEPT kigi-private history artifacts, which no non-Kimi
// wire understands.
let mut body = json!({
"model": "gpt-oss",
"reasoning_effort": "high",
"messages": [
{ "role": "user", "content": "hi" },
{ "role": "assistant", "content": "yo",
"reasoning_content": "internal", "model_id": "kigi/x" }
]
});
adapt_chat_completions_body_for(ChatCompat::Passthrough, &mut body);
assert_eq!(
body,
json!({
"model": "gpt-oss",
"reasoning_effort": "high",
"messages": [
{ "role": "user", "content": "hi" },
{ "role": "assistant", "content": "yo" }
]
}),
"reasoning_effort stays OpenAI-style; private fields are stripped"
);
}
#[test]
fn kimi_dialect_dispatch_matches_legacy_pipeline() {
use kigi_sampling_types::ChatCompat;
let mut via_dispatch = json!({ "model": "k3", "reasoning_effort": "max" });
adapt_chat_completions_body_for(ChatCompat::Kimi, &mut via_dispatch);
let mut via_legacy = json!({ "model": "k3", "reasoning_effort": "max" });
adapt_chat_completions_body(&mut via_legacy);
assert_eq!(via_dispatch, via_legacy, "Kimi dispatch = legacy pipeline");
}
#[test]
fn reasoning_effort_maps_to_kimi_thinking_field() {
// Level rides along as thinking.effort (live wire: 200 with
@@ -292,8 +577,9 @@ mod tests {
json!({ "type": "enabled", "effort": "high" })
);
// Canonical `xhigh` is spelled `max` on the Kimi wire (the K3
// valid_efforts vocabulary is low/high/max).
// Legacy canonical `xhigh` (pre-Max configs/sessions) is spelled
// `max` on the Kimi wire (the K3 valid_efforts vocabulary is
// low/high/max — there is no `xhigh` there).
let mut body = json!({ "model": "k3", "reasoning_effort": "xhigh" });
adapt_chat_completions_body(&mut body);
assert_eq!(
@@ -301,6 +587,15 @@ mod tests {
json!({ "type": "enabled", "effort": "max" })
);
// Canonical `max` (what the K3 menu token parses to since the
// ReasoningEffort::Max split) passes through unchanged.
let mut body = json!({ "model": "k3", "reasoning_effort": "max" });
adapt_chat_completions_body(&mut body);
assert_eq!(
body["thinking"],
json!({ "type": "enabled", "effort": "max" })
);
// kimi.py:218: "off" (our ReasoningEffort::None) → disabled, and no
// effort key (a disabled+effort combination would be contradictory).
let mut body = json!({ "reasoning_effort": "none" });
@@ -456,4 +751,56 @@ mod tests {
assert_eq!(props["num"]["type"], json!("number"));
assert_eq!(props["free"]["type"], json!("string"));
}
/// Mistral dialect: exactly-nine `[a-zA-Z0-9]` tool-call ids. A
/// conforming id survives; foreign ids (OpenAI `call_…`, UUIDs) remap
/// deterministically; the SAME map serves `tool_calls[].id` and
/// `tool_call_id`, so pairing survives; distinct inputs never collide.
#[test]
fn mistral_dialect_normalizes_tool_call_ids_symmetrically() {
let mut body = serde_json::json!({
"messages": [
{"role": "assistant", "tool_calls": [
{"id": "abc123XYZ", "type": "function", "function": {"name": "a", "arguments": "{}"}},
{"id": "call_0123456789abcdef", "type": "function", "function": {"name": "b", "arguments": "{}"}}
]},
{"role": "tool", "tool_call_id": "abc123XYZ", "content": "r1"},
{"role": "tool", "tool_call_id": "call_0123456789abcdef", "content": "r2"},
],
"stream_options": {"include_usage": true}
});
adapt_chat_completions_body_for(kigi_sampling_types::ChatCompat::Mistral, &mut body);
let msgs = body["messages"].as_array().unwrap();
let ids: Vec<String> = msgs[0]["tool_calls"]
.as_array()
.unwrap()
.iter()
.map(|tc| tc["id"].as_str().unwrap().to_string())
.collect();
// Conforming id kept verbatim.
assert_eq!(ids[0], "abc123XYZ");
// Foreign id remapped to exactly nine alphanumerics.
assert_eq!(ids[1].len(), 9, "{ids:?}");
assert!(ids[1].chars().all(|ch| ch.is_ascii_alphanumeric()));
assert_ne!(ids[0], ids[1], "distinct inputs must not collide");
// Results carry the SAME mapped ids.
assert_eq!(msgs[1]["tool_call_id"].as_str().unwrap(), ids[0]);
assert_eq!(msgs[2]["tool_call_id"].as_str().unwrap(), ids[1]);
// StrictOpenAi base behavior rides along.
assert!(body.get("stream_options").is_none());
// Determinism: the same foreign id maps identically in a fresh body.
let mut body2 = serde_json::json!({
"messages": [
{"role": "tool", "tool_call_id": "call_0123456789abcdef", "content": "r"}
]
});
adapt_chat_completions_body_for(kigi_sampling_types::ChatCompat::Mistral, &mut body2);
assert_eq!(
body2["messages"][0]["tool_call_id"].as_str().unwrap(),
ids[1],
"remap must be deterministic across requests (prefix-cache stability)"
);
}
}
@@ -517,6 +517,85 @@ mod tests {
}
}
/// Deserialize a full chunk from a JSON `delta` so it flows through the
/// `#[serde(from = "RawChatChunkDelta")]` content-split path (Mistral
/// sends array `content`).
fn chunk_from_delta(delta: serde_json::Value) -> ChatCompletionChunk {
serde_json::from_value(serde_json::json!({
"id": "c",
"object": "chat.completion.chunk",
"created": 0,
"model": "mistral-medium-latest",
"choices": [{ "index": 0, "delta": delta, "finish_reason": null }],
}))
.expect("mistral chunk deserializes")
}
/// End-to-end: a Mistral reasoning stream (array `content` thinking
/// deltas → a transition delta carrying both a thinking and a text chunk
/// → plain-string answer deltas) is split by deserialization and consumed
/// into the SAME reasoning-sibling + assistant-answer result the
/// `reasoning_content` string path produces. Closes the array-path
/// integration gap.
#[tokio::test]
async fn mistral_reasoning_array_stream_splits_into_reasoning_and_answer() {
let chunks: Vec<Result<ChatCompletionChunk, SamplingError>> = vec![
Ok(chunk_from_delta(serde_json::json!({ "content": [
{ "type": "thinking",
"thinking": [{ "type": "text", "text": "Let me think. " }] }
]}))),
Ok(chunk_from_delta(serde_json::json!({ "content": [
{ "type": "thinking",
"thinking": [{ "type": "text", "text": "It's 22." }] }
]}))),
// Transition: one array with a closing thinking chunk AND the
// first answer text chunk.
Ok(chunk_from_delta(serde_json::json!({ "content": [
{ "type": "thinking", "thinking": [{ "type": "text", "text": " Done." }] },
{ "type": "text", "text": "Answer: " }
]}))),
// Answer phase: plain-string deltas (no longer arrays).
Ok(chunk_from_delta(serde_json::json!({ "content": "22." }))),
Ok(final_chunk(FinishReason::Stop)),
];
let raw = stream::iter(chunks).boxed();
let events = collect(stream_chat_completions(
raw,
None,
rid(),
Duration::from_secs(60),
))
.await;
// Channel tokens: thinking rode the Reasoning channel, answer the Text
// channel — never crossed.
let mut reasoning = String::new();
let mut answer = String::new();
for e in &events {
if let SamplingEvent::ChannelToken { channel, text, .. } = e {
match channel {
SamplingChannel::Reasoning => reasoning.push_str(text),
SamplingChannel::Text => answer.push_str(text),
}
}
}
assert_eq!(reasoning, "Let me think. It's 22. Done.");
assert_eq!(answer, "Answer: 22.");
// The accumulated final response carries the same split.
match events.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
let r = response
.reasoning_items()
.next()
.expect("array thinking became a reasoning sibling");
let rs::SummaryPart::SummaryText(t) = &r.summary[0];
assert_eq!(t.text, "Let me think. It's 22. Done.");
}
other => panic!("expected Completed, got {other:?}"),
}
}
#[tokio::test]
async fn tool_call_stream_emits_deltas_and_assembles_final_call() {
// First chunk has id + name + part of arguments.
@@ -78,6 +78,10 @@ fn test_config(base_url: String, model: &str) -> SamplerConfig {
top_p: None,
api_backend: ApiBackend::ChatCompletions,
auth_scheme: Default::default(),
anthropic_oauth: false,
github_copilot: false,
openai_codex: false,
chat_compat: Default::default(),
extra_headers: IndexMap::new(),
context_window: 128_000,
force_http1: false,
@@ -7,6 +7,7 @@ description = "Pure data types for the xAI sampling / chat-completion API layer"
[dependencies]
async-openai = { workspace = true }
base64 = { workspace = true }
indexmap = { workspace = true, features = ["serde"] }
reqwest = { workspace = true }
serde = { workspace = true, features = ["derive"] }
File diff suppressed because it is too large Load Diff
+741 -30
View File
@@ -487,7 +487,69 @@ pub enum FinishReason {
FunctionCall,
}
/// Wire `content` field on a chat response/delta. OpenAI-compatible providers
/// send a plain string, but reasoning providers (Mistral) send an ARRAY of
/// typed chunks: `{"type":"text","text":".."}` for the answer and
/// `{"type":"thinking","thinking":[{"type":"text","text":".."}],...}` for the
/// chain of thought (verified against the mistralai/client-python SDK models).
/// The chunk union is OPEN — unknown chunk types are ignored, never fatal.
#[derive(Deserialize)]
#[serde(untagged)]
enum WireContent {
Text(String),
Chunks(Vec<Value>),
}
/// Read a chunk's `text` string field into `dst` when present (defensive: a
/// reference/tool-reference/unknown chunk simply has no `text`).
fn push_chunk_text(dst: &mut String, chunk: &Value) {
if let Some(t) = chunk.get("text").and_then(Value::as_str) {
dst.push_str(t);
}
}
fn non_empty(s: String) -> Option<String> {
if s.is_empty() { None } else { Some(s) }
}
impl WireContent {
/// Split into `(visible answer text, thinking text)`. A plain string is
/// the answer with no thinking; an array routes `text` chunks to the
/// answer and the nested `text` of `thinking` chunks to the reasoning
/// channel. Byte-identical for string content (the only shape non-Mistral
/// providers ever send).
fn split(self) -> (Option<String>, Option<String>) {
match self {
// Preserve string content verbatim (incl. empty) — byte-identical
// to the pre-change `content: Option<String>` for every provider
// that sends a string.
WireContent::Text(s) => (Some(s), None),
WireContent::Chunks(chunks) => {
let mut answer = String::new();
let mut thinking = String::new();
for c in &chunks {
match c.get("type").and_then(Value::as_str) {
Some("text") => push_chunk_text(&mut answer, c),
Some("thinking") => {
// `thinking` is a nested list of chunks.
if let Some(inner) = c.get("thinking").and_then(Value::as_array) {
for ic in inner {
push_chunk_text(&mut thinking, ic);
}
}
}
// reference / tool_reference / unknown → not text.
_ => {}
}
}
(non_empty(answer), non_empty(thinking))
}
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(from = "RawChatResponseMessage")]
pub struct ChatResponseMessage {
pub role: Role,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -502,6 +564,38 @@ pub struct ChatResponseMessage {
pub citations: Option<Vec<String>>,
}
/// Deserialization mirror of [`ChatResponseMessage`] that accepts array
/// `content` and folds thinking chunks into `reasoning_content`.
#[derive(Deserialize)]
struct RawChatResponseMessage {
role: Role,
#[serde(default)]
content: Option<WireContent>,
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default)]
tool_calls: Vec<ToolCallResponse>,
#[serde(default)]
tool_call_id: Option<String>,
#[serde(default)]
citations: Option<Vec<String>>,
}
impl From<RawChatResponseMessage> for ChatResponseMessage {
fn from(r: RawChatResponseMessage) -> Self {
let (answer, thinking) = r.content.map(WireContent::split).unwrap_or((None, None));
ChatResponseMessage {
role: r.role,
content: answer,
// The wire's own `reasoning_content` wins; else thinking chunks.
reasoning_content: r.reasoning_content.or(thinking),
tool_calls: r.tool_calls,
tool_call_id: r.tool_call_id,
citations: r.citations,
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ToolCallResponse {
pub id: String,
@@ -649,6 +743,7 @@ pub struct ToolCallFunctionDelta {
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
#[serde(from = "RawChatChunkDelta")]
pub struct ChatChunkDelta {
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<Role>,
@@ -656,16 +751,43 @@ pub struct ChatChunkDelta {
pub content: Option<String>,
pub reasoning_content: Option<String>,
/// Tool call deltas. Handles `null` in JSON as empty vec.
#[serde(
default,
skip_serializing_if = "Vec::is_empty",
deserialize_with = "deserialize_null_default"
)]
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_calls: Vec<ToolCallDelta>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
/// Deserialization mirror of [`ChatChunkDelta`] that accepts array `content`
/// (Mistral reasoning streams a thinking-chunk array during the thinking
/// phase, then plain-string answer deltas) and folds thinking into
/// `reasoning_content`.
#[derive(Deserialize)]
struct RawChatChunkDelta {
#[serde(default)]
role: Option<Role>,
#[serde(default)]
content: Option<WireContent>,
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default, deserialize_with = "deserialize_null_default")]
tool_calls: Vec<ToolCallDelta>,
#[serde(default)]
tool_call_id: Option<String>,
}
impl From<RawChatChunkDelta> for ChatChunkDelta {
fn from(r: RawChatChunkDelta) -> Self {
let (answer, thinking) = r.content.map(WireContent::split).unwrap_or((None, None));
ChatChunkDelta {
role: r.role,
content: answer,
reasoning_content: r.reasoning_content.or(thinking),
tool_calls: r.tool_calls,
tool_call_id: r.tool_call_id,
}
}
}
/// Parameters to control realtime data.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SearchParameters {
@@ -787,22 +909,23 @@ pub enum ReasoningEffort {
Medium,
High,
Xhigh,
/// Distinct top tier above `xhigh` (OpenAI Responses and Anthropic
/// Messages both accept `xhigh` AND `max` as separate levels in 2026;
/// the Kimi wire spells its top tier `max` with no `xhigh`).
Max,
/// Codex-only top tier above `max` (the ChatGPT Codex backend exposes an
/// `ultra` reasoning effort on its flagship models). Reachable ONLY via a
/// model's server-declared effort menu (openai-codex); no built-in fallback
/// menu offers it, so other providers never emit it.
Ultra,
}
impl ReasoningEffort {
pub fn to_responses_api(self) -> crate::rs::ReasoningEffort {
match self {
Self::None => crate::rs::ReasoningEffort::None,
Self::Minimal => crate::rs::ReasoningEffort::Minimal,
Self::Low => crate::rs::ReasoningEffort::Low,
Self::Medium => crate::rs::ReasoningEffort::Medium,
Self::High => crate::rs::ReasoningEffort::High,
Self::Xhigh => crate::rs::ReasoningEffort::Xhigh,
}
}
/// Inverse of [`to_responses_api`](Self::to_responses_api): the effort the
/// Responses API echoes back on `response.reasoning.effort`.
/// The canonical effort behind a typed Responses-API echo
/// (`response.reasoning.effort`). `max` echoes never reach the typed
/// enum — [`normalize_effort_echo`] drops them pre-parse (async-openai
/// has no such variant); the request direction writes the wire string
/// via [`patch_reasoning_effort`].
pub fn from_responses_api(effort: crate::rs::ReasoningEffort) -> Self {
match effort {
crate::rs::ReasoningEffort::None => Self::None,
@@ -822,17 +945,25 @@ impl ReasoningEffort {
Self::Medium => "medium",
Self::High => "high",
Self::Xhigh => "xhigh",
Self::Max => "max",
Self::Ultra => "ultra",
}
}
/// Anthropic Messages API `output_config.effort` string; `None` for unsupported variants.
/// Anthropic Messages API effort string; `None` for unsupported variants.
/// `xhigh` and `max` are distinct levels on the 2026 Messages API (both
/// appear in `GET /v1/models` `capabilities.effort`). `ultra` is codex-only
/// and never selected on an Anthropic model, but maps to its own string for
/// completeness (the Responses path writes effort via `as_str`, not this).
pub fn to_messages_api(self) -> Option<&'static str> {
match self {
Self::None | Self::Minimal => None,
Self::Low => Some("low"),
Self::Medium => Some("medium"),
Self::High => Some("high"),
Self::Xhigh => Some("max"),
Self::Xhigh => Some("xhigh"),
Self::Max => Some("max"),
Self::Ultra => Some("ultra"),
}
}
}
@@ -853,19 +984,299 @@ impl std::str::FromStr for ReasoningEffort {
"low" => Ok(Self::Low),
"medium" => Ok(Self::Medium),
"high" => Ok(Self::High),
"xhigh" | "max" => Ok(Self::Xhigh), // max is a CLI/UX alias of xhigh
"xhigh" => Ok(Self::Xhigh),
"max" => Ok(Self::Max),
"ultra" => Ok(Self::Ultra),
_ => Err(format!(
"invalid reasoning effort: {s:?} (expected one of: none, minimal, low, medium, high, xhigh, max)"
"invalid reasoning effort: {s:?} (expected one of: none, minimal, low, medium, high, xhigh, max, ultra)"
)),
}
}
}
/// Canonical wire parse only (`max` → `Xhigh`); remapped menu ids need a model catalog.
/// Canonical wire parse; remapped menu ids need a model catalog.
pub fn parse_canonical_effort_token(token: &str) -> Option<ReasoningEffort> {
token.parse().ok()
}
/// Deserialize an optional effort LENIENTLY for persisted stores (session
/// summaries, chat history): a token this binary doesn't know — written by a
/// newer kigi after the effort vocabulary grew, exactly what happened when
/// `max` split from `xhigh` — degrades to `None` with a warning instead of
/// failing the whole record, so listings and resume survive version
/// rollback. Non-string values still error (real corruption stays loud), and
/// config-TOML parsing stays strict — its layer warn-skips explicitly.
pub fn lenient_reasoning_effort_opt<'de, D>(d: D) -> Result<Option<ReasoningEffort>, D::Error>
where
D: Deserializer<'de>,
{
let raw = Option::<String>::deserialize(d)?;
Ok(raw.and_then(|s| match s.parse::<ReasoningEffort>() {
Ok(effort) => Some(effort),
Err(error) => {
tracing::warn!(token = %s, %error, "persisted reasoning_effort unknown; dropping");
None
}
}))
}
/// Write the canonical effort onto a serialized Responses request body
/// (`body.reasoning.effort`). This is the ONLY place effort reaches the
/// Responses wire: the typed `rs::ReasoningEffort` tops out at `xhigh`, so
/// `max` must be written post-serialize. A `None` effort leaves the body
/// untouched (the provider default applies).
pub fn patch_reasoning_effort(body: &mut Value, effort: Option<ReasoningEffort>) {
let Some(effort) = effort else { return };
let Some(obj) = body.as_object_mut() else {
return;
};
let reasoning = obj
.entry("reasoning")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if let Some(reasoning) = reasoning.as_object_mut() {
reasoning.insert(
"effort".to_string(),
Value::String(effort.as_str().to_string()),
);
}
}
/// Adapt a serialized Responses request body to the ChatGPT/Codex backend
/// contract (`chatgpt.com/backend-api/codex/responses`) — ported from the
/// same reference as the identity headers (official Codex CLI + Pi's
/// `api/openai-codex-responses.ts`):
///
/// 1. The backend rejects `role: system` input items outright
/// (400 `{"detail":"System messages are not allowed"}`); its system
/// channel is the top-level `instructions` field. Hoist every system
/// input message there (order preserved, blank-line joined, appended to
/// any existing instructions) and remove them from `input`.
/// 2. `store` is always `false` on this backend, so reasoning continuity
/// is stateless: `include: ["reasoning.encrypted_content"]` is required
/// for the response to carry replayable encrypted reasoning.
/// 3. For the same reason, a replayed reasoning item WITHOUT
/// `encrypted_content` (captured from a stateful api.openai.com session
/// that never requested the include) references server state
/// chatgpt.com does not have — drop it rather than 400.
///
/// openai-codex-GATED at the call sites — API-key `openai` Responses
/// bodies stay byte-identical.
pub fn adapt_body_for_codex_backend(body: &mut Value) {
// 1. Hoist system messages into `instructions`.
let mut hoisted: Vec<String> = Vec::new();
if let Some(input) = body.get_mut("input").and_then(|v| v.as_array_mut()) {
input.retain(|item| {
let is_system = item.get("role").and_then(|r| r.as_str()) == Some("system");
if is_system {
match item.get("content") {
Some(Value::String(s)) => hoisted.push(s.clone()),
Some(Value::Array(parts)) => {
for p in parts {
if let Some(t) = p.get("text").and_then(|t| t.as_str()) {
hoisted.push(t.to_string());
}
}
}
_ => {}
}
}
!is_system
});
}
if !hoisted.is_empty() {
let mut instructions = body
.get("instructions")
.and_then(|v| v.as_str())
.map(str::to_owned)
.unwrap_or_default();
for part in hoisted {
if !instructions.is_empty() {
instructions.push_str("\n\n");
}
instructions.push_str(&part);
}
body["instructions"] = Value::String(instructions);
}
// 2. Request replayable encrypted reasoning.
let include = body
.as_object_mut()
.map(|obj| obj.entry("include").or_insert_with(|| Value::Array(vec![])));
if let Some(Value::Array(entries)) = include {
let key = Value::String("reasoning.encrypted_content".to_string());
if !entries.contains(&key) {
entries.push(key);
}
}
// 3. Drop reasoning items with no encrypted payload: stateless codex
// cannot resolve a bare `rs_*` reference.
if let Some(input) = body.get_mut("input").and_then(|v| v.as_array_mut()) {
input.retain(|item| {
item.get("type").and_then(|t| t.as_str()) != Some("reasoning")
|| item
.get("encrypted_content")
.and_then(|v| v.as_str())
.is_some_and(|s| !s.is_empty())
});
}
}
/// Neutralize a `reasoning.effort` echo the typed `rs` enum cannot parse
/// (`max`): remove it so response deserialization succeeds. The turn's
/// canonical effort lives in the session sampling config regardless; only
/// the per-item echo metadata is dropped, recorded as not-echoed.
/// (Ceiling: async-openai lacks a Max variant; delete this when it grows
/// one.) Handles both bare response bodies (`/reasoning/effort`) and
/// stream-event envelopes (`/response/reasoning/effort`).
pub fn normalize_effort_echo(value: &mut Value) {
for path in ["/reasoning", "/response/reasoning"] {
if let Some(reasoning) = value.pointer_mut(path).and_then(|v| v.as_object_mut())
&& let Some(effort) = reasoning.get("effort").and_then(|v| v.as_str())
&& crate::rs::ReasoningEffort::deserialize(serde_json::Value::String(
effort.to_string(),
))
.is_err()
{
tracing::debug!(
effort,
"reasoning.effort echo unrepresentable in the typed enum; dropping"
);
reasoning.remove("effort");
}
}
}
/// The `anthropic-version` header value kigi speaks on Anthropic-style
/// wires (Messages inference and the /v1/models listing).
pub const ANTHROPIC_VERSION: &str = "2023-06-01";
/// `anthropic-beta` value for the Claude-Code OAuth (Pro/Max subscription)
/// path — required on BOTH the Messages inference request and the `/v1/models`
/// listing when the bearer is an OAuth `sk-ant-oat…` token. API-key Anthropic
/// and MiniMax NEVER send this (their requests stay byte-identical).
pub const ANTHROPIC_OAUTH_BETA: &str = "claude-code-20250219,oauth-2025-04-20";
/// User-Agent kigi presents on the Claude-Code OAuth path (mirrors the
/// official Claude Code CLI). OAuth-gated: unrelated to the default kigi UA.
pub const CLAUDE_CODE_USER_AGENT: &str = "claude-cli/2.1.75";
/// System-prompt prefix REQUIRED on the Claude-Code OAuth Messages path: the
/// OAuth token is Claude-Code-scoped, so Anthropic rejects the request unless
/// the system prompt's first block is exactly this line. OAuth-gated.
pub const CLAUDE_CODE_SYSTEM_PREFIX: &str =
"You are Claude Code, Anthropic's official CLI for Claude.";
// ── GitHub Copilot editor-identity headers ──────────────────────────────────
// The VS Code Copilot Chat client identity. Copilot's proxy authorizes the
// short-lived copilot token AND validates these editor headers, so they ride
// the `copilot_internal/v2/token` exchange, the `/models` listing, and every
// `/chat/completions` inference request. github-copilot-GATED: no other
// platform sends them, so their requests stay byte-identical. Values are
// non-secret wire constants (ported from Pi `api/github-copilot-headers.ts` +
// `auth/oauth/github-copilot.ts`).
/// `User-Agent` for the Copilot path (overrides the default kigi UA, OAuth-gated).
pub const COPILOT_USER_AGENT: &str = "GitHubCopilotChat/0.35.0";
/// `Editor-Version` — the host editor Copilot believes it is talking to.
pub const COPILOT_EDITOR_VERSION: &str = "vscode/1.107.0";
/// `Editor-Plugin-Version` — the Copilot Chat plugin build.
pub const COPILOT_EDITOR_PLUGIN_VERSION: &str = "copilot-chat/0.35.0";
/// `Copilot-Integration-Id` — the integration the token is scoped to.
pub const COPILOT_INTEGRATION_ID: &str = "vscode-chat";
/// `X-GitHub-Api-Version` — sent ONLY on the `/models` listing.
pub const COPILOT_API_VERSION: &str = "2026-06-01";
/// `X-Initiator` value — sent ONLY on inference (`user`, per the spec).
pub const COPILOT_INITIATOR: &str = "user";
// ── ChatGPT/Codex (openai-codex) OAuth-inference headers ─────────────────────
// The ChatGPT Codex backend authorizes an OAuth bearer AND validates the Codex
// client identity. These ride the `/codex/responses` inference request ONLY.
// openai-codex-GATED: no other Responses provider (API-key `openai`) sends them,
// so their requests stay byte-identical. Values are non-secret wire constants
// (ported from the official Codex CLI + Pi `api/openai-codex-responses.ts`).
/// `originator` header identifying the Codex CLI client (matches the authorize
/// `originator` param).
pub const CODEX_ORIGINATOR: &str = "codex_cli_rs";
/// `OpenAI-Beta` opt-in the Codex Responses endpoint requires.
pub const CODEX_OPENAI_BETA: &str = "responses=experimental";
/// `User-Agent` presented on the Codex path (overrides the default kigi UA,
/// openai-codex-gated). The Codex backend does not strictly validate the UA
/// string (Pi ships its own and it works), so this is a stable best-effort
/// identity, not a pinned build.
pub const CODEX_USER_AGENT: &str = "codex_cli_rs/0.104.0";
/// JWT payload claim namespace carrying the ChatGPT account id.
const CODEX_JWT_AUTH_CLAIM: &str = "https://api.openai.com/auth";
/// Extract the `chatgpt_account_id` from a Codex OAuth access token (a JWT):
/// base64url-decode the payload segment and read
/// `["https://api.openai.com/auth"]["chatgpt_account_id"]`. Returns `None` when
/// the token is not a well-formed JWT or the claim is missing/empty.
///
/// Used BOTH at login (fail-fast: a token without the claim is useless) and at
/// inference (the header is derived STATELESSLY from the current bearer, so a
/// refreshed token — which still carries the claim — needs no persisted field).
///
/// SECURITY: the token, its payload, and the returned account id are NEVER
/// logged by this function or its callers.
pub fn chatgpt_account_id_from_jwt(token: &str) -> Option<String> {
use base64::Engine;
// A JWT is exactly three dot-separated segments; anything else is not a
// token we can read (fail closed rather than decode a lookalike).
let mut segments = token.split('.');
let (_header, payload_b64, _signature) = (segments.next()?, segments.next()?, segments.next()?);
if segments.next().is_some() {
return None;
}
// JWT payloads are base64url without padding; be tolerant of either.
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload_b64)
.or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload_b64))
.ok()?;
let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
let account_id = claims
.get(CODEX_JWT_AUTH_CLAIM)?
.get("chatgpt_account_id")?
.as_str()?;
(!account_id.is_empty()).then(|| account_id.to_string())
}
/// ChatCompletions request-body adaptation dialect. Providers disagree on
/// how thinking rides an OpenAI-compatible body: Kimi wants
/// `thinking:{type,effort}`, DeepSeek wants
/// `thinking:{type,reasoning_effort}`, most others take the OpenAI-style
/// `reasoning_effort` scalar untouched.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChatCompat {
/// Kimi wire (`thinking:{type,effort}`, canonical xhigh spelled max).
/// The default: BYOK/custom ChatCompletions endpoints keep the
/// historical kigi behavior.
#[default]
Kimi,
/// DeepSeek wire (`thinking:{type,reasoning_effort}`, official docs:
/// low/medium map to high and xhigh to max server-side).
DeepSeek,
/// Leave the body as-is (OpenAI-style `reasoning_effort` passes through).
Passthrough,
/// Strict OpenAI-compatible validators (Cerebras, NVIDIA) reject any
/// out-of-schema request field with a 4xx (`additionalProperties:false`).
/// kigi injects `stream_options.include_usage` on every streaming
/// request, which such validators reject, so it is stripped (streaming
/// usage falls back to token estimation). `reasoning_effort` passes
/// through; private message fields are stripped like Passthrough.
StrictOpenAi,
/// Mistral: [`Self::StrictOpenAi`] behavior plus its exactly-nine
/// `[a-zA-Z0-9]` tool-call id contract — foreign/OpenAI-style ids are
/// deterministically remapped on call+result in one shared map (the
/// Pi `mistral-conversations` normalizer). Serializes as `mistral`, so
/// sessions persisted before the StrictOpenAi rename (which carried
/// the `mistral` alias) resolve here — correct, they were Mistral
/// sessions.
Mistral,
}
pub const REASONING_EFFORT_META_KEY: &str = "reasoningEffort";
pub const SUPPORTS_REASONING_EFFORT_META_KEY: &str = "supportsReasoningEffort";
@@ -1054,6 +1465,11 @@ pub struct SamplingConfig {
pub max_completion_tokens: Option<u32>,
pub temperature: Option<f32>,
pub top_p: Option<f32>,
/// ChatCompletions body-adaptation dialect (per-platform; serde-default
/// Kimi keeps pre-field sessions and BYOK endpoints on the historical
/// behavior).
#[serde(default)]
pub chat_compat: ChatCompat,
/// Which API backend to use for this model
#[serde(default)]
pub api_backend: ApiBackend,
@@ -1081,6 +1497,12 @@ pub struct CreateResponseWrapper {
/// The inner Responses API request.
pub inner: crate::rs::CreateResponse,
/// Canonical reasoning effort for this request. The typed
/// `inner.reasoning.effort` stays `None` (async-openai's enum cannot
/// spell `max`); [`patch_reasoning_effort`] writes this value onto the
/// serialized body just before send.
pub reasoning_effort: Option<ReasoningEffort>,
/// Custom header: conversation ID for tracking.
pub x_kigi_conv_id: Option<String>,
@@ -1107,6 +1529,7 @@ impl CreateResponseWrapper {
pub fn new(inner: crate::rs::CreateResponse) -> Self {
Self {
inner,
reasoning_effort: None,
x_kigi_conv_id: None,
x_kigi_req_id: None,
x_kigi_session_id: None,
@@ -1215,6 +1638,211 @@ mod tests {
use super::*;
use serde_json::json;
/// The Codex backend rejects `role: system` input outright
/// (400 `{"detail":"System messages are not allowed"}`) — its system
/// channel is the top-level `instructions` field, and stateless
/// (`store: false`) reasoning replay needs
/// `include: ["reasoning.encrypted_content"]`. The adapter must hoist
/// every system item (string or parts content, order preserved),
/// append to existing instructions, and leave the rest of the input
/// untouched.
#[test]
fn codex_adapter_hoists_system_messages_and_requests_encrypted_reasoning() {
let mut body = json!({
"model": "gpt-5.2-codex",
"instructions": "base",
"input": [
{"type": "message", "role": "system", "content": "sys head"},
{"type": "message", "role": "user", "content": "hello"},
{"type": "message", "role": "system", "content": [
{"type": "input_text", "text": "memory reminder"}
]},
{"type": "message", "role": "assistant", "content": "hi"}
]
});
adapt_body_for_codex_backend(&mut body);
let input = body["input"].as_array().unwrap();
assert_eq!(input.len(), 2, "system items removed from input: {body:#}");
assert!(
input.iter().all(|i| i["role"] != "system"),
"no system role may remain: {body:#}"
);
assert_eq!(
body["instructions"], "base\n\nsys head\n\nmemory reminder",
"system content hoisted into instructions, order preserved"
);
assert_eq!(
body["include"],
json!(["reasoning.encrypted_content"]),
"stateless reasoning replay requires the include"
);
// Idempotent: a second pass changes nothing.
let before = body.clone();
adapt_body_for_codex_backend(&mut body);
assert_eq!(body, before);
}
/// Stateless codex cannot resolve a bare `rs_*` reference: reasoning
/// input items without an encrypted payload are dropped; items WITH
/// one pass through untouched.
#[test]
fn codex_adapter_drops_reasoning_without_encrypted_payload() {
let mut body = json!({
"model": "gpt-5.2-codex",
"input": [
{"type": "message", "role": "user", "content": "q"},
{"type": "reasoning", "id": "rs_bare", "summary": []},
{"type": "reasoning", "id": "rs_full", "summary": [],
"encrypted_content": "gAAAA-blob"},
{"type": "message", "role": "assistant", "content": "a"}
]
});
adapt_body_for_codex_backend(&mut body);
let input = body["input"].as_array().unwrap();
assert_eq!(input.len(), 3, "bare rs_* item dropped: {body:#}");
assert!(
input
.iter()
.any(|i| i.get("id").and_then(|v| v.as_str()) == Some("rs_full")),
"encrypted reasoning passes through: {body:#}"
);
assert!(
!input
.iter()
.any(|i| i.get("id").and_then(|v| v.as_str()) == Some("rs_bare")),
"{body:#}"
);
}
/// No system items and no prior instructions: input untouched, no
/// empty-string instructions invented, include still requested.
#[test]
fn codex_adapter_without_system_messages_only_adds_include() {
let mut body = json!({
"model": "gpt-5.2-codex",
"input": [{"type": "message", "role": "user", "content": "q"}]
});
adapt_body_for_codex_backend(&mut body);
assert!(body.get("instructions").is_none(), "{body:#}");
assert_eq!(body["input"].as_array().unwrap().len(), 1);
assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
}
/// String content (the only shape non-Mistral providers send) stays the
/// answer verbatim with no thinking — byte-identical to the pre-change
/// deserialization.
/// The StrictOpenAi dialect kept the serde alias `mistral`, so sessions
/// persisted before the rename still deserialize.
#[test]
fn chat_compat_mistral_alias_deserializes_to_strict_openai() {
// `mistral` resolves to the dedicated Mistral dialect — including
// sessions persisted before the StrictOpenAi rename (they were
// Mistral sessions and now get the 9-char id contract too).
let v: ChatCompat = serde_json::from_str("\"mistral\"").unwrap();
assert_eq!(v, ChatCompat::Mistral);
assert_eq!(
serde_json::to_string(&ChatCompat::Mistral).unwrap(),
"\"mistral\""
);
let v: ChatCompat = serde_json::from_str("\"strict_open_ai\"").unwrap();
assert_eq!(v, ChatCompat::StrictOpenAi);
assert_eq!(
serde_json::to_string(&ChatCompat::StrictOpenAi).unwrap(),
"\"strict_open_ai\""
);
}
#[test]
fn chunk_delta_string_content_unchanged() {
let delta: ChatChunkDelta =
serde_json::from_value(json!({ "content": "hello world" })).unwrap();
assert_eq!(delta.content.as_deref(), Some("hello world"));
assert_eq!(delta.reasoning_content, None);
// A DeepSeek-style wire reasoning_content still rides its own field.
let delta: ChatChunkDelta =
serde_json::from_value(json!({ "content": "hi", "reasoning_content": "because" }))
.unwrap();
assert_eq!(delta.content.as_deref(), Some("hi"));
assert_eq!(delta.reasoning_content.as_deref(), Some("because"));
// Null / absent content → None.
let delta: ChatChunkDelta = serde_json::from_value(json!({})).unwrap();
assert_eq!(delta.content, None);
}
/// Mistral reasoning: array content routes `text` chunks to the answer
/// and the nested `text` of `thinking` chunks to reasoning_content
/// (verified shapes from the mistralai/client-python SDK).
#[test]
fn chunk_delta_array_content_splits_thinking_and_answer() {
// Thinking phase: array with only a thinking chunk (nested list).
let delta: ChatChunkDelta = serde_json::from_value(json!({
"content": [
{ "type": "thinking", "thinking": [{ "type": "text", "text": "step 1" }],
"signature": null, "closed": false }
]
}))
.unwrap();
assert_eq!(delta.content, None, "thinking phase has no visible answer");
assert_eq!(delta.reasoning_content.as_deref(), Some("step 1"));
// Answer phase: plain string delta.
let delta: ChatChunkDelta =
serde_json::from_value(json!({ "content": "the answer" })).unwrap();
assert_eq!(delta.content.as_deref(), Some("the answer"));
assert_eq!(delta.reasoning_content, None);
// Transition delta: one array carrying both a closing thinking chunk
// and the first text chunk → both channels populated.
let delta: ChatChunkDelta = serde_json::from_value(json!({
"content": [
{ "type": "thinking", "thinking": [{ "type": "text", "text": "done" }] },
{ "type": "text", "text": "Answer: 22" }
]
}))
.unwrap();
assert_eq!(delta.content.as_deref(), Some("Answer: 22"));
assert_eq!(delta.reasoning_content.as_deref(), Some("done"));
}
/// The chunk union is OPEN: unknown chunk types and reference chunks are
/// ignored, never fatal (SDK's UnknownContentChunk fallback).
#[test]
fn content_array_tolerates_unknown_chunk_types() {
let delta: ChatChunkDelta = serde_json::from_value(json!({
"content": [
{ "type": "reference", "reference_ids": [1, 2] },
{ "type": "some_future_chunk", "payload": { "x": 1 } },
{ "type": "text", "text": "visible" }
]
}))
.unwrap();
assert_eq!(delta.content.as_deref(), Some("visible"));
assert_eq!(delta.reasoning_content, None);
}
/// Non-streaming ChatResponseMessage gets the same array handling.
#[test]
fn response_message_array_content_splits() {
let msg: ChatResponseMessage = serde_json::from_value(json!({
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": [{ "type": "text", "text": "reasoning" }] },
{ "type": "text", "text": "final" }
]
}))
.unwrap();
assert_eq!(msg.content.as_deref(), Some("final"));
assert_eq!(msg.reasoning_content.as_deref(), Some("reasoning"));
// String content still works.
let msg: ChatResponseMessage = serde_json::from_value(json!({
"role": "assistant", "content": "plain"
}))
.unwrap();
assert_eq!(msg.content.as_deref(), Some("plain"));
}
#[test]
fn reasoning_effort_serde_lowercase_round_trip() {
for v in [
@@ -1224,6 +1852,7 @@ mod tests {
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::Xhigh,
ReasoningEffort::Max,
] {
let json = serde_json::to_string(&v).unwrap();
assert_eq!(json, format!("\"{}\"", v.as_str()), "serialize {v:?}");
@@ -1231,31 +1860,112 @@ mod tests {
assert_eq!(back, v, "round-trip {v:?}");
}
assert!(serde_json::from_str::<ReasoningEffort>("\"BOGUS\"").is_err());
assert!(serde_json::from_str::<ReasoningEffort>("\"max\"").is_err());
}
#[test]
fn reasoning_effort_from_str_accepts_max_as_xhigh() {
fn reasoning_effort_from_str_max_and_xhigh_are_distinct() {
assert_eq!(
"max".parse::<ReasoningEffort>().unwrap(),
ReasoningEffort::Xhigh
ReasoningEffort::Max
);
assert_eq!(
"MAX".parse::<ReasoningEffort>().unwrap(),
ReasoningEffort::Xhigh
ReasoningEffort::Max
);
assert_eq!(
"xhigh".parse::<ReasoningEffort>().unwrap(),
ReasoningEffort::Xhigh
);
assert_eq!(ReasoningEffort::Xhigh.as_str(), "xhigh");
// Messages API: distinct wire tokens per level (2026 capabilities).
assert_eq!(ReasoningEffort::Xhigh.to_messages_api(), Some("xhigh"));
assert_eq!(ReasoningEffort::Max.to_messages_api(), Some("max"));
}
/// The codex-only `ultra` tier parses, serializes, and patches onto a
/// Responses body as `reasoning.effort = "ultra"` (the crux of surfacing a
/// codex model's full thinking menu). It is a DISTINCT level above `max`.
#[test]
fn reasoning_effort_ultra_is_a_distinct_codex_tier() {
assert_eq!(
"ultra".parse::<ReasoningEffort>().unwrap(),
ReasoningEffort::Ultra
);
assert_eq!(
"ULTRA".parse::<ReasoningEffort>().unwrap(),
ReasoningEffort::Ultra
);
assert_ne!(ReasoningEffort::Ultra, ReasoningEffort::Max);
assert_eq!(ReasoningEffort::Ultra.as_str(), "ultra");
let json = serde_json::to_string(&ReasoningEffort::Ultra).unwrap();
assert_eq!(json, "\"ultra\"");
assert_eq!(
serde_json::from_str::<ReasoningEffort>("\"ultra\"").unwrap(),
ReasoningEffort::Ultra
);
let mut body = serde_json::json!({ "model": "gpt-5.6-sol" });
patch_reasoning_effort(&mut body, Some(ReasoningEffort::Ultra));
assert_eq!(body["reasoning"]["effort"], "ultra");
}
/// The account id is decoded STATELESSLY from the bearer JWT payload's
/// `["https://api.openai.com/auth"]["chatgpt_account_id"]` claim; a token
/// without the claim (or not a JWT) yields `None` (login fails fast on it).
#[test]
fn chatgpt_account_id_extracted_from_jwt_claim() {
use base64::Engine;
let make_jwt = |payload: serde_json::Value| -> String {
let header =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
let body = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&payload).unwrap());
format!("{header}.{body}.sig")
};
let good = make_jwt(serde_json::json!({
"https://api.openai.com/auth": { "chatgpt_account_id": "acct-abc-123" },
"sub": "user-1"
}));
assert_eq!(
chatgpt_account_id_from_jwt(&good).as_deref(),
Some("acct-abc-123")
);
// Claim namespace present but no account id → None (fail-fast).
let no_account = make_jwt(serde_json::json!({
"https://api.openai.com/auth": { "user_id": "u" }
}));
assert_eq!(chatgpt_account_id_from_jwt(&no_account), None);
// Empty account id → None.
let empty = make_jwt(serde_json::json!({
"https://api.openai.com/auth": { "chatgpt_account_id": "" }
}));
assert_eq!(chatgpt_account_id_from_jwt(&empty), None);
// Not a JWT (no payload segment) → None.
assert_eq!(chatgpt_account_id_from_jwt("not-a-jwt"), None);
assert_eq!(chatgpt_account_id_from_jwt(""), None);
// A JWT is EXACTLY three segments: a lookalike with too few or too many
// is rejected outright rather than decoded (fail closed).
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&serde_json::json!({
"https://api.openai.com/auth": { "chatgpt_account_id": "acct-abc-123" }
}))
.unwrap(),
);
assert_eq!(
chatgpt_account_id_from_jwt(&format!("hdr.{payload}")),
None,
"two segments is not a JWT"
);
assert_eq!(
chatgpt_account_id_from_jwt(&format!("hdr.{payload}.sig.extra")),
None,
"four segments is not a JWT"
);
}
#[test]
fn parse_canonical_effort_token_helper() {
assert_eq!(
parse_canonical_effort_token("max"),
Some(ReasoningEffort::Xhigh)
Some(ReasoningEffort::Max)
);
assert_eq!(
parse_canonical_effort_token("high"),
@@ -1415,7 +2125,8 @@ mod tests {
);
let bad_type = as_map(serde_json::json!({"reasoningEffort": 3}));
assert_eq!(parse_reasoning_effort_meta(Some(&bad_type)), None);
let unknown = as_map(serde_json::json!({"reasoningEffort": "ULTRA"}));
// `ultra` is now a real codex tier; a genuinely-unknown token still None.
let unknown = as_map(serde_json::json!({"reasoningEffort": "MEGA"}));
assert_eq!(parse_reasoning_effort_meta(Some(&unknown)), None);
}
@@ -0,0 +1,87 @@
//! Filesystem primitives shared across the shell.
use std::io;
use std::path::Path;
/// Replace `dest` with `tmp` — the commit step of every tmp+rename atomic
/// write in the product. This is the ONE place that knows how to make that
/// commit stick on Windows; call sites must never inline a bare
/// `fs::rename` replace again.
///
/// Unix `rename(2)` replaces atomically and needs no help. Windows
/// `MoveFileExW(REPLACE_EXISTING)` fails with a sharing violation while
/// ANOTHER process (antivirus scanner, search indexer, cloud sync) holds
/// `dest` open — the classic "persists on macOS, silently doesn't on
/// Windows" failure (a model switch that never sticks, a stale models
/// cache). On Windows a failed rename therefore deletes the destination
/// first (the pattern `auth/storage.rs` shipped first) and retries with two
/// short back-offs for scanners that hold the file for a few milliseconds.
///
/// On final failure the tmp file is removed (no litter) and the error is
/// returned — callers decide severity, but MUST at least log it (errors
/// never pass silently).
pub fn replace_file(tmp: &Path, dest: &Path) -> io::Result<()> {
let result = replace_file_inner(tmp, dest);
if result.is_err() {
let _ = std::fs::remove_file(tmp);
}
result
}
#[cfg(not(windows))]
fn replace_file_inner(tmp: &Path, dest: &Path) -> io::Result<()> {
std::fs::rename(tmp, dest)
}
#[cfg(windows)]
fn replace_file_inner(tmp: &Path, dest: &Path) -> io::Result<()> {
let mut last = match std::fs::rename(tmp, dest) {
Ok(()) => return Ok(()),
Err(e) => e,
};
for backoff_ms in [0u64, 10, 50] {
if backoff_ms > 0 {
std::thread::sleep(std::time::Duration::from_millis(backoff_ms));
}
// Delete-first: marks an open-with-delete-sharing file for deletion
// and clears the way for a plain rename; harmless when absent.
let _ = std::fs::remove_file(dest);
match std::fs::rename(tmp, dest) {
Ok(()) => return Ok(()),
Err(e) => last = e,
}
}
Err(last)
}
#[cfg(test)]
mod tests {
use super::*;
/// The common contract on every platform: replace over an existing
/// destination, create a missing one, and error (cleaning the tmp)
/// when the tmp itself is missing.
#[test]
fn replace_file_commits_and_cleans_up() {
let dir = tempfile::tempdir().expect("tempdir");
let dest = dir.path().join("target.json");
let tmp = dir.path().join("target.json.tmp");
// Create-missing.
std::fs::write(&tmp, b"v1").unwrap();
replace_file(&tmp, &dest).expect("create");
assert_eq!(std::fs::read(&dest).unwrap(), b"v1");
assert!(!tmp.exists(), "tmp must be consumed");
// Replace-existing.
std::fs::write(&tmp, b"v2").unwrap();
replace_file(&tmp, &dest).expect("replace");
assert_eq!(std::fs::read(&dest).unwrap(), b"v2");
assert!(!tmp.exists());
// Missing tmp → error, dest untouched.
let err = replace_file(&tmp, &dest).expect_err("missing tmp must fail");
assert_eq!(err.kind(), io::ErrorKind::NotFound);
assert_eq!(std::fs::read(&dest).unwrap(), b"v2");
}
}
+12 -1
View File
@@ -1,4 +1,5 @@
pub mod event_id;
pub mod fs;
pub mod kigi_home;
pub mod secure_file;
pub mod tips;
@@ -34,7 +35,17 @@ pub fn random_f64() -> f64 {
pub fn probabilistic_sample(rate: f64) -> bool {
random_f64() < rate
}
fn matches_trusted_base_url(candidate: &str, trusted_base: &str) -> bool {
/// True when `candidate` is `trusted_base` or a path below it, comparing
/// scheme, host and effective port exactly (so suffix attacks such as
/// `api.kimi.com.evil.example` never match).
///
/// Public because the credential chokepoint
/// ([`kigi_shell::auth::credential_authority`](../../../kigi_shell/auth/credential_authority/index.html))
/// must compare a request URL against the SESSION's effective endpoints
/// (`[endpoints] coding_api_base_url` from config.toml, `models_base_url`, a
/// platform's own registry host) — none of which the env-var-only predicates
/// below can see.
pub fn matches_trusted_base_url(candidate: &str, trusted_base: &str) -> bool {
let Ok(candidate) = reqwest::Url::parse(candidate) else {
return false;
};
+35 -13
View File
@@ -109,21 +109,43 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
t = asset_triple
);
// Transient CDN hiccups (502/503/timeouts) are retried with backoff: a
// single flaky response must not kill a ~50-minute release build (it
// did — the v0.1.5 tag build failed on one 502). Genuine failures
// (404, offline) still error out with the offline-build hint.
let bytes: Vec<u8> = {
let resp = reqwest::blocking::get(&url).map_err(|e| {
format!(
"Failed to download ripgrep: {}\nSet KIGI_SHELL_BUNDLE_RG_PATH to a local rg for offline builds.",
e
)
})?;
if !resp.status().is_success() {
return Err(format!(
"HTTP {} downloading ripgrep. Set KIGI_SHELL_BUNDLE_RG_PATH for offline builds.",
resp.status()
)
.into());
let mut last_err = String::new();
let mut bytes = None;
for (attempt, backoff_secs) in [0u64, 2, 8].into_iter().enumerate() {
if backoff_secs > 0 {
std::thread::sleep(std::time::Duration::from_secs(backoff_secs));
}
match reqwest::blocking::get(&url) {
Ok(resp) if resp.status().is_success() => match resp.bytes() {
Ok(b) => {
bytes = Some(b.to_vec());
break;
}
Err(e) => last_err = format!("reading ripgrep body: {e}"),
},
Ok(resp) => {
let status = resp.status();
last_err = format!("HTTP {status} downloading ripgrep");
// Only server-side/transient statuses are worth retrying.
if !(status.is_server_error() || status.as_u16() == 429) {
break;
}
}
Err(e) => last_err = format!("Failed to download ripgrep: {e}"),
}
println!(
"cargo:warning=ripgrep download attempt {} failed: {last_err}",
attempt + 1
);
}
resp.bytes()?.to_vec()
bytes.ok_or_else(|| {
format!("{last_err}. Set KIGI_SHELL_BUNDLE_RG_PATH for offline builds.")
})?
};
let gz = flate2::read::GzDecoder::new(&bytes[..]);
@@ -170,9 +170,7 @@ fn write_data_file_atomic(
let json = serde_json::to_string_pretty(sessions)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
fs::write(tmp_path, json.as_bytes())?;
fs::rename(tmp_path, data_path).inspect_err(|_| {
let _ = fs::remove_file(tmp_path);
})
crate::util::fs::replace_file(tmp_path, data_path)
}
fn is_pid_alive(pid: u32) -> bool {
+8 -1
View File
@@ -178,7 +178,13 @@ async fn prefetch_models(agent_config: &AgentConfig) -> Option<IndexMap<String,
if auth.is_some() || endpoints.has_custom_endpoint() || platform_keys.any() {
tokio::task::spawn_blocking(move || {
prefetch_models_blocking(&endpoints, auth.as_ref(), fetch_auth, &platform_keys)
prefetch_models_blocking(
&endpoints,
auth.as_ref(),
&Default::default(),
fetch_auth,
&platform_keys,
)
})
.await
.ok()
@@ -621,6 +627,7 @@ pub async fn run_leader(
crate::agent::models::prefetch_models_blocking(
&endpoints_for_prefetch,
auth_for_prefetch.as_ref(),
&Default::default(),
fetch_auth_for_prefetch,
&platform_keys_for_prefetch,
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -311,7 +311,7 @@ mod tests {
w.kind == ModelOverrideWarningKind::DuplicateAlias
&& w.field.as_deref() == Some("send_compactions_remaining")
}));
let resolved = crate::agent::config::resolve_model_list(&cfg, None);
let resolved = crate::agent::config::resolve_model_list(&cfg, None, &Default::default());
assert!(resolved.contains_key("kigi-4.5"));
}
@@ -0,0 +1,432 @@
//! models.dev enrichment catalog loading (agent-side IO).
//!
//! Providers whose `/models` wire serves no context/thinking metadata get it
//! from models.dev (see `kigi_models::enrichment`). This module owns the IO:
//! a 24h-TTL disk cache under `~/.kigi`, a runtime refresh of
//! `https://models.dev/api.json` (filtered to registry providers before
//! caching), and the bundled-snapshot fallback. NO NETWORK unless some
//! enabled platform actually needs enrichment (`wire_serves_metadata` false)
//! — with only Kimi/Moonshot configured this module never leaves disk.
use std::collections::BTreeSet;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use kigi_models::enrichment::{EnrichmentCatalog, bundled_enrichment, parse_api_json};
/// Override the refresh URL (e2e mock), or disable refresh entirely with
/// `0`/`off` (bundled snapshot + existing cache only).
pub(crate) const MODELS_DEV_URL_ENV: &str = "KIGI_MODELS_DEV_URL";
const DEFAULT_MODELS_DEV_URL: &str = "https://models.dev/api.json";
const CACHE_FILE: &str = "models_dev_cache.json";
const CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
#[derive(serde::Serialize, serde::Deserialize)]
struct DiskCache {
/// Unix seconds of the successful fetch.
fetched_at: u64,
/// The kigi version that wrote the cache — a different binary (upgrade
/// OR downgrade) refetches rather than trusting old filtering rules.
#[serde(default)]
kigi_version: String,
/// The keep-set the catalog was filtered to. A registry change (new
/// provider row, models_dev_id rename) invalidates the cache instead of
/// silently serving a catalog missing the new provider for up to 24h.
#[serde(default)]
keep_set: Vec<String>,
/// Already filtered + transformed catalog.
catalog: EnrichmentCatalog,
}
fn current_keep_set() -> Vec<String> {
registry_models_dev_ids()
.into_iter()
.map(str::to_owned)
.collect()
}
/// Whether any of `platforms` needs enrichment at all.
pub(crate) fn any_platform_needs_enrichment(platforms: &[kigi_models::PlatformId]) -> bool {
platforms.iter().any(|p| !p.wire_serves_metadata())
}
/// The registry's models.dev provider ids (the refresh filter).
fn registry_models_dev_ids() -> BTreeSet<&'static str> {
kigi_models::PlatformId::ALL
.into_iter()
.filter_map(|p| p.models_dev_id())
.collect()
}
/// Cache dir override (tests re-home the cache away from the real
/// `~/.kigi` — same pattern as `KIGI_MODELS_CACHE_DIR`).
pub(crate) const MODELS_DEV_CACHE_DIR_ENV: &str = "KIGI_MODELS_DEV_CACHE_DIR";
fn cache_path() -> std::path::PathBuf {
match std::env::var(MODELS_DEV_CACHE_DIR_ENV) {
Ok(dir) if !dir.trim().is_empty() => std::path::PathBuf::from(dir).join(CACHE_FILE),
_ => crate::util::kigi_home::kigi_home().join(CACHE_FILE),
}
}
fn refresh_url() -> Option<String> {
match std::env::var(MODELS_DEV_URL_ENV) {
Ok(v)
if matches!(
v.trim().to_ascii_lowercase().as_str(),
"0" | "off" | "false"
) =>
{
None
}
Ok(v) if !v.trim().is_empty() => Some(v.trim().to_string()),
_ => Some(DEFAULT_MODELS_DEV_URL.to_string()),
}
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Load the enrichment catalog for a fetch pass over `enabled` platforms.
///
/// Fast path: nothing needs enrichment → empty catalog, zero IO (the merge
/// branch is never taken for wire-served platforms, and NOT forcing the
/// bundled parse keeps its cost/panic surface off the kimi/moonshot path).
/// Otherwise: fresh valid disk cache → use it; else refresh over HTTP
/// (filter + transform + best-effort cache write); on refresh failure fall
/// back to a STALE cache, then the bundled snapshot — each step logged,
/// never silent.
pub(crate) fn load_enrichment_catalog(
enabled: &[kigi_models::PlatformId],
) -> std::borrow::Cow<'static, EnrichmentCatalog> {
if !any_platform_needs_enrichment(enabled) {
return std::borrow::Cow::Owned(EnrichmentCatalog::new());
}
load_enrichment_catalog_at(&cache_path())
}
/// Path-injectable core (tests use a tempdir path directly — no env, no
/// `kigi_home()` OnceLock interaction).
fn load_enrichment_catalog_at(
path: &std::path::Path,
) -> std::borrow::Cow<'static, EnrichmentCatalog> {
use std::borrow::Cow;
let cached: Option<DiskCache> =
std::fs::read_to_string(path)
.ok()
.and_then(|s| match serde_json::from_str(&s) {
Ok(c) => Some(c),
Err(e) => {
tracing::warn!(path = %path.display(), error = %e,
"models.dev cache unreadable; refetching");
None
}
});
let now = now_unix();
let cache_is_fresh = cached.as_ref().is_some_and(|c| {
// A future fetched_at (clock jump backwards, corrupt stamp) is
// stale, not fresh-forever; a different binary or keep-set means the
// cache was filtered under other rules — refetch instead of serving
// a catalog that may miss newly-registered providers for 24h.
c.fetched_at <= now
&& now - c.fetched_at < CACHE_TTL.as_secs()
&& c.kigi_version == kigi_version::VERSION
&& c.keep_set == current_keep_set()
});
if cache_is_fresh {
tracing::debug!("models.dev enrichment: fresh disk cache");
return Cow::Owned(cached.expect("cache_is_fresh implies Some").catalog);
}
match refresh_url() {
Some(url) => match fetch_and_filter(&url) {
Ok(catalog) => {
let cache = DiskCache {
fetched_at: now_unix(),
kigi_version: kigi_version::VERSION.to_string(),
keep_set: current_keep_set(),
catalog,
};
// Best-effort write: a read-only home must not fail the fetch.
match serde_json::to_string(&cache) {
Ok(body) => {
if let Err(e) = crate::util::config::atomic_write_string(path, &body) {
tracing::warn!(path = %path.display(), error = %e,
"models.dev cache write failed; continuing in-memory");
}
}
Err(e) => {
tracing::warn!(error = %e, "models.dev cache serialize failed")
}
}
tracing::info!("models.dev enrichment refreshed");
Cow::Owned(cache.catalog)
}
Err(e) => {
if let Some(c) = cached {
tracing::warn!(error = %e,
"models.dev refresh failed; using STALE cache");
Cow::Owned(c.catalog)
} else {
tracing::warn!(error = %e,
"models.dev refresh failed; using bundled snapshot");
Cow::Borrowed(bundled_enrichment())
}
}
},
None => {
tracing::info!("models.dev refresh disabled; using cache/bundled");
match cached {
Some(c) => Cow::Owned(c.catalog),
None => Cow::Borrowed(bundled_enrichment()),
}
}
}
}
fn fetch_and_filter(url: &str) -> anyhow::Result<EnrichmentCatalog> {
let response = crate::http::shared_blocking_client().get(url).send()?;
let status = response.status();
anyhow::ensure!(status.is_success(), "GET {url}: HTTP {}", status.as_u16());
let body = response.text()?;
let keep = registry_models_dev_ids();
Ok(parse_api_json(&body, Some(&keep))?)
}
#[cfg(test)]
mod tests {
use super::*;
use kigi_test_support::EnvGuard;
use serial_test::serial;
/// Wire-served-only platform sets never trigger IO — and never force the
/// bundled parse (empty owned catalog; the merge branch is gated off).
/// Kimi/Moonshot users therefore keep a zero-egress, zero-cache fetch
/// path even now that enrichment-needing platforms (OpenAI) exist.
#[test]
fn wire_served_platforms_get_empty_catalog_without_io() {
let wire_served = [
kigi_models::PlatformId::KimiCode,
kigi_models::PlatformId::MoonshotCn,
kigi_models::PlatformId::MoonshotAi,
];
assert!(!any_platform_needs_enrichment(&wire_served));
let catalog = load_enrichment_catalog(&wire_served);
assert!(catalog.is_empty());
// The full registry now DOES need enrichment (OpenAI is
// wire_serves_metadata=false) — the fast path must not hide that.
assert!(any_platform_needs_enrichment(&kigi_models::PlatformId::ALL));
}
fn cache_file_in(dir: &tempfile::TempDir) -> std::path::PathBuf {
dir.path().join(CACHE_FILE)
}
fn write_cache(path: &std::path::Path, fetched_at: u64, versioned: bool) {
let cache = DiskCache {
fetched_at,
kigi_version: if versioned {
kigi_version::VERSION.to_string()
} else {
"0.0.0-other".to_string()
},
keep_set: current_keep_set(),
catalog: EnrichmentCatalog::from([(
"moonshotai".to_string(),
std::collections::BTreeMap::from([(
"from-cache".to_string(),
kigi_models::enrichment::EnrichmentModel {
context: 111,
..Default::default()
},
)]),
)]),
};
std::fs::write(path, serde_json::to_string(&cache).unwrap()).unwrap();
}
/// Fresh valid cache short-circuits — no HTTP (no mock server mounted:
/// a fetch attempt would fail and fall to bundled, failing the assert).
#[test]
#[serial]
fn fresh_valid_cache_is_served_without_refresh() {
let dir = tempfile::tempdir().unwrap();
let path = cache_file_in(&dir);
write_cache(&path, now_unix(), true);
let _url = EnvGuard::set(MODELS_DEV_URL_ENV, "http://127.0.0.1:1/api.json");
let catalog = load_enrichment_catalog_at(&path);
assert!(
kigi_models::enrichment::lookup(&catalog, "moonshotai", "from-cache").is_some(),
"fresh cache must be served"
);
}
/// Version/keep-set/future-stamp guards: each invalidates a fresh-aged
/// cache. Refresh is disabled here, so invalidation falls through to the
/// STALE cache (resource degradation, not data loss) — proving both the
/// guard firing and the fallback order.
#[test]
#[serial]
fn cache_guards_invalidate_and_fall_back_to_stale() {
let dir = tempfile::tempdir().unwrap();
let path = cache_file_in(&dir);
let _url = EnvGuard::set(MODELS_DEV_URL_ENV, "0");
// Wrong binary version → not fresh → (refresh disabled) → stale used.
write_cache(&path, now_unix(), false);
let catalog = load_enrichment_catalog_at(&path);
assert!(
kigi_models::enrichment::lookup(&catalog, "moonshotai", "from-cache").is_some(),
"stale-fallback must still serve the cached data"
);
// Future fetched_at → same path (guard fired: debug-log absence is
// not observable here; the behavioral pin is refresh-disabled + the
// wiremock test below proving a fired guard refetches).
write_cache(&path, now_unix() + 10_000, true);
let catalog = load_enrichment_catalog_at(&path);
assert!(kigi_models::enrichment::lookup(&catalog, "moonshotai", "from-cache").is_some());
}
/// An invalidated cache (wrong version) REFETCHES when refresh is
/// enabled: wiremock expect(1) proves the HTTP call happened; the new
/// cache file carries the current version + keep-set and the fetched
/// content replaces the stale entry.
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn invalidated_cache_refetches_and_rewrites() {
let _ = tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_test_writer()
.try_init();
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api.json"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"moonshotai": { "models": { "from-refresh": {
"limit": {"context": 222}
}}}
})),
)
.expect(1)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let path = cache_file_in(&dir);
write_cache(&path, now_unix(), false);
let _url = EnvGuard::set(MODELS_DEV_URL_ENV, format!("{}/api.json", server.uri()));
let path2 = path.clone();
let catalog =
tokio::task::spawn_blocking(move || load_enrichment_catalog_at(&path2).into_owned())
.await
.unwrap();
assert!(
kigi_models::enrichment::lookup(&catalog, "moonshotai", "from-refresh").is_some(),
"guard-invalidated cache must refetch"
);
let rewritten: DiskCache =
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
assert_eq!(rewritten.kigi_version, kigi_version::VERSION);
assert_eq!(rewritten.keep_set, current_keep_set());
assert!(rewritten.catalog.contains_key("moonshotai"));
}
/// Corrupted cache file → refetch (not a crash, not trust-garbage).
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn corrupted_cache_refetches() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api.json"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "moonshotai": { "models": {} } })),
)
.expect(1)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let path = cache_file_in(&dir);
std::fs::write(&path, "not json {").unwrap();
let _url = EnvGuard::set(MODELS_DEV_URL_ENV, format!("{}/api.json", server.uri()));
let path2 = path.clone();
let catalog =
tokio::task::spawn_blocking(move || load_enrichment_catalog_at(&path2).into_owned())
.await
.unwrap();
assert!(catalog.contains_key("moonshotai"));
}
/// Refresh failure with NO cache → bundled snapshot fallback.
#[test]
#[serial]
fn refresh_failure_without_cache_falls_back_to_bundled() {
let dir = tempfile::tempdir().unwrap();
let path = cache_file_in(&dir);
let _url = EnvGuard::set(MODELS_DEV_URL_ENV, "http://127.0.0.1:1/api.json");
let catalog = load_enrichment_catalog_at(&path);
assert!(
kigi_models::enrichment::lookup(&catalog, "kimi-for-coding", "k3").is_some(),
"bundled snapshot must back a total refresh failure"
);
assert!(!path.exists(), "failed refresh must not write a cache");
}
/// Kill switch through the FULL load path (not just refresh_url): no
/// cache + refresh disabled → bundled, no HTTP attempted (an attempt
/// against the sentinel URL would be a hang/refusal, not bundled data).
#[test]
#[serial]
fn kill_switch_full_path_serves_bundled() {
let dir = tempfile::tempdir().unwrap();
let path = cache_file_in(&dir);
for token in ["0", "off", "FALSE", " Off "] {
let _url = EnvGuard::set(MODELS_DEV_URL_ENV, token);
assert!(refresh_url().is_none(), "token {token:?} must disable");
let catalog = load_enrichment_catalog_at(&path);
assert!(kigi_models::enrichment::lookup(&catalog, "kimi-for-coding", "k3").is_some());
}
}
/// Refresh path: mock server → transform runs and the keep-set filters
/// to registry providers (`moonshotai` is a real registry models_dev id;
/// unknown providers are dropped).
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn refresh_transforms_and_filters_to_registry_ids() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api.json"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"moonshotai": { "models": { "kimi-test": {
"limit": {"context": 262144},
"reasoning": true,
"reasoning_options": [
{"type": "effort", "values": ["low", "high"]}
]
}}},
"not-in-registry": { "models": { "m": {} } }
})),
)
.expect(1)
.mount(&server)
.await;
let url = format!("{}/api.json", server.uri());
let catalog = tokio::task::spawn_blocking(move || fetch_and_filter(&url))
.await
.unwrap()
.expect("fetch must succeed");
assert!(
!catalog.contains_key("not-in-registry"),
"keep-set must drop providers outside the registry"
);
let m = kigi_models::enrichment::lookup(&catalog, "moonshotai", "kimi-test")
.expect("registry provider survives the filter");
assert_eq!(m.context, 262_144);
assert_eq!(m.efforts, ["low", "high"]);
}
}
@@ -110,6 +110,29 @@ pub(crate) async fn apply(
.models_manager
.model_supports_reasoning_effort(model_id.0.as_ref())
{
// Legacy migration: pre-split sessions persisted canonical
// `xhigh` for models whose live menu now spells the top tier
// `max` (K3). The wire is identical either way (kimi_compat
// renames xhigh→max), but the menu has no xhigh-valued row, so
// display/active-row would drift from the model vocabulary and
// the stale token would be re-persisted forever. Migrate once.
let eff = if eff == kigi_sampling_types::ReasoningEffort::Xhigh
&& !agent
.models_manager
.model_offers_effort(model_id.0.as_ref(), eff)
&& agent.models_manager.model_offers_effort(
model_id.0.as_ref(),
kigi_sampling_types::ReasoningEffort::Max,
) {
tracing::info!(
session_id = % session_id.0,
"set_session_model: migrating legacy xhigh override to max \
(model menu offers max, not xhigh)"
);
kigi_sampling_types::ReasoningEffort::Max
} else {
eff
};
tracing::info!(
session_id = % session_id.0, effort = % eff,
"set_session_model: applying reasoning_effort override from meta"
@@ -169,9 +192,18 @@ pub(crate) async fn apply(
model.map(|e| &e.info),
)
};
// H4: hand the session the catalog KEY the picker actually resolved. The
// slug in `model_sampling.model` cannot distinguish `xai/grok-*` from
// `xai-grok/grok-*` (duplicate ids across an API-key platform and its
// subscription-OAuth twin are by design), and the process-global
// `current_model_id()` below is not written at all in Leader mode.
let catalog_key =
crate::agent::models::resolve_catalog_key(&agent.models_manager.models(), &model_id)
.map(|k| k.0.to_string());
let (tx, rx) = oneshot::channel();
let _ = handle.cmd_tx.send(SessionCommand::SetSessionModel {
sampling_config: model_sampling,
catalog_key,
use_concise,
apply_prompt_override,
skip_prompt_rewrite: did_rebuild || model_unchanged,
@@ -4,6 +4,7 @@ pub mod auth_method;
pub mod chat_modes;
pub mod config;
pub mod config_model_override_parse;
pub(crate) mod enrichment_fetch;
mod ext_parsers;
pub(crate) mod feedback_client;
pub mod folder_trust;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -165,7 +165,7 @@ impl acp::Agent for MvpAgent {
&crate::util::kigi_home::kigi_home(),
)
{
unsafe { std::env::set_var("XAI_API_KEY", &api_key) };
unsafe { std::env::set_var("KIGI_API_KEY", &api_key) };
tracing::info!("auth: loaded API key from auth.json (xai::api_key scope)");
kigi_log::unified_log::info(
"auth: loaded API key from auth.json (xai::api_key scope)",
@@ -221,7 +221,24 @@ impl acp::Agent for MvpAgent {
has_cached_token,
login_label: None,
});
let auth_methods = built.methods;
let mut auth_methods = built.methods;
// Connected badges for the client's login picker: probe stored
// credentials once (auth.json scopes + resolved platform keys) and
// stamp `_meta.connected` on every method that already has one.
{
let store = crate::auth::read_auth_json(
&crate::util::kigi_home::kigi_home().join("auth.json"),
)
.unwrap_or_default();
let keys = crate::agent::models::PlatformApiKeys::resolve_from_effective_config();
let connected = auth_method::connected_method_ids(
has_cached_token,
has_external_api_key,
|scope| store.contains_key(scope),
|p| keys.key_for(p).is_some(),
);
auth_method::stamp_connected_meta(&mut auth_methods, &connected);
}
kigi_log::unified_log::info(
"auth: initialize() built auth_methods for ACP response",
None,
@@ -330,10 +347,28 @@ impl acp::Agent for MvpAgent {
);
match arguments.method_id.0.as_ref() {
auth_method::XAI_API_KEY_METHOD_ID => {
// C1: the SECOND writer of the shared `sampling_config.api_key`.
// The house `KIGI_API_KEY` is the user's own key for the
// session's own endpoint, so the stamp requires the authority to
// classify that endpoint `CredentialClass::Primary` — NOT merely
// "takes some session credential", which is also true on a
// subscription-OAuth platform's host, where this key has no
// business (the shared config is the subagent baseline and the
// unresolved-model fallback). The key is still persisted to
// auth.json either way; only the stamp is guarded.
let takes_house_key = self.shared_config_takes_house_key();
let mut sampling_config = self.sampling_config.borrow_mut();
if sampling_config.api_key.is_none() {
if let Ok(api_key) = auth_method::read_xai_api_key_env() {
sampling_config.api_key = Some(api_key.clone());
if takes_house_key {
sampling_config.api_key = Some(api_key.clone());
} else {
tracing::debug!(
model = sampling_config.model.as_str(),
"auth: house api key withheld from the shared sampling config \
(its endpoint is not this session's own)"
);
}
if let Err(e) = crate::auth::store_api_key(
&crate::util::kigi_home::kigi_home(),
&api_key,
@@ -357,7 +392,7 @@ impl acp::Agent for MvpAgent {
return Err(
acp::Error::auth_required()
.data(
"Set XAI_API_KEY or add api_key/env_key to config.toml.",
"Set KIGI_API_KEY or add api_key/env_key to config.toml.",
),
);
}
@@ -389,7 +424,7 @@ impl acp::Agent for MvpAgent {
),
),
);
let Some(auth) = self.auth_manager.current() else {
let Some(_auth) = self.auth_manager.current() else {
let message = if self.auth_manager.is_expired() {
"Session expired, re-authentication required"
} else {
@@ -408,9 +443,15 @@ impl acp::Agent for MvpAgent {
.await;
};
self.emit_settings_update_notification();
{
let mut sampling_config = self.sampling_config.borrow_mut();
sampling_config.api_key = Some(auth.key);
// H2/C1: route the stamp through the ONE guard, which asks the
// authority which credential governs the shared config rather
// than being handed this one. That config may already point at
// a third-party model or at another provider's subscription
// host (it is the subagent baseline and the unresolved-model
// fallback), and `auth.key` authorizes only the session's own
// coding endpoint. The manager already holds this token, so the
// authority reads it back where it belongs.
if self.stamp_session_credential(true) {
tracing::debug!(
"auth: cached_token handler set api_key (SessionToken)"
);
@@ -484,9 +525,15 @@ impl acp::Agent for MvpAgent {
err.message = e.to_string();
err
})?;
{
let mut sampling_config = self.sampling_config.borrow_mut();
sampling_config.api_key = Some(auth.key.clone());
// C1: hot-swap FIRST, then let the authority read the fresh
// token back where it belongs. Nothing hand-carries `auth.key`
// to the shared config any more — the stamp is whatever
// credential governs that config's own model + endpoint, which
// for a session whose current model is another provider's
// subscription model is that provider's pooled token, and for a
// third-party host is nothing at all.
self.auth_manager.hot_swap(auth.clone());
if self.stamp_session_credential(true) {
tracing::debug!(
"auth: kimi.com/oidc handler set api_key (SessionToken)"
);
@@ -496,7 +543,6 @@ impl acp::Agent for MvpAgent {
None,
);
}
self.auth_manager.hot_swap(auth.clone());
self.emit_settings_update_notification();
self.set_auth_method(arguments.method_id.clone());
self.models_manager.on_auth_changed().await;
@@ -508,21 +554,29 @@ impl acp::Agent for MvpAgent {
);
Ok(self.auth_response_with_meta())
}
auth_method::MOONSHOT_CN_METHOD_ID | auth_method::MOONSHOT_AI_METHOD_ID => {
let platform = auth_method::moonshot_platform_for_method_id(
&arguments.method_id,
)
.expect("match arm guarantees a moonshot method id");
self.authenticate_moonshot(platform, arguments.method_id.clone())
.await
}
_ => {
Err(
acp::Error::invalid_params()
.data(
format!("unsupported auth method: {}", arguments.method_id.0),
),
if let Some(platform) = auth_method::AuthMethodKind::from_id(
&arguments.method_id,
)
.oauth_platform()
{
self.authenticate_oauth_platform(platform, arguments).await
} else if let Some(platform) =
auth_method::platform_for_method_id(&arguments.method_id)
{
self.authenticate_api_key_platform(
platform,
arguments.method_id.clone(),
)
.await
} else {
Err(
acp::Error::invalid_params()
.data(
format!("unsupported auth method: {}", arguments.method_id.0),
),
)
}
}
}
}
@@ -25,20 +25,35 @@ impl MvpAgent {
primary: &SamplingConfig,
) -> Result<(OaiCompatClient, String), acp::Error> {
let slug = self.resolve_session_summary_model();
let session_key = self.auth_manager.current_or_expired().map(|a| a.key.clone());
let models = self.models_manager.models();
// Resolve the aux token by the summary model's OWN platform AND
// endpoint: a grok (oauth-platform) summary model draws its pooled grok
// token or `None`, and an API-key registry platform draws NOTHING —
// NEVER the primary Kimi session token (which `resolve_credentials`
// would otherwise stamp onto an api.x.ai / api.deepseek.com request).
// The first-party subscription channel still gets the primary
// (byte-identical).
let authority = self.credential_authority();
let session_key = authority.credential_for_slug(&models, None, &slug);
let endpoints = self.models_manager.endpoints();
let alpha_test_key = self.cfg.borrow().endpoints.alpha_test_key.clone();
let config = match crate::agent::config::resolve_aux_model_sampling_config(
&slug,
&models,
&endpoints,
session_key.as_deref(),
session_key.as_ref(),
alpha_test_key,
) {
Some(mut cfg) => {
cfg.attribution_callback = primary.attribution_callback.clone();
cfg.bearer_resolver = primary.bearer_resolver.clone();
// The SESSION model's bearer_resolver must not ride to a summary
// model on a different provider — `SamplingClient::post`
// REPLACES the request's auth header from it, overwriting the
// summary model's own resolved key on ITS host. The chokepoint
// resolves the resolver from the SUMMARY model's platform +
// endpoint instead; the session's is never even read.
cfg.bearer_resolver =
self.summary_bearer_resolver(&models, &slug, &cfg.base_url);
cfg.max_retries = primary.max_retries;
cfg
}
@@ -52,6 +67,36 @@ impl MvpAgent {
let client = OaiCompatClient::new(config).map_err(map_sampling_err_to_acp)?;
Ok((client, model))
}
/// The `bearer_resolver` the SESSION-SUMMARY client may carry — the SAME
/// rule the session actor's aux path applies
/// ([`crate::session::acp_session::sampler_turn::aux_bearer_resolver_for`]),
/// not a second copy of it.
///
/// M3 completed: the aux path was gated on the session-token gate and this
/// one was not, so an api-key / house-key session whose
/// `[model.session-summary]` block carries its OWN `env_key` on the
/// session's own coding endpoint had that key REPLACED on the wire by the
/// primary bearer on every summary request. Named (rather than inlined
/// above) so the gate is reachable from a test — the ungated version stayed
/// green because the resolver is consumed by `OaiCompatClient::new`.
///
/// Aux slugs are not the session's selection, so `current_key = None`
/// (H-b then refuses a collided slug rather than guessing its OAuth twin).
pub(super) fn summary_bearer_resolver(
&self,
models: &indexmap::IndexMap<String, ModelEntry>,
slug: &str,
base_url: &str,
) -> Option<kigi_sampler::SharedBearerResolver> {
let auth_method = self.auth_method_id.load();
crate::session::acp_session::sampler_turn::aux_bearer_resolver_for(
&self.credential_authority(),
auth_method.as_deref(),
crate::agent::models::platform_for_slug(models, None, slug),
crate::agent::config::resolve_model_auth_facts(slug).byok,
base_url,
)
}
/// `true` for session-based ACP auth methods.
fn is_session_based_auth(&self) -> bool {
self.auth_method_id
@@ -452,18 +497,19 @@ impl MvpAgent {
)
.await
}
/// `authenticate(moonshot-cn / moonshot-ai)`: interactive open-platform
/// API-key login from the welcome picker.
/// `authenticate(<api-key platform id>)`: interactive API-key login from
/// the welcome picker for any non-OAuth registry platform.
///
/// Reloads the platform keys from disk+env (the TUI persists the pasted
/// key to `[platforms.<id>]` in config.toml immediately before this call),
/// fails with an actionable error when none is configured, validates the
/// key against `GET {platform_base}/models`, then marks the session
/// authenticated exactly like an external API key: publish the method id
/// (NOT session-based — no token refresh), swap the freshly-stamped config
/// into the models manager, and trigger the model sync so the catalog
/// gains the platform's entries. The key itself is never logged.
pub(super) async fn authenticate_moonshot(
/// key to auth.json under the platform-id scope immediately before this
/// call), fails with an actionable error when none is configured,
/// validates the key against `GET {platform_base}/models`, then marks the
/// session authenticated exactly like an external API key: publish the
/// method id (NOT session-based — no token refresh), swap the
/// freshly-stamped config into the models manager, and trigger the model
/// sync so the catalog gains the platform's entries. The key itself is
/// never logged.
pub(super) async fn authenticate_api_key_platform(
&self,
platform: kigi_models::PlatformId,
method_id: acp::AuthMethodId,
@@ -480,10 +526,11 @@ impl MvpAgent {
Some("platform_key_invalid_or_missing"),
);
})?;
// Swap the on-disk config (now carrying the key) into the models
// manager so `apply_platform_credentials` stamps the platform's
// catalog entries; a parse failure keeps the last-known-good config
// (`on_auth_changed` below still re-resolves keys from disk itself).
// Rebuild the catalog from the on-disk config: the rebuild freshly
// resolves platform keys (env > auth.json > config), so the key just
// persisted to auth.json is stamped onto the platform's entries; a
// parse failure keeps the last-known-good config (`on_auth_changed`
// below still re-resolves keys from disk itself).
match crate::config::load_effective_config()
.map_err(|e| e.to_string())
.and_then(|raw| crate::agent::config::Config::new_from_toml_cfg(&raw))
@@ -512,6 +559,78 @@ impl MvpAgent {
.and_then(|v| v.as_object().cloned());
Ok(AuthenticateResponse::new().meta(meta))
}
/// `authenticate(<generic-oauth platform id>)`: interactive device-code
/// login for a `uses_oauth` platform carrying an `OAuthConfig` (xai-grok).
///
/// Uses a per-provider [`AuthManager`] scoped to the platform's `scope_key`
/// (NOT the primary Kimi manager) so the minted session is persisted under
/// its own `auth.json` scope, then triggers a catalog re-sync so the
/// platform's models appear (their bearer is resolved per-provider at fetch
/// / sampling time). The tokens are never logged.
pub(super) async fn authenticate_oauth_platform(
&self,
platform: kigi_models::PlatformId,
arguments: acp::AuthenticateRequest,
) -> Result<AuthenticateResponse, acp::Error> {
let method_id = arguments.method_id.clone();
let oauth = platform
.oauth()
.expect("oauth_platform() guarantees a device-code OAuthConfig");
let auth_meta = AuthRequestMeta::from_json(arguments.meta.as_ref());
tracing::info!(
method = method_id.0.as_ref(),
headless = auth_meta.headless,
reauth = auth_meta.reauth,
"auth: generic oauth device login",
);
// M4: the SAME home the pool reads from
// (`oauth_registry::pool_home()`), not `kigi_home()` directly —
// identical in production, but a login driven from a lib test would
// otherwise write into the developer's real `~/.kigi` while every
// inference-time lookup read the disposable test pool home.
let kigi_home = crate::auth::oauth_registry::pool_home();
let auth_manager =
std::sync::Arc::new(crate::auth::AuthManager::new_oauth_provider(&kigi_home, oauth));
auth_manager.configure_refresher();
let flow_result = if !auth_meta.headless {
let (url_tx, url_rx) = tokio::sync::oneshot::channel();
let (code_tx, code_rx) = tokio::sync::mpsc::channel(1);
*self.auth_code_tx.borrow_mut() = Some(code_tx);
*self.auth_url_rx.borrow_mut() = Some(url_rx);
let result = crate::auth::run_oauth_provider_flow(
&auth_manager,
oauth,
auth_meta.reauth,
Some(crate::auth::AuthChannels {
url_tx: Some(url_tx),
code_rx,
}),
)
.await;
*self.auth_code_tx.borrow_mut() = None;
*self.auth_url_rx.borrow_mut() = None;
result
} else {
crate::auth::run_oauth_provider_flow(&auth_manager, oauth, auth_meta.reauth, None).await
};
let (_auth, _did_auth) = flow_result.map_err(|e| {
emit_login_span(false, method_id.0.as_ref(), None, Some("login_flow_failed"));
let mut err = acp::Error::auth_required();
err.message = e.to_string();
err
})?;
// Do NOT stamp this token onto the shared sampling_config: it authorizes
// ONLY this platform's models (api.x.ai/v1), not the primary session.
// The catalog re-sync below resolves it per-provider.
self.set_auth_method(method_id.clone());
self.models_manager.on_auth_changed().await;
emit_login_span(true, method_id.0.as_ref(), None, None);
Ok(self.auth_response_with_meta())
}
pub(crate) fn deployment_key(&self) -> Option<String> {
self.cfg.borrow().endpoints.deployment_key.clone()
}
@@ -591,21 +710,60 @@ impl MvpAgent {
);
Ok(entry.clone())
}
/// This agent's credential chokepoint: the EFFECTIVE endpoints (so a
/// managed `[endpoints] coding_api_base_url` deployment keeps the session
/// bearer — H3) plus the primary manager, which the authority keeps private.
pub(crate) fn credential_authority(
&self,
) -> crate::auth::credential_authority::CredentialAuthority {
crate::auth::credential_authority::CredentialAuthority::new(
self.models_manager.endpoints(),
Some(self.auth_manager.clone()),
)
}
/// The SESSION credential for `model`, resolved by the model's OWN platform
/// AND endpoint at the chokepoint — the single guard against the
/// api_key-channel token leak (C1).
///
/// A subscription-OAuth model draws from ITS OWN pooled manager (`None`,
/// never the Kimi key, when that provider has no stored session). Every
/// API-key registry platform and every `[model.*]` block pointed at a
/// third-party host gets `None`: `resolve_credentials` would otherwise take
/// the `else if let Some(key) = session_key` arm and set
/// `api_key = <Kimi bearer>` with the THIRD-PARTY `base_url`, which
/// `SamplingClient` builds into `Authorization: Bearer …` — reachable with
/// ZERO configuration, since `default_models.json` bundles `moonshot-cn/*`
/// and `moonshot-ai/*` entries a Kimi-subscription user sees on first
/// launch / offline. The first-party subscription channel uses the primary,
/// and only under a session-based auth method — byte-identical.
///
/// SECURITY: the resolved token is never logged.
fn session_token_for_model(
&self,
model: &ModelEntry,
) -> Option<crate::auth::credential_authority::SessionCredential> {
let is_primary_channel = crate::auth::credential_authority::entry_platform(model)
.is_none_or(|platform| platform.oauth().is_none());
if is_primary_channel && !self.is_session_based_auth() {
return None;
}
self.credential_authority().credential_for_model(model)
}
pub(crate) fn prepare_sampling_config_for_model(
&self,
model: &ModelEntry,
origin_client: Option<crate::http::OriginClientInfo>,
) -> SamplingConfig {
let session = if self.is_session_based_auth() {
self.auth_manager.current_or_expired()
} else {
None
};
// Resolve the session token by the MODEL's platform, not the primary
// auth method: an oauth-platform model (xai-grok) uses its OWN
// pool-backed token (`None` — never the Kimi key — when the user has not
// logged into that provider), closing the api_key-channel leak where the
// primary Kimi session token was stamped onto a grok request. A
// first-party / Kimi model is unchanged. GUARANTEE: a grok model's
// api_key is its own grok token or `None`, never the primary Kimi key.
let session = self.session_token_for_model(model);
let has_session_key = session.is_some();
let mut credentials = resolve_credentials(
model,
session.as_ref().map(|a| a.key.as_str()),
);
let mut credentials = resolve_credentials(model, session.as_ref());
if !has_session_key && credentials.auth_type == kigi_chat_state::AuthType::ApiKey
&& !model.has_own_credentials() && self.is_session_based_auth()
{
@@ -792,7 +950,8 @@ impl MvpAgent {
models_manager: crate::agent::models::ModelsManager,
) -> Self {
models_manager.set_gateway(gateway.clone());
let sampling_config = models_manager.sampling_config();
// H-a: the config AND the platform it was built from, from ONE call.
let baseline = models_manager.sampling_config();
let storage_mode = cfg.storage_mode;
let default_yolo_mode = cfg.default_yolo_mode;
let default_auto_mode = cfg.default_auto_mode;
@@ -847,7 +1006,8 @@ impl MvpAgent {
models_manager,
cfg: RefCell::new(cfg.clone()),
auth_method_id: crate::agent::auth_method::new_shared_auth_method_id(None),
sampling_config: RefCell::new(sampling_config),
sampling_config: RefCell::new(baseline.config),
sampling_config_platform: std::cell::Cell::new(baseline.platform),
auth_manager,
auth_code_tx: RefCell::new(None),
auth_url_rx: RefCell::new(None),
@@ -1437,38 +1597,133 @@ impl MvpAgent {
);
(serde_json::json!({ "options" : config_options }), serde_json::json!(detail))
}
/// The registry platform the SHARED `sampling_config` routes to: the one
/// captured WITH the config when it was built, never a fresh lookup.
///
/// H-a: `ModelsManager::sampling_config` builds the shared config from the
/// catalog entry `current_model_id()` named AT THAT MOMENT, and the config
/// is never rebuilt. A guard that re-resolves `config.model` (the BARE
/// routing slug) against the LIVE cell therefore answers about a DIFFERENT
/// entry the moment the two drift — and they drift on every non-Leader model
/// switch (`handlers/model_switch.rs`) and on catalog reselection.
/// `entry_for_slug_resolution`'s `entry.info.model == slug` test then fails
/// and the resolution falls through to `resolve_catalog_key`'s `.rev()`
/// scan; `PlatformId::ALL` lists `kimi-code` first and its API-key twin
/// `kimi-coding` 19th, so the LAST match is the twin, which takes no session
/// credential. For a Kimi-subscription user who also has `KIMI_API_KEY` set,
/// a post-expiry `kigi login` then found no governing manager and — because
/// the stamp only overwrites on success — left the EXPIRED bearer in place:
/// every unresolved-model fallback and every subagent baseline turn 401'd
/// until restart.
fn shared_config_platform(&self) -> Option<kigi_models::PlatformId> {
self.sampling_config_platform.get()
}
/// H2/C1: stamp the shared `sampling_config`'s OWN governing session
/// credential — whatever the authority says that is for this config's model
/// and endpoint.
///
/// The shared config is not inert: `resolve_sampling_config_for_model`
/// returns it verbatim whenever a model id fails to resolve, and
/// `SubagentSpawnContext` clones it as every subagent's baseline — so an
/// `api_key` stamped here reaches the wire against whatever `base_url` the
/// config carries. The login/seed sites used to stamp it unconditionally,
/// exactly the mistake `authenticate_oauth_platform` already documents ("Do
/// NOT stamp this token onto the shared sampling_config").
///
/// C1: this function does NOT take a credential. The previous shape took
/// `key: String` — always the primary Kimi bearer — and guarded it with a
/// predicate asking whether **a** session credential may ride. For a
/// subscription-OAuth platform at its own host that is correctly `true`, but
/// the credential that may ride there is that platform's POOLED token: a
/// Claude Pro/Max user running `kigi login` stamped the Kimi subscription
/// bearer onto a config routed at `api.anthropic.com`. Asking
/// `credential_for` instead makes the question and the credential the same
/// object, so the pairing cannot be wrong — and there is no primary handle
/// here to hand-carry (M2).
///
/// `overwrite = false` keeps the historical "only if missing" seeding
/// behaviour; the login handlers pass `true` because a fresh login must
/// replace a stale bearer, and they call this AFTER the manager holds the
/// new token so it is read back through the authority.
///
/// SECURITY: the token is never logged.
pub(super) fn stamp_session_credential(&self, overwrite: bool) -> bool {
let (model, base_url) = {
let sampling_config = self.sampling_config.borrow();
if !overwrite && sampling_config.api_key.is_some() {
return false;
}
(
sampling_config.model.clone(),
sampling_config.base_url.clone(),
)
};
let platform = self.shared_config_platform();
let Some(credential) = self.credential_authority().credential_for(platform, &base_url)
else {
tracing::debug!(
model = model.as_str(),
"auth: no session credential governs the shared sampling config \
(its model + endpoint take none, or the governing provider has no session)"
);
return false;
};
self.sampling_config.borrow_mut().api_key = Some(credential.expose().to_owned());
true
}
/// Whether the shared `sampling_config` may receive a credential that
/// authorizes only the SESSION's own first-party endpoint — the house
/// `KIGI_API_KEY` the `xai.api_key` login handler reads from the
/// environment, which the authority does not own and so cannot produce.
///
/// The house key rides the [`CredentialClass::Primary`] channel and no
/// other. `Pooled` is deliberately excluded: a subscription-OAuth platform's
/// own host DOES take a session credential, but that credential is the
/// platform's pooled token, where the house key has no business (C1).
pub(super) fn shared_config_takes_house_key(&self) -> bool {
let base_url = self.sampling_config.borrow().base_url.clone();
matches!(
self.credential_authority()
.credential_class(self.shared_config_platform(), &base_url),
crate::auth::credential_authority::CredentialClass::Primary
)
}
/// Seed the global sampling config with login auth when available.
///
/// Only sets the `api_key` if missing. Does NOT resolve `base_url` from
/// Only sets the `api_key` if missing, and only with the credential that
/// governs the config's own model + endpoint (see
/// [`Self::stamp_session_credential`]). Does NOT resolve `base_url` from
/// `current_model_id` — that's deferred to session creation time to avoid
/// cross-client contamination in leader mode (where `current_model_id` is
/// shared mutable state).
pub(super) fn seed_client_config_auth_if_available(&self) {
let mut sampling_config = self.sampling_config.borrow_mut();
if sampling_config.api_key.is_none() {
if let Some(auth) = self.auth_manager.current_or_expired() {
sampling_config.api_key = Some(auth.key);
tracing::debug!("auth: seed_client_config set auth (SessionToken)");
kigi_log::unified_log::debug(
"auth: seed_client_config set auth (SessionToken)",
None,
None,
);
} else if !self
if self.sampling_config.borrow().api_key.is_some() {
return;
}
if self.stamp_session_credential(false) {
tracing::debug!("auth: seed_client_config set auth (SessionToken)");
kigi_log::unified_log::debug(
"auth: seed_client_config set auth (SessionToken)",
None,
None,
);
return;
}
// No credential was stamped. Only the total-absence case is worth
// warning about: a withheld-by-endpoint seed is the rule working.
if self.auth_manager.current_or_expired().is_none()
&& !self
.models_manager
.models()
.values()
.any(|m| m.has_own_credentials())
{
tracing::warn!(
"No credentials found: no login token and no model api_key/env_key"
);
kigi_log::unified_log::warn(
"No credentials found: no login token and no model api_key/env_key",
None,
None,
);
}
{
tracing::warn!("No credentials found: no login token and no model api_key/env_key");
kigi_log::unified_log::warn(
"No credentials found: no login token and no model api_key/env_key",
None,
None,
);
}
}
/// Allocate the next monotonic telemetry turn number for a session.
@@ -2190,12 +2445,34 @@ impl MvpAgent {
}
let (mut handle, agent_system_prompt, session_thread) = {
let _timer = crate::instrumentation_timer!("session.spawn_actor_call");
let session_key = self.auth_manager.current_or_expired().map(|a| a.key);
// Classify the credential's auth_type against the bearer this
// model's endpoint would ACTUALLY receive, not the raw primary:
// an API-key-platform model has no session credential at all, and
// reading the primary here reported one.
//
// H-a: the platform is resolved with the SAME catalog key the
// actor about to be spawned seeds itself with
// (`selected_catalog_key_for_spawn`, spawn.rs), so this
// classification and every per-turn decision that session makes
// agree by construction instead of re-resolving the bare slug.
let models_for_key = self.models_manager.models();
let session_key = self.credential_authority().credential_for(
crate::agent::models::platform_for_slug(
&models_for_key,
crate::agent::models::selected_catalog_key_for_spawn(
&models_for_key,
&session_model_id,
)
.as_deref(),
sampling_config.model.as_str(),
),
&sampling_config.base_url,
);
let credentials = kigi_chat_state::Credentials {
api_key: sampling_config.api_key.clone(),
auth_type: crate::agent::config::resolve_chat_state_auth_type(
sampling_config.model.as_str(),
session_key.as_deref(),
session_key.as_ref(),
self.auth_type(),
),
alpha_test_key: self.alpha_test_key(),
@@ -501,6 +501,19 @@ pub struct MvpAgent {
/// only api_key is written here (same for all clients). Per-session base_url
/// is resolved at session creation time in `new_session` / `load_session`.
pub(crate) sampling_config: RefCell<SamplingConfig>,
/// The registry platform [`Self::sampling_config`] was BUILT from, captured
/// in the same `ModelsManager::sampling_config()` call that produced it.
///
/// H-a: the shared config is built ONCE (`Self::with_models`) and never
/// rebuilt, but `ModelsManager::current_model_id()` moves on every
/// non-Leader model switch and on catalog reselection. Its guards
/// (`stamp_session_credential`, `shared_config_takes_house_key`)
/// therefore read THIS cell, never the live one: after a switch the live
/// cell names a different entry, and re-resolving the config's bare slug
/// against it fell through to `resolve_catalog_key`'s `.rev()` scan —
/// answering the API-key twin, which takes no session credential, so a
/// successful `kigi login` silently failed to replace the expired bearer.
pub(crate) sampling_config_platform: std::cell::Cell<Option<kigi_models::PlatformId>>,
pub(crate) auth_manager: Arc<AuthManager>,
pub(crate) models_manager: crate::agent::models::ModelsManager,
/// Forwards pasted codes from `handle_auth_submit_code` to the auth flow.
@@ -1158,6 +1158,80 @@ fn build_agent_with_auth(auth: crate::auth::KimiAuth) -> MvpAgent {
let cfg = AgentConfig::default();
MvpAgent::new(gateway, &cfg, auth_manager, None).expect("valid test config")
}
/// Regression (token-leak, Facet B via the api_key channel): under a
/// session-based (Kimi) primary auth method, `prepare_sampling_config_for_model`
/// must resolve the session token by the MODEL's platform — so a grok
/// (oauth-platform) model NEVER carries the primary Kimi session key as its
/// `api_key`, while a first-party Kimi model still does. Relies on the
/// process-global OAuth pool (there is no per-session snapshot).
///
/// Reverting the fix (resolving the session token from the primary regardless of
/// the model's platform) fails the grok assertion below — it would stamp the
/// live Kimi key on a request bound for api.x.ai.
#[tokio::test]
#[serial_test::serial]
async fn prepare_sampling_config_never_stamps_kimi_key_on_grok_model() {
use crate::agent::auth_method::{
CACHED_TOKEN_AUTH_METHOD_ID, HOUSE_API_KEY_ENV_VAR, LEGACY_XAI_API_KEY_ENV_VAR,
XAI_API_KEY_ENV_VAR,
};
use crate::agent::config::{EndpointsConfig, ModelEntry};
use kigi_test_support::EnvGuard;
const KIMI_KEY: &str = "kimi-session-secret-DO-NOT-LEAK";
// No ambient BYOK env key: a grok model with no stored oauth session then
// resolves to no api_key at all, rather than a global-key fallback that could
// mask the leak under test.
let _house = EnvGuard::unset(HOUSE_API_KEY_ENV_VAR);
let _xai = EnvGuard::unset(XAI_API_KEY_ENV_VAR);
let _legacy = EnvGuard::unset(LEGACY_XAI_API_KEY_ENV_VAR);
// Primary: a live Kimi session token under a session-based auth method.
let agent = build_agent_with_auth(crate::auth::KimiAuth {
key: KIMI_KEY.to_string(),
auth_mode: crate::auth::AuthMode::OAuth,
..crate::auth::KimiAuth::test_default()
});
agent.set_auth_method(acp::AuthMethodId::new(CACHED_TOKEN_AUTH_METHOD_ID));
let endpoints = EndpointsConfig::default();
// First-party SUBSCRIPTION model (kimi-code): the primary session key IS its
// api_key — the byte-identical primary path, and proof the Kimi token is
// live (so it WOULD leak if mis-routed onto a grok request). This assertion
// also confirms the session-based primary path is active.
//
// This used to use `moonshot-cn/kimi-k2-0905-preview` and assert the SAME
// thing, which encoded the C1 defect: moonshot-cn is an API-key registry
// platform on `api.moonshot.cn`, NOT first-party, so "must carry the primary
// session key" was asserting the leak. `api_key_channel_leak_tests` now pins
// the opposite for every moonshot entry.
let mut kimi_model = ModelEntry::fallback("kimi-for-coding", &endpoints);
kimi_model.info.id = Some("kimi-code/kimi-for-coding".to_string());
kimi_model.info.base_url = kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url.to_string();
assert!(!kimi_model.has_own_credentials());
let kimi_cfg = agent.prepare_sampling_config_for_model(&kimi_model, None);
assert_eq!(
kimi_cfg.api_key.as_deref(),
Some(KIMI_KEY),
"the first-party subscription model must carry the primary session key \
(primary path unchanged)"
);
// xai-grok model (oauth platform): the session token resolves from its OWN
// pool-backed manager, INDEPENDENT of the Kimi primary — so its api_key can
// NEVER be the Kimi session key.
let mut grok_model = ModelEntry::fallback("grok-4-latest", &endpoints);
grok_model.info.id = Some("xai-grok/grok-4-latest".to_string());
assert!(!grok_model.has_own_credentials());
let grok_cfg = agent.prepare_sampling_config_for_model(&grok_model, None);
assert_ne!(
grok_cfg.api_key.as_deref(),
Some(KIMI_KEY),
"LEAK: a grok model must never carry the primary Kimi session key as api_key"
);
}
/// Regression: boot-time plugin discovery is deferred past ACP
/// `initialize`, so the shared plugin registry starts empty.
/// `resolve_mcp_servers` reads that snapshot to merge plugin-contributed
@@ -1214,6 +1288,10 @@ async fn ensure_plugin_registry_lazily_populates_snapshot() {
);
}
mod subagent_spawn_context_tests;
/// LEAK guard for the `api_key` channel (C1/C2), through the real
/// `prepare_sampling_config_for_model` resolution path.
mod api_key_channel_leak_tests;
mod chokepoint_leak_tests;
/// No load in flight and no session → the wait returns immediately
/// (the caller then surfaces "unknown session id" exactly as before).
#[tokio::test]
@@ -1904,10 +1982,12 @@ async fn cached_token_fallthrough_prefers_api_key_for_deployment_key() {
#[serial_test::serial]
async fn cached_token_fallthrough_falls_to_kigi_com_without_credentials() {
use crate::agent::auth_method::{
KIMI_CODE_METHOD_ID, LEGACY_XAI_API_KEY_ENV_VAR, XAI_API_KEY_ENV_VAR,
HOUSE_API_KEY_ENV_VAR, KIMI_CODE_METHOD_ID, LEGACY_XAI_API_KEY_ENV_VAR,
XAI_API_KEY_ENV_VAR,
};
use kigi_test_support::EnvGuard;
let _lockdown = EnvGuard::unset("KIGI_DISABLE_API_KEY_AUTH");
let _house = EnvGuard::unset(HOUSE_API_KEY_ENV_VAR);
let _new = EnvGuard::unset(XAI_API_KEY_ENV_VAR);
let _legacy = EnvGuard::unset(LEGACY_XAI_API_KEY_ENV_VAR);
let agent = build_minimal_agent_for_tests();
@@ -0,0 +1,314 @@
//! LEAK GUARD (`api_key` channel) — C1/C2, driven through the REAL resolution
//! path, `MvpAgent::prepare_sampling_config_for_model`.
//!
//! This is the channel the `bearer_resolver` guard does NOT close, and the one
//! the first round of leak tests assumed away by hand-stamping a provider key
//! into chat state. The chain: `session_token_for_model` used to fall through to
//! `self.auth_manager.current_or_expired()` (the primary Kimi bearer) for every
//! non-OAuth model, `resolve_credentials` then took its
//! `else if let Some(key) = session_key` arm and set `api_key = <Kimi token>`
//! with the THIRD-PARTY `base_url`, and `SamplingClient` builds
//! `Authorization: Bearer <api_key>` straight into `default_headers` — which
//! `post()` only overrides when a resolver exists, so `bearer_resolver: None`
//! does not save it.
//!
//! Nothing here stamps a credential by hand: every assertion reads what the
//! resolution path actually produced.
use super::super::*;
use crate::agent::auth_method::{
CACHED_TOKEN_AUTH_METHOD_ID, HOUSE_API_KEY_ENV_VAR, LEGACY_XAI_API_KEY_ENV_VAR,
XAI_API_KEY_ENV_VAR,
};
use crate::agent::config::{Config as AgentConfig, EndpointsConfig, EnvKeys, ModelEntry};
use crate::auth::{AuthManager, AuthMode, KimiAuth, KimiCodeConfig};
use kigi_test_support::EnvGuard;
pub(super) const KIMI_TOKEN: &str = "kimi-subscription-token-DO-NOT-LEAK";
/// Ambient BYOK env vars unset, so a model with no resolvable credential ends up
/// with `api_key == None` rather than a global-key fallback that could mask the
/// leak under test. Every test holding these must be `#[serial]`.
pub(super) fn without_ambient_byok_env() -> [EnvGuard; 3] {
[
EnvGuard::unset(HOUSE_API_KEY_ENV_VAR),
EnvGuard::unset(XAI_API_KEY_ENV_VAR),
EnvGuard::unset(LEGACY_XAI_API_KEY_ENV_VAR),
]
}
/// An `MvpAgent` on a session-based (`cached_token`) ACP method holding a live
/// Kimi subscription bearer — the mainstream configuration in which the leak
/// fires. `(tempdir, agent)`; the tempdir is the auth store and is returned so
/// the caller keeps it alive.
pub(super) fn kimi_session_agent() -> (tempfile::TempDir, MvpAgent) {
let dir = tempfile::tempdir().expect("tempdir");
let auth_manager = std::sync::Arc::new(AuthManager::new(dir.path(), KimiCodeConfig::default()));
auth_manager.hot_swap(KimiAuth {
key: KIMI_TOKEN.to_string(),
auth_mode: AuthMode::OAuth,
refresh_token: Some("rt".into()),
expires_at: Some(chrono::Utc::now() + chrono::Duration::hours(1)),
..KimiAuth::test_default()
});
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let agent = MvpAgent::new(
GatewaySender::new(tx),
&AgentConfig::default(),
auth_manager,
None,
)
.expect("valid test config");
agent.set_auth_method(acp::AuthMethodId::new(CACHED_TOKEN_AUTH_METHOD_ID));
(dir, agent)
}
/// A catalog entry as `resolve_model_list` builds one for a fetched registry
/// model: managed catalog key, platform base URL, no credential of its own.
pub(super) fn platform_entry(catalog_key: &str, slug: &str, base_url: &str) -> ModelEntry {
let mut entry = ModelEntry::fallback(slug, &EndpointsConfig::default());
entry.info.id = Some(catalog_key.to_string());
entry.info.base_url = base_url.to_string();
entry
}
/// C1, the ZERO-CONFIGURATION repro. `default_models.json` bundles
/// `moonshot-cn/*` and `moonshot-ai/*` entries with `api_key: None`, and
/// `resolve_model_list` keeps the bundled defaults whenever no catalog fetch has
/// succeeded — so on first launch / offline a Kimi-subscription user sees them
/// in the picker with no configuration whatsoever. Selecting one used to send
/// `Authorization: Bearer <Kimi OAuth token>` to `api.moonshot.cn`, which is NOT
/// first-party.
///
/// Revert-to-red: make `CredentialAuthority::governing_manager`'s
/// `Some(platform) => None` arm return `self.primary.clone()` and every `api_key`
/// below becomes `Some(KIMI_TOKEN)`.
#[tokio::test]
#[serial_test::serial]
async fn bundled_default_moonshot_models_never_carry_the_kimi_bearer() {
let _env = without_ambient_byok_env();
let (_dir, agent) = kimi_session_agent();
let bundled = crate::agent::config::default_model_entries(&EndpointsConfig::default());
let moonshot: Vec<_> = bundled
.iter()
.filter(|(key, _)| key.starts_with("moonshot-cn/") || key.starts_with("moonshot-ai/"))
.collect();
assert_eq!(
moonshot.len(),
4,
"default_models.json still bundles the four moonshot open-platform entries"
);
for (key, entry) in moonshot {
assert!(
!entry.has_own_credentials(),
"{key}: the bundled entry carries no credential of its own"
);
assert!(
!crate::util::is_effective_coding_endpoint_url(&entry.info().base_url),
"{key}: routes to a third-party host ({})",
entry.info().base_url
);
let cfg = agent.prepare_sampling_config_for_model(entry, None);
assert_ne!(
cfg.api_key.as_deref(),
Some(KIMI_TOKEN),
"LEAK: selecting the bundled {key} sent the Kimi subscription bearer to {}",
entry.info().base_url
);
assert_eq!(
cfg.base_url,
entry.info().base_url,
"{key}: still routes to its own host (the fix must not reroute traffic)"
);
}
}
/// C1 across the API-key registry platform shapes a fetched catalog produces.
#[tokio::test]
#[serial_test::serial]
async fn api_key_platform_models_never_carry_the_kimi_bearer_as_api_key() {
let _env = without_ambient_byok_env();
let (_dir, agent) = kimi_session_agent();
for (catalog_key, slug, base_url) in [
("deepseek/deepseek-chat", "deepseek-chat", "https://api.deepseek.com/v1"),
("openai/gpt-5.2", "gpt-5.2", "https://api.openai.com/v1"),
("anthropic/claude-opus-4-8", "claude-opus-4-8", "https://api.anthropic.com/v1"),
("groq/llama-4", "llama-4", "https://api.groq.com/openai/v1"),
("xai/grok-4.5", "grok-4.5", "https://api.x.ai/v1"),
] {
let entry = platform_entry(catalog_key, slug, base_url);
let cfg = agent.prepare_sampling_config_for_model(&entry, None);
assert_ne!(
cfg.api_key.as_deref(),
Some(KIMI_TOKEN),
"LEAK: {catalog_key} carried the primary Kimi session bearer to {base_url}"
);
}
}
/// C2, the `[model.*]` repro. A `[model.gpt-4o]` block has `info.id == None`, so
/// it has no platform at all — which used to be a blanket allow. BYOK is
/// `has_own_credentials()`, which probes `std::env::var` AT CALL TIME, so an
/// unset (or mistyped) `env_key` classifies the model NotByok and the Kimi
/// bearer went to `api.openai.com` on BOTH channels.
///
/// Revert-to-red: make `CredentialAuthority::is_session_coding_endpoint` return
/// `true` unconditionally and `api_key` here becomes `Some(KIMI_TOKEN)`.
#[tokio::test]
#[serial_test::serial]
async fn config_model_with_an_unset_env_key_never_carries_the_kimi_bearer() {
let _env = without_ambient_byok_env();
let _typo = EnvGuard::unset("OPENAI_API_KEY_TYPO");
let (_dir, agent) = kimi_session_agent();
let mut entry = ModelEntry::fallback("gpt-4o", &EndpointsConfig::default());
entry.info.id = None; // a `[model.gpt-4o]` config block
entry.info.base_url = "https://api.openai.com/v1".to_string();
entry.env_key = Some(EnvKeys::single("OPENAI_API_KEY_TYPO"));
assert!(
!entry.has_own_credentials(),
"the env var is unset, so this classifies NotByok — the precondition of the defect"
);
let cfg = agent.prepare_sampling_config_for_model(&entry, None);
assert_ne!(
cfg.api_key.as_deref(),
Some(KIMI_TOKEN),
"LEAK: a [model.*] block with an unset env_key sent the Kimi bearer to api.openai.com"
);
assert_eq!(cfg.api_key, None, "no credential resolves — fail fast");
}
/// The first-party subscription channel must stay BYTE-IDENTICAL: `kimi-code/*`
/// (and a `[model.*]` block on the session's own coding endpoint, including a
/// `KIGI_CODE_BASE_URL` deployment / a local dev proxy) still carries the
/// primary session key. This assertion is also what proves the Kimi token is
/// live in the tests above — it WOULD leak if the guard were missing.
#[tokio::test]
#[serial_test::serial]
async fn the_first_party_subscription_channel_still_carries_the_session_key() {
let _env = without_ambient_byok_env();
let (_dir, agent) = kimi_session_agent();
let kimi = platform_entry(
"kimi-code/kimi-for-coding",
"kimi-for-coding",
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
);
assert_eq!(
agent
.prepare_sampling_config_for_model(&kimi, None)
.api_key
.as_deref(),
Some(KIMI_TOKEN),
"the kimi-code subscription channel must be unchanged"
);
for base_url in [
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
"http://127.0.0.1:4141/v1",
"http://localhost:8080/v1",
] {
let mut bare = ModelEntry::fallback("kigi-4.5", &EndpointsConfig::default());
bare.info.id = None;
bare.info.base_url = base_url.to_string();
assert_eq!(
agent
.prepare_sampling_config_for_model(&bare, None)
.api_key
.as_deref(),
Some(KIMI_TOKEN),
"{base_url}: a custom deployment / local proxy keeps the session key"
);
}
}
/// A subscription-OAuth model draws its `api_key` from ITS OWN pooled manager,
/// never the Kimi primary — and never falls back to it when that provider has no
/// stored session (the pool home is an empty TempDir under `cfg(test)`).
#[tokio::test]
#[serial_test::serial]
async fn oauth_platform_models_never_carry_the_kimi_bearer_as_api_key() {
let _env = without_ambient_byok_env();
let (_dir, agent) = kimi_session_agent();
for (catalog_key, slug, base_url) in [
("xai-grok/grok-4-latest", "grok-4-latest", "https://api.x.ai/v1"),
(
"claude-pro-max/claude-opus-4-8",
"claude-opus-4-8",
"https://api.anthropic.com/v1",
),
("github-copilot/gpt-4.1", "gpt-4.1", "https://api.githubcopilot.com"),
(
"openai-codex/gpt-5.5",
"gpt-5.5",
"https://chatgpt.com/backend-api/codex",
),
] {
let entry = platform_entry(catalog_key, slug, base_url);
let cfg = agent.prepare_sampling_config_for_model(&entry, None);
assert_ne!(
cfg.api_key.as_deref(),
Some(KIMI_TOKEN),
"LEAK: {catalog_key} carried the primary Kimi session bearer to {base_url}"
);
}
}
/// H5 at the api_key channel: `resolve_model_id` (the picker's own lookup) must
/// hand `prepare_sampling_config_for_model` the entry the user SELECTED, even
/// when an API-key platform and its subscription-OAuth twin list the same
/// routing slug in `PlatformId::ALL` order. Selecting the OAuth twin by catalog
/// key must not resolve the API-key twin — and vice versa.
#[tokio::test]
#[serial_test::serial]
async fn dual_credential_slug_collision_resolves_the_selected_catalog_key() {
let _env = without_ambient_byok_env();
let (_dir, agent) = kimi_session_agent();
// API-key platform FIRST, exactly as `PlatformId::ALL` orders them.
for key in ["xai/grok-4.5", "xai-grok/grok-4.5"] {
agent.models_manager.insert_test_entry(
key,
platform_entry(key, "grok-4.5", "https://api.x.ai/v1"),
);
}
for key in ["xai/grok-4.5", "xai-grok/grok-4.5"] {
let resolved = agent
.resolve_model_id(&acp::ModelId::new(key))
.expect("both twins resolve");
assert_eq!(
resolved.info().id.as_deref(),
Some(key),
"selecting {key} must resolve THAT catalog entry, not its slug twin"
);
}
// And the bare slug resolves the same entry the picker's `resolve_catalog_key`
// does — one direction, one answer (the auth layer used to first-match).
let by_slug = agent
.resolve_model_id(&acp::ModelId::new("grok-4.5"))
.expect("the bare slug resolves");
let models = agent.models_manager.models();
let picker_key = crate::agent::models::resolve_catalog_key(
&models,
&acp::ModelId::new("grok-4.5"),
)
.expect("the picker resolves the bare slug");
assert_eq!(
by_slug.info().id.as_deref(),
Some(picker_key.0.as_ref()),
"the auth layer and the picker must resolve the SAME entry for one slug"
);
assert_eq!(
crate::agent::config::find_model_by_id(&models, "grok-4.5")
.and_then(|e| e.info().id.as_deref()),
Some(picker_key.0.as_ref()),
"find_model_by_id must agree with resolve_catalog_key by construction"
);
}
@@ -0,0 +1,589 @@
//! LEAK GUARD (the chokepoint's own call sites) — H1, H2 and the H3 regression.
//!
//! The companion of `api_key_channel_leak_tests`, which drives
//! `MvpAgent::prepare_sampling_config_for_model`. These three pin the OTHER
//! producers of an outgoing credential: `ModelsManager::sampling_config()` (the
//! agent-wide baseline), the shared `MvpAgent::sampling_config` the login/seed
//! paths stamp, and the SESSION's effective coding endpoint as configured from
//! config.toml rather than the environment.
//!
//! Every assertion reads what the real resolution path produced; nothing is
//! hand-stamped.
use super::super::*;
use super::api_key_channel_leak_tests::{
KIMI_TOKEN, kimi_session_agent, platform_entry, without_ambient_byok_env,
};
use crate::agent::auth_method::CACHED_TOKEN_AUTH_METHOD_ID;
use crate::agent::config::{Config as AgentConfig, EndpointsConfig, ModelEntry};
use crate::auth::credential_authority::CredentialClass;
use crate::auth::{AuthManager, AuthMode, KimiAuth, KimiCodeConfig};
use kigi_sampler::BearerResolver;
use kigi_test_support::EnvGuard;
/// Re-seed the shared config the way `MvpAgent::with_models` does: the config
/// AND the platform it was BUILT from, from ONE `ModelsManager::sampling_config()`
/// against whatever `current_model_id` names right now. A test can therefore
/// never set one without the other — which is the whole point of H-a.
fn rebuild_shared_config(agent: &MvpAgent) {
let baseline = agent.models_manager.sampling_config();
agent.sampling_config_platform.set(baseline.platform);
*agent.sampling_config.borrow_mut() = baseline.config;
}
/// H1 — `ModelsManager::sampling_config()`, the OTHER `api_key` producer, and a
/// byte-for-byte repeat of the round-1 defect: it resolved the session bearer
/// itself (`platform.oauth()`, else `auth_manager.current_or_expired()`), so
/// every non-OAuth platform got the primary Kimi token.
///
/// This config is not incidental — it is the `MvpAgent` baseline
/// (`Self::with_models`), which `resolve_sampling_config_for_model` returns
/// verbatim for an unresolved model id and `SubagentSpawnContext` clones as
/// every subagent's baseline, so its `api_key` reaches the wire against its own
/// `base_url`. Zero-config repro: a Kimi subscription + a bundled
/// `moonshot-cn/*` default.
///
/// Revert-to-red (L: this edit COMPILES — the previous wording named a
/// `Option<String>` argument that the `Option<&SessionCredential>` signature
/// rejects, so it could never have been run): in
/// `ModelsManager::sampling_config`, ask the authority about the SESSION's
/// endpoint instead of the current model's own —
/// `.credential_for(None, &config.endpoints.proxy_url())` in place of
/// `.credential_for_model(current_model)`. That is the round-1 defect's shape
/// (the credential decided by something other than the model's own platform +
/// endpoint) and every `assert_ne!` below sees `Some(KIMI_TOKEN)`.
#[tokio::test]
#[serial_test::serial]
async fn models_manager_sampling_config_never_carries_the_kimi_bearer() {
let _env = without_ambient_byok_env();
let (_dir, agent) = kimi_session_agent();
for (catalog_key, slug, base_url) in [
(
"moonshot-cn/kimi-k2-turbo-preview",
"kimi-k2-turbo-preview",
"https://api.moonshot.cn/v1",
),
(
"deepseek/deepseek-chat",
"deepseek-chat",
"https://api.deepseek.com/v1",
),
("openai/gpt-5.2", "gpt-5.2", "https://api.openai.com/v1"),
] {
agent
.models_manager
.insert_test_entry(catalog_key, platform_entry(catalog_key, slug, base_url));
agent
.models_manager
.set_current_model_id(acp::ModelId::new(catalog_key));
let cfg = agent.models_manager.sampling_config().config;
assert_eq!(cfg.base_url, base_url, "{catalog_key}: routed to its own host");
assert_ne!(
cfg.api_key.as_deref(),
Some(KIMI_TOKEN),
"LEAK: ModelsManager::sampling_config sent the Kimi bearer to {base_url}"
);
}
// …and the first-party subscription channel is unchanged, which is what
// proves the Kimi bearer was reachable above.
let kimi_key = "kimi-code/kimi-for-coding";
agent.models_manager.insert_test_entry(
kimi_key,
platform_entry(
kimi_key,
"kimi-for-coding",
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
),
);
agent
.models_manager
.set_current_model_id(acp::ModelId::new(kimi_key));
assert_eq!(
agent
.models_manager
.sampling_config()
.config
.api_key
.as_deref(),
Some(KIMI_TOKEN),
"the kimi-code subscription channel must be byte-identical"
);
}
/// H2 — the SHARED `MvpAgent::sampling_config`. `seed_client_config_auth_if_available`
/// (from `new_session` / `load_session`) and the `cached_token` / `kimi.com/oidc`
/// login handlers all stamped `sampling_config.api_key = Some(<primary bearer>)`
/// with NO platform or endpoint guard — while that very config may point at a
/// third-party model, and is both the subagent baseline and the
/// unresolved-model fallback. `agent_ops`' generic-OAuth login handler already
/// documents the correct rule ("Do NOT stamp this token onto the shared
/// sampling_config"); the Kimi handlers violated it.
///
/// Revert-to-red: make `stamp_session_credential` skip the authority entirely —
/// `self.sampling_config.borrow_mut().api_key =
/// self.auth_manager.current_or_expired().map(|a| a.key); return true;` — and
/// the third-party rows below become `Some(KIMI_TOKEN)`.
#[tokio::test]
#[serial_test::serial]
async fn shared_sampling_config_is_never_stamped_off_the_session_endpoint() {
let _env = without_ambient_byok_env();
let (_dir, agent) = kimi_session_agent();
for (catalog_key, slug, base_url) in [
(
"deepseek/deepseek-chat",
"deepseek-chat",
"https://api.deepseek.com/v1",
),
(
"moonshot-cn/kimi-k2-turbo-preview",
"kimi-k2-turbo-preview",
"https://api.moonshot.cn/v1",
),
] {
agent
.models_manager
.insert_test_entry(catalog_key, platform_entry(catalog_key, slug, base_url));
agent
.models_manager
.set_current_model_id(acp::ModelId::new(catalog_key));
rebuild_shared_config(&agent);
{
let mut shared = agent.sampling_config.borrow_mut();
assert_eq!(shared.model, slug, "{catalog_key}: built from this entry");
assert_eq!(shared.base_url, base_url);
shared.api_key = None;
}
// The `new_session` / `load_session` seed…
agent.seed_client_config_auth_if_available();
assert_eq!(
agent.sampling_config.borrow().api_key, None,
"LEAK: seeding stamped the Kimi bearer onto a config routed at {base_url}"
);
// …and the login handlers, which overwrite rather than seed.
assert!(
!agent.stamp_session_credential(true),
"LEAK: a login handler stamped the Kimi bearer onto a config routed at {base_url}"
);
assert_eq!(agent.sampling_config.borrow().api_key, None);
}
// Byte-identical on the session's own endpoint: both paths still stamp.
let kimi_key = "kimi-code/kimi-for-coding";
agent.models_manager.insert_test_entry(
kimi_key,
platform_entry(
kimi_key,
"kimi-for-coding",
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
),
);
agent
.models_manager
.set_current_model_id(acp::ModelId::new(kimi_key));
rebuild_shared_config(&agent);
agent.sampling_config.borrow_mut().api_key = None;
agent.seed_client_config_auth_if_available();
assert_eq!(
agent.sampling_config.borrow().api_key.as_deref(),
Some(KIMI_TOKEN),
"the subscription endpoint must still be seeded (this is what makes the \
assertions above meaningful)"
);
}
/// C1 — THE CRITICAL. The login stamp used to pair the right QUESTION (*may
/// **a** session credential ride here?*) with the wrong CREDENTIAL (always
/// `auth_manager.current_or_expired().key`, the primary Kimi bearer). For a
/// subscription-OAuth platform at its OWN host the answer is correctly "yes" —
/// the credential that may ride there is that platform's POOLED token
/// ([`CredentialClass::Pooled`]) — so a Claude Pro/Max user whose current model is
/// `claude-pro-max/*` ran `kigi login` and the Kimi subscription bearer landed
/// on a config routed at `api.anthropic.com`. From there it reaches the wire via
/// `resolve_sampling_config_for_model`'s verbatim fallback (offline / stale
/// catalog) and via `SubagentSpawnContext`'s baseline clone.
///
/// All FOUR subscription platforms, each at its own registry host, derived from
/// the registry so the fixture cannot drift.
///
/// The pooled managers are empty here (`pool_home()` is a per-process temp path
/// under `cfg(test)`), so the correct answer is `None` — and `None` is also what
/// proves the primary is not being substituted, because the same agent DOES
/// stamp `KIMI_TOKEN` on its own coding endpoint at the end of the test.
///
/// Revert-to-red (production, compiles): restore the old shape in
/// `MvpAgent::stamp_session_credential` —
/// ```ignore
/// if self.credential_authority().credential_class(platform, &base_url)
/// == CredentialClass::None
/// {
/// return false;
/// }
/// let Some(auth) = self.auth_manager.current_or_expired() else { return false };
/// self.sampling_config.borrow_mut().api_key = Some(auth.key);
/// true
/// ```
/// and every OAuth row below becomes `Some(KIMI_TOKEN)`.
#[tokio::test]
#[serial_test::serial]
async fn oauth_platform_shared_config_never_receives_the_primary_on_login() {
let _env = without_ambient_byok_env();
let (_dir, agent) = kimi_session_agent();
for (platform_id, slug) in [
("claude-pro-max", "claude-opus-4-8"),
("openai-codex", "gpt-5.5-codex"),
("github-copilot", "gpt-4.1"),
("xai-grok", "grok-4.5"),
] {
let platform = kigi_models::PlatformId::parse(platform_id).expect("known platform");
let base_url = platform.base_url();
let catalog_key = format!("{platform_id}/{slug}");
agent
.models_manager
.insert_test_entry(&catalog_key, platform_entry(&catalog_key, slug, &base_url));
agent
.models_manager
.set_current_model_id(acp::ModelId::new(catalog_key.clone()));
rebuild_shared_config(&agent);
{
let mut shared = agent.sampling_config.borrow_mut();
assert_eq!(shared.model, slug, "{catalog_key}: built from this entry");
assert_eq!(shared.base_url, base_url);
shared.api_key = None;
}
// Precondition: this endpoint DOES take a session credential — that is
// exactly why guarding a hand-carried primary with "may a session
// credential ride?" was the bug. The class names WHICH one: the
// platform's POOLED token, never the primary / house key.
assert_eq!(
agent
.credential_authority()
.credential_class(Some(platform), &base_url),
CredentialClass::Pooled,
"{catalog_key}: precondition — its own host takes its POOLED token, \
and never the primary / house credential"
);
// `kigi login` (cached_token and kimi.com/oidc both land here) …
assert!(
!agent.stamp_session_credential(true),
"LEAK: a Kimi login stamped a credential onto a config routed at {base_url}"
);
assert_eq!(
agent.sampling_config.borrow().api_key,
None,
"LEAK: {catalog_key} received a bearer that is not its own pooled token"
);
// … and the `new_session` / `load_session` seed.
agent.seed_client_config_auth_if_available();
assert_ne!(
agent.sampling_config.borrow().api_key.as_deref(),
Some(KIMI_TOKEN),
"LEAK: seeding sent the Kimi subscription bearer to {base_url}"
);
}
// The first-party channel is untouched — this is what makes every
// assertion above meaningful (the Kimi bearer IS live and IS stampable).
let kimi_key = "kimi-code/kimi-for-coding";
agent.models_manager.insert_test_entry(
kimi_key,
platform_entry(
kimi_key,
"kimi-for-coding",
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
),
);
agent
.models_manager
.set_current_model_id(acp::ModelId::new(kimi_key));
rebuild_shared_config(&agent);
agent.sampling_config.borrow_mut().api_key = None;
assert!(agent.stamp_session_credential(true));
assert_eq!(
agent.sampling_config.borrow().api_key.as_deref(),
Some(KIMI_TOKEN),
"the Kimi subscription channel must be byte-identical"
);
}
/// H-a (AVAILABILITY, HIGH) — the stamp guard must resolve the model the shared
/// config was BUILT from, not the one `current_model_id()` names NOW.
///
/// The shared config is built ONCE (`MvpAgent::with_models`) and never rebuilt,
/// while `current_model_id()` moves on every non-Leader model switch
/// (`handlers/model_switch.rs`) and on catalog reselection. Once they drift, a
/// guard re-resolving the config's BARE slug against the live cell fails
/// `entry_for_slug_resolution`'s `entry.info.model == slug` test and falls
/// through to `resolve_catalog_key`'s `.rev()` scan.
///
/// `kimi-code` (subscription, `uses_oauth`) and `kimi-coding` (API-key twin,
/// SAME coding host, `uses_oauth: false`) list the same routing slug, and
/// `PlatformId::ALL` puts `kimi-code` 1st and `kimi-coding` 19th — so that scan
/// answers `kimi-coding`, which takes NO session credential and therefore has NO
/// governing manager. Because `stamp_session_credential` only overwrites ON
/// SUCCESS, a Kimi-subscription user who also has `KIMI_API_KEY` set kept the
/// EXPIRED bearer in the shared config after a successful re-login: every
/// unresolved-model fallback and every subagent baseline turn 401'd until
/// restart.
///
/// The switch happens AFTER the config is built — the previous version of this
/// test left both on the same model, so it could not catch this.
///
/// Revert-to-red (production, compiles): make `MvpAgent::shared_config_platform`
/// re-resolve from the live cell instead of returning the captured value —
/// ```ignore
/// fn shared_config_platform(&self) -> Option<kigi_models::PlatformId> {
/// let model = self.sampling_config.borrow().model.clone();
/// let current_key = self.models_manager.current_model_id();
/// crate::agent::models::platform_for_slug(
/// &self.models_manager.models(),
/// Some(current_key.0.as_ref()),
/// &model,
/// )
/// }
/// ```
/// and the re-login assertion below sees the stale bearer.
#[tokio::test]
#[serial_test::serial]
async fn relogin_restamps_the_shared_config_after_the_model_switched() {
let _env = without_ambient_byok_env();
let (_dir, agent) = kimi_session_agent();
let coding_host = kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url;
let slug = "kimi-for-coding";
// Insertion order mirrors `PlatformId::ALL`: the subscription platform
// first, its API-key twin later — so the `.rev()` scan answers the twin.
for catalog_key in ["kimi-code/kimi-for-coding", "kimi-coding/kimi-for-coding"] {
agent
.models_manager
.insert_test_entry(catalog_key, platform_entry(catalog_key, slug, coding_host));
}
assert_eq!(
crate::agent::models::platform_for_slug(&agent.models_manager.models(), None, slug),
Some(kigi_models::PlatformId::KimiCoding),
"precondition: a `None`-keyed slug scan answers the API-key twin, which takes \
no session credential"
);
// Startup: the picker selected the SUBSCRIPTION entry, and the shared config
// was built from it (config + platform, one call).
agent
.models_manager
.set_current_model_id(acp::ModelId::new("kimi-code/kimi-for-coding"));
rebuild_shared_config(&agent);
assert_eq!(
agent.sampling_config.borrow().model,
slug,
"precondition: the shared config carries the BARE slug, which collides"
);
// The user switches model: `current_model_id` moves to the API-key twin
// while the shared config still represents the subscription entry.
agent
.models_manager
.set_current_model_id(acp::ModelId::new("kimi-coding/kimi-for-coding"));
// The session expires and `kigi login` re-mints it. The handlers overwrite
// (`stamp_session_credential(true)`) AFTER the manager holds the new token.
agent.sampling_config.borrow_mut().api_key = Some("expired-bearer".to_string());
assert!(
agent.stamp_session_credential(true),
"a successful re-login must restamp the shared config"
);
assert_eq!(
agent.sampling_config.borrow().api_key.as_deref(),
Some(KIMI_TOKEN),
"the re-login must REPLACE the expired bearer: resolving the API-key twin \
instead finds no governing manager, leaves the stale token in place, and \
401s every subagent baseline turn until restart"
);
// And the seed path (`new_session` / `load_session`) agrees.
agent.sampling_config.borrow_mut().api_key = None;
agent.seed_client_config_auth_if_available();
assert_eq!(
agent.sampling_config.borrow().api_key.as_deref(),
Some(KIMI_TOKEN),
);
}
/// H3 (REGRESSION) — a MANAGED deployment configures its coding endpoint with
/// `[endpoints] coding_api_base_url` in **config.toml** (what the managed-config
/// sync writes), NOT the `KIGI_CODE_BASE_URL` env var. The previous round's
/// predicate knew only the env var, so `EndpointsConfig::proxy_url()`'s
/// config-key branch was invisible: every model inheriting the managed endpoint
/// classified third-party, lost its api_key AND its resolver, and 401'd on every
/// turn.
///
/// NOTE: no env var is set anywhere in this test — that is the point.
///
/// Revert-to-red: drop the `proxy_url()` arm from
/// `CredentialAuthority::is_session_coding_endpoint` and both `assert_eq!`s
/// below become `None`.
#[tokio::test]
#[serial_test::serial]
async fn managed_config_toml_coding_endpoint_keeps_the_session_bearer() {
let _env = without_ambient_byok_env();
let _no_env_override = EnvGuard::unset("KIGI_CODE_BASE_URL");
let managed = "https://proxy.acme.example/v1";
let dir = tempfile::tempdir().expect("tempdir");
let auth_manager = std::sync::Arc::new(AuthManager::new(dir.path(), KimiCodeConfig::default()));
auth_manager.hot_swap(KimiAuth {
key: KIMI_TOKEN.to_string(),
auth_mode: AuthMode::OAuth,
refresh_token: Some("rt".into()),
expires_at: Some(chrono::Utc::now() + chrono::Duration::hours(1)),
..KimiAuth::test_default()
});
let cfg = AgentConfig {
endpoints: EndpointsConfig {
coding_api_base_url: Some(managed.to_string()),
..EndpointsConfig::default()
},
..AgentConfig::default()
};
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let agent = MvpAgent::new(GatewaySender::new(tx), &cfg, auth_manager, None)
.expect("valid test config");
agent.set_auth_method(acp::AuthMethodId::new(CACHED_TOKEN_AUTH_METHOD_ID));
assert_eq!(
agent.models_manager.endpoints().proxy_url(),
managed,
"precondition: the session's effective coding endpoint is the config.toml key"
);
// A `[model.*]` entry inheriting the managed endpoint …
let mut bare = ModelEntry::fallback("kigi-4.5", &cfg.endpoints);
bare.info.id = None;
bare.info.base_url = managed.to_string();
assert_eq!(
agent
.prepare_sampling_config_for_model(&bare, None)
.api_key
.as_deref(),
Some(KIMI_TOKEN),
"a managed deployment must still receive the session bearer"
);
// … and the kimi-code catalog entry, whose base_url IS `proxy_url()`.
let kimi = platform_entry("kimi-code/kimi-for-coding", "kimi-for-coding", managed);
assert_eq!(
agent
.prepare_sampling_config_for_model(&kimi, None)
.api_key
.as_deref(),
Some(KIMI_TOKEN),
"the managed kimi-code entry must still receive the session bearer"
);
// The spec's requirement was "rides AND still refreshes": an api_key alone
// freezes at login and 401s unrecoverably ~1h in. The managed endpoint must
// also keep a LIVE manager — the primary's, so mid-session refresh and 401
// recovery run against the credential that actually owns that host.
for platform in [None, Some(kigi_models::PlatformId::KimiCode)] {
let manager = agent
.credential_authority()
.manager_for(platform, managed)
.unwrap_or_else(|| {
panic!("{platform:?}: a managed deployment must keep a live manager")
});
assert!(
std::sync::Arc::ptr_eq(&manager, &agent.auth_manager),
"{platform:?}: and it must be the session's OWN primary manager"
);
let resolver = agent
.credential_authority()
.bearer_resolver_for(platform, managed)
.unwrap_or_else(|| panic!("{platform:?}: … exposed as a live bearer_resolver"));
assert_eq!(
resolver.current_bearer(),
Some(KIMI_TOKEN.to_string()),
"{platform:?}: the resolver reads the live primary session bearer"
);
}
// The guard still holds: a third-party host under the SAME managed config
// gets nothing — no key, and no resolver either.
let third_party = platform_entry(
"deepseek/deepseek-chat",
"deepseek-chat",
"https://api.deepseek.com/v1",
);
assert_eq!(
agent
.prepare_sampling_config_for_model(&third_party, None)
.api_key,
None,
"LEAK: a managed deployment must not widen the trust set to third parties"
);
assert!(
agent
.credential_authority()
.bearer_resolver_for(
Some(kigi_models::PlatformId::DeepSeek),
"https://api.deepseek.com/v1"
)
.is_none(),
"LEAK: nor hand it a live resolver over the primary"
);
}
/// M3 (COMPLETION) — the SUMMARY client's `bearer_resolver` must honour the
/// session-token gate, exactly as the session actor's aux path does.
///
/// `build_summary_client` set `cfg.bearer_resolver =
/// authority.bearer_resolver_for(platform_for_slug(…), &cfg.base_url)` with NO
/// gate while `SessionActor::aux_bearer_resolver` had one. `SamplingClient::post`
/// REPLACES the request's auth header from that resolver, so an api-key /
/// house-key session whose `[model.session-summary]` block carries its OWN
/// `env_key` on the session's own coding endpoint had that key overwritten by
/// the primary bearer on every summary request. Both now go through ONE rule,
/// `sampler_turn::aux_bearer_resolver_for`.
///
/// The summary slug is deliberately absent from the catalog and from any
/// config: it classifies `NotByok` definitively, so the only variable left is
/// the ACP auth method — which is the gate term under test.
///
/// Revert-to-red (production, compiles): in `MvpAgent::summary_bearer_resolver`,
/// return `self.credential_authority().bearer_resolver_for(platform, base_url)`
/// directly (the pre-fix shape) and the api-key row below resolves `KIMI_TOKEN`.
#[tokio::test]
#[serial_test::serial]
async fn summary_client_resolver_honours_the_session_gate() {
let _env = without_ambient_byok_env();
let (_dir, agent) = kimi_session_agent();
let coding_host = kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url;
let slug = "kigi-summary-aux-not-in-any-catalog";
let models = agent.models_manager.models();
// A session-based method on the session's OWN endpoint: byte-identical, the
// summary model keeps a LIVE resolver over the primary.
agent.set_auth_method(acp::AuthMethodId::new(CACHED_TOKEN_AUTH_METHOD_ID));
let resolver = agent
.summary_bearer_resolver(&models, slug, coding_host)
.expect("the first-party subscription summary channel keeps its resolver");
assert_eq!(
resolver.current_bearer(),
Some(KIMI_TOKEN.to_string()),
"…and it reads the live primary session bearer"
);
// An API-KEY session: the gate is inactive, so the summary model's own key
// must survive to the wire instead of being replaced by the primary.
agent.set_auth_method(acp::AuthMethodId::new(
crate::agent::auth_method::XAI_API_KEY_METHOD_ID,
));
assert!(
agent
.summary_bearer_resolver(&models, slug, coding_host)
.is_none(),
"LEAK: an api-key session's summary model had its own key replaced on the \
wire by the primary bearer"
);
}
@@ -176,6 +176,7 @@ async fn handle_connection(ws: WebSocket, state: Arc<ServerState>, peer_addr: So
prefetch_models_blocking(
&agent_config.endpoints,
auth.as_ref(),
&Default::default(),
fetch_auth,
&platform_keys,
)
@@ -59,6 +59,8 @@ fn effort_label(effort: ReasoningEffort) -> String {
ReasoningEffort::Medium => "Medium",
ReasoningEffort::High => "High",
ReasoningEffort::Xhigh => "X-High",
ReasoningEffort::Max => "Max",
ReasoningEffort::Ultra => "Ultra",
}
.to_string()
}
@@ -880,6 +880,24 @@ async fn read_parent_sampling_config(
let auth_scheme = crate::agent::config::try_resolve_model_credentials(&cfg.model, None)
.map(|r| r.auth_scheme)
.unwrap_or_default();
// Claude Pro/Max OAuth Messages adaptation inherits from the parent
// model's platform (claude-pro-max → true); every other platform,
// and BYOK, → false, so the API-key paths stay byte-identical.
let anthropic_oauth = kigi_models::parse_managed_model_key(ctx.model_id.0.as_ref())
.is_some_and(|(platform, _)| {
platform.oauth().is_some()
&& platform.wire_api() == kigi_models::PlatformWireApi::Messages
});
// GitHub Copilot editor headers inherit from the parent model's
// platform (github-copilot → true); every other platform / BYOK →
// false, so the other ChatCompletions paths stay byte-identical.
let github_copilot = kigi_models::parse_managed_model_key(ctx.model_id.0.as_ref())
.is_some_and(|(platform, _)| platform.sends_copilot_editor_headers());
// ChatGPT/Codex headers inherit from the parent model's platform
// (openai-codex → true); every other platform / BYOK → false, so the
// API-key openai Responses path stays byte-identical.
let openai_codex = kigi_models::parse_managed_model_key(ctx.model_id.0.as_ref())
.is_some_and(|(platform, _)| platform.sends_codex_responses_headers());
let inherited = kigi_sampler::SamplerConfig {
api_key: creds.api_key,
base_url: cfg.base_url,
@@ -889,6 +907,10 @@ async fn read_parent_sampling_config(
top_p: cfg.top_p,
api_backend: cfg.api_backend,
auth_scheme,
anthropic_oauth,
github_copilot,
openai_codex,
chat_compat: cfg.chat_compat,
extra_headers,
context_window: cfg.context_window.get(),
reasoning_effort: cfg.reasoning_effort,
@@ -981,9 +1003,21 @@ fn resolve_model_override_to_config(
} else {
acp::ModelId::new(entry.info().model.clone())
};
let session_key = ctx.auth.as_ref().map(|a| a.key.as_str());
// Resolve the child's session token by the OVERRIDE model's OWN platform
// AND endpoint, not the parent's primary auth: a grok (oauth-platform)
// override draws its pooled grok token or `None`, and an API-key registry
// platform / a third-party `[model.*]` host draws NOTHING — NEVER the
// primary Kimi session token, which `resolve_credentials` would otherwise
// stamp onto the child's api.x.ai / api.moonshot.cn credentials. The
// first-party subscription channel still resolves to the primary
// (byte-identical).
let session_key = crate::auth::credential_authority::CredentialAuthority::new(
ctx.models_manager.endpoints(),
Some(ctx.auth_manager.clone()),
)
.credential_for_model(&entry);
let has_session_key = session_key.is_some();
let mut credentials = resolve_credentials(&entry, session_key);
let mut credentials = resolve_credentials(&entry, session_key.as_ref());
credentials.auth_type = subagent_auth_type(Some(&entry), &ctx.auth_method_id);
let resolved_auth_type = credentials.auth_type;
let config = sampling_config_for_model(&entry, credentials, ctx.alpha_test_key.clone());
@@ -3131,19 +3131,25 @@ fn fresh_tool_model_rejects_unavailable_exact_key_over_visible_slug_collision()
"validation must inspect the unavailable exact-key entry selected by execution"
);
}
/// Validation must inspect the SAME slug-collision entry execution selects.
/// Both go through `find_model_by_id`, whose slug scan takes the LAST match —
/// aligned with the picker's `resolve_catalog_key` so the auth layer and the
/// picker can never resolve different platforms for one slug (the H5 collision).
/// So a blocked LAST entry must be rejected even though an available earlier one
/// shares the slug.
#[test]
fn fresh_tool_model_rejects_unavailable_first_slug_collision() {
fn fresh_tool_model_rejects_unavailable_last_slug_collision() {
let mut models = indexmap::IndexMap::new();
let mut unavailable_first = test_model_entry("shared-routing-slug");
unavailable_first.info.user_selectable = false;
models.insert("blocked-first".to_string(), unavailable_first);
models.insert("visible-second".to_string(), test_model_entry("shared-routing-slug"));
models.insert("visible-first".to_string(), test_model_entry("shared-routing-slug"));
let mut unavailable_last = test_model_entry("shared-routing-slug");
unavailable_last.info.user_selectable = false;
models.insert("blocked-last".to_string(), unavailable_last);
assert_eq!(
super::handle_request::task_model_override_error(Some("shared-routing-slug"),
ModelOverrideProvenance::Tool, false, & models, false,).as_deref(),
Some("Unknown Task.model slug 'shared-routing-slug'. Valid model slugs: \
visible-second. Omit `model` to inherit the parent model."),
"validation must inspect the first routing-slug entry selected by execution"
visible-first. Omit `model` to inherit the parent model."),
"validation must inspect the last routing-slug entry selected by execution"
);
}
#[test]
@@ -3272,6 +3278,7 @@ fn test_sampling_config(model_slug: &str) -> kigi_sampling_types::SamplingConfig
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: NonZeroU64::new(256_000).expect("non-zero context window"),
reasoning_effort: None,
@@ -2467,6 +2467,111 @@ async fn resolve_subagent_config_override_unknown_model_falls_through_to_inherit
assert_eq!(config.model, "kigi-4.5");
assert_eq!(model_id.0.as_ref(), "kigi-4.5");
}
/// Build an `Arc<AuthManager>` (primary Kimi) holding `key` as its live bearer.
/// The `TempDir` is returned so the caller keeps it alive.
fn kimi_primary_with_token(key: &str) -> (tempfile::TempDir, std::sync::Arc<crate::auth::AuthManager>) {
let dir = tempfile::tempdir().unwrap();
let manager = std::sync::Arc::new(crate::auth::AuthManager::new(
dir.path(),
crate::auth::KimiCodeConfig::default(),
));
manager.hot_swap(crate::auth::KimiAuth {
key: key.to_string(),
auth_mode: crate::auth::AuthMode::OAuth,
..crate::auth::KimiAuth::test_default()
});
(dir, manager)
}
/// LEAK 2 (subagent model-override): a grok (oauth-platform) override with a
/// Kimi primary must NEVER receive the primary Kimi session token as its
/// `api_key` — it draws grok's own pooled token (or `None`). Revert-to-red: the
/// pre-fix code passed `ctx.auth` (Kimi) straight to `resolve_credentials`, so
/// `config.api_key == "kimi-secret"` and this assertion fails.
#[tokio::test]
async fn subagent_override_grok_model_never_leaks_kimi_session_token() {
let (_kd, manager) = kimi_primary_with_token("kimi-secret");
let mut grok = test_model_entry("grok-4-latest");
grok.info.id = Some("xai-grok/grok-4-latest".to_string());
grok.info.base_url = "https://api.x.ai/v1".to_string();
let mut models = indexmap::IndexMap::new();
models.insert("grok".to_string(), grok);
let mut ctx = ctx_with_toggle(HashMap::new());
ctx.available_models = models;
ctx.auth = Some(crate::auth::KimiAuth {
key: "kimi-secret".to_string(),
auth_mode: crate::auth::AuthMode::OAuth,
..crate::auth::KimiAuth::test_default()
});
ctx.auth_manager = manager;
let (config, _model_id) =
resolve_model_override_to_config("grok", &ctx).expect("grok override resolves to a config");
assert_ne!(
config.api_key.as_deref(),
Some("kimi-secret"),
"a grok override must never receive the primary Kimi session token",
);
}
/// Byte-identical guard: an override on the SESSION's own first-party endpoint
/// (the kimi-code subscription channel) still resolves to the primary session
/// token — the primary path is unchanged.
#[tokio::test]
async fn subagent_override_first_party_model_still_gets_primary_token() {
let (_kd, manager) = kimi_primary_with_token("kimi-secret");
let mut entry = test_model_entry("kimi-for-coding");
entry.info.id = Some("kimi-code/kimi-for-coding".to_string());
entry.info.base_url = kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url.to_string();
let mut models = indexmap::IndexMap::new();
models.insert("kfc".to_string(), entry);
let mut ctx = ctx_with_toggle(HashMap::new());
ctx.available_models = models;
ctx.auth = Some(crate::auth::KimiAuth {
key: "kimi-secret".to_string(),
auth_mode: crate::auth::AuthMode::OAuth,
..crate::auth::KimiAuth::test_default()
});
ctx.auth_manager = manager;
let (config, _model_id) = resolve_model_override_to_config("kfc", &ctx)
.expect("first-party override resolves to a config");
assert_eq!(
config.api_key.as_deref(),
Some("kimi-secret"),
"a first-party override must still receive the primary session token",
);
}
/// LEAK guard (C1, subagent-override `api_key` channel): an API-key registry
/// platform override must NOT receive the parent's primary Kimi session token —
/// `resolve_credentials` would stamp it as the child's `api_key` on
/// `api.moonshot.cn`. This test previously asserted the opposite
/// (`subagent_override_non_oauth_model_still_gets_primary_token`), which encoded
/// the defect.
///
/// Revert-to-red: make `CredentialAuthority::governing_manager`'s
/// `Some(platform) => None` arm return `self.primary.clone()` and `api_key`
/// becomes `Some("kimi-secret")`.
#[tokio::test]
async fn subagent_override_api_key_platform_never_gets_the_primary_token() {
let (_kd, manager) = kimi_primary_with_token("kimi-secret");
let mut entry = test_model_entry("kimi-k2-0905-preview");
entry.info.id = Some("moonshot-cn/kimi-k2".to_string());
entry.info.base_url = "https://api.moonshot.cn/v1".to_string();
let mut models = indexmap::IndexMap::new();
models.insert("k2".to_string(), entry);
let mut ctx = ctx_with_toggle(HashMap::new());
ctx.available_models = models;
ctx.auth = Some(crate::auth::KimiAuth {
key: "kimi-secret".to_string(),
auth_mode: crate::auth::AuthMode::OAuth,
..crate::auth::KimiAuth::test_default()
});
ctx.auth_manager = manager;
let (config, _model_id) = resolve_model_override_to_config("k2", &ctx)
.expect("an API-key-platform override still resolves to a config");
assert_ne!(
config.api_key.as_deref(),
Some("kimi-secret"),
"LEAK: an API-key-platform override must never receive the primary Kimi session token",
);
}
/// An unresolvable `AgentDefinition.model` pin (model absent from
/// `available_models`) falls through to inherit the parent model.
#[tokio::test]
@@ -0,0 +1,568 @@
//! THE credential chokepoint.
//!
//! One authority answers, for every outgoing inference request, the only
//! question that matters: **which credential — if any — may ride it?**
//! ([`CredentialAuthority::credential_class`]). Before this module the answer was
//! re-derived — differently — at every call site (`ModelsManager`, `MvpAgent`,
//! `SessionActor`, the aux/summary/subagent paths), and three separate rounds
//! of fixes each closed some sites and missed others.
//!
//! # How omission is structurally prevented
//!
//! 1. [`SessionCredential`] wraps the bearer and has **no production
//! constructor outside this module**. The only function in the crate that
//! can build one is [`CredentialAuthority::credential_for`], which *requires*
//! `(platform, base_url)` and holds the session's `EndpointsConfig` and
//! primary [`AuthManager`] privately.
//! 2. Every API that stamps a session credential onto a request —
//! `resolve_credentials`, `resolve_aux_model_sampling_config`,
//! `try_resolve_model_credentials`,
//! `resolve_chat_state_auth_type` — takes `Option<&SessionCredential>`,
//! never `Option<&str>`. A new call site therefore *cannot compile* a leak:
//! there is no way to produce the value without going through the rule.
//! 3. The authority owns the primary manager privately and exposes it only via
//! [`CredentialAuthority::manager_for`] /
//! [`CredentialAuthority::bearer_resolver_for`], which take the same
//! `(platform, base_url)` pair — so the `bearer_resolver` sink is funnelled
//! through the identical rule as the `api_key` sink.
//! 4. A guard asks [`CredentialAuthority::credential_class`] and MATCHES on the
//! answer. There is no second, similarly-named boolean to pick by mistake:
//! the round-3 defect (C1) was `takes_session_credential` — *may **a**
//! session credential ride?* — paired with a hand-carried PRIMARY bearer,
//! and the two predicates that made that pairing expressible are gone.
//!
//! SECURITY: no token is ever logged, `Debug`-printed or `Display`ed here.
use std::sync::Arc;
use crate::agent::config::{EndpointsConfig, ModelEntry};
use crate::auth::AuthManager;
/// A session bearer this authority has cleared for one specific request
/// endpoint.
///
/// Opaque by construction: the inner `String` is private, the type is not
/// `Debug`/`Clone`-into-`String`, and the only production constructor is
/// [`CredentialAuthority::credential_for`]. See the module docs for why that
/// matters.
pub(crate) struct SessionCredential(String);
impl SessionCredential {
/// The raw bearer. SECURITY: callers stamp this straight onto a request —
/// never log it.
pub(crate) fn expose(&self) -> &str {
&self.0
}
/// Test-only forgery, so unit tests can exercise the *downstream*
/// credential plumbing (`resolve_credentials`' BYOK-vs-session precedence,
/// aux config shapes) without standing up an `AuthManager`. Deliberately
/// `#[cfg(test)]`: production code has no way to build one.
#[cfg(test)]
pub(crate) fn for_test(key: &str) -> Self {
Self(key.to_owned())
}
}
/// WHICH credential — if any — may ride a request routed to a given
/// `(platform, base_url)` pair.
///
/// ONE question with three answers, replacing the two look-alike booleans
/// `takes_session_credential` / `takes_primary_credential` (identical
/// signatures, near-identical names, opposite answers on a subscription host).
/// C1 was caused by asking the first and stamping the credential the second
/// describes; with a single classifier a call site must MATCH on the answer, so
/// that mistake is no longer expressible.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CredentialClass {
/// `platform`'s OWN pooled subscription-OAuth token, at its own registry
/// host. NEVER the primary session bearer and never the house key.
Pooled,
/// The credential that authorizes the SESSION's own coding endpoint:
/// the primary (`kimi-code` / platform-less) bearer.
///
/// Deliberately NOT split into a separate `HouseKey` variant: the house
/// `KIGI_API_KEY` is accepted by exactly this endpoint and no other, so it
/// rides precisely this class. A fourth variant would re-create the
/// two-similar-answers hazard this enum exists to remove.
Primary,
/// Nothing rides: every API-key registry platform, an OAuth platform
/// redirected off its own host, and any endpoint that is not the session's.
None,
}
impl CredentialClass {
/// Stable label for structured logs. SECURITY: names a channel, never a
/// token.
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Pooled => "pooled",
Self::Primary => "primary",
Self::None => "none",
}
}
}
/// The single authority over inference-time session credentials.
///
/// Construct one from the session's EFFECTIVE endpoints plus its primary
/// (first-party / Kimi) manager, then ask it about a request. Cheap to build
/// (a handful of `Option<String>` clones + an `Arc` clone).
#[derive(Clone)]
pub(crate) struct CredentialAuthority {
/// The session's effective `[endpoints]` — config.toml layered over env.
/// H3: `EndpointsConfig::proxy_url()` prefers `[endpoints]
/// coding_api_base_url` from **config.toml** and only then falls back to
/// `KIGI_CODE_BASE_URL`. A predicate that knows only the env var makes a
/// managed/enterprise deployment lose its session bearer entirely (401 on
/// every turn), so the endpoints are part of the authority's identity, not
/// an afterthought.
endpoints: EndpointsConfig,
/// The primary session manager. PRIVATE: nothing hands it back, so a path
/// holding a `CredentialAuthority` cannot reach `current_or_expired()`
/// without naming an endpoint.
primary: Option<Arc<AuthManager>>,
}
impl CredentialAuthority {
pub(crate) fn new(endpoints: EndpointsConfig, primary: Option<Arc<AuthManager>>) -> Self {
Self { endpoints, primary }
}
/// THE rule, stated once.
///
/// - a subscription-OAuth platform (claude-pro-max, openai-codex,
/// github-copilot, xai-grok) rides ITS OWN pooled manager — never the
/// primary — and only to its own registry host (L10: a
/// `[model."claude-pro-max/x"]` override keeps `info.id` but can point
/// `base_url` anywhere, and used to ship the Claude OAuth bearer there);
/// - `kimi-code` — the one `uses_oauth` platform with no `OAuthConfig` —
/// rides the PRIMARY session, and only at the session's own effective
/// coding endpoint;
/// - every API-key registry platform (deepseek, openai, anthropic,
/// moonshot-*, …) rides NOTHING: its credential is that platform's API
/// key, already resolved into the catalog entry;
/// - a platform-less model (a bare slug or a `[model.*]` block) is decided
/// purely by the ENDPOINT — BYOK detection probes `std::env::var` at call
/// time, so an unset/mistyped `env_key` must not turn into "send the
/// subscription bearer to `api.openai.com`".
pub(crate) fn credential_class(
&self,
platform: Option<kigi_models::PlatformId>,
base_url: &str,
) -> CredentialClass {
match platform {
Some(platform) => match platform.oauth() {
Some(_) if self.endpoint_is_platform_host(platform, base_url) => {
CredentialClass::Pooled
}
// An OAuth platform pointed at a host that is NOT its own.
Some(_) => CredentialClass::None,
None if platform.uses_oauth() && self.is_session_coding_endpoint(base_url) => {
CredentialClass::Primary
}
// `kimi-code` off the session's endpoint, and every API-key
// registry platform.
None => CredentialClass::None,
},
None if self.is_session_coding_endpoint(base_url) => CredentialClass::Primary,
None => CredentialClass::None,
}
}
/// The manager behind [`Self::credential_class`]. Derived from the class, so
/// the rule is stated exactly once and the two can never disagree.
fn governing_manager(
&self,
platform: Option<kigi_models::PlatformId>,
base_url: &str,
) -> Option<Arc<AuthManager>> {
match self.credential_class(platform, base_url) {
CredentialClass::Pooled => {
platform
.and_then(kigi_models::PlatformId::oauth)
.map(|oauth| {
crate::auth::oauth_registry::global_manager_for(
&crate::auth::oauth_registry::pool_home(),
oauth,
)
})
}
CredentialClass::Primary => self.primary.clone(),
CredentialClass::None => None,
}
}
/// Whether `base_url` is the SESSION's own coding endpoint: the effective
/// `[endpoints] coding_api_base_url` from **config.toml** (what a managed /
/// enterprise deployment actually sets — H3), the `models_base_url`
/// custom-endpoint mode, the `KIGI_CODE_BASE_URL` env override, a loopback
/// dev proxy, or the compiled production endpoint.
///
/// Deliberately NOT [`crate::util::is_first_party_url`], which is
/// production-only and would break every custom deployment.
fn is_session_coding_endpoint(&self, base_url: &str) -> bool {
if crate::util::is_effective_coding_endpoint_url(base_url) {
return true;
}
if crate::util::matches_trusted_base_url(base_url, &self.endpoints.proxy_url()) {
return true;
}
self.endpoints
.models_base_url
.as_deref()
.is_some_and(|models_base| crate::util::matches_trusted_base_url(base_url, models_base))
}
/// Whether `base_url` is `platform`'s own registry host — the guard that
/// keeps a subscription-OAuth bearer from riding a redirected `[model.*]`
/// override to a third party (L10).
fn endpoint_is_platform_host(&self, platform: kigi_models::PlatformId, base_url: &str) -> bool {
crate::util::matches_trusted_base_url(base_url, &platform.base_url())
}
/// The `AuthManager` that governs this request's bearer resolution,
/// mid-session refresh and 401 recovery — or `None` when no session
/// credential may ride (fail fast; never a silent fallback to the primary).
pub(crate) fn manager_for(
&self,
platform: Option<kigi_models::PlatformId>,
base_url: &str,
) -> Option<Arc<AuthManager>> {
self.governing_manager(platform, base_url)
}
/// The session bearer to stamp as this request's `api_key`, or `None`.
///
/// The ONLY production constructor of [`SessionCredential`].
pub(crate) fn credential_for(
&self,
platform: Option<kigi_models::PlatformId>,
base_url: &str,
) -> Option<SessionCredential> {
self.governing_manager(platform, base_url)
.and_then(|am| am.current_or_expired())
.map(|auth| SessionCredential(auth.key))
}
/// A live sampler `bearer_resolver` over the governing manager, so the
/// request keeps mid-session refresh / 401 recovery against the credential
/// that actually belongs to its host.
pub(crate) fn bearer_resolver_for(
&self,
platform: Option<kigi_models::PlatformId>,
base_url: &str,
) -> Option<kigi_sampler::SharedBearerResolver> {
self.manager_for(platform, base_url)
.map(crate::session::acp_session::sampler_turn::auth_manager_bearer_resolver)
}
/// [`Self::credential_for`] for a resolved catalog entry: derives the
/// platform and the base URL from the SAME entry, so the two can never be
/// mismatched by a call site.
pub(crate) fn credential_for_model(&self, entry: &ModelEntry) -> Option<SessionCredential> {
let info = entry.info();
self.credential_for(entry_platform(entry), &info.base_url)
}
/// [`Self::credential_for`] for the catalog model a routing slug resolves
/// to. `current_key` is the SESSION's own selected catalog key (see
/// [`crate::agent::models::entry_for_slug`]); pass `None` for aux /
/// override slugs, which are not the session's selection.
///
/// M5: a slug that is NOT in the catalog resolves through the SAME endpoint
/// rule against the aux fallback endpoint
/// (`EndpointsConfig::resolve_inference_base_url`, which is exactly where
/// `resolve_aux_model_sampling_config`'s Tier-2 entry routes) instead of
/// being handed the primary unconditionally — the old "first-party by
/// construction" justification was false once `models_base_url` could point
/// anywhere.
///
/// M6: the platform and the base URL come from ONE
/// [`crate::agent::models::entry_for_slug`] lookup, so they can no longer
/// disagree (the aux path used to resolve the platform with `current_key`
/// and the credential with a separate `find_model_by_id`).
pub(crate) fn credential_for_slug(
&self,
models: &indexmap::IndexMap<String, ModelEntry>,
current_key: Option<&str>,
slug: &str,
) -> Option<SessionCredential> {
match crate::agent::models::entry_for_slug(models, current_key, slug) {
Some(entry) => self.credential_for_model(entry),
None => self.credential_for(None, &self.endpoints.resolve_inference_base_url()),
}
}
}
/// The registry platform a catalog entry belongs to (`info.id` is the managed
/// key `{platform}/{model}`). `None` for a bare / `[model.*]` entry.
pub(crate) fn entry_platform(entry: &ModelEntry) -> Option<kigi_models::PlatformId> {
entry
.info()
.id
.as_deref()
.and_then(kigi_models::parse_managed_model_key)
.map(|(platform, _)| platform)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::{AuthMode, KimiAuth, KimiCodeConfig};
/// A primary holding a fixed in-memory bearer. The `TempDir` is returned so
/// the caller keeps it alive; the token is read from memory, so on-disk
/// contents are irrelevant.
fn primary(key: &str) -> (tempfile::TempDir, Arc<AuthManager>) {
let dir = tempfile::tempdir().unwrap();
let manager = Arc::new(AuthManager::new(dir.path(), KimiCodeConfig::default()));
manager.hot_swap(KimiAuth {
key: key.to_string(),
auth_mode: AuthMode::OAuth,
..KimiAuth::test_default()
});
(dir, manager)
}
fn authority(endpoints: EndpointsConfig, primary: Arc<AuthManager>) -> CredentialAuthority {
CredentialAuthority::new(endpoints, Some(primary))
}
fn platform(id: &str) -> kigi_models::PlatformId {
kigi_models::PlatformId::parse(id).expect("known platform")
}
/// H3 (REGRESSION): the effective coding endpoint is
/// `EndpointsConfig::proxy_url()`, which prefers `[endpoints]
/// coding_api_base_url` from **config.toml** — the key the managed-config
/// sync writes. A predicate that knows only `KIGI_CODE_BASE_URL` classifies
/// such a deployment as third-party, withholds the api_key AND the
/// resolver, and 401s on every turn.
///
/// Revert-to-red: drop the `proxy_url()` arm from
/// `is_session_coding_endpoint` (leaving only
/// `is_effective_coding_endpoint_url`) and every assertion here fails —
/// with NO env var set anywhere in the test.
#[test]
fn config_toml_coding_endpoint_still_rides_the_session_bearer() {
let (_d, kimi) = primary("kimi-tok");
let managed = "https://proxy.acme.com/v1";
let auth = authority(
EndpointsConfig {
coding_api_base_url: Some(managed.to_string()),
..EndpointsConfig::default()
},
kimi.clone(),
);
assert_eq!(
auth.credential_class(None, managed),
CredentialClass::Primary,
"a [model.*] entry inheriting the managed coding endpoint takes the session bearer"
);
assert_eq!(
auth.credential_for(None, managed)
.map(|c| c.expose().to_owned()),
Some("kimi-tok".to_string()),
"the managed deployment must still receive the session bearer"
);
assert!(
auth.manager_for(None, managed).is_some(),
"and must keep a live manager, or it loses refresh and 401 recovery"
);
// kimi-code entries route to `proxy_url()` too (models_fetch's
// `platform_fetch_base`), so the platform arm must honour it as well.
assert_eq!(
auth.credential_for(Some(platform("kimi-code")), managed)
.map(|c| c.expose().to_owned()),
Some("kimi-tok".to_string()),
);
// A DIFFERENT authority (no managed key configured) must NOT trust it.
let default_auth = authority(EndpointsConfig::default(), kimi);
assert_eq!(
default_auth.credential_class(None, managed),
CredentialClass::None,
"the managed host is only trusted for the session that configured it"
);
}
/// The `models_base_url` custom-endpoint mode is equally invisible to the
/// env-var-only predicate.
#[test]
fn config_toml_models_base_url_still_rides_the_session_bearer() {
let (_d, kimi) = primary("kimi-tok");
let custom = "https://models.acme.internal/v1";
let auth = authority(
EndpointsConfig {
models_base_url: Some(custom.to_string()),
..EndpointsConfig::default()
},
kimi,
);
assert_eq!(
auth.credential_for(None, custom)
.map(|c| c.expose().to_owned()),
Some("kimi-tok".to_string()),
);
}
/// The compiled production endpoint and loopback proxies are unchanged.
#[test]
fn production_and_loopback_endpoints_are_unchanged() {
let (_d, kimi) = primary("kimi-tok");
let auth = authority(EndpointsConfig::default(), kimi);
for url in [
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
"http://127.0.0.1:8080/v1",
"http://localhost:3000/v1",
"http://[::1]:9000/v1",
] {
assert_eq!(
auth.credential_class(None, url),
CredentialClass::Primary,
"{url}: the session's own endpoint is byte-identical"
);
}
}
/// LEAK guard: every API-key registry platform, and any platform-less model
/// on a third-party host, gets NO session credential and NO manager.
#[test]
fn third_party_endpoints_never_receive_the_primary() {
let (_d, kimi) = primary("kimi-tok");
let auth = authority(EndpointsConfig::default(), kimi);
for id in [
"deepseek",
"openai",
"anthropic",
"moonshot-cn",
"moonshot-ai",
] {
let p = platform(id);
assert!(
auth.credential_for(Some(p), &p.base_url()).is_none(),
"LEAK: {id} is an API-key platform — no session bearer may ride there"
);
assert!(auth.manager_for(Some(p), &p.base_url()).is_none());
}
for url in [
"https://api.openai.com/v1",
"https://api.deepseek.com/v1",
"https://api.moonshot.cn/v1",
"",
] {
assert!(
auth.credential_for(None, url).is_none(),
"LEAK: {url} is a third-party host"
);
}
}
/// L10: an OAuth platform whose `[model.*]` override redirects `base_url`
/// to a third-party host keeps `info.id` — and must NOT ship that
/// platform's pooled OAuth bearer there.
#[tokio::test]
async fn oauth_platform_redirected_to_a_third_party_host_gets_nothing() {
let (_d, kimi) = primary("kimi-tok");
let auth = authority(EndpointsConfig::default(), kimi);
for id in [
"claude-pro-max",
"openai-codex",
"github-copilot",
"xai-grok",
] {
let p = platform(id);
assert!(
auth.manager_for(Some(p), &p.base_url()).is_some(),
"{id} keeps its own pooled manager on its own host"
);
assert!(
auth.manager_for(Some(p), "https://third.party/v1")
.is_none(),
"LEAK: {id} redirected to a third-party host must ship no bearer"
);
assert_eq!(
auth.credential_class(Some(p), "https://third.party/v1"),
CredentialClass::None,
"LEAK: {id} redirected to a third-party host takes no session credential"
);
}
}
/// C1 — a subscription platform's own host DOES take a session credential,
/// but it is that platform's POOLED token, never the primary / house key.
/// Two look-alike booleans used to encode this, and picking the wrong one is
/// the whole defect; one classifier makes the distinction impossible to
/// mis-read.
#[test]
fn a_subscription_host_classifies_pooled_never_primary() {
let (_d, kimi) = primary("kimi-tok");
let auth = authority(EndpointsConfig::default(), kimi);
for id in [
"claude-pro-max",
"openai-codex",
"github-copilot",
"xai-grok",
] {
let p = platform(id);
assert_eq!(
auth.credential_class(Some(p), &p.base_url()),
CredentialClass::Pooled,
"LEAK: {id}'s own host takes its POOLED token — never the primary / house key"
);
}
// Every API-key registry platform: nothing at all.
for id in ["deepseek", "openai", "anthropic", "moonshot-cn"] {
let p = platform(id);
assert_eq!(
auth.credential_class(Some(p), &p.base_url()),
CredentialClass::None
);
}
// The primary channel is unchanged: kimi-code and a platform-less model
// on the session's own endpoint, and nothing on a third-party host.
for url in [
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
"http://127.0.0.1:8080/v1",
] {
assert_eq!(auth.credential_class(None, url), CredentialClass::Primary);
assert_eq!(
auth.credential_class(Some(platform("kimi-code")), url),
CredentialClass::Primary
);
}
assert_eq!(
auth.credential_class(None, "https://api.openai.com/v1"),
CredentialClass::None
);
}
/// The four subscription-OAuth platforms draw from their OWN pooled
/// managers — never the primary Kimi one, even under a Kimi session.
#[tokio::test]
async fn oauth_platforms_never_resolve_the_primary() {
let (_d, kimi) = primary("kimi-tok");
let auth = authority(EndpointsConfig::default(), kimi.clone());
for id in [
"claude-pro-max",
"openai-codex",
"github-copilot",
"xai-grok",
] {
let p = platform(id);
let resolved = auth
.manager_for(Some(p), &p.base_url())
.expect("pooled manager");
assert!(
!Arc::ptr_eq(&resolved, &kimi),
"{id} must NOT resolve the primary Kimi manager"
);
assert_ne!(
auth.credential_for(Some(p), &p.base_url())
.map(|c| c.expose().to_owned()),
Some("kimi-tok".to_string()),
"{id} must never receive the primary Kimi bearer"
);
}
}
}
+198 -10
View File
@@ -13,15 +13,70 @@
use std::sync::Arc;
use crate::auth::kimi_oauth::{
DeviceAuthorization, DevicePollResult, poll_device_token, request_device_authorization,
};
use kigi_models::OAuthConfig;
use crate::auth::kimi_oauth::{DeviceAuthorization, DevicePollResult};
use crate::auth::{AuthChannels, AuthManager, AuthUrlInfo, AuthUrlMode, KimiAuth};
/// Extra wait added to the poll interval when the server answers `slow_down`
/// (OAuth-standard device-flow backpressure).
const SLOW_DOWN_INCREMENT_SECS: u64 = 5;
/// The wire behind a device-code login. The `Kimi` arm calls the bespoke Kimi
/// Code wire (X-Msh headers, `/api/oauth/*`) verbatim — byte-identical to the
/// pre-generalization path; the `Generic` arm drives a registry
/// [`OAuthConfig`] provider (xai-grok) through [`crate::auth::oauth_device`].
enum DeviceFlowBackend<'a> {
Kimi {
host: &'a str,
},
Generic(&'a OAuthConfig),
/// GitHub Copilot two-stage flow: the device authorization is the generic
/// one, but the token POLL reads GitHub's 200-body errors, and login
/// FINALIZES the durable github token into a copilot session token via the
/// Stage-2 exchange (see [`DeviceFlowBackend::finalize`]).
GithubCopilot(&'a OAuthConfig),
}
impl DeviceFlowBackend<'_> {
async fn request(&self) -> anyhow::Result<DeviceAuthorization> {
match self {
Self::Kimi { host } => {
crate::auth::kimi_oauth::request_device_authorization(host).await
}
Self::Generic(cfg) | Self::GithubCopilot(cfg) => {
crate::auth::oauth_device::request_device_authorization(cfg).await
}
}
}
async fn poll(&self, device_code: &str) -> anyhow::Result<DevicePollResult> {
match self {
Self::Kimi { host } => {
crate::auth::kimi_oauth::poll_device_token(host, device_code).await
}
Self::Generic(cfg) => {
crate::auth::oauth_device::poll_device_token(cfg, device_code).await
}
Self::GithubCopilot(cfg) => {
crate::auth::github_copilot::poll_github_device_token(cfg, device_code).await
}
}
}
/// Transform the device-grant credential before it is persisted. The Kimi
/// and generic flows persist the poll result verbatim; the GitHub Copilot
/// flow exchanges the durable github token (in `auth.key`) for the
/// short-lived copilot token, persisting BOTH (copilot as `key`, github as
/// `refresh_token`).
async fn finalize(&self, auth: KimiAuth) -> anyhow::Result<KimiAuth> {
match self {
Self::Kimi { .. } | Self::Generic(_) => Ok(auth),
Self::GithubCopilot(cfg) => {
crate::auth::github_copilot::exchange_copilot_token(cfg, &auth.key).await
}
}
}
}
/// Outcome of one full poll loop over a single device authorization.
enum PollLoopOutcome {
/// Access token issued.
@@ -40,11 +95,46 @@ pub async fn run_device_code_login_channels(
host: &str,
auth_manager: &Arc<AuthManager>,
channels: &mut Option<AuthChannels>,
) -> anyhow::Result<(KimiAuth, bool)> {
run_device_code_login_backend(DeviceFlowBackend::Kimi { host }, auth_manager, channels).await
}
/// Device-code login for a GENERIC [`OAuthConfig`] provider (xai-grok). Same
/// TUI/CLI presentation as the Kimi login; only the wire differs.
pub async fn run_device_code_login_generic(
oauth: &OAuthConfig,
auth_manager: &Arc<AuthManager>,
channels: &mut Option<AuthChannels>,
) -> anyhow::Result<(KimiAuth, bool)> {
run_device_code_login_backend(DeviceFlowBackend::Generic(oauth), auth_manager, channels).await
}
/// GitHub Copilot two-stage login (github-copilot): the same device-flow
/// presentation as the generic path, but the token poll reads GitHub's 200-body
/// errors and the minted github token is finalized into a copilot session token
/// before it is persisted (see [`DeviceFlowBackend::finalize`]).
pub async fn run_device_code_login_github_copilot(
oauth: &OAuthConfig,
auth_manager: &Arc<AuthManager>,
channels: &mut Option<AuthChannels>,
) -> anyhow::Result<(KimiAuth, bool)> {
run_device_code_login_backend(
DeviceFlowBackend::GithubCopilot(oauth),
auth_manager,
channels,
)
.await
}
async fn run_device_code_login_backend(
backend: DeviceFlowBackend<'_>,
auth_manager: &Arc<AuthManager>,
channels: &mut Option<AuthChannels>,
) -> anyhow::Result<(KimiAuth, bool)> {
let interactive_tui = channels.is_some();
let mut channels = channels.take();
loop {
let device_auth = request_device_authorization(host).await?;
let device_auth = backend.request().await?;
let display_uri = device_auth.verification_uri_complete.clone();
if interactive_tui {
@@ -62,10 +152,14 @@ pub async fn run_device_code_login_channels(
prompt_on_stderr(&device_auth).await;
}
match complete_device_code_login(host, &device_auth).await? {
match complete_device_code_login(&backend, &device_auth).await? {
PollLoopOutcome::Done(auth) => {
// Finalize before persisting: the GitHub Copilot flow exchanges
// the durable github token for the short-lived copilot token
// here; the Kimi / generic flows pass the credential through.
let auth = backend.finalize(*auth).await?;
let auth = auth_manager
.update(*auth)
.update(auth)
.await
.map_err(|e| anyhow::anyhow!("Failed to save credentials: {e}"))?;
return Ok((auth, true));
@@ -112,7 +206,7 @@ async fn prompt_on_stderr(device_auth: &DeviceAuthorization) {
/// Poll the token endpoint until the user approves, the device code expires
/// (→ [`PollLoopOutcome::Restart`]), or the wire fails.
async fn complete_device_code_login(
host: &str,
backend: &DeviceFlowBackend<'_>,
device_auth: &DeviceAuthorization,
) -> anyhow::Result<PollLoopOutcome> {
let mut poll_interval = std::time::Duration::from_secs(device_auth.interval.max(1) as u64);
@@ -120,7 +214,7 @@ async fn complete_device_code_login(
// Sleep first: an immediate poll on a fresh code only returns
// authorization_pending (and risks slow_down).
tokio::time::sleep(poll_interval).await;
match poll_device_token(host, &device_auth.device_code).await? {
match backend.poll(&device_auth.device_code).await? {
DevicePollResult::Success(auth) => {
tracing::info!("auth: device login authorized");
return Ok(PollLoopOutcome::Done(auth));
@@ -148,8 +242,8 @@ async fn complete_device_code_login(
/// Open `url` in the browser off-thread: `webbrowser::open` is synchronous and
/// would stall the single-threaded TUI loop. Returns `true` on success so the
/// caller can decide how to notify the user (eprintln on CLI, nothing on TUI
/// where the URL is already rendered in the widget).
async fn open_browser_detached(url: &str) -> bool {
/// where the URL is already rendered in the widget). Shared with the PKCE flow.
pub(super) async fn open_browser_detached(url: &str) -> bool {
// Unit tests drive the full login flow against mock servers — their
// fixture URLs must never reach a real browser.
if cfg!(test) {
@@ -338,6 +432,100 @@ mod tests {
);
}
/// GitHub Copilot two-stage login e2e (mock wire): device authorization →
/// poll (pending → github token) → Stage-2 copilot-token exchange (Bearer
/// github token + editor headers → copilot token + expiry). The persisted
/// credential keys the COPILOT token, keeps the GITHUB token as
/// `refresh_token`, and carries the copilot expiry.
#[tokio::test]
async fn github_copilot_two_stage_login_persists_copilot_and_github() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
let cfg = OAuthConfig {
auth_host: host,
token_host: host,
copilot_exchange: Some((host, "/copilot_internal/v2/token")),
..kigi_models::COPILOT_OAUTH_CONFIG
};
// Stage 1a: device authorization (github.com/login/device/code).
Mock::given(method("POST"))
.and(path("/login/device/code"))
.and(body_string_contains("client_id=Iv1.b507a08c87ecfe98"))
.and(body_string_contains("scope=read"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"device_code": "gh-dev-1",
"user_code": "WDJB-MJHT",
"verification_uri": "https://github.com/login/device",
"expires_in": 900,
"interval": 0,
})))
.mount(&server)
.await;
// Stage 1b: token poll — GitHub returns errors AND success in a 200 body.
Mock::given(method("POST"))
.and(path("/login/oauth/access_token"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "error": "authorization_pending" })),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/login/oauth/access_token"))
.and(body_string_contains("device_code=gh-dev-1"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "access_token": "gho_github_tok" })),
)
.mount(&server)
.await;
// Stage 2: copilot-token exchange (Bearer github token + editor headers).
let future = (chrono::Utc::now() + chrono::Duration::minutes(30)).timestamp();
Mock::given(method("GET"))
.and(path("/copilot_internal/v2/token"))
.and(wiremock::matchers::header(
"Authorization",
"Bearer gho_github_tok",
))
.and(wiremock::matchers::header(
"Editor-Plugin-Version",
"copilot-chat/0.35.0",
))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "token": "copilot-session-tok", "expires_at": future }),
))
.expect(1)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let mgr = auth_manager(&dir);
let mut channels = None;
let (auth, is_new) = run_device_code_login_github_copilot(&cfg, &mgr, &mut channels)
.await
.unwrap();
assert!(is_new);
assert_eq!(
auth.key, "copilot-session-tok",
"key = copilot session token"
);
assert_eq!(
auth.refresh_token.as_deref(),
Some("gho_github_tok"),
"the durable github token is persisted as refresh_token"
);
assert!(
auth.expires_at.is_some_and(|e| e > chrono::Utc::now()),
"the copilot expiry must be persisted"
);
assert_eq!(
mgr.current_or_expired().map(|a| a.key),
Some("copilot-session-tok".into()),
"login must land the copilot token in the manager cache"
);
}
/// A 5xx from the token endpoint is a hard error (kimi-cli parity).
#[tokio::test]
async fn server_error_during_poll_fails_login() {
+172
View File
@@ -67,6 +67,178 @@ pub async fn run_auth_flow(
run_auth_flow_inner(auth_manager, kimi_code_config, reauth, false, channels).await
}
/// Login flow for a GENERIC OAuth provider (xai-grok device-code,
/// claude-pro-max PKCE-localhost): use a valid cached session unless re-authing,
/// otherwise dispatch by `oauth.flow` to the device-code or PKCE-localhost login
/// (persisting under the provider's own scope via `auth_manager`). Unlike the
/// Kimi flow this does not run the silent-refresh dance — the login's
/// `AuthManager::update` persists a fresh token set directly.
pub async fn run_oauth_provider_flow(
auth_manager: &Arc<AuthManager>,
oauth: &'static kigi_models::OAuthConfig,
reauth: bool,
channels: Option<AuthChannels>,
) -> anyhow::Result<(KimiAuth, bool)> {
tracing::info!(
scope_key = oauth.scope_key,
reauth,
"auth: starting generic oauth login"
);
if reauth {
auth_manager.clear()?;
}
if !reauth && let Some(auth) = auth_manager.current() {
tracing::info!(
scope_key = oauth.scope_key,
"auth: using cached oauth session"
);
return Ok((auth, false));
}
let mut channels = channels;
match oauth.flow {
kigi_models::OAuthFlow::DeviceCode => {
crate::auth::device_code::run_device_code_login_generic(
oauth,
auth_manager,
&mut channels,
)
.await
}
kigi_models::OAuthFlow::PkceLocalhost {
redirect_port,
redirect_path,
} => {
run_pkce_localhost_login(
oauth,
redirect_port,
redirect_path,
auth_manager,
&mut channels,
)
.await
}
kigi_models::OAuthFlow::GithubDeviceCopilot => {
crate::auth::device_code::run_device_code_login_github_copilot(
oauth,
auth_manager,
&mut channels,
)
.await
}
}
}
/// PKCE-localhost login (claude-pro-max JSON, openai-codex FORM): generate PKCE,
/// present the browser authorize URL (TUI channel or stderr), open the browser,
/// then await the code from EITHER the `127.0.0.1:{redirect_port}{redirect_path}`
/// loopback callback OR a manual paste (headless fallback). Exchange it at the
/// token endpoint and persist.
///
/// The `token_body` selects the wire dialect: `Json` is the claude path
/// (`state == verifier`, JSON exchange carrying `state`); `Form` is the codex
/// path (fresh-random `state`, FORM exchange WITHOUT `state`, then a FAIL-FAST
/// check that the minted JWT carries a `chatgpt_account_id` — a token without it
/// is useless for inference, so the login bails rather than persisting it).
///
/// SECURITY: the verifier / code / tokens / JWT / account id are never logged;
/// the loopback binds `127.0.0.1` only and validates `state` strictly.
async fn run_pkce_localhost_login(
oauth: &'static kigi_models::OAuthConfig,
redirect_port: u16,
redirect_path: &'static str,
auth_manager: &Arc<AuthManager>,
channels: &mut Option<AuthChannels>,
) -> anyhow::Result<(KimiAuth, bool)> {
use crate::auth::oauth_pkce;
// The token-body encoding also selects the PKCE `state` convention: the JSON
// dialect is Claude's/Pi's `state == verifier`, while form-encoded endpoints
// (the OAuth norm) get an INDEPENDENT random state. A new form provider
// inheriting the standard random state is correct by default.
let uses_form_exchange = matches!(oauth.token_body, kigi_models::OAuthTokenBody::Form);
let pkce = if uses_form_exchange {
oauth_pkce::generate_pkce_random_state()
} else {
oauth_pkce::generate_pkce()
};
let redirect = oauth_pkce::redirect_uri(redirect_port, redirect_path);
let authorize_url = oauth_pkce::build_authorize_url(oauth, &redirect, &pkce);
let mut chans = channels.take();
if let Some(tx) = chans.as_mut().and_then(|c| c.url_tx.take()) {
// TUI: push the URL BEFORE opening the browser (never block the UI on a
// slow/headless browser launch).
let _ = tx.send(AuthUrlInfo {
url: authorize_url.clone(),
mode: AuthUrlMode::Device,
});
crate::auth::device_code::open_browser_detached(&authorize_url).await;
} else {
eprintln!();
eprintln!("To sign in, open this URL in your browser:");
eprintln!();
eprintln!(" {authorize_url}");
eprintln!();
if !crate::auth::device_code::open_browser_detached(&authorize_url).await {
eprintln!(" (Could not open the browser automatically — open the URL above.)");
eprintln!();
}
eprintln!("Waiting for the sign-in to complete...");
}
let code = await_pkce_code(redirect_port, redirect_path, &pkce, chans.as_mut()).await?;
let auth = if uses_form_exchange {
oauth_pkce::exchange_code_form(oauth, &code, &pkce, &redirect).await?
} else {
oauth_pkce::exchange_code(oauth, &code, &pkce, &redirect).await?
};
// FAIL FAST: an access token that yields no chatgpt_account_id cannot
// authorize inference (it becomes the `chatgpt-account-id` header) — bail
// rather than persist a dead session. Gated on the EXPLICIT provider fact,
// never on the token-body encoding.
if oauth.requires_chatgpt_account_id
&& kigi_sampling_types::chatgpt_account_id_from_jwt(&auth.key).is_none()
{
anyhow::bail!(
"ChatGPT login did not return a usable account id \
(the access token is missing the chatgpt_account_id claim)"
);
}
let auth = auth_manager
.update(auth)
.await
.map_err(|e| anyhow::anyhow!("Failed to save credentials: {e}"))?;
Ok((auth, true))
}
/// Await the authorization code: the loopback callback is primary; when a TUI
/// channel is present, a pasted code (redirect URL / `code#state` / bare code)
/// is accepted concurrently as a headless fallback. State is validated in both
/// arms (strict on the loopback, mismatch-rejecting on the paste).
async fn await_pkce_code(
redirect_port: u16,
redirect_path: &str,
pkce: &crate::auth::oauth_pkce::PkceCodes,
channels: Option<&mut AuthChannels>,
) -> anyhow::Result<String> {
use crate::auth::oauth_pkce;
match channels {
Some(ch) => {
tokio::select! {
code = oauth_pkce::await_loopback_code(redirect_port, redirect_path, &pkce.state) => code,
pasted = ch.code_rx.recv() => {
let pasted = pasted
.ok_or_else(|| anyhow::anyhow!("auth code channel closed before a code arrived"))?;
let params = oauth_pkce::parse_manual_paste(&pasted)?;
oauth_pkce::validate_pasted_state(&params, &pkce.state)?;
Ok(params.code)
}
}
}
None => oauth_pkce::await_loopback_code(redirect_port, redirect_path, &pkce.state).await,
}
}
async fn run_auth_flow_inner(
auth_manager: &Arc<AuthManager>,
_kimi_code_config: &KimiCodeConfig,
@@ -0,0 +1,485 @@
//! GitHub Copilot two-stage OAuth wire (github-copilot), driven by a registry
//! [`kigi_models::OAuthConfig`] whose `flow` is [`OAuthFlow::GithubDeviceCopilot`].
//!
//! Stage 1 — RFC-8628 device flow on `auth_host` (github.com). The device
//! authorization POST is the generic one ([`super::oauth_device`]); the token
//! POLL is Copilot-specific because GitHub returns its device errors in a `200`
//! body (`{error: "authorization_pending"|"slow_down"|"expired_token"}`), not a
//! `4xx`, and the success payload carries ONLY `access_token` (the DURABLE
//! GitHub token — no refresh token, no expiry).
//!
//! Stage 2 — copilot-token exchange: `GET {copilot_exchange}` bearing the GitHub
//! token + the editor headers re-mints the SHORT-LIVED copilot session token
//! (`{token, expires_at}`). This runs at login ([`exchange_copilot_token`]) and
//! on every "refresh" ([`remint_copilot_token`], dispatched by the generic
//! refresher) — the github token is unchanged and re-persisted as the
//! `refresh_token`; the copilot token becomes the `key`.
//!
//! SECURITY: the github token and the copilot token are NEVER logged (only
//! non-secret events: poll succeeded, copilot token minted/re-minted).
use chrono::{DateTime, Utc};
use kigi_models::OAuthConfig;
use serde::Deserialize;
use super::kimi_oauth::{DevicePollResult, RefreshError};
use super::model::{AuthMode, KimiAuth};
/// RFC-8628 device grant type (shared with the generic device wire).
const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
/// Copilot-exchange retry budget over 5xx / network blips (parity with the
/// device/PKCE refresh wires); 401/403 fails fast (the github token is dead).
const MAX_EXCHANGE_RETRIES: u32 = 3;
const RETRYABLE_EXCHANGE_STATUSES: [u16; 5] = [429, 500, 502, 503, 504];
/// The four VS Code Copilot editor-identity headers every Copilot request
/// carries. Non-secret wire constants owned by `kigi_sampling_types` (the
/// single source shared with the sampler's inference gate).
fn editor_headers() -> [(&'static str, &'static str); 4] {
[
("User-Agent", kigi_sampling_types::COPILOT_USER_AGENT),
(
"Editor-Version",
kigi_sampling_types::COPILOT_EDITOR_VERSION,
),
(
"Editor-Plugin-Version",
kigi_sampling_types::COPILOT_EDITOR_PLUGIN_VERSION,
),
(
"Copilot-Integration-Id",
kigi_sampling_types::COPILOT_INTEGRATION_ID,
),
]
}
/// GitHub's device-token poll response: EITHER `access_token` (the durable
/// GitHub token) OR an `error` (in a `200` body). No refresh token / expiry.
#[derive(Deserialize, Default)]
struct GithubDeviceTokenResponse {
#[serde(default)]
access_token: Option<String>,
#[serde(default)]
error: Option<String>,
}
/// One poll of `POST {auth_host}{token_path}` (github.com/login/oauth/
/// access_token) with the device grant. GitHub answers `200` for BOTH success
/// and the pending/slow_down/expired errors, so the outcome is read from the
/// body, not the status. On success the [`KimiAuth`] carries the GitHub token as
/// `key` with NO refresh token / expiry — the caller finalizes it via the
/// copilot exchange before persisting.
pub(crate) async fn poll_github_device_token(
cfg: &OAuthConfig,
device_code: &str,
) -> anyhow::Result<DevicePollResult> {
let url = format!("{}{}", cfg.auth_host.trim_end_matches('/'), cfg.token_path);
let resp = crate::http::shared_client()
.post(&url)
.header("Accept", "application/json")
.header("User-Agent", kigi_sampling_types::COPILOT_USER_AGENT)
.form(&[
("client_id", cfg.client_id),
("device_code", device_code),
("grant_type", DEVICE_GRANT_TYPE),
])
.send()
.await
.map_err(|e| anyhow::anyhow!("Token polling request failed: {e}"))?;
let status = resp.status();
if status.is_server_error() {
anyhow::bail!("Token polling server error: {status}");
}
let body = resp.bytes().await?;
let parsed: GithubDeviceTokenResponse = serde_json::from_slice(&body).unwrap_or_default();
if let Some(access) = parsed.access_token.filter(|t| !t.is_empty()) {
tracing::info!("auth: github device poll succeeded, github token issued (copilot)");
return Ok(DevicePollResult::Success(Box::new(github_token_auth(
access,
))));
}
match parsed.error.as_deref() {
Some("expired_token") => {
tracing::info!("auth: github device code expired; restarting (copilot)");
Ok(DevicePollResult::Expired)
}
Some(error) => {
tracing::debug!(error, "auth: github device poll pending (copilot)");
Ok(DevicePollResult::Pending {
error: error.to_owned(),
description: None,
})
}
None => Ok(DevicePollResult::Pending {
error: "missing_access_token".to_owned(),
description: None,
}),
}
}
/// A transient [`KimiAuth`] holding ONLY the durable GitHub token (no refresh
/// token / expiry) — the intermediate device-flow result, finalized by the
/// copilot exchange before it is ever persisted.
fn github_token_auth(github_token: String) -> KimiAuth {
KimiAuth {
key: github_token,
auth_mode: AuthMode::OAuth,
create_time: Utc::now(),
user_id: String::new(),
email: None,
refresh_token: None,
expires_at: None,
expires_in: None,
scope: None,
token_type: None,
}
}
/// Stage-2 copilot-token exchange response (`GET copilot_internal/v2/token`).
/// `endpoints`/`proxy-ep` are ignored: Kigi resolves the base URL from the
/// platform registry (the individual-subscription endpoint, or the
/// `KIGI_COPILOT_BASE_URL` override).
#[derive(Deserialize)]
struct CopilotTokenResponse {
token: String,
/// Unix seconds when the copilot token expires (~30 min out).
expires_at: i64,
}
/// Materialize the persisted credential from a copilot-token exchange:
/// `key` = the short-lived copilot token, `refresh_token` = the DURABLE github
/// token (so every re-mint re-exchanges it), `expires_at` = the copilot expiry.
fn copilot_auth(resp: CopilotTokenResponse, github_token: &str) -> anyhow::Result<KimiAuth> {
let now = Utc::now();
// FAIL-FAST: an uninterpretable expiry means we cannot schedule the re-mint,
// so reject it rather than silently falling back to a long default TTL — which
// would let the ~30-min copilot token 401 on the wire ~30 min later.
let expires_at = DateTime::from_timestamp(resp.expires_at, 0).ok_or_else(|| {
anyhow::anyhow!(
"copilot token has an out-of-range expires_at: {}",
resp.expires_at
)
})?;
// The manager's dynamic threshold (`max(300, expires_in × 0.5)`) drives the
// proactive re-mint; `expires_in` is the copilot token's remaining life.
let expires_in = (expires_at - now).num_seconds();
Ok(KimiAuth {
key: resp.token,
auth_mode: AuthMode::OAuth,
create_time: now,
user_id: String::new(),
email: None,
refresh_token: Some(github_token.to_owned()),
expires_at: Some(expires_at),
expires_in: Some(expires_in),
scope: None,
token_type: Some("bearer".to_owned()),
})
}
/// The `(host, path)` of the copilot-token exchange endpoint, or a fatal error
/// when the config lacks it (a non-Copilot config reaching this wire is a bug).
fn exchange_url(cfg: &OAuthConfig) -> anyhow::Result<String> {
let (host, path) = cfg.copilot_exchange.ok_or_else(|| {
anyhow::anyhow!("github-copilot config missing copilot_exchange endpoint")
})?;
Ok(format!("{}{path}", host.trim_end_matches('/')))
}
/// `GET {copilot_exchange}` bearing the github token + editor headers.
async fn send_copilot_exchange(
cfg: &OAuthConfig,
github_token: &str,
) -> anyhow::Result<reqwest::Response> {
let url = exchange_url(cfg)?;
let mut req = crate::http::shared_client()
.get(&url)
.header("Accept", "application/json")
.header("Authorization", format!("Bearer {github_token}"));
for (name, value) in editor_headers() {
req = req.header(name, value);
}
req.send()
.await
.map_err(|e| anyhow::anyhow!("copilot-token exchange request failed: {e}"))
}
/// Exchange the durable GitHub token for a copilot session token (login path).
/// FAIL-FAST: a non-2xx response aborts login (never a silent fallback).
pub(crate) async fn exchange_copilot_token(
cfg: &OAuthConfig,
github_token: &str,
) -> anyhow::Result<KimiAuth> {
tracing::info!(
scope_key = cfg.scope_key,
"auth: exchanging github token for copilot token"
);
let resp = send_copilot_exchange(cfg, github_token).await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
tracing::warn!(%status, scope_key = cfg.scope_key, "auth: copilot-token exchange failed");
anyhow::bail!("Copilot token exchange failed (HTTP {status}): {body}");
}
let parsed: CopilotTokenResponse = resp
.json()
.await
.map_err(|e| anyhow::anyhow!("malformed copilot token payload: {e}"))?;
tracing::info!(scope_key = cfg.scope_key, "auth: copilot token minted");
copilot_auth(parsed, github_token)
}
/// Re-mint the copilot token from the durable GitHub token (refresh path,
/// dispatched from the generic refresher). This is NOT a `refresh_token` grant:
/// it re-runs the copilot exchange. `refresh_token` is the github token; the
/// returned [`KimiAuth`] preserves it. Retries 5xx / network blips; 401/403
/// (the github token is revoked) fails fast as [`RefreshError::Unauthorized`].
pub(crate) async fn remint_copilot_token(
cfg: &OAuthConfig,
github_token: &str,
) -> Result<KimiAuth, RefreshError> {
let mut last_error = String::from("no attempt made");
for attempt in 0..MAX_EXCHANGE_RETRIES {
if attempt > 0 {
let backoff = std::time::Duration::from_secs(1 << (attempt - 1));
tracing::warn!(
attempt,
backoff_secs = backoff.as_secs(),
"auth: retrying copilot-token re-mint"
);
tokio::time::sleep(backoff).await;
}
let resp = match send_copilot_exchange(cfg, github_token).await {
Ok(resp) => resp,
Err(e) => {
last_error = format!("{e}");
continue;
}
};
let status = resp.status().as_u16();
let bytes = resp.bytes().await.unwrap_or_default();
if status == 401 || status == 403 {
return Err(RefreshError::Unauthorized {
status,
description: "GitHub token rejected at copilot-token exchange.".to_owned(),
});
}
if status == 200 {
return match serde_json::from_slice::<CopilotTokenResponse>(&bytes) {
Ok(parsed) => match copilot_auth(parsed, github_token) {
Ok(auth) => {
tracing::info!(scope_key = cfg.scope_key, "auth: copilot token re-minted");
Ok(auth)
}
Err(e) => Err(RefreshError::Fatal {
status,
description: format!("{e}"),
}),
},
Err(e) => Err(RefreshError::Fatal {
status,
description: format!("malformed copilot token payload: {e}"),
}),
};
}
let description = format!("copilot-token exchange failed (HTTP {status}).");
if RETRYABLE_EXCHANGE_STATUSES.contains(&status) {
last_error = description;
continue;
}
return Err(RefreshError::Fatal {
status,
description,
});
}
Err(RefreshError::Exhausted { last_error })
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
use kigi_models::COPILOT_OAUTH_CONFIG;
use wiremock::matchers::{body_string_contains, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// A COPILOT_OAUTH_CONFIG pointed at a mock server for both stages.
fn mock_cfg(host: &'static str, exchange: &'static str) -> OAuthConfig {
OAuthConfig {
auth_host: host,
token_host: host,
copilot_exchange: Some((exchange, "/copilot_internal/v2/token")),
..COPILOT_OAUTH_CONFIG
}
}
/// GitHub's device poll returns pending errors in a 200 body — mapped to
/// Pending (authorization_pending / slow_down) and Expired (expired_token),
/// never mis-read as a token.
#[tokio::test]
async fn github_device_poll_maps_200_body_errors() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/login/oauth/access_token"))
.and(body_string_contains("grant_type=urn"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "error": "authorization_pending" })),
)
.mount(&server)
.await;
let result = poll_github_device_token(&mock_cfg(host, host), "dev-1")
.await
.unwrap();
assert!(
matches!(result, DevicePollResult::Pending { error, .. } if error == "authorization_pending")
);
}
#[tokio::test]
async fn github_device_poll_expired_restarts() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/login/oauth/access_token"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "error": "expired_token" })),
)
.mount(&server)
.await;
let result = poll_github_device_token(&mock_cfg(host, host), "dev-1")
.await
.unwrap();
assert!(matches!(result, DevicePollResult::Expired));
}
/// A successful poll yields the DURABLE github token as `key` with NO
/// refresh token / expiry (the copilot exchange finalizes it next).
#[tokio::test]
async fn github_device_poll_success_is_bare_github_token() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/login/oauth/access_token"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "access_token": "gho_github_tok" })),
)
.mount(&server)
.await;
let DevicePollResult::Success(auth) = poll_github_device_token(&mock_cfg(host, host), "d")
.await
.unwrap()
else {
panic!("expected success");
};
assert_eq!(auth.key, "gho_github_tok");
assert_eq!(
auth.refresh_token, None,
"github token is not a refresh grant"
);
assert_eq!(auth.expires_at, None, "the github token is long-lived");
}
/// The Stage-2 exchange rides the github Bearer + editor headers and maps
/// `{token, expires_at}` onto `key=copilot`, `refresh_token=github`, with a
/// future `expires_at`.
#[tokio::test]
async fn copilot_exchange_maps_token_and_persists_github_as_refresh() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
let future = (Utc::now() + Duration::minutes(30)).timestamp();
Mock::given(method("GET"))
.and(path("/copilot_internal/v2/token"))
.and(header("Authorization", "Bearer gho_github_tok"))
.and(header("Editor-Version", "vscode/1.107.0"))
.and(header("Copilot-Integration-Id", "vscode-chat"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "token": "tid=abc;copilot-tok", "expires_at": future }),
))
.expect(1)
.mount(&server)
.await;
let auth = exchange_copilot_token(&mock_cfg(host, host), "gho_github_tok")
.await
.unwrap();
assert_eq!(auth.key, "tid=abc;copilot-tok", "key = copilot token");
assert_eq!(
auth.refresh_token.as_deref(),
Some("gho_github_tok"),
"the durable github token is persisted as refresh_token"
);
assert!(
auth.expires_at.is_some_and(|e| e > Utc::now()),
"copilot expiry must be in the future"
);
}
/// The copilot re-mint (refresh) re-exchanges the github token for a NEW
/// copilot token, keeping the github token as refresh_token.
#[tokio::test]
async fn copilot_remint_returns_new_copilot_token() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
let future = (Utc::now() + Duration::minutes(30)).timestamp();
Mock::given(method("GET"))
.and(path("/copilot_internal/v2/token"))
.and(header("Authorization", "Bearer gho_github_tok"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "token": "copilot-tok-2", "expires_at": future }),
))
.mount(&server)
.await;
let auth = remint_copilot_token(&mock_cfg(host, host), "gho_github_tok")
.await
.unwrap();
assert_eq!(auth.key, "copilot-tok-2");
assert_eq!(auth.refresh_token.as_deref(), Some("gho_github_tok"));
}
/// A 401 at the exchange (github token revoked) fails fast as Unauthorized
/// (drives the manager's permanent-failure / re-login path).
#[tokio::test]
async fn copilot_remint_401_is_unauthorized() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("GET"))
.and(path("/copilot_internal/v2/token"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let err = remint_copilot_token(&mock_cfg(host, host), "dead-github-tok")
.await
.unwrap_err();
assert!(
matches!(err, RefreshError::Unauthorized { status: 401, .. }),
"got {err:?}"
);
}
/// FAIL-FAST: an out-of-range `expires_at` is rejected rather than silently
/// degrading to a long default TTL (which would 401 on the wire ~30 min in).
#[tokio::test]
async fn copilot_exchange_rejects_out_of_range_expiry() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("GET"))
.and(path("/copilot_internal/v2/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "token": "copilot-tok", "expires_at": i64::MAX }),
))
.mount(&server)
.await;
let err = exchange_copilot_token(&mock_cfg(host, host), "gho_github_tok")
.await
.unwrap_err();
assert!(
format!("{err}").contains("out-of-range expires_at"),
"got {err}"
);
}
}
@@ -130,8 +130,9 @@ fn with_device_headers(
}
/// Defend against control characters / non-https redirects from a
/// compromised or mis-configured OAuth host.
fn validate_verification_uri(uri: &str) -> anyhow::Result<()> {
/// compromised or mis-configured OAuth host. Shared with the generic
/// device-code wire ([`super::oauth_device`]).
pub(crate) fn validate_verification_uri(uri: &str) -> anyhow::Result<()> {
if uri.chars().any(|c| c.is_ascii_control()) {
anyhow::bail!("Server returned invalid verification URI");
}
@@ -12,6 +12,7 @@ use tokio_util::sync::CancellationToken;
#[path = "manager/lock.rs"]
mod lock;
pub(crate) use lock::try_lock_auth_file_nonblocking;
#[path = "manager/sleep_gate.rs"]
mod sleep_gate;
@@ -384,6 +385,59 @@ impl AuthManager {
)
}
/// Build a manager for a GENERIC device-code OAuth provider (xai-grok),
/// scoped to `oauth.scope_key`. Unlike [`Self::new`] this path is
/// file-store only (no keyring — that is gated to the default Kimi install)
/// and ignores the Kimi-specific `KIGI_AUTH` inline-credential env; it
/// otherwise shares the same multi-scope `auth.json` (honoring
/// `KIGI_AUTH_PATH`). The refresher is selected from the scope by
/// [`super::refresh::build_refresher`].
pub(crate) fn new_oauth_provider(
kigi_home: &Path,
oauth: &'static kigi_models::OAuthConfig,
) -> Self {
let scope = oauth.scope_key.to_owned();
let path = std::env::var("KIGI_AUTH_PATH")
.map(PathBuf::from)
.unwrap_or_else(|_| kigi_home.join("auth.json"));
let (auth, disk_state) = match read_auth_json(&path) {
Ok(map) => {
let found = lookup_auth(&map, &scope);
let state = if found.is_some() {
DiskAuthState::Ok
} else {
DiskAuthState::EntryMissing
};
(found, state)
}
Err(e) => {
let state = if e.kind() == std::io::ErrorKind::NotFound {
DiskAuthState::FileMissing
} else {
DiskAuthState::Unreadable
};
(None, state)
}
};
kigi_log::unified_log::info(
"AuthManager::new_oauth_provider",
None,
Some(serde_json::json!({
"scope": &scope,
"found": auth.is_some(),
"is_expired": auth.as_ref().map(is_expired),
})),
);
Self::assemble(
auth,
path,
scope,
KimiCodeConfig::default(),
Some(disk_state),
)
}
/// Single field-assembly point for [`Self::new`]'s two construction paths
/// (inline `KIGI_AUTH` vs. on-disk `auth.json`), which differ only in the
/// threaded fields. One literal means a newly added field can't be silently
@@ -788,6 +842,13 @@ impl AuthManager {
&self.kimi_code_config
}
/// The auth.json / keyring scope key this manager persists under
/// (`oauth/kimi-code` for Kimi, `oauth/xai` for xai-grok, …). Drives the
/// refresher selection in [`super::refresh::build_refresher`].
pub(crate) fn scope(&self) -> &str {
&self.scope
}
/// Handle notified after every successful token refresh.
///
/// Used by [`ModelsManager`] to trigger model catalog recovery
+9 -2
View File
@@ -1,13 +1,18 @@
pub(crate) mod attribution;
mod config;
pub(crate) mod credential_authority;
pub mod credential_provider;
pub(crate) mod device;
pub mod device_code;
pub mod error;
mod flow;
pub(crate) mod github_copilot;
pub(crate) mod kimi_oauth;
pub(crate) mod manager;
mod model;
pub(crate) mod oauth_device;
pub(crate) mod oauth_pkce;
pub(crate) mod oauth_registry;
pub(crate) mod recovery;
pub(crate) mod refresh;
mod storage;
@@ -17,7 +22,8 @@ pub(crate) use flow::try_ensure_session_noninteractive;
pub use flow::{
AuthChannels, AuthUrlInfo, AuthUrlMode, LogoutResult, ensure_authenticated,
ensure_authenticated_or_noninteractive, perform_logout, run_auth_flow,
run_auth_flow_with_stderr_bridge, run_cli_login, run_cli_logout, try_ensure_fresh_auth,
run_auth_flow_with_stderr_bridge, run_cli_login, run_cli_logout, run_oauth_provider_flow,
try_ensure_fresh_auth,
};
mod meta;
pub use device::device_headers;
@@ -27,5 +33,6 @@ pub use meta::AuthMeta;
pub use model::{AuthMode, KimiAuth, lookup_auth};
pub(crate) use model::{TOKEN_TTL, is_expired, token_suffix};
pub use storage::{
clear_api_key, read_api_key, read_auth_json, read_token_by_scope, store_api_key,
clear_api_key, read_api_key, read_auth_json, read_platform_api_key, read_token_by_scope,
store_api_key, store_platform_api_key,
};
@@ -0,0 +1,423 @@
//! Generic RFC-8628 device-code OAuth wire, driven by a registry
//! [`kigi_models::OAuthConfig`] (xai-grok today; Copilot/Claude later).
//!
//! Three `application/x-www-form-urlencoded` POSTs against `{auth_host}`:
//!
//! - `POST {device_path}` — form `client_id` + `scope` + the optional
//! `extra_device_field` (e.g. `referrer=kigi`)
//! - `POST {token_path}` (poll) — form `client_id` + `device_code` +
//! `grant_type=urn:ietf:params:oauth:grant-type:device_code`
//! - `POST {token_path}` (refresh) — form `client_id` +
//! `grant_type=refresh_token` + `refresh_token`, with the same exponential
//! backoff / status handling as the Kimi wire.
//!
//! Unlike [`super::kimi_oauth`] this sends NO X-Msh device headers — just the
//! shared kigi `User-Agent` and `Accept: application/json`. Access/refresh
//! tokens are NEVER logged (only non-secret events: requested, poll succeeded,
//! refreshed).
use kigi_models::OAuthConfig;
use serde::Deserialize;
use super::kimi_oauth::{
DeviceAuthorization, DevicePollResult, RefreshError, TokenResponse, validate_verification_uri,
};
const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
const REFRESH_GRANT_TYPE: &str = "refresh_token";
/// Refresh retry budget over the retryable statuses / network blips.
const MAX_REFRESH_RETRIES: u32 = 3;
/// HTTP statuses worth retrying a refresh for (kimi-cli parity).
const RETRYABLE_REFRESH_STATUSES: [u16; 5] = [429, 500, 502, 503, 504];
#[derive(Deserialize)]
struct DeviceAuthorizationResponse {
user_code: String,
device_code: String,
#[serde(default)]
verification_uri: Option<String>,
/// Optional here (the Kimi wire requires it): Pi's xAI response may omit
/// `verification_uri_complete` and carry only `verification_uri`.
#[serde(default)]
verification_uri_complete: Option<String>,
#[serde(default)]
expires_in: Option<i64>,
#[serde(default)]
interval: Option<i64>,
}
#[derive(Deserialize, Default)]
struct OAuthErrorBody {
#[serde(default)]
error: Option<String>,
#[serde(default)]
error_description: Option<String>,
}
fn oauth_url(host: &str, path: &str) -> String {
format!("{}{path}", host.trim_end_matches('/'))
}
/// The device-authorization form fields: `client_id`, `scope`, and the
/// optional non-standard `extra_device_field`.
fn device_form(cfg: &OAuthConfig) -> Vec<(&'static str, &'static str)> {
let mut form = vec![("client_id", cfg.client_id), ("scope", cfg.scope)];
if let Some((name, value)) = cfg.extra_device_field {
form.push((name, value));
}
form
}
/// `POST {auth_host}{device_path}` — start a device login.
pub(crate) async fn request_device_authorization(
cfg: &OAuthConfig,
) -> anyhow::Result<DeviceAuthorization> {
let url = oauth_url(cfg.auth_host, cfg.device_path);
tracing::info!(url = %url, "auth: requesting device authorization (generic oauth)");
let resp = crate::http::shared_client()
.post(&url)
.header("Accept", "application/json")
.form(&device_form(cfg))
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
tracing::warn!(%status, "auth: device authorization failed (generic oauth)");
anyhow::bail!("Device authorization failed (HTTP {status}): {body}");
}
let parsed: DeviceAuthorizationResponse = resp.json().await?;
if !parsed
.user_code
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-')
{
anyhow::bail!("Server returned invalid user_code format (expected [A-Z0-9-])");
}
// Pi forces the displayed URI to https; we require a valid https (or
// localhost) verification target, preferring the pre-filled complete form.
let verification_uri_complete = parsed
.verification_uri_complete
.clone()
.or_else(|| parsed.verification_uri.clone())
.ok_or_else(|| anyhow::anyhow!("Server returned no verification URI"))?;
validate_verification_uri(&verification_uri_complete)?;
if let Some(ref uri) = parsed.verification_uri {
validate_verification_uri(uri)?;
}
tracing::info!(
user_code = %parsed.user_code,
interval = parsed.interval.unwrap_or(5),
expires_in = ?parsed.expires_in,
"auth: device authorization issued (generic oauth)"
);
Ok(DeviceAuthorization {
user_code: parsed.user_code,
device_code: parsed.device_code,
verification_uri: parsed.verification_uri.filter(|u| !u.is_empty()),
verification_uri_complete,
expires_in: parsed.expires_in.filter(|&e| e > 0),
interval: parsed.interval.unwrap_or(5),
})
}
/// One poll of `POST {auth_host}{token_path}` with the device grant.
pub(crate) async fn poll_device_token(
cfg: &OAuthConfig,
device_code: &str,
) -> anyhow::Result<DevicePollResult> {
let url = oauth_url(cfg.auth_host, cfg.token_path);
let resp = crate::http::shared_client()
.post(&url)
.header("Accept", "application/json")
.form(&[
("client_id", cfg.client_id),
("device_code", device_code),
("grant_type", DEVICE_GRANT_TYPE),
])
.send()
.await
.map_err(|e| anyhow::anyhow!("Token polling request failed: {e}"))?;
let status = resp.status();
if status.is_server_error() {
anyhow::bail!("Token polling server error: {status}");
}
let body = resp.bytes().await?;
if status.is_success() {
if let Ok(tokens) = serde_json::from_slice::<TokenResponse>(&body) {
tracing::info!("auth: device poll succeeded, access token issued (generic oauth)");
return Ok(DevicePollResult::Success(Box::new(tokens.into_auth())));
}
tracing::warn!(
"auth: device poll returned 200 without access_token; continuing (generic oauth)"
);
return Ok(DevicePollResult::Pending {
error: "missing_access_token".to_owned(),
description: None,
});
}
let err: OAuthErrorBody = serde_json::from_slice(&body).unwrap_or_default();
let error = err.error.unwrap_or_else(|| "unknown_error".to_owned());
if error == "expired_token" {
tracing::info!(
"auth: device code expired; restarting device authorization (generic oauth)"
);
return Ok(DevicePollResult::Expired);
}
tracing::debug!(error = %error, "auth: device poll pending (generic oauth)");
Ok(DevicePollResult::Pending {
error,
description: err.error_description,
})
}
/// `POST {auth_host}{token_path}` with `grant_type=refresh_token`. Retries the
/// retryable statuses / network errors with exponential backoff; 401/403
/// returns immediately as [`RefreshError::Unauthorized`].
pub(crate) async fn refresh_token(
cfg: &OAuthConfig,
refresh_token: &str,
) -> Result<super::model::KimiAuth, RefreshError> {
let url = oauth_url(cfg.auth_host, cfg.token_path);
let mut last_error = String::from("no attempt made");
for attempt in 0..MAX_REFRESH_RETRIES {
if attempt > 0 {
let backoff = std::time::Duration::from_secs(1 << (attempt - 1));
tracing::warn!(
attempt,
backoff_secs = backoff.as_secs(),
last_error = %last_error,
"auth: retrying token refresh (generic oauth)"
);
tokio::time::sleep(backoff).await;
}
tracing::info!(attempt, "auth: token refresh attempt (generic oauth)");
let send_result = crate::http::shared_client()
.post(&url)
.header("Accept", "application/json")
.form(&[
("client_id", cfg.client_id),
("grant_type", REFRESH_GRANT_TYPE),
("refresh_token", refresh_token),
])
.send()
.await;
let resp = match send_result {
Ok(resp) => resp,
Err(e) => {
last_error = format!("network error: {e}");
continue;
}
};
let status = resp.status().as_u16();
let body = resp.bytes().await.unwrap_or_default();
if status == 401 || status == 403 {
let err: OAuthErrorBody = serde_json::from_slice(&body).unwrap_or_default();
return Err(RefreshError::Unauthorized {
status,
description: err
.error_description
.unwrap_or_else(|| "Token refresh unauthorized.".to_owned()),
});
}
if status == 200 {
return match serde_json::from_slice::<TokenResponse>(&body) {
Ok(tokens) => Ok(tokens.into_auth()),
Err(e) => Err(RefreshError::Fatal {
status,
description: format!("malformed token payload: {e}"),
}),
};
}
let err: OAuthErrorBody = serde_json::from_slice(&body).unwrap_or_default();
let description = err
.error_description
.unwrap_or_else(|| format!("Token refresh failed (HTTP {status})."));
if RETRYABLE_REFRESH_STATUSES.contains(&status) {
last_error = description;
continue;
}
return Err(RefreshError::Fatal {
status,
description,
});
}
Err(RefreshError::Exhausted { last_error })
}
#[cfg(test)]
mod tests {
use super::*;
use kigi_models::XAI_OAUTH_CONFIG;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// An OAuthConfig pointed at a mock server (copies XAI's client_id/scope/
/// paths but overrides the host).
fn mock_cfg(host: &'static str) -> OAuthConfig {
OAuthConfig {
auth_host: host,
..XAI_OAUTH_CONFIG
}
}
fn token_json(access: &str, refresh: &str) -> serde_json::Value {
serde_json::json!({
"access_token": access,
"refresh_token": refresh,
"expires_in": 3600,
"scope": "grok-cli:access",
"token_type": "bearer",
})
}
#[tokio::test]
async fn device_authorization_sends_client_scope_and_referrer() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/oauth2/device/code"))
.and(body_string_contains(
"client_id=b1a00492-073a-47ea-816f-4c329264a828",
))
.and(body_string_contains("scope=openid"))
.and(body_string_contains("referrer=kigi"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"user_code": "GROK-1234",
"device_code": "dev-xai-1",
"verification_uri": "https://x.ai/device",
"verification_uri_complete": "https://x.ai/device?user_code=GROK-1234",
"expires_in": 900,
"interval": 5,
})))
.expect(1)
.mount(&server)
.await;
let auth = request_device_authorization(&mock_cfg(host)).await.unwrap();
assert_eq!(auth.user_code, "GROK-1234");
assert_eq!(auth.device_code, "dev-xai-1");
assert_eq!(
auth.verification_uri_complete,
"https://x.ai/device?user_code=GROK-1234"
);
assert_eq!(auth.expires_in, Some(900));
}
/// A response with only `verification_uri` (no `_complete`) still yields a
/// valid display URI.
#[tokio::test]
async fn device_authorization_falls_back_to_verification_uri() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/oauth2/device/code"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"user_code": "GROK-9",
"device_code": "d",
"verification_uri": "https://x.ai/device",
})))
.mount(&server)
.await;
let auth = request_device_authorization(&mock_cfg(host)).await.unwrap();
assert_eq!(auth.verification_uri_complete, "https://x.ai/device");
assert_eq!(auth.interval, 5, "default interval");
}
#[tokio::test]
async fn poll_success_builds_auth_with_expiry() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/oauth2/token"))
.and(body_string_contains("grant_type=urn"))
.and(body_string_contains("device_code=dev-xai-1"))
.respond_with(
ResponseTemplate::new(200).set_body_json(token_json("grok-at", "grok-rt")),
)
.expect(1)
.mount(&server)
.await;
let result = poll_device_token(&mock_cfg(host), "dev-xai-1")
.await
.unwrap();
let DevicePollResult::Success(auth) = result else {
panic!("expected success, got {result:?}");
};
assert_eq!(auth.key, "grok-at");
assert_eq!(auth.refresh_token.as_deref(), Some("grok-rt"));
assert_eq!(auth.expires_in, Some(3600));
}
#[tokio::test]
async fn poll_maps_authorization_pending_to_pending() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/oauth2/token"))
.respond_with(
ResponseTemplate::new(400)
.set_body_json(serde_json::json!({ "error": "authorization_pending" })),
)
.mount(&server)
.await;
let result = poll_device_token(&mock_cfg(host), "dev-xai-1")
.await
.unwrap();
match result {
DevicePollResult::Pending { error, .. } => assert_eq!(error, "authorization_pending"),
other => panic!("expected pending, got {other:?}"),
}
}
#[tokio::test]
async fn refresh_success_round_trip() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/oauth2/token"))
.and(body_string_contains("grant_type=refresh_token"))
.and(body_string_contains("refresh_token=grok-rt-old"))
.respond_with(
ResponseTemplate::new(200).set_body_json(token_json("grok-at-new", "grok-rt-new")),
)
.expect(1)
.mount(&server)
.await;
let auth = refresh_token(&mock_cfg(host), "grok-rt-old").await.unwrap();
assert_eq!(auth.key, "grok-at-new");
assert_eq!(auth.refresh_token.as_deref(), Some("grok-rt-new"));
}
#[tokio::test]
async fn refresh_401_maps_to_unauthorized() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/oauth2/token"))
.respond_with(
ResponseTemplate::new(401)
.set_body_json(serde_json::json!({ "error_description": "refresh revoked" })),
)
.expect(1)
.mount(&server)
.await;
let err = refresh_token(&mock_cfg(host), "grok-rt-dead")
.await
.unwrap_err();
match err {
RefreshError::Unauthorized {
status,
description,
} => {
assert_eq!(status, 401);
assert_eq!(description, "refresh revoked");
}
other => panic!("expected Unauthorized, got {other:?}"),
}
}
}
@@ -0,0 +1,904 @@
//! Generic authorization-code + PKCE (S256) OAuth wire with a `127.0.0.1`
//! loopback callback, driven by a registry [`kigi_models::OAuthConfig`] whose
//! `flow` is [`OAuthFlow::PkceLocalhost`] (claude-pro-max JSON + openai-codex
//! FORM).
//!
//! Shape (Pi `earendil-works/pi` `auth/oauth/{anthropic,openai-codex}.ts`):
//! - `verifier = base64url(32 random bytes)`; `challenge = base64url(SHA-256(
//! verifier))`. `state` is `verifier` for claude ([`generate_pkce`]) or a
//! fresh-random value for codex ([`generate_pkce_random_state`]).
//! - Browser opens `{auth_host}{device_path}?client_id&response_type=code&
//! scope&redirect_uri&state&code_challenge&code_challenge_method=S256` plus any
//! `authorize_extra` params (codex only).
//! - The code returns to a loopback listener on `127.0.0.1:{redirect_port}`
//! answering ONLY `{redirect_path}` (claude `/callback`, codex
//! `/auth/callback`), with STRICT `state` validation (a mismatch is rejected —
//! CSRF guard). A manual paste (redirect URL / `code#state` / bare code) is
//! accepted as a headless fallback.
//! - Code → token exchange POSTs `{token_host}{token_path}` as JSON
//! ([`exchange_code`], claude) or FORM ([`exchange_code_form`], codex; NO
//! `state` field). Refresh: claude JSON here ([`refresh_token`], rotating);
//! codex takes the generic device refresher's FORM path.
//!
//! SECURITY: the verifier, authorization code, access token, and refresh token
//! are NEVER logged (only non-secret events: authorize URL requested, callback
//! received, token issued, token refreshed).
use anyhow::Context;
use base64::Engine;
use kigi_models::{OAuthConfig, OAuthTokenBody};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use super::kimi_oauth::{RefreshError, TokenResponse};
use super::model::KimiAuth;
const CODE_GRANT_TYPE: &str = "authorization_code";
const REFRESH_GRANT_TYPE: &str = "refresh_token";
/// Refresh retry budget over the retryable statuses / network blips.
const MAX_REFRESH_RETRIES: u32 = 3;
/// HTTP statuses worth retrying a refresh for (parity with the device wire).
const RETRYABLE_REFRESH_STATUSES: [u16; 5] = [429, 500, 502, 503, 504];
/// PKCE secrets for one login attempt. Two `state` conventions ship:
/// [`generate_pkce`] sets `state == verifier` (Pi's/Claude's convention), while
/// [`generate_pkce_random_state`] mints an INDEPENDENT random state (the OAuth
/// standard, used by ChatGPT/Codex). Either way the state is validated on the
/// callback as the CSRF guard.
#[derive(Debug, Clone)]
pub(crate) struct PkceCodes {
/// `code_verifier` — the 43-char base64url secret, sent at token exchange.
pub verifier: String,
/// `code_challenge = base64url(SHA-256(verifier))`, sent at authorize.
pub challenge: String,
/// `state` — the verifier itself, or an independent random value depending
/// on the provider's dialect; validated on the callback (CSRF guard).
pub state: String,
}
/// Generate PKCE S256 codes: `verifier = base64url(32 random bytes)`,
/// `challenge = base64url(SHA-256(verifier))`, `state = verifier`.
pub(crate) fn generate_pkce() -> PkceCodes {
use rand::RngCore;
let mut raw = [0u8; 32];
rand::rng().fill_bytes(&mut raw);
let verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
let digest = Sha256::digest(verifier.as_bytes());
let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
PkceCodes {
state: verifier.clone(),
verifier,
challenge,
}
}
/// Like [`generate_pkce`] but with an INDEPENDENT fresh-random `state` (16
/// random bytes) instead of `state == verifier`. The ChatGPT/Codex flow uses a
/// distinct state (the verifier never doubles as the CSRF token there), so the
/// verifier stays out of the state carried on the loopback callback.
pub(crate) fn generate_pkce_random_state() -> PkceCodes {
use rand::RngCore;
let mut raw = [0u8; 16];
rand::rng().fill_bytes(&mut raw);
let state = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
PkceCodes {
state,
..generate_pkce()
}
}
/// The loopback redirect URI for a PKCE-localhost provider (claude `/callback`,
/// codex `/auth/callback`).
pub(crate) fn redirect_uri(redirect_port: u16, redirect_path: &str) -> String {
format!("http://localhost:{redirect_port}{redirect_path}")
}
/// Build the browser authorize URL:
/// `{auth_host}{device_path}?client_id&response_type=code&scope&redirect_uri&
/// state&code_challenge&code_challenge_method=S256` plus any config
/// `authorize_extra` params (empty for every config but codex, so their URLs
/// stay byte-identical).
pub(crate) fn build_authorize_url(
cfg: &OAuthConfig,
redirect_uri: &str,
pkce: &PkceCodes,
) -> String {
let base = format!("{}{}", cfg.auth_host.trim_end_matches('/'), cfg.device_path);
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
serializer
.append_pair("client_id", cfg.client_id)
.append_pair("response_type", "code")
.append_pair("scope", cfg.scope)
.append_pair("redirect_uri", redirect_uri)
.append_pair("state", &pkce.state)
.append_pair("code_challenge", &pkce.challenge)
.append_pair("code_challenge_method", "S256");
for (key, value) in cfg.authorize_extra {
serializer.append_pair(key, value);
}
format!("{base}?{}", serializer.finish())
}
/// `code` + `state` extracted from a callback (loopback query OR manual paste).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CallbackParams {
pub code: String,
pub state: Option<String>,
}
/// Parse `code`/`state` from the raw query string of a `/callback?…` request
/// (e.g. `code=abc&state=xyz`). An `error=` param surfaces as an `Err`.
pub(crate) fn parse_callback_query(query: &str) -> anyhow::Result<CallbackParams> {
let mut code = None;
let mut state = None;
let mut error = None;
for (k, v) in url::form_urlencoded::parse(query.as_bytes()) {
match k.as_ref() {
"code" => code = Some(v.into_owned()),
"state" => state = Some(v.into_owned()),
"error" => error = Some(v.into_owned()),
_ => {}
}
}
if let Some(error) = error {
anyhow::bail!("Authorization server returned an error: {error}");
}
let code = code.context("callback missing authorization code")?;
if code.is_empty() {
anyhow::bail!("callback authorization code was empty");
}
Ok(CallbackParams { code, state })
}
/// Parse a MANUAL paste (headless fallback). Accepts, in order:
/// - a full redirect URL (`http://localhost:…/callback?code=…&state=…`),
/// - a `code#state` pair (Anthropic's console shows this form),
/// - a bare `code` (state then unknown → `None`, caller validation applies).
pub(crate) fn parse_manual_paste(input: &str) -> anyhow::Result<CallbackParams> {
let trimmed = input.trim();
if trimmed.is_empty() {
anyhow::bail!("empty paste");
}
// Full redirect URL.
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
let url = url::Url::parse(trimmed).context("pasted value is not a valid URL")?;
return parse_callback_query(url.query().unwrap_or_default());
}
// `code#state`.
if let Some((code, state)) = trimmed.split_once('#') {
if code.is_empty() {
anyhow::bail!("pasted code was empty");
}
return Ok(CallbackParams {
code: code.to_owned(),
state: (!state.is_empty()).then(|| state.to_owned()),
});
}
// Bare code.
Ok(CallbackParams {
code: trimmed.to_owned(),
state: None,
})
}
/// STRICT state validation (CSRF guard): the callback `state` MUST be present
/// AND equal to the expected value. A mismatch (or absence, when a paste has no
/// state) is rejected — the flow NEVER proceeds on an unverified callback.
pub(crate) fn validate_state(params: &CallbackParams, expected_state: &str) -> anyhow::Result<()> {
match params.state.as_deref() {
Some(state) if state == expected_state => Ok(()),
Some(_) => anyhow::bail!("OAuth state mismatch — rejecting callback (CSRF guard)"),
None => anyhow::bail!("OAuth callback carried no state — rejecting (CSRF guard)"),
}
}
/// State validation for a MANUAL paste (headless fallback): a present state
/// MUST match (mismatch rejected — CSRF guard), but an ABSENT state is allowed
/// — a bare-code paste is user-initiated (not a network-reachable callback), so
/// there is no state to check. The loopback path uses the stricter
/// [`validate_state`] (an absent state there IS rejected).
pub(crate) fn validate_pasted_state(
params: &CallbackParams,
expected_state: &str,
) -> anyhow::Result<()> {
match params.state.as_deref() {
Some(state) if state == expected_state => Ok(()),
Some(_) => anyhow::bail!("OAuth state mismatch — rejecting pasted code (CSRF guard)"),
None => Ok(()),
}
}
/// The token-endpoint URL (`{token_host}{token_path}`).
fn token_url(cfg: &OAuthConfig) -> String {
format!("{}{}", cfg.token_host.trim_end_matches('/'), cfg.token_path)
}
/// POST the token endpoint with a JSON body, honoring `cfg.token_body`. Claude
/// is JSON; a `Form`-bodied config would be handled by the device wire, so the
/// PKCE path asserts JSON (never silently mis-encodes).
async fn post_token_json(
cfg: &OAuthConfig,
body: serde_json::Value,
) -> reqwest::Result<reqwest::Response> {
debug_assert!(
matches!(cfg.token_body, OAuthTokenBody::Json),
"PKCE token exchange expects a JSON token body"
);
crate::http::shared_client()
.post(token_url(cfg))
.header("Accept", "application/json")
.json(&body)
.send()
.await
}
/// Exchange an authorization `code` for a token set (JSON body):
/// `{grant_type:"authorization_code", code, state, client_id, redirect_uri,
/// code_verifier}`. Returns the materialized [`KimiAuth`].
pub(crate) async fn exchange_code(
cfg: &OAuthConfig,
code: &str,
pkce: &PkceCodes,
redirect_uri: &str,
) -> anyhow::Result<KimiAuth> {
let body = serde_json::json!({
"grant_type": CODE_GRANT_TYPE,
"code": code,
"state": pkce.state,
"client_id": cfg.client_id,
"redirect_uri": redirect_uri,
"code_verifier": pkce.verifier,
});
tracing::info!(
scope_key = cfg.scope_key,
"auth: exchanging code for token (pkce)"
);
let resp = post_token_json(cfg, body)
.await
.context("token exchange request failed")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
tracing::warn!(%status, scope_key = cfg.scope_key, "auth: code exchange failed (pkce)");
anyhow::bail!("Token exchange failed (HTTP {status}): {body}");
}
let tokens: TokenResponse = resp.json().await.context("malformed token payload")?;
tracing::info!(
scope_key = cfg.scope_key,
"auth: pkce code exchange succeeded"
);
Ok(tokens.into_auth())
}
/// Exchange an authorization `code` for a token set with a FORM body (codex):
/// `{grant_type=authorization_code, client_id, code, code_verifier,
/// redirect_uri}`. Unlike [`exchange_code`], the `state` is NOT sent in the
/// token body (the ChatGPT/Codex token endpoint does not expect it). Asserts a
/// `Form` config so a JSON provider can never silently mis-encode.
pub(crate) async fn exchange_code_form(
cfg: &OAuthConfig,
code: &str,
pkce: &PkceCodes,
redirect_uri: &str,
) -> anyhow::Result<KimiAuth> {
debug_assert!(
matches!(cfg.token_body, OAuthTokenBody::Form),
"PKCE form exchange expects a Form token body"
);
tracing::info!(
scope_key = cfg.scope_key,
"auth: exchanging code for token (pkce form)"
);
let resp = crate::http::shared_client()
.post(token_url(cfg))
.header("Accept", "application/json")
.form(&[
("grant_type", CODE_GRANT_TYPE),
("client_id", cfg.client_id),
("code", code),
("code_verifier", pkce.verifier.as_str()),
("redirect_uri", redirect_uri),
])
.send()
.await
.context("token exchange request failed")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
tracing::warn!(%status, scope_key = cfg.scope_key, "auth: code exchange failed (pkce form)");
anyhow::bail!("Token exchange failed (HTTP {status}): {body}");
}
let tokens: TokenResponse = resp.json().await.context("malformed token payload")?;
tracing::info!(
scope_key = cfg.scope_key,
"auth: pkce form code exchange succeeded"
);
Ok(tokens.into_auth())
}
/// `POST {token_host}{token_path}` with `grant_type=refresh_token` (JSON body).
/// Claude ROTATES the refresh token, so the caller MUST persist the returned
/// one. Retries the retryable statuses / network errors with exponential
/// backoff; 401/403 returns immediately as [`RefreshError::Unauthorized`].
pub(crate) async fn refresh_token(
cfg: &OAuthConfig,
refresh_token: &str,
) -> Result<KimiAuth, RefreshError> {
let mut last_error = String::from("no attempt made");
for attempt in 0..MAX_REFRESH_RETRIES {
if attempt > 0 {
let backoff = std::time::Duration::from_secs(1 << (attempt - 1));
tracing::warn!(
attempt,
backoff_secs = backoff.as_secs(),
last_error = %last_error,
"auth: retrying token refresh (pkce)"
);
tokio::time::sleep(backoff).await;
}
tracing::info!(
attempt,
scope_key = cfg.scope_key,
"auth: token refresh attempt (pkce)"
);
let body = serde_json::json!({
"grant_type": REFRESH_GRANT_TYPE,
"client_id": cfg.client_id,
"refresh_token": refresh_token,
});
let resp = match post_token_json(cfg, body).await {
Ok(resp) => resp,
Err(e) => {
last_error = format!("network error: {e}");
continue;
}
};
let status = resp.status().as_u16();
let bytes = resp.bytes().await.unwrap_or_default();
if status == 401 || status == 403 {
let err: OAuthErrorBody = serde_json::from_slice(&bytes).unwrap_or_default();
return Err(RefreshError::Unauthorized {
status,
description: err
.error_description
.unwrap_or_else(|| "Token refresh unauthorized.".to_owned()),
});
}
if status == 200 {
return match serde_json::from_slice::<TokenResponse>(&bytes) {
Ok(tokens) => Ok(tokens.into_auth()),
Err(e) => Err(RefreshError::Fatal {
status,
description: format!("malformed token payload: {e}"),
}),
};
}
let err: OAuthErrorBody = serde_json::from_slice(&bytes).unwrap_or_default();
let description = err
.error_description
.unwrap_or_else(|| format!("Token refresh failed (HTTP {status})."));
if RETRYABLE_REFRESH_STATUSES.contains(&status) {
last_error = description;
continue;
}
return Err(RefreshError::Fatal {
status,
description,
});
}
Err(RefreshError::Exhausted { last_error })
}
#[derive(Deserialize, Default)]
struct OAuthErrorBody {
#[serde(default)]
error_description: Option<String>,
}
/// Bind a loopback HTTP listener on `127.0.0.1:{redirect_port}` and wait for a
/// single `GET {redirect_path}?code=…&state=…`, validating `state` STRICTLY
/// against `expected_state` (mismatch → rejected). Returns the authorization
/// code.
///
/// The listener answers ONLY `redirect_path` (claude `/callback`, codex
/// `/auth/callback`); any other path gets 404. It binds `127.0.0.1` (never
/// `0.0.0.0`), so no non-loopback host can reach it.
pub(crate) async fn await_loopback_code(
redirect_port: u16,
redirect_path: &str,
expected_state: &str,
) -> anyhow::Result<String> {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", redirect_port))
.await
.with_context(|| format!("could not bind loopback 127.0.0.1:{redirect_port}"))?;
tracing::info!(port = redirect_port, "auth: pkce loopback listener bound");
loop {
let (stream, _peer) = listener.accept().await.context("loopback accept failed")?;
match handle_loopback_conn(stream, redirect_path, expected_state).await {
LoopbackOutcome::Code(code) => return Ok(code),
LoopbackOutcome::Rejected(err) => return Err(err),
// Not the callback GET (favicon, health probe): keep listening.
LoopbackOutcome::Ignore => continue,
}
}
}
enum LoopbackOutcome {
Code(String),
Rejected(anyhow::Error),
Ignore,
}
/// Read the request line of one loopback connection, answer with a small HTML
/// page, and classify the outcome. STRICT: a `/callback` with a bad/missing
/// state is [`LoopbackOutcome::Rejected`] (the browser sees an error page).
async fn handle_loopback_conn(
mut stream: tokio::net::TcpStream,
redirect_path: &str,
expected_state: &str,
) -> LoopbackOutcome {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
// Read only enough for the request line — a GET has no body.
let mut buf = [0u8; 8192];
let n = match stream.read(&mut buf).await {
Ok(0) => return LoopbackOutcome::Ignore,
Ok(n) => n,
Err(_) => return LoopbackOutcome::Ignore,
};
let head = String::from_utf8_lossy(&buf[..n]);
let Some(request_line) = head.lines().next() else {
return LoopbackOutcome::Ignore;
};
// `GET {redirect_path}?code=…&state=… HTTP/1.1`
let mut parts = request_line.split_whitespace();
let (Some(method), Some(target)) = (parts.next(), parts.next()) else {
return LoopbackOutcome::Ignore;
};
if method != "GET" {
let _ = write_http(&mut stream, 405, "Method Not Allowed").await;
return LoopbackOutcome::Ignore;
}
let (path, query) = target.split_once('?').unwrap_or((target, ""));
if path != redirect_path {
let _ = write_http(&mut stream, 404, "Not Found").await;
return LoopbackOutcome::Ignore;
}
let result = parse_callback_query(query)
.and_then(|params| validate_state(&params, expected_state).map(|()| params.code));
match result {
Ok(code) => {
let _ = write_http(
&mut stream,
200,
"Signed in. You can close this window and return to kigi.",
)
.await;
let _ = stream.flush().await;
LoopbackOutcome::Code(code)
}
Err(e) => {
let _ = write_http(&mut stream, 400, "Login failed — return to kigi and retry.").await;
let _ = stream.flush().await;
LoopbackOutcome::Rejected(e)
}
}
}
/// Write a minimal HTTP/1.1 response with an HTML body.
async fn write_http(
stream: &mut tokio::net::TcpStream,
status: u16,
message: &str,
) -> std::io::Result<()> {
use tokio::io::AsyncWriteExt;
let reason = match status {
200 => "OK",
400 => "Bad Request",
404 => "Not Found",
405 => "Method Not Allowed",
_ => "Error",
};
let body = format!("<!doctype html><meta charset=utf-8><p>{message}</p>");
let response = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: text/html; charset=utf-8\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(response.as_bytes()).await
}
#[cfg(test)]
mod tests {
use super::*;
use kigi_models::CLAUDE_OAUTH_CONFIG;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// A config pointed at a mock token host (copies Claude's client_id/scope/
/// paths but overrides the token host).
fn mock_cfg(token_host: &'static str) -> OAuthConfig {
OAuthConfig {
token_host,
..CLAUDE_OAUTH_CONFIG
}
}
/// PKCE codes: verifier/challenge are non-empty base64url (no padding), the
/// challenge is the base64url SHA-256 of the verifier, and state == verifier.
#[test]
fn generate_pkce_produces_valid_s256_codes() {
let pkce = generate_pkce();
assert_eq!(pkce.state, pkce.verifier, "state must equal the verifier");
assert!(!pkce.verifier.is_empty() && !pkce.challenge.is_empty());
for s in [&pkce.verifier, &pkce.challenge] {
assert!(
s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
"base64url (no pad) only: {s}"
);
assert!(!s.contains('='), "no padding: {s}");
}
// challenge == base64url(SHA-256(verifier)).
let expect = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(Sha256::digest(pkce.verifier.as_bytes()));
assert_eq!(pkce.challenge, expect);
// Fresh entropy each call.
assert_ne!(pkce.verifier, generate_pkce().verifier);
}
/// The authorize URL carries the fixed params + the PKCE state and S256
/// challenge, and targets `claude.ai/oauth/authorize`.
#[test]
fn authorize_url_has_state_and_s256_challenge() {
let pkce = generate_pkce();
let redirect = redirect_uri(53692, "/callback");
let url = build_authorize_url(&CLAUDE_OAUTH_CONFIG, &redirect, &pkce);
let parsed = url::Url::parse(&url).expect("valid URL");
assert_eq!(parsed.host_str(), Some("claude.ai"));
assert_eq!(parsed.path(), "/oauth/authorize");
let q: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect();
assert_eq!(q.get("response_type").map(String::as_str), Some("code"));
assert_eq!(
q.get("code_challenge_method").map(String::as_str),
Some("S256")
);
assert_eq!(
q.get("state").map(String::as_str),
Some(pkce.state.as_str())
);
assert_eq!(
q.get("code_challenge").map(String::as_str),
Some(pkce.challenge.as_str())
);
assert_eq!(
q.get("client_id").map(String::as_str),
Some(CLAUDE_OAUTH_CONFIG.client_id)
);
assert_eq!(
q.get("redirect_uri").map(String::as_str),
Some(redirect.as_str())
);
// The verifier itself must NEVER appear in the browser URL.
assert!(
!url.contains("code_verifier"),
"the verifier must not ride the authorize URL"
);
}
/// STRICT state validation: an exact match passes; a mismatch or an absent
/// state is REJECTED (CSRF guard — the flow must never proceed).
#[test]
fn state_validation_is_strict() {
let ok = CallbackParams {
code: "c".into(),
state: Some("expected".into()),
};
assert!(validate_state(&ok, "expected").is_ok());
let mismatch = CallbackParams {
code: "c".into(),
state: Some("attacker".into()),
};
assert!(
validate_state(&mismatch, "expected").is_err(),
"a state mismatch MUST be rejected"
);
let missing = CallbackParams {
code: "c".into(),
state: None,
};
assert!(
validate_state(&missing, "expected").is_err(),
"an absent state MUST be rejected"
);
}
/// A loopback `/callback` with the WRONG state is rejected end-to-end (the
/// listener returns an error, never a code) — the CSRF guard on the wire.
#[tokio::test]
async fn loopback_rejects_state_mismatch() {
// Ephemeral port: bind, learn the port, then drive a client at it.
let probe = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let server =
tokio::spawn(
async move { await_loopback_code(port, "/callback", "the-real-state").await },
);
// Give the listener a moment to bind.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Attacker callback: valid code, WRONG state.
let _ = reqwest::get(format!(
"http://127.0.0.1:{port}/callback?code=stolen&state=wrong-state"
))
.await;
let outcome = server.await.unwrap();
let err = outcome.expect_err("a state mismatch must be rejected, never yield a code");
assert!(err.to_string().contains("state mismatch"), "{err}");
}
/// A loopback `/callback` with the MATCHING state yields the code.
#[tokio::test]
async fn loopback_returns_code_on_valid_state() {
let probe = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let server =
tokio::spawn(async move { await_loopback_code(port, "/callback", "good-state").await });
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let _ = reqwest::get(format!(
"http://127.0.0.1:{port}/callback?code=auth-code-123&state=good-state"
))
.await;
let code = server.await.unwrap().expect("valid state yields the code");
assert_eq!(code, "auth-code-123");
}
/// Manual-paste parsing: full redirect URL, `code#state`, and bare code.
#[test]
fn manual_paste_parses_all_three_forms() {
let from_url =
parse_manual_paste("http://localhost:53692/callback?code=abc123&state=st-9").unwrap();
assert_eq!(from_url.code, "abc123");
assert_eq!(from_url.state.as_deref(), Some("st-9"));
let from_hash = parse_manual_paste("abc123#st-9").unwrap();
assert_eq!(from_hash.code, "abc123");
assert_eq!(from_hash.state.as_deref(), Some("st-9"));
let bare = parse_manual_paste(" abc123 ").unwrap();
assert_eq!(bare.code, "abc123");
assert_eq!(bare.state, None);
assert!(parse_manual_paste("").is_err());
// A pasted redirect that carries an error param surfaces the error.
assert!(parse_manual_paste("http://localhost/callback?error=access_denied").is_err());
}
/// Code → token exchange: JSON body carries the grant + verifier, response
/// materializes a `KimiAuth` with the rotating refresh token.
#[tokio::test]
async fn exchange_code_posts_json_and_returns_auth() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/v1/oauth/token"))
.and(body_string_contains(
"\"grant_type\":\"authorization_code\"",
))
.and(body_string_contains("\"code\":\"auth-code-xyz\""))
.and(body_string_contains("\"code_verifier\""))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "sk-ant-oat-new",
"refresh_token": "sk-ant-ort-new",
"expires_in": 3600,
"token_type": "bearer",
})))
.expect(1)
.mount(&server)
.await;
let cfg = mock_cfg(host);
let pkce = generate_pkce();
let auth = exchange_code(
&cfg,
"auth-code-xyz",
&pkce,
&redirect_uri(53692, "/callback"),
)
.await
.unwrap();
assert_eq!(auth.key, "sk-ant-oat-new");
assert_eq!(auth.refresh_token.as_deref(), Some("sk-ant-ort-new"));
assert_eq!(auth.expires_in, Some(3600));
}
/// Refresh rotates the refresh token (JSON body, refresh grant).
#[tokio::test]
async fn refresh_rotates_refresh_token() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/v1/oauth/token"))
.and(body_string_contains("\"grant_type\":\"refresh_token\""))
.and(body_string_contains("\"refresh_token\":\"ort-old\""))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "oat-fresh",
"refresh_token": "ort-rotated",
"expires_in": 3600,
})))
.expect(1)
.mount(&server)
.await;
let auth = refresh_token(&mock_cfg(host), "ort-old").await.unwrap();
assert_eq!(auth.key, "oat-fresh");
assert_eq!(
auth.refresh_token.as_deref(),
Some("ort-rotated"),
"the rotated refresh token must be adopted"
);
}
/// A 401 on refresh maps to Unauthorized (drives the permanent-failure path).
#[tokio::test]
async fn refresh_401_maps_to_unauthorized() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/v1/oauth/token"))
.respond_with(
ResponseTemplate::new(401)
.set_body_json(serde_json::json!({ "error_description": "refresh revoked" })),
)
.expect(1)
.mount(&server)
.await;
let err = refresh_token(&mock_cfg(host), "ort-dead")
.await
.unwrap_err();
match err {
RefreshError::Unauthorized {
status,
description,
} => {
assert_eq!(status, 401);
assert_eq!(description, "refresh revoked");
}
other => panic!("expected Unauthorized, got {other:?}"),
}
}
// ── ChatGPT/Codex PKCE (openai-codex) ────────────────────────────────────
/// Codex PKCE uses an INDEPENDENT fresh-random state (NOT `state ==
/// verifier`) so the verifier never rides the callback.
#[test]
fn codex_pkce_state_is_independent_of_the_verifier() {
let pkce = generate_pkce_random_state();
assert_ne!(
pkce.state, pkce.verifier,
"codex state must be fresh-random, not the verifier"
);
assert!(!pkce.state.is_empty() && !pkce.verifier.is_empty());
// Challenge is still the S256 of the verifier.
let expect = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(Sha256::digest(pkce.verifier.as_bytes()));
assert_eq!(pkce.challenge, expect);
assert_ne!(
generate_pkce_random_state().state,
pkce.state,
"fresh state"
);
}
/// The codex authorize URL carries the PKCE state + S256 challenge AND the
/// three codex-only extra params, and targets `auth.openai.com/oauth/
/// authorize` with the `/auth/callback` redirect. The verifier never rides it.
#[test]
fn codex_authorize_url_has_state_challenge_and_three_extra_params() {
use kigi_models::CODEX_OAUTH_CONFIG;
let pkce = generate_pkce_random_state();
let redirect = redirect_uri(1455, "/auth/callback");
assert_eq!(redirect, "http://localhost:1455/auth/callback");
let url = build_authorize_url(&CODEX_OAUTH_CONFIG, &redirect, &pkce);
let parsed = url::Url::parse(&url).expect("valid URL");
assert_eq!(parsed.host_str(), Some("auth.openai.com"));
assert_eq!(parsed.path(), "/oauth/authorize");
let q: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect();
assert_eq!(
q.get("state").map(String::as_str),
Some(pkce.state.as_str())
);
assert_eq!(
q.get("code_challenge").map(String::as_str),
Some(pkce.challenge.as_str())
);
assert_eq!(
q.get("code_challenge_method").map(String::as_str),
Some("S256")
);
// The three codex-only extra params.
assert_eq!(
q.get("id_token_add_organizations").map(String::as_str),
Some("true")
);
assert_eq!(
q.get("codex_cli_simplified_flow").map(String::as_str),
Some("true")
);
assert_eq!(
q.get("originator").map(String::as_str),
Some("codex_cli_rs")
);
assert!(
!url.contains("code_verifier"),
"the verifier must not ride the authorize URL"
);
}
/// The claude authorize URL is UNCHANGED (no extra params) — its empty
/// `authorize_extra` keeps it byte-identical.
#[test]
fn claude_authorize_url_carries_no_extra_params() {
let pkce = generate_pkce();
let url = build_authorize_url(
&CLAUDE_OAUTH_CONFIG,
&redirect_uri(53692, "/callback"),
&pkce,
);
assert!(!url.contains("id_token_add_organizations"));
assert!(!url.contains("codex_cli_simplified_flow"));
assert!(!url.contains("originator"));
}
fn codex_mock_cfg(token_host: &'static str) -> OAuthConfig {
OAuthConfig {
token_host,
..kigi_models::CODEX_OAUTH_CONFIG
}
}
/// Codex code→token exchange posts a FORM body carrying the grant + code +
/// verifier + redirect_uri, and NOTABLY NO `state` field (the codex token
/// endpoint does not expect it). Response materializes a `KimiAuth`.
#[tokio::test]
async fn codex_exchange_code_posts_form_without_state() {
use wiremock::matchers::{body_string_contains, header, method, path};
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/oauth/token"))
.and(header("content-type", "application/x-www-form-urlencoded"))
.and(body_string_contains("grant_type=authorization_code"))
.and(body_string_contains("code=codex-auth-code"))
.and(body_string_contains("code_verifier="))
.and(body_string_contains("redirect_uri="))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "codex-access-jwt",
"refresh_token": "codex-refresh",
"expires_in": 3600,
})))
.expect(1)
.mount(&server)
.await;
let cfg = codex_mock_cfg(host);
let pkce = generate_pkce_random_state();
let auth = exchange_code_form(
&cfg,
"codex-auth-code",
&pkce,
"http://localhost:1455/auth/callback",
)
.await
.unwrap();
assert_eq!(auth.key, "codex-access-jwt");
assert_eq!(auth.refresh_token.as_deref(), Some("codex-refresh"));
assert_eq!(auth.expires_in, Some(3600));
}
}
@@ -0,0 +1,193 @@
//! Process-global per-provider OAuth `AuthManager` pool for INFERENCE-time auth.
//!
//! A session binds its primary (Kimi / first-party) [`AuthManager`] for the
//! subscription path, but a `uses_oauth` platform that carries an
//! [`kigi_models::OAuthConfig`] (xai-grok today) needs its OWN scope-keyed
//! manager for every per-turn decision — bearer resolution, proactive /
//! on-expiry refresh, and 401 recovery. Reusing the Kimi manager for a grok
//! turn would transmit the Kimi subscription bearer to `api.x.ai` (a
//! cross-provider leak, guaranteed 401) and, without proactive refresh, would
//! 401 every turn once the ~1h grok token expired until a process restart.
//!
//! The pool is the SINGLE SOURCE OF TRUTH: one long-lived `AuthManager` per
//! generic-oauth scope, each wired with the SAME lifecycle as the primary Kimi
//! manager (`configure_refresher()` + `start_proactive_refresh()`) so the
//! on-disk token stays fresh and a 401 recovers via the provider's own manager.
//! Managers are built ON DEMAND from the on-disk token ([`global_manager_for`]),
//! so a login landing AFTER a session spawned self-heals — no frozen per-session
//! snapshot.
//!
//! ROUTING LIVES ELSEWHERE. This module is only the pool; the decision of which
//! credential governs a request belongs to the single chokepoint,
//! [`crate::auth::credential_authority::CredentialAuthority`]. Keeping the two
//! apart is deliberate: three rounds of leaks came from routing rules being
//! re-derived per call site.
//!
//! SECURITY: access/refresh tokens and resolved bearers are NEVER logged here.
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, OnceLock};
use parking_lot::Mutex;
use crate::auth::AuthManager;
/// Process-wide pool of live per-scope OAuth managers.
///
/// Auth is process-global (one user), so a single manager per scope is correct
/// and lets the proactive-refresh task start exactly once per scope no matter
/// how many sessions spawn. Keyed by the OAuth `scope_key` (`oauth/xai`, …).
fn oauth_manager_pool() -> &'static Mutex<HashMap<&'static str, Arc<AuthManager>>> {
static POOL: OnceLock<Mutex<HashMap<&'static str, Arc<AuthManager>>>> = OnceLock::new();
POOL.get_or_init(|| Mutex::new(HashMap::new()))
}
/// The kigi home EVERY OAuth-provider construction in this crate resolves from
/// — the pool, the catalog fetch's per-platform token resolution, the
/// aux/summary token routing and the session's inference manager — so they can
/// never read different homes.
///
/// Production: [`crate::util::kigi_home::kigi_home`]. LIB TESTS: a
/// per-process path under the system temp dir that is deliberately **never
/// created**. The pool is process-global and every manager it builds starts a
/// never-cancelled proactive-refresh loop, so a unit test resolving the real
/// `~/.kigi` would read the developer's stored OAuth tokens and, 60 s later,
/// fire REAL refresh requests against them. Deliberately not a per-test opt-in
/// that can be forgotten: `kigi_home()` is itself a `OnceLock` an earlier test
/// has usually already resolved to the real home, so setting `KIGI_SHARE_DIR`
/// in a test cannot pin it after the fact.
///
/// M4 — THE LIMIT, STATED: `cfg(test)` is set only for THIS crate's `--lib`
/// tests. `crates/codegen/kigi-shell/tests/*.rs` link the library built WITHOUT
/// it, so for an integration test this resolves the real home unless that test
/// binary itself isolates one, which it must do through the two overrides the
/// auth stack already honours and BEFORE anything resolves `kigi_home()`:
/// `KIGI_SHARE_DIR` (read by `kigi_home()`, a `OnceLock`) or `KIGI_AUTH_PATH`
/// (read by [`AuthManager::new_oauth_provider`], which pins the token file
/// outright and so overrides this home entirely). 12 of the 28 integration
/// binaries under `crates/codegen/kigi-shell/tests/` set `KIGI_SHARE_DIR`; the
/// other 16 never reach an OAuth-platform inference path today, which is a
/// property of those tests, not a guarantee of this function. No
/// production-readable env override is added here on purpose: a knob that
/// redirects where OAuth tokens are read from is not worth a test convenience.
///
/// M8: this used to be a `static OnceLock<TempDir>`. Statics are never dropped,
/// so that leaked one temp directory per test binary — against the project's
/// "tests are TempDir self-cleaning" discipline. Nothing is created here
/// instead, and nothing in the lib-test suite creates it: a manager reads a
/// missing `auth.json` as "no session", and the only two paths that WRITE one
/// are a successful token refresh (which needs a stored refresh token that by
/// construction does not exist here) and a completed device login
/// ([`crate::agent::mvp_agent::MvpAgent::authenticate_oauth_platform`], which
/// M4 repointed at this same home). Both require the network, so no lib test
/// performs either. That is an observation about the suite, not an invariant of
/// this function — [`tests::test_pool_home_is_disposable_and_never_the_real_home`]
/// asserts the directory does not exist and is the tripwire if one ever does
/// (the path is per-PROCESS under the system temp dir, so the blast radius of a
/// future login-driving test is one disposable directory, never `~/.kigi`).
pub(crate) fn pool_home() -> std::path::PathBuf {
#[cfg(test)]
{
std::env::temp_dir().join(format!("kigi-oauth-pool-test-{}", std::process::id()))
}
#[cfg(not(test))]
crate::util::kigi_home::kigi_home()
}
/// Get-or-create the process-global manager for `oauth`, wiring the same
/// refresher + proactive-refresh lifecycle as the primary Kimi manager the
/// FIRST time a scope is seen. The manager reads the on-disk token at
/// construction (thereafter kept fresh by the proactive-refresh loop), so a
/// grok login that lands after this scope was first built is adopted on the
/// manager's own refresh tick — no session ever needs re-spawning.
///
/// MUST be called from within a Tokio runtime (the proactive-refresh loop
/// spawns a task, mirroring the primary).
pub(crate) fn global_manager_for(
kigi_home: &Path,
oauth: &'static kigi_models::OAuthConfig,
) -> Arc<AuthManager> {
let mut pool = oauth_manager_pool().lock();
if let Some(existing) = pool.get(oauth.scope_key) {
return existing.clone();
}
let manager = Arc::new(AuthManager::new_oauth_provider(kigi_home, oauth));
manager.configure_refresher();
// Never-cancelled token = process-lifetime, matching the api-server /
// per-session eager-refresh sites that pass a fresh token.
manager.start_proactive_refresh(tokio_util::sync::CancellationToken::new());
pool.insert(oauth.scope_key, manager.clone());
manager
}
#[cfg(test)]
mod tests {
use super::*;
fn oauth_for(id: &str) -> &'static kigi_models::OAuthConfig {
kigi_models::PlatformId::parse(id)
.expect("known platform")
.oauth()
.expect("subscription-OAuth platform carries an OAuthConfig")
}
/// Each subscription-OAuth platform gets its OWN process-global pooled
/// manager, and no two share one. (Which credential governs a REQUEST is
/// not decided here — see
/// [`crate::auth::credential_authority::CredentialAuthority`].)
#[tokio::test]
async fn every_oauth_scope_gets_its_own_pooled_manager() {
let home = tempfile::tempdir().unwrap();
let ids = [
"xai-grok",
"claude-pro-max",
"github-copilot",
"openai-codex",
];
let managers: Vec<_> = ids
.iter()
.map(|id| global_manager_for(home.path(), oauth_for(id)))
.collect();
for (i, a) in managers.iter().enumerate() {
assert!(
Arc::ptr_eq(a, &global_manager_for(home.path(), oauth_for(ids[i]))),
"{}: the pool must return the SAME manager for a scope",
ids[i]
);
for (j, b) in managers.iter().enumerate() {
if i != j {
assert!(
!Arc::ptr_eq(a, b),
"{} and {} must not share a pooled manager",
ids[i],
ids[j]
);
}
}
}
}
/// M8: the test pool home is a per-process path that is never created, so a
/// test binary leaves nothing behind (and never resolves the developer's
/// real `~/.kigi`, whose stored OAuth tokens the pool would otherwise read
/// and proactively refresh over the network).
///
/// This is also the tripwire for the cleanup claim in [`pool_home`]: a
/// completed device login through `authenticate_oauth_platform` WOULD create
/// this directory, so if a lib test ever drives one, this assertion fires
/// and the cleanup has to be added rather than silently regressing the
/// "tests are TempDir self-cleaning" discipline.
#[test]
fn test_pool_home_is_disposable_and_never_the_real_home() {
let home = pool_home();
assert!(
home.starts_with(std::env::temp_dir()),
"the test pool home must live under the system temp dir, got {home:?}"
);
assert!(
!home.exists(),
"the test pool home must not be created — nothing to clean up"
);
}
}
@@ -0,0 +1,314 @@
//! Generic OAuth token refresher for any [`kigi_models::OAuthConfig`] provider,
//! driven through the [`TokenRefresher`] seam. The wire call is selected by the
//! config's `token_body`: form-encoded `grant_type=refresh_token` (xai-grok,
//! openai-codex), JSON (claude-pro-max), or the GitHub Copilot copilot-token
//! RE-MINT. Providers with `requires_chatgpt_account_id` additionally fail fast
//! when the refreshed token drops the claim.
//!
//! Structurally identical to [`super::kimi_refresher::KimiRefresher`] — same
//! sibling-adoption + post-401 grace — but the wire call goes through
//! [`crate::auth::oauth_device`] (no X-Msh headers) instead of the Kimi wire.
//! Access/refresh tokens are NEVER logged.
use std::sync::Arc;
use kigi_models::{OAuthConfig, OAuthTokenBody};
use crate::auth::error::RefreshTokenFailedReason;
use crate::auth::kimi_oauth::RefreshError;
use crate::auth::manager::RefreshReason;
use crate::auth::{github_copilot, oauth_device, oauth_pkce};
use super::{AuthSnapshot, RefreshOutcome, TokenRefresher};
/// Grace period after a 401/403 before concluding the refresh token is dead:
/// a concurrent instance may still be persisting its rotated token.
const POST_UNAUTHORIZED_GRACE: std::time::Duration = std::time::Duration::from_secs(1);
pub(crate) struct GenericDeviceRefresher {
auth: Arc<dyn AuthSnapshot>,
cfg: &'static OAuthConfig,
}
impl GenericDeviceRefresher {
pub(crate) fn new(auth: Arc<dyn AuthSnapshot>, cfg: &'static OAuthConfig) -> Self {
Self { auth, cfg }
}
/// Post-401 sibling check: wait a beat, re-read the persisted credential,
/// and adopt it when its refresh token differs from the rejected one.
async fn adopt_rotation_after_unauthorized(&self, tried_rt: &str) -> Option<RefreshOutcome> {
tokio::time::sleep(POST_UNAUTHORIZED_GRACE).await;
let latest = self.auth.read_disk_auth()?;
let latest_rt = latest.refresh_token.as_deref()?;
if latest_rt == tried_rt {
return None;
}
kigi_log::unified_log::info(
"auth.refresh.adopted_rotation_after_401",
None,
Some(serde_json::json!({
"scope_key": self.cfg.scope_key,
"adopted_rt_prefix": crate::auth::token_suffix(latest_rt),
"rejected_rt_prefix": crate::auth::token_suffix(tried_rt),
})),
);
Some(RefreshOutcome::success(latest))
}
}
#[async_trait::async_trait]
impl TokenRefresher for GenericDeviceRefresher {
async fn refresh(&self, reason: RefreshReason) -> RefreshOutcome {
tracing::info!(
?reason,
scope_key = self.cfg.scope_key,
"auth: generic refresh attempt"
);
let disk_auth = self.auth.read_disk_auth();
// Sibling short-circuit: a valid persisted token whose key differs from
// in-memory means another process refreshed already — adopt directly.
if let Some(ref d) = disk_auth
&& !crate::auth::is_expired(d)
&& self.auth.current().map(|a| a.key).as_deref() != Some(&d.key)
{
kigi_log::unified_log::info(
"auth.refresh.adopted_sibling_token",
None,
Some(serde_json::json!({
"scope_key": self.cfg.scope_key,
"disk_key_prefix": crate::auth::token_suffix(&d.key),
})),
);
return RefreshOutcome::success(d.clone());
}
let Some(auth) = super::resolve_refresh_credential(self.auth.as_ref(), disk_auth, reason)
else {
tracing::warn!(
?reason,
"auth: no credential available for refresh (generic)"
);
return RefreshOutcome::transient("no token with refresh_token available");
};
let Some(refresh_token) = auth.refresh_token.clone() else {
tracing::warn!(
?reason,
"auth: resolved credential has no refresh token (generic)"
);
return RefreshOutcome::transient("credential has no refresh token");
};
tracing::info!(
rt_prefix = crate::auth::token_suffix(&refresh_token),
expires_at = ?auth.expires_at,
"auth: sending refresh_token grant (generic oauth)"
);
// Refresh over the provider's token-body encoding: xai's endpoint is
// form-encoded (device wire); Claude's is JSON (PKCE wire); GitHub
// Copilot's "refresh" is a copilot-token RE-MINT — a `GET
// copilot_internal/v2/token` bearing the durable github token (the
// `refresh_token` field here), NOT a refresh_token grant. All three
// return the same `Result<KimiAuth, RefreshError>`.
let wire_result = match self.cfg.token_body {
OAuthTokenBody::Form => oauth_device::refresh_token(self.cfg, &refresh_token).await,
OAuthTokenBody::Json => oauth_pkce::refresh_token(self.cfg, &refresh_token).await,
OAuthTokenBody::GithubCopilotExchange => {
github_copilot::remint_copilot_token(self.cfg, &refresh_token).await
}
};
match wire_result {
Ok(new_auth)
if self.cfg.requires_chatgpt_account_id
&& kigi_sampling_types::chatgpt_account_id_from_jwt(&new_auth.key)
.is_none() =>
{
// FAIL FAST (never silently): the refreshed token carries no
// `chatgpt_account_id`, so every inference request would go out
// WITHOUT the required `chatgpt-account-id` header and draw an
// opaque backend 4xx. Surface it as a permanent failure so the
// user is told to re-login.
tracing::warn!(
scope_key = self.cfg.scope_key,
"auth: refreshed token is missing the chatgpt_account_id claim"
);
RefreshOutcome::permanent(RefreshTokenFailedReason::Other, Some(refresh_token))
}
Ok(new_auth) => {
kigi_log::unified_log::info(
"auth.refresh.token_rotated",
None,
Some(serde_json::json!({
"scope_key": self.cfg.scope_key,
"new_key_prefix": crate::auth::token_suffix(&new_auth.key),
"expires_at": new_auth.expires_at.map(|e| e.to_rfc3339()),
})),
);
RefreshOutcome::success(new_auth)
}
Err(RefreshError::Unauthorized {
status,
description,
}) => {
tracing::warn!(status, %description, "auth: refresh token rejected (generic)");
if let Some(adopted) = self.adopt_rotation_after_unauthorized(&refresh_token).await
{
return adopted;
}
kigi_log::unified_log::warn(
"auth.refresh.unauthorized",
None,
Some(serde_json::json!({
"scope_key": self.cfg.scope_key,
"status": status,
"description": description,
"rt_prefix": crate::auth::token_suffix(&refresh_token),
})),
);
RefreshOutcome::permanent(
RefreshTokenFailedReason::RefreshTokenRejected,
Some(refresh_token),
)
}
Err(
e @ (RefreshError::Exhausted { .. }
| RefreshError::Fatal { .. }
| RefreshError::Local(_)),
) => {
tracing::warn!(error = %e, "auth: refresh attempt failed (transient, generic)");
kigi_log::unified_log::warn(
"auth.refresh.transient_wire_failure",
None,
Some(serde_json::json!({
"scope_key": self.cfg.scope_key,
"error": format!("{e}"),
})),
);
RefreshOutcome::transient(format!("token refresh failed: {e}"))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::model::KimiAuth;
use chrono::{Duration, Utc};
use kigi_models::XAI_OAUTH_CONFIG;
use parking_lot::Mutex;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
struct FakeSnapshot {
current: Mutex<Option<KimiAuth>>,
disk: Mutex<Option<KimiAuth>>,
}
impl FakeSnapshot {
fn new(current: Option<KimiAuth>, disk: Option<KimiAuth>) -> Arc<Self> {
Arc::new(Self {
current: Mutex::new(current),
disk: Mutex::new(disk),
})
}
}
impl AuthSnapshot for FakeSnapshot {
fn current(&self) -> Option<KimiAuth> {
self.current
.lock()
.clone()
.filter(|a| !crate::auth::is_expired(a))
}
fn expired_auth(&self) -> Option<KimiAuth> {
self.current.lock().clone().filter(crate::auth::is_expired)
}
fn read_disk_auth(&self) -> Option<KimiAuth> {
self.disk.lock().clone()
}
fn is_expired(&self) -> bool {
self.current
.lock()
.as_ref()
.is_some_and(crate::auth::is_expired)
}
}
fn expired_session(key: &str, rt: &str) -> KimiAuth {
KimiAuth {
key: key.into(),
refresh_token: Some(rt.into()),
expires_at: Some(Utc::now() - Duration::hours(1)),
expires_in: Some(3600),
..KimiAuth::test_default()
}
}
fn mock_cfg(host: &'static str) -> OAuthConfig {
OAuthConfig {
auth_host: host,
..XAI_OAUTH_CONFIG
}
}
/// A successful refresh rotates the token via the generic wire.
#[tokio::test]
async fn refresh_success_returns_rotated_token() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/oauth2/token"))
.and(body_string_contains("refresh_token=grok-rt-old"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "grok-at-new",
"refresh_token": "grok-rt-new",
"expires_in": 3600,
})))
.expect(1)
.mount(&server)
.await;
let stale = expired_session("grok-at-old", "grok-rt-old");
let snap = FakeSnapshot::new(Some(stale.clone()), Some(stale));
let cfg: &'static OAuthConfig = Box::leak(Box::new(mock_cfg(host)));
let refresher = GenericDeviceRefresher::new(snap, cfg);
let outcome = refresher.refresh(RefreshReason::PreRequest).await;
let RefreshOutcome::Success(new_auth) = outcome else {
panic!("expected success, got {outcome:?}");
};
assert_eq!(new_auth.key, "grok-at-new");
assert_eq!(new_auth.refresh_token.as_deref(), Some("grok-rt-new"));
}
/// A 401 on refresh (with no sibling rotation) tombstones the rejected
/// refresh token as a permanent failure.
#[tokio::test]
async fn unauthorized_is_permanent_failure() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/oauth2/token"))
.respond_with(
ResponseTemplate::new(401)
.set_body_json(serde_json::json!({ "error_description": "revoked" })),
)
.expect(1)
.mount(&server)
.await;
let stale = expired_session("grok-at-old", "grok-rt-dead");
let snap = FakeSnapshot::new(Some(stale.clone()), Some(stale));
let cfg: &'static OAuthConfig = Box::leak(Box::new(mock_cfg(host)));
let refresher = GenericDeviceRefresher::new(snap, cfg);
let outcome = refresher.refresh(RefreshReason::PreRequest).await;
let RefreshOutcome::PermanentFailure {
error,
rejected_refresh_token,
} = outcome
else {
panic!("expected permanent failure, got {outcome:?}");
};
assert_eq!(error.reason, RefreshTokenFailedReason::RefreshTokenRejected);
assert_eq!(rejected_refresh_token.as_deref(), Some("grok-rt-dead"));
}
}
@@ -1,3 +1,4 @@
mod generic_refresher;
mod kimi_refresher;
use std::sync::Arc;
@@ -6,6 +7,7 @@ use crate::auth::manager::AuthManager;
pub(crate) use crate::auth::manager::RefreshReason;
use crate::auth::model::KimiAuth;
pub(crate) use generic_refresher::GenericDeviceRefresher;
pub(crate) use kimi_refresher::KimiRefresher;
/// Read-only view of `AuthManager` for refreshers. Enforces the
@@ -120,8 +122,20 @@ pub(crate) trait TokenRefresher: Send + Sync {
async fn refresh(&self, reason: RefreshReason) -> RefreshOutcome;
}
/// Build the production refresher against `kigi_env::oauth_host()`.
/// Build the production refresher for this manager's scope. A scope that maps
/// to a generic device-code [`kigi_models::OAuthConfig`] (xai-grok) gets the
/// [`GenericDeviceRefresher`]; every other scope — Kimi Code, whose registry
/// `oauth` field is `None` by design — gets the bespoke [`KimiRefresher`]
/// against `kigi_env::oauth_host()`.
pub(crate) fn build_refresher(auth_manager: Arc<AuthManager>) -> Arc<dyn TokenRefresher> {
let snapshot: Arc<dyn AuthSnapshot> = auth_manager;
Arc::new(KimiRefresher::new(snapshot, kigi_env::oauth_host()))
match kigi_models::oauth_config_for_scope_key(auth_manager.scope()) {
Some(cfg) => {
let snapshot: Arc<dyn AuthSnapshot> = auth_manager;
Arc::new(GenericDeviceRefresher::new(snapshot, cfg))
}
None => {
let snapshot: Arc<dyn AuthSnapshot> = auth_manager;
Arc::new(KimiRefresher::new(snapshot, kigi_env::oauth_host()))
}
}
}
+145 -11
View File
@@ -96,9 +96,9 @@ pub(crate) fn disable_mock_keyring_for_test() {
fn keyring_entry() -> Result<&'static keyring::Entry, keyring::Error> {
static ENTRY: std::sync::OnceLock<Result<keyring::Entry, keyring::Error>> =
std::sync::OnceLock::new();
match ENTRY.get_or_init(|| {
keyring::Entry::new(KEYRING_SERVICE, crate::auth::config::KIMI_CODE_OAUTH_SCOPE)
}) {
match ENTRY
.get_or_init(|| keyring::Entry::new(KEYRING_SERVICE, crate::auth::KIMI_CODE_OAUTH_SCOPE))
{
Ok(entry) => Ok(entry),
// `keyring::Error` is not `Clone`; surface a stable equivalent.
Err(e) => {
@@ -430,17 +430,12 @@ fn write_store_to(path: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
Ok(())
}
/// Atomic write: tmp + rename. Unix `rename(2)` replaces atomically;
/// Windows `rename` requires removing the target first.
/// Atomic write: tmp + Windows-safe replace (see `util::fs::replace_file`,
/// which this site's inline delete-first pattern graduated into).
fn write_auth_json_atomic(auth_file: &Path, auth_store: &AuthStore) -> std::io::Result<()> {
let tmp = auth_file.with_extension(format!("json.{}.tmp", std::process::id()));
write_store_to(&tmp, auth_store)?;
#[cfg(windows)]
{
let _ = std::fs::remove_file(auth_file);
}
std::fs::rename(&tmp, auth_file)?;
Ok(())
crate::util::fs::replace_file(&tmp, auth_file)
}
/// Non-atomic fallback: truncate and rewrite `auth.json` in place.
@@ -536,6 +531,71 @@ pub fn store_api_key(kigi_home: &Path, api_key: &str) -> std::io::Result<()> {
write_auth_json(&path, &map)
}
/// Read an API-key platform's key from auth.json. The scope is the platform
/// id itself (`anthropic`, `moonshot-cn`, …) — the stable per-provider
/// auth.json key contract. `None` when absent or unreadable.
pub fn read_platform_api_key(
kigi_home: &Path,
platform: kigi_models::PlatformId,
) -> Option<String> {
let path = kigi_home.join("auth.json");
let map = read_auth_json(&path).ok()?;
map.get(platform.as_str()).map(|a| a.key.clone())
}
/// Store an API-key platform's key in auth.json under its platform-id scope.
/// Same corrupt-recovery + atomic-write path as [`store_api_key`]; all other
/// scopes (OAuth session, other platforms) are preserved.
///
/// SECURITY: the key must never be logged; errors carry only IO context.
pub fn store_platform_api_key(
kigi_home: &Path,
platform: kigi_models::PlatformId,
api_key: &str,
) -> std::io::Result<()> {
if platform.uses_oauth() {
// Real error, not debug_assert: an OAuth scope written here would
// shadow the session entry in release builds too.
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"{} authenticates via OAuth and takes no API key",
platform.as_str()
),
));
}
let path = kigi_home.join("auth.json");
// Serialize with the manager's cross-process auth.json writers (token
// refresh holds the same flock): an unlocked read-modify-write here
// could write back a pre-refresh map and revert a rotated refresh
// token (token-family revocation → forced re-login). Bounded retry;
// a sustained holder fails loudly rather than racing.
let mut lock = None;
for _ in 0..20 {
lock = super::manager::try_lock_auth_file_nonblocking(&path);
if lock.is_some() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
let Some(_lock) = lock else {
return Err(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
"auth.json is locked by another kigi process; try again",
));
};
let mut map = read_auth_json_or_empty_recovering_corrupt(&path)?;
map.insert(
platform.as_str().to_owned(),
KimiAuth {
key: api_key.to_owned(),
auth_mode: AuthMode::ApiKey,
..Default::default()
},
);
write_auth_json(&path, &map)
}
/// Remove the `kigi::api_key` scope from auth.json.
pub fn clear_api_key(kigi_home: &Path) -> std::io::Result<()> {
let path = kigi_home.join("auth.json");
@@ -550,6 +610,80 @@ pub fn clear_api_key(kigi_home: &Path) -> std::io::Result<()> {
Ok(())
}
#[cfg(test)]
mod platform_key_tests {
use super::*;
/// Store/read round-trip under the platform-id scope; the OAuth session
/// scope and other platform scopes in the same file are preserved.
#[test]
fn platform_key_round_trip_preserves_other_scopes() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path();
// Pre-existing OAuth session entry must survive platform-key writes.
let path = home.join("auth.json");
let mut map = AuthStore::new();
map.insert(
crate::auth::KIMI_CODE_OAUTH_SCOPE.to_owned(),
KimiAuth {
key: "oauth-token".to_owned(),
auth_mode: AuthMode::OAuth,
..Default::default()
},
);
write_auth_json(&path, &map).unwrap();
store_platform_api_key(home, kigi_models::PlatformId::MoonshotCn, "sk-cn").unwrap();
store_platform_api_key(home, kigi_models::PlatformId::MoonshotAi, "sk-ai").unwrap();
assert_eq!(
read_platform_api_key(home, kigi_models::PlatformId::MoonshotCn).as_deref(),
Some("sk-cn")
);
assert_eq!(
read_platform_api_key(home, kigi_models::PlatformId::MoonshotAi).as_deref(),
Some("sk-ai")
);
let stored = read_auth_json(&path).unwrap();
assert_eq!(
stored
.get(crate::auth::KIMI_CODE_OAUTH_SCOPE)
.map(|a| a.key.as_str()),
Some("oauth-token"),
"platform-key writes must not clobber the OAuth session scope"
);
assert_eq!(
stored.get("moonshot-cn").map(|a| a.auth_mode.clone()),
Some(AuthMode::ApiKey),
"platform keys are stored as api_key mode under the platform id"
);
}
/// The OAuth platform takes no API key — a real error in release builds.
#[test]
fn storing_key_for_oauth_platform_is_invalid_input() {
let dir = tempfile::tempdir().unwrap();
let err = store_platform_api_key(dir.path(), kigi_models::PlatformId::KimiCode, "sk-x")
.expect_err("oauth platform must reject api keys");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert!(
!dir.path().join("auth.json").exists(),
"rejected write must not create auth.json"
);
}
/// Missing file reads as None (not an error) — resolution treats absent
/// auth.json as "no stored key".
#[test]
fn reading_platform_key_without_auth_json_is_none() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
read_platform_api_key(dir.path(), kigi_models::PlatformId::MoonshotCn),
None
);
}
}
#[cfg(test)]
mod write_fallback_tests {
use super::*;
@@ -670,10 +670,7 @@ fn write_import_marker(config_path: &Path) -> anyhow::Result<()> {
let _ = std::fs::remove_file(&tmp);
return Err(e.into());
}
if let Err(e) = std::fs::rename(&tmp, config_path) {
let _ = std::fs::remove_file(&tmp);
return Err(e.into());
}
crate::util::fs::replace_file(&tmp, config_path)?;
Ok(())
}
@@ -854,7 +851,7 @@ fn apply_items_to_config(config_path: &Path, items: &[ImportableItem]) -> anyhow
std::fs::create_dir_all(parent)?;
}
std::fs::write(&tmp, &toml_str)?;
std::fs::rename(&tmp, config_path)?;
crate::util::fs::replace_file(&tmp, config_path)?;
info!(
path = %config_path.display(),
count,
@@ -1204,7 +1201,7 @@ fn apply_hooks_to_dir(hooks_dir: &Path, items: &[ImportableItem]) -> anyhow::Res
let json_str = serde_json::to_string_pretty(&root)?;
let tmp = target.with_extension("json.tmp");
std::fs::write(&tmp, &json_str)?;
std::fs::rename(&tmp, &target)?;
crate::util::fs::replace_file(&tmp, &target)?;
info!(
path = %target.display(),
count,
@@ -90,7 +90,7 @@ pub fn save_import_state(state: &ImportState) -> std::io::Result<()> {
// `claude_import_state.json.tmp` (the last extension is replaced).
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &json)?;
std::fs::rename(&tmp, &path)?;
crate::util::fs::replace_file(&tmp, &path)?;
Ok(())
}
+10 -3
View File
@@ -35,7 +35,11 @@ impl AuthStatus {
.unwrap_or(&origin);
return Self::LoggedIn(host.to_owned());
}
let models = crate::agent::config::resolve_model_list(agent_config, None);
let models = crate::agent::config::resolve_model_list(
agent_config,
None,
&crate::agent::models::PlatformApiKeys::resolve(&agent_config.platforms),
);
if crate::agent::auth_method::should_advertise_xai_api_key(models.values())
&& let Some(name) = models
.iter()
@@ -88,7 +92,9 @@ pub async fn list_models(
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::auth_method::{LEGACY_XAI_API_KEY_ENV_VAR, XAI_API_KEY_ENV_VAR};
use crate::agent::auth_method::{
HOUSE_API_KEY_ENV_VAR, LEGACY_XAI_API_KEY_ENV_VAR, XAI_API_KEY_ENV_VAR,
};
use crate::agent::config::Config;
use crate::auth::{AuthMode, KimiAuth};
use kigi_test_support::EnvGuard;
@@ -98,10 +104,11 @@ mod tests {
///
/// Uses `KIGI_AUTH_PATH` (not `KIGI_SHARE_DIR`) so a OnceLock-cached real home
/// with `auth.json` cannot leak into these tests.
fn isolate_auth_sources() -> (tempfile::TempDir, [EnvGuard; 7]) {
fn isolate_auth_sources() -> (tempfile::TempDir, [EnvGuard; 8]) {
let dir = tempfile::tempdir().unwrap();
let auth_path = dir.path().join("no-auth.json");
let guards = [
EnvGuard::unset(HOUSE_API_KEY_ENV_VAR),
EnvGuard::unset(XAI_API_KEY_ENV_VAR),
EnvGuard::unset(LEGACY_XAI_API_KEY_ENV_VAR),
EnvGuard::unset("KIGI_AUTH"),
@@ -56,18 +56,18 @@ fn handle_set_api_key(args: &acp::ExtRequest) -> ExtResult {
crate::auth::clear_api_key(&kigi_home)
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
// SAFETY: ext_method is single-threaded per agent
unsafe { std::env::remove_var("XAI_API_KEY") };
unsafe { std::env::remove_var("KIGI_API_KEY") };
} else {
crate::auth::store_api_key(&kigi_home, k)
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
// SAFETY: ext_method is single-threaded per agent
unsafe { std::env::set_var("XAI_API_KEY", k) };
unsafe { std::env::set_var("KIGI_API_KEY", k) };
}
} else {
crate::auth::clear_api_key(&kigi_home)
.map_err(|e| acp::Error::internal_error().data(e.to_string()))?;
// SAFETY: ext_method is single-threaded per agent
unsafe { std::env::remove_var("XAI_API_KEY") };
unsafe { std::env::remove_var("KIGI_API_KEY") };
}
ExtMethodResult::success(serde_json::json!({ "ok": true }))
.to_ext_response()
+18 -17
View File
@@ -160,18 +160,22 @@ struct KimiProviderToml {
}
/// Built-in kigi platform a kimi provider duplicates, if any: provider type
/// `kimi` is the Kimi Code subscription channel; the two Moonshot open
/// platforms are recognized by their fixed production hosts (the same hosts
/// `kigi_models::PlatformId::base_url` compiles in).
/// `kimi` is the Kimi Code subscription channel; API-key platforms are
/// recognized by their production hosts (the same hosts
/// `kigi_models::PlatformId::base_url` compiles in — moonshot, openai, and
/// every future registry row automatically).
fn builtin_platform(provider: &KimiProviderToml) -> Option<PlatformId> {
if provider.provider_type == "kimi" {
return Some(PlatformId::KimiCode);
}
match url_host(&provider.base_url) {
Some("api.moonshot.cn") => Some(PlatformId::MoonshotCn),
Some("api.moonshot.ai") => Some(PlatformId::MoonshotAi),
_ => None,
}
let host = url_host(&provider.base_url)?;
PlatformId::ALL.into_iter().find(|platform| {
if platform.uses_oauth() {
return false;
}
let base = platform.base_url();
url_host(&base) == Some(host)
})
}
/// Host component of an http(s) URL. `None` for other schemes.
@@ -524,10 +528,7 @@ pub fn apply_at(plan: &KimiImportPlan, kigi_home: &Path) -> anyhow::Result<KimiA
let _ = std::fs::remove_file(&tmp);
return Err(e.into());
}
if let Err(e) = std::fs::rename(&tmp, &config_path) {
let _ = std::fs::remove_file(&tmp);
return Err(e.into());
}
crate::util::fs::replace_file(&tmp, &config_path)?;
info!(
path = %config_path.display(),
added = applied.total_added(),
@@ -607,7 +608,7 @@ model = "kimi-for-coding"
max_context_size = 262144
[models.my-openai]
provider = "openrouter"
provider = "customllm"
model = "gpt-x"
max_context_size = 128000
@@ -616,9 +617,9 @@ type = "kimi"
base_url = "https://api.kimi.com/coding/v1"
api_key = "sk-kimi-secret"
[providers.openrouter]
[providers.customllm]
type = "openai_legacy"
base_url = "https://openrouter.ai/api/v1"
base_url = "https://llm.example.test/v1"
api_key = "sk-or-secret"
"#,
)
@@ -707,7 +708,7 @@ api_key = "sk-or-secret"
let m = &plan.custom_models[0];
assert_eq!(m.alias, "my-openai");
assert_eq!(m.model, "gpt-x");
assert_eq!(m.base_url, "https://openrouter.ai/api/v1");
assert_eq!(m.base_url, "https://llm.example.test/v1");
assert_eq!(m.api_key.as_deref(), Some("sk-or-secret"));
assert_eq!(m.context_window, Some(128_000));
@@ -846,7 +847,7 @@ api_key = "sk-ms"
assert_eq!(model["model"].as_str().unwrap(), "gpt-x");
assert_eq!(
model["base_url"].as_str().unwrap(),
"https://openrouter.ai/api/v1"
"https://llm.example.test/v1"
);
assert_eq!(model["api_key"].as_str().unwrap(), "sk-or-secret");
assert_eq!(model["context_window"].as_integer().unwrap(), 128_000);
@@ -76,7 +76,7 @@ pub fn map_sampling_err_to_acp(err: SamplingError) -> acp::Error {
&& crate::agent::auth_method::has_xai_api_key_env()
{
format!(
"{message}\n\nYou have an API key set (XAI_API_KEY). \
"{message}\n\nYou have an API key set (KIGI_API_KEY). \
Your cached OAuth session is being used instead. \
To use your API key, run `kigi logout` or type /logout in the TUI."
)
@@ -441,24 +441,32 @@ mod tests {
);
}
/// Helper: run a closure with XAI_API_KEY temporarily set (or cleared).
/// Cleans up even if the closure panics.
/// Helper: run a closure with the house BYOK key (KIGI_API_KEY) temporarily
/// set (or cleared). Clears every house-key env (KIGI_API_KEY plus the
/// back-compat XAI_API_KEY / KIGI_CODE_XAI_API_KEY) so the "no key" case is
/// hermetic. Cleans up even if the closure panics.
fn with_api_key_env<F: FnOnce()>(key: Option<&str>, f: F) {
let prev_house = std::env::var("KIGI_API_KEY").ok();
let prev = std::env::var("XAI_API_KEY").ok();
let prev_legacy = std::env::var("KIGI_CODE_XAI_API_KEY").ok();
// SAFETY: serial_test ensures no concurrent env mutation.
unsafe {
std::env::remove_var("KIGI_API_KEY");
std::env::remove_var("XAI_API_KEY");
std::env::remove_var("KIGI_CODE_XAI_API_KEY");
if let Some(k) = key {
std::env::set_var("XAI_API_KEY", k);
std::env::set_var("KIGI_API_KEY", k);
}
}
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
// Restore original state.
unsafe {
std::env::remove_var("KIGI_API_KEY");
std::env::remove_var("XAI_API_KEY");
std::env::remove_var("KIGI_CODE_XAI_API_KEY");
if let Some(v) = prev_house {
std::env::set_var("KIGI_API_KEY", v);
}
if let Some(v) = prev {
std::env::set_var("XAI_API_KEY", v);
}
@@ -135,7 +135,7 @@ use prompt_build::*;
mod session_mode;
use session_mode::*;
#[path = "acp_session_impl/sampler_turn.rs"]
mod sampler_turn;
pub(crate) mod sampler_turn;
use sampler_turn::*;
#[path = "acp_session_impl/tool_dispatch.rs"]
mod tool_dispatch;
@@ -417,6 +417,21 @@ pub(crate) struct SessionActor {
/// [`SessionActor::model_auth_facts`].
pub(crate) model_auth_facts:
std::cell::RefCell<Option<(String, crate::agent::config::ModelAuthFacts)>>,
/// The catalog KEY this session's model was selected by (`{platform}/{model}`
/// for a registry model), owned PER SESSION.
///
/// H4: `SamplingConfig::model` is the bare routing slug, and duplicate slugs
/// across an API-key platform and its subscription-OAuth twin
/// (`xai`/`xai-grok`, `anthropic`/`claude-pro-max`, `openai`/`openai-codex`)
/// are BY DESIGN, so the slug alone cannot name the platform. This used to
/// be read from `ModelsManager::current_model_id()` — a single
/// PROCESS-GLOBAL cell that Leader mode never writes
/// (`agent/handlers/model_switch.rs`) and that is last-writer-wins across
/// concurrent sessions, so both collision directions resolved the wrong
/// platform: the subscription session lost its resolver (unrecoverable 401
/// ~1h in) and the API-key session got the pooled OAuth bearer stamped over
/// its own `sk-…` key. Written at spawn and on every `SetSessionModel`.
pub(crate) selected_catalog_key: std::cell::RefCell<Option<String>>,
/// 401-attribution callback. Joined with the bearer the
/// sampler sends on the wire to emit an `auth 401 attribution`
/// event at each of the six `OaiCompatClient` 401 arms in
@@ -1132,7 +1147,7 @@ fn persist_chat_history_jsonl_sync(session_info: &SessionInfo, conversation: &[C
buf.push(b'\n');
}
std::fs::File::create(&tmp_path)?.write_all(&buf)?;
std::fs::rename(&tmp_path, &final_path)?;
crate::util::fs::replace_file(&tmp_path, &final_path)?;
Ok(())
})();
if let Err(e) = result {
@@ -1256,6 +1271,17 @@ mod rewind_synthetic_turn_tests;
#[cfg(test)]
#[path = "acp_session_tests/rewrite_zero_turn_prefix_tests.rs"]
mod rewrite_zero_turn_prefix_tests;
/// The same guard for the model→platform lookup (the dual-credential slug
/// collision) and for the stamped aux/summary configs.
#[cfg(test)]
#[path = "acp_session_tests/session_bearer_leak_platform_tests.rs"]
mod session_bearer_leak_platform_tests;
/// LEAK guard: the primary Kimi subscription bearer must never ride a request
/// to an API-key registry platform's host, while the subscription-OAuth
/// platforms keep a live resolver from their OWN pooled manager.
#[cfg(test)]
#[path = "acp_session_tests/session_bearer_leak_tests.rs"]
mod session_bearer_leak_tests;
/// Pins the `SubagentFinished` usage-fold attribution gate.
#[cfg(test)]
#[path = "acp_session_tests/subagent_usage_fold_tests.rs"]
@@ -5,12 +5,18 @@ impl SessionActor {
pub(super) async fn handle_set_session_model(
&self,
sampling_config: kigi_sampler::SamplerConfig,
catalog_key: Option<String>,
use_concise: bool,
apply_prompt_override: bool,
skip_prompt_rewrite: bool,
auto_compact_threshold_percent: u8,
) -> Result<acp::ModelId, acp::Error> {
let model_id = acp::ModelId::new(sampling_config.model.clone());
// H4: record the picker's catalog KEY as this SESSION's own selection.
// `sampling_config.model` is the ambiguous bare slug; the key is what
// disambiguates an API-key platform from its subscription-OAuth twin,
// and it must never come from the process-global `current_model_id()`.
*self.selected_catalog_key.borrow_mut() = catalog_key;
let new_context_window = self.compaction.context_window_override.unwrap_or_else(|| {
std::num::NonZeroU64::new(sampling_config.context_window).unwrap_or_else(|| {
std::num::NonZeroU64::new(DEFAULT_CONTEXT_WINDOW)
@@ -53,22 +59,24 @@ impl SessionActor {
temperature: sampling_config.temperature,
top_p: sampling_config.top_p,
api_backend: sampling_config.api_backend.clone(),
chat_compat: sampling_config.chat_compat,
extra_headers: sampling_config.extra_headers.clone(),
context_window: new_context_window,
reasoning_effort: sampling_config.reasoning_effort,
stream_tool_calls: Some(sampling_config.stream_tool_calls),
});
let existing = self.chat_state_handle.get_credentials().await;
let session_key = self
.auth_manager
.as_ref()
.and_then(|am| am.current_or_expired().map(|a| a.key));
// Read the session bearer from the switched-to model's OWN manager: a
// grok model reads the xai-grok token (used only to classify the
// credential's auth_type here), never the Kimi one. Kimi / non-oauth
// models resolve to the primary — byte-identical.
let session_key = self.session_credential_for_model(&sampling_config.model);
self.chat_state_handle
.update_credentials(kigi_chat_state::Credentials {
api_key: sampling_config.api_key.clone(),
auth_type: crate::agent::config::resolve_chat_state_auth_type(
sampling_config.model.as_str(),
session_key.as_deref(),
session_key.as_ref(),
existing.auth_type,
),
alpha_test_key: existing.alpha_test_key,
@@ -624,10 +624,20 @@ impl SessionActor {
let resolved_describe = self
.resolve_aux_sampler_config(&self.image_description_model)
.await;
// LEAK 1b: the aux bearer_resolver is decided at the chokepoint from the
// IMAGE-DESCRIBE model's own platform + endpoint and passed in
// explicitly, so an aux model on another provider can never inherit the
// session (Kimi) resolver and have its own key overwritten on the aux
// host. The `None` fallback yields the SESSION config verbatim, whose
// own resolver must stay as-is.
let describe_resolver = resolved_describe
.as_ref()
.map(|cfg| self.aux_bearer_resolver(&self.image_description_model, &cfg.base_url));
let (describe_model, sampler_config) =
crate::agent::config::finalize_image_describe_sampler_config(
resolved_describe,
&active_session_config,
describe_resolver.flatten(),
Some(self.max_retries),
);
let client = kigi_sampler::SamplingClient::new(sampler_config).map_err(|e| {
@@ -300,10 +300,10 @@ pub(super) async fn run_session(
SessionActor::maybe_start_running_task(session.clone(), completion_tx
.clone()). await; } SessionCommand::SessionMode { session_mode, responds_to }
=> { session.handle_session_mode(session_mode). await; let _ = responds_to
.send(()); } SessionCommand::SetSessionModel { sampling_config, use_concise,
apply_prompt_override, skip_prompt_rewrite, auto_compact_threshold_percent,
responds_to } => { let updated_model_id = session
.handle_set_session_model(sampling_config, use_concise,
.send(()); } SessionCommand::SetSessionModel { sampling_config, catalog_key,
use_concise, apply_prompt_override, skip_prompt_rewrite,
auto_compact_threshold_percent, responds_to } => { let updated_model_id =
session.handle_set_session_model(sampling_config, catalog_key, use_concise,
apply_prompt_override, skip_prompt_rewrite, auto_compact_threshold_percent).
await; let _ = responds_to.send(updated_model_id); }
SessionCommand::RebuildAgentForDefinition { definition, responds_to } => {
@@ -319,11 +319,24 @@ pub(super) async fn run_session(
.signals_handle().set_primary_model(& model_name); cfg.model = model_name
.clone(); cfg.extra_headers.extend(extra_headers); if let Some(cw) =
context_window && session.compaction.context_window_override.is_none() { cfg
.context_window = cw; } session.chat_state_handle
.context_window = cw; } let override_base_url = cfg
.base_url.clone(); session.chat_state_handle
.update_sampling_config(cfg); let existing = session.chat_state_handle
.get_credentials(). await; if let Some(r) = crate
::agent::config::try_resolve_model_credentials(model_name.as_str(), existing
.api_key.as_deref()) { session.chat_state_handle
.get_credentials(). await;
// H-c: the rename makes the session's own selected catalog
// key stale unless it still names this model; a stale key is
// exactly what the model→platform rule must not trust.
session.retain_selected_catalog_key_for(& model_name);
// The override model routes to the SAME endpoint the session
// already had; ask the chokepoint whether that endpoint takes
// a session credential rather than re-offering the key
// already in chat state. The platform comes from the session's
// OWN lookup so this and every later turn agree.
let override_session_key = session.credential_authority()
.credential_for(session.model_platform(model_name.as_str()), &
override_base_url); if let Some(r) = crate
::agent::config::try_resolve_model_credentials(model_name.as_str(),
override_session_key.as_ref()) { session.chat_state_handle
.update_credentials(kigi_chat_state::Credentials { api_key : r.api_key,
auth_type : r.auth_type, alpha_test_key : existing.alpha_test_key, }); } session.model_auth_facts
.replace(None); } } SessionCommand::GetCurrentModel { responds_to } => { let
@@ -29,37 +29,105 @@ pub(super) fn is_auth_tool_error(err: &kigi_tool_runtime::ToolError) -> bool {
/// Gate inputs bundled with the composed decision so the 401-recovery log can
/// report the components.
#[derive(Clone, Copy)]
struct SessionTokenAuthGate {
pub(crate) struct SessionTokenAuthGate {
is_session_based: bool,
model_byok: crate::agent::auth_method::ModelByok,
/// Whether the request targets a first-party host. Lets an `Unknown`
/// BYOK status still refresh against the first-party cli-chat-proxy hosts without
/// risking a session-token leak to a third-party BYOK endpoint.
endpoint_is_first_party: bool,
/// WHICH credential this model's platform/endpoint pair accepts, per the
/// single credential chokepoint
/// ([`crate::auth::credential_authority::CredentialAuthority::credential_class`]).
/// `None` for every API-key registry platform, which keeps the primary Kimi
/// bearer off `api.deepseek.com` / `api.openai.com` / … .
credential_class: crate::auth::credential_authority::CredentialClass,
}
impl SessionTokenAuthGate {
/// Single place `is_session_based` / `endpoint_is_first_party` are derived,
/// so all call sites assemble the gate identically.
fn new(
/// so all call sites assemble the gate identically. `model_platform` is the
/// registry platform the model routes to (`None` for a bare / `[model.*]`
/// entry) — it MUST be derived from the same lookup
/// ([`SessionActor::model_platform`]) that
/// [`SessionActor::auth_manager_for_endpoint`] uses, so the gate's verdict
/// and the manager actually wrapped as the bearer resolver can never
/// disagree. `authority` is that same chokepoint, so the gate cannot answer
/// the endpoint question differently from the manager routing.
pub(crate) fn new(
auth_method_id: Option<&acp::AuthMethodId>,
model_byok: crate::agent::auth_method::ModelByok,
base_url: &str,
model_platform: Option<kigi_models::PlatformId>,
authority: &crate::auth::credential_authority::CredentialAuthority,
) -> Self {
Self {
// L13: a model whose OWN credential is a pooled subscription-OAuth
// session is session-based BY ITSELF, whatever the primary ACP
// method is. A user logged in with an API-KEY platform (e.g.
// `deepseek`) who selects a `claude-pro-max/*` model still gets that
// platform's pooled bearer as the request's `api_key` — without this
// term the gate would be inactive, so the config would carry NO
// resolver: the token freezes at selection time and the session dies
// with an unrecoverable 401 once it expires (~1h). The outer
// `credential_class` conjunct keeps this confined to that
// platform's own host.
is_session_based: auth_method_id
.is_some_and(crate::agent::auth_method::is_session_based_method),
.is_some_and(crate::agent::auth_method::is_session_based_method)
|| model_platform.is_some_and(|p| p.oauth().is_some()),
model_byok,
endpoint_is_first_party: crate::util::is_first_party_url(base_url),
credential_class: authority.credential_class(model_platform, base_url),
}
}
fn active(self) -> bool {
pub(crate) fn active(self) -> bool {
crate::agent::auth_method::session_token_auth_gate(
self.is_session_based,
self.model_byok,
self.endpoint_is_first_party,
self.credential_class,
)
}
}
/// THE aux / summary `bearer_resolver` rule, stated ONCE.
///
/// `SamplingClient::post` REPLACES the request's auth header from the resolver,
/// so an aux model on a different provider would have its own correctly-resolved
/// key overwritten by the session bearer ON THE AUX HOST. An OAuth aux model
/// gets a live resolver over ITS OWN pooled manager (keeping mid-session
/// refresh); a first-party aux model gets the primary's, but ONLY when the
/// session-token gate is active; everything else gets `None`, so the aux model's
/// own key survives to the wire.
///
/// M3 — the FIRST-PARTY case honours the gate, which is what the old "copy
/// `active_session_config.bearer_resolver`" shape did implicitly: that field is
/// `None` whenever the gate is inactive. Without it, a BYOK / api-key session
/// with a `[model.*]` aux entry carrying its OWN `env_key` on the session's own
/// coding endpoint has that key REPLACED on the wire by the primary bearer on
/// every image-describe / auto-mode-classifier / summary request. A
/// subscription-OAuth aux model is deliberately NOT gated this way: its pooled
/// token IS its credential, and withholding the resolver only costs it
/// mid-session refresh (L13).
///
/// Shared by [`SessionActor::aux_bearer_resolver`] and
/// `MvpAgent::summary_bearer_resolver`: the summary client is built by the
/// AGENT, not the session actor, and its own private copy of this rule is
/// exactly how it stayed ungated after M3 closed the session-actor side.
pub(crate) fn aux_bearer_resolver_for(
authority: &crate::auth::credential_authority::CredentialAuthority,
auth_method_id: Option<&acp::AuthMethodId>,
platform: Option<kigi_models::PlatformId>,
model_byok: crate::agent::auth_method::ModelByok,
base_url: &str,
) -> Option<kigi_sampler::SharedBearerResolver> {
let is_primary_channel = platform.is_none_or(|p| p.oauth().is_none());
if is_primary_channel
&& !SessionTokenAuthGate::new(auth_method_id, model_byok, base_url, platform, authority)
.active()
{
return None;
}
authority.bearer_resolver_for(platform, base_url)
}
/// Run a tool call; on an auth-shaped failure, attempt recovery via
/// `AuthManager` and one retry. When `shared_recovery` is `Some`, concurrent
/// 401s in the same batch deduplicate via `OnceCell::get_or_init`.
@@ -103,6 +171,31 @@ where
result
}
}
/// Wraps an [`AuthManager`](crate::auth::AuthManager) as a sampler
/// [`BearerResolver`](kigi_sampler::BearerResolver), resolving the live
/// (current-or-expired) bearer at request time. Shared by
/// [`SessionActor::reconstruct_full_config`] (the session model) and the
/// aux-model bearer routing
/// ([`CredentialAuthority::bearer_resolver_for`](crate::auth::credential_authority::CredentialAuthority::bearer_resolver_for))
/// so both wrap ONE definition. SECURITY: the bearer is resolved per request
/// and never logged.
pub(crate) struct AuthManagerBearerResolver(pub(crate) std::sync::Arc<crate::auth::AuthManager>);
impl std::fmt::Debug for AuthManagerBearerResolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthManagerBearerResolver").finish()
}
}
impl kigi_sampler::BearerResolver for AuthManagerBearerResolver {
fn current_bearer(&self) -> Option<String> {
self.0.current_or_expired().map(|a| a.key)
}
}
/// Wrap `am` as a shared sampler bearer resolver.
pub(crate) fn auth_manager_bearer_resolver(
am: std::sync::Arc<crate::auth::AuthManager>,
) -> kigi_sampler::SharedBearerResolver {
std::sync::Arc::new(AuthManagerBearerResolver(am))
}
impl SessionActor {
pub(super) async fn prepare_tool_definitions_timed(&self) -> (Vec<ToolDefinition>, u64) {
let mcp_wait_start = std::time::Instant::now();
@@ -146,8 +239,8 @@ impl SessionActor {
let plan_active = self.plan_mode.lock().is_active();
filter_cursor_tools_by_plan_mode(defs, plan_active)
}
/// Memoized per-model [`ModelAuthFacts`](crate::agent::config::ModelAuthFacts),
/// keyed by `model_id`.
/// Memoized per-model [`ModelAuthFacts`](crate::agent::config::ModelAuthFacts)
/// for the SESSION's own model, keyed by `model_id`.
///
/// A fresh `Unknown` (config currently unparseable) falls back to the last
/// definite value for the same `model_id` rather than demoting a live session
@@ -156,6 +249,32 @@ impl SessionActor {
/// `model_id`, keying on `model_id` alone is insufficient — each
/// model/credential chokepoint must clear this memo (`replace(None)`).
pub(super) fn model_auth_facts(&self, model_id: &str) -> crate::agent::config::ModelAuthFacts {
self.resolve_auth_facts(model_id, true)
}
/// [`Self::model_auth_facts`] for a model that is NOT the session's own — an
/// AUX / summary / image-describe slug.
///
/// Identical resolution, but it NEVER WRITES the slot. The memo is a SINGLE
/// slot: when the aux path shared it, one classifier or image-describe call
/// evicted the session model's entry, and (a) the next
/// [`Self::reconstruct_full_config`] paid another `load_effective_config()`
/// + `resolve_model_list()` — the per-turn disk read M7/M9 removed — while
/// (b) a transient `Unknown` for the SESSION model then had no same-`model_id`
/// definite value to fall back to, so it degraded to `endpoint_is_first_party`
/// — `false` for every subscription-OAuth host, costing the session its
/// `bearer_resolver` and 401ing unrecoverably ~1h in (the failure L13
/// prevents). Reading a matching entry is still allowed: it can only hit when
/// the slot already names this same slug.
fn aux_model_auth_facts(&self, model_id: &str) -> crate::agent::config::ModelAuthFacts {
self.resolve_auth_facts(model_id, false)
}
/// Shared body of [`Self::model_auth_facts`] / [`Self::aux_model_auth_facts`].
/// `memoize` is the ONLY difference, so the two can never resolve differently.
fn resolve_auth_facts(
&self,
model_id: &str,
memoize: bool,
) -> crate::agent::config::ModelAuthFacts {
use crate::agent::auth_method::ModelByok;
if let Some((cached_id, facts)) = self.model_auth_facts.borrow().as_ref()
&& cached_id == model_id
@@ -172,17 +291,187 @@ impl SessionActor {
}
return fresh;
}
*self.model_auth_facts.borrow_mut() = Some((model_id.to_string(), fresh));
if memoize {
*self.model_auth_facts.borrow_mut() = Some((model_id.to_string(), fresh));
}
fresh
}
/// Gate inputs for `model_id` routed to `base_url`. See
/// Gate inputs for the SESSION model `model_id` routed to `base_url`. See
/// [`crate::agent::auth_method::session_token_auth_gate`] for the rationale
/// (`base_url` keeps an `Unknown` BYOK status refreshable only
/// against first-party xAI hosts).
fn auth_gate(&self, model_id: &str, base_url: &str) -> SessionTokenAuthGate {
let byok = self.model_auth_facts(model_id).byok;
let auth_method = self.auth_method_id.load();
SessionTokenAuthGate::new(auth_method.as_deref(), byok, base_url)
SessionTokenAuthGate::new(
auth_method.as_deref(),
byok,
base_url,
self.model_platform(model_id),
&self.credential_authority(),
)
}
/// This session's credential chokepoint: its EFFECTIVE endpoints (so a
/// managed `[endpoints] coding_api_base_url` deployment keeps the session
/// bearer — H3) plus its primary manager, which the authority keeps
/// private. Every inference-auth question this actor asks goes through it.
pub(crate) fn credential_authority(
&self,
) -> crate::auth::credential_authority::CredentialAuthority {
crate::auth::credential_authority::CredentialAuthority::new(
self.models_manager.endpoints(),
self.auth_manager.clone(),
)
}
/// The [`AuthManager`](crate::auth::AuthManager) that governs INFERENCE auth
/// for the routing slug `model` against the endpoint the request will
/// ACTUALLY be sent to, from the ONE chokepoint
/// ([`crate::auth::credential_authority::CredentialAuthority`]).
///
/// A subscription-OAuth platform routes to ITS OWN scope-keyed pooled
/// manager; `kimi-code` and a platform-less model on the session's own
/// coding endpoint route to the primary; every API-key registry platform,
/// and any endpoint that is neither, routes to `None` — fail fast, never a
/// silent fallback to the primary. `None` also for a BYOK / test session
/// with no primary.
///
/// Callers pass the LIVE sampling config's `base_url` so the manager, the
/// gate and the wire can never be resolved against three different endpoints
/// (an `OverrideModelName` session keeps its original `base_url` under a
/// routing name absent from the catalog). A `model`-only sibling that
/// re-derived the endpoint from the CATALOG instead used to exist beside
/// this; it had zero callers and was deleted rather than left as a second,
/// unexercised way to answer the same question (`lib.rs`'s
/// `#![allow(dead_code)]` means such a helper raises no warning).
pub(super) fn auth_manager_for_endpoint(
&self,
model: &str,
base_url: &str,
) -> Option<std::sync::Arc<crate::auth::AuthManager>> {
self.credential_authority()
.manager_for(self.model_platform(model), base_url)
}
/// The SESSION credential (if any) that may ride a request for the routing
/// slug `model`. The only producer is the chokepoint.
pub(super) fn session_credential_for_model(
&self,
model: &str,
) -> Option<crate::auth::credential_authority::SessionCredential> {
self.credential_authority()
.credential_for(self.model_platform(model), &self.model_base_url(model))
}
/// The base URL a routing slug actually resolves to in the live catalog.
/// Falls back to the session's own inference endpoint for an unlisted slug,
/// which is exactly where `resolve_aux_model_sampling_config`'s Tier-2
/// fallback entry routes — so the endpoint the rule is applied to is always
/// the endpoint the request is sent to.
fn model_base_url(&self, model: &str) -> String {
let models = self.models_manager.models();
match crate::agent::config::find_model_by_id(&models, model) {
Some(entry) => entry.info().base_url.clone(),
None => self.models_manager.endpoints().resolve_inference_base_url(),
}
}
/// This SESSION's own selected catalog key (H4) — never the process-global
/// `ModelsManager::current_model_id()`, which Leader mode never writes and
/// which is last-writer-wins across concurrent sessions.
pub(super) fn selected_catalog_key(&self) -> Option<String> {
self.selected_catalog_key.borrow().clone()
}
/// Keep the session's own selected catalog key consistent with an
/// `OverrideModelName` rename: KEEP it when it still names `model_name`
/// (same entry, new routing name), otherwise CLEAR it.
///
/// H-c: `OverrideModelName` is the one command that rewrites
/// `SamplingConfig::model` without going through `SetSessionModel`, so it
/// used to leave the field naming a model the session is no longer on.
/// Clearing rather than re-resolving is deliberate: re-resolving would put
/// `resolve_catalog_key`'s `.rev()` guess INTO the field the whole rule
/// treats as the session's deliberate selection, and a cleared field
/// refuses a collided slug instead of guessing its OAuth twin (H-b).
pub(super) fn retain_selected_catalog_key_for(&self, model_name: &str) {
let models = self.models_manager.models();
let still_names_it = self.selected_catalog_key().is_some_and(|key| {
key == model_name
|| models
.get(key.as_str())
.is_some_and(|entry| entry.info.model == model_name)
});
if !still_names_it {
*self.selected_catalog_key.borrow_mut() = None;
}
}
/// The registry platform the routing slug `model` belongs to, from the SAME
/// lookup [`Self::auth_manager_for_endpoint`] routes on. `None` for a bare /
/// `[model.*]` / unlisted model.
pub(super) fn model_platform(&self, model: &str) -> Option<kigi_models::PlatformId> {
let models = self.models_manager.models();
crate::agent::models::platform_for_slug(
&models,
self.selected_catalog_key().as_deref(),
model,
)
}
/// Whether `model` routes to the Claude Pro/Max OAuth-Messages platform
/// (claude-pro-max) — the gate for the sampler's OAuth Messages adaptation
/// (identity headers + "You are Claude Code" system prefix). A generic-OAuth
/// platform speaking the Messages wire; every other model (incl. xai-grok,
/// which is ChatCompletions) returns `false`, keeping the API-key Anthropic
/// / MiniMax Messages requests byte-identical.
fn model_is_anthropic_oauth(&self, model: &str) -> bool {
self.model_platform(model).is_some_and(|platform| {
platform.oauth().is_some()
&& platform.wire_api() == kigi_models::PlatformWireApi::Messages
})
}
/// Whether `model` routes to the GitHub Copilot ChatCompletions platform
/// (github-copilot) — the gate for the sampler's editor-identity headers +
/// `X-Initiator`. Every other model returns `false`, keeping the other
/// ChatCompletions providers byte-identical.
fn model_is_github_copilot(&self, model: &str) -> bool {
self.model_platform(model)
.is_some_and(kigi_models::PlatformId::sends_copilot_editor_headers)
}
/// Whether `model` routes to the ChatGPT/Codex Responses platform
/// (openai-codex) — the gate for the sampler's Codex identity headers
/// (`chatgpt-account-id` + originator + OpenAI-Beta). Every other model
/// returns `false`, keeping the API-key `openai` Responses request
/// byte-identical.
fn model_is_openai_codex(&self, model: &str) -> bool {
self.model_platform(model)
.is_some_and(kigi_models::PlatformId::sends_codex_responses_headers)
}
/// The `bearer_resolver` an AUX / summary `SamplerConfig` may carry — the
/// shared [`aux_bearer_resolver_for`] rule applied to the AUX model's own
/// platform + endpoint.
///
/// The aux config never inherits the session resolver: it is passed this
/// value explicitly (see
/// [`crate::agent::config::stamp_session_local_sampler_fields`]), so
/// "forgot to re-point" is not expressible.
///
/// The BYOK status comes from [`Self::aux_model_auth_facts`], which does NOT
/// write the session model's single-slot memo.
pub(super) fn aux_bearer_resolver(
&self,
slug: &str,
base_url: &str,
) -> Option<kigi_sampler::SharedBearerResolver> {
let auth_method = self.auth_method_id.load();
// An aux slug is NOT the session's selection, so it must not be resolved
// against `selected_catalog_key` — the same rule the aux `api_key` obeys
// (`credential_for_slug(.., None, ..)`). Keying an aux model on the
// SESSION's selection let a colliding same-vendor slug resolve the OAuth
// twin, whose pooled resolver would then overwrite the user's own key on
// the aux request.
let models = self.models_manager.models();
aux_bearer_resolver_for(
&self.credential_authority(),
auth_method.as_deref(),
crate::agent::models::platform_for_slug(&models, None, slug),
self.aux_model_auth_facts(slug).byok,
base_url,
)
}
/// Emit a unified-log breadcrumb whenever the session-token refresh gate is
/// evaluated with an **`Unknown`** per-model BYOK status on a session-based
@@ -202,8 +491,8 @@ impl SessionActor {
let ctx = serde_json::json!(
{ "site" : site, "model_byok" : gate.model_byok.as_str(), "is_session_based"
: gate.is_session_based, "endpoint_is_first_party" : gate
.endpoint_is_first_party, "refresh_active" : refresh_active, "base_url" :
base_url, }
.endpoint_is_first_party, "credential_class" : gate.credential_class
.as_str(), "refresh_active" : refresh_active, "base_url" : base_url, }
);
let sid = Some(self.session_info.id.0.as_ref());
if refresh_active {
@@ -237,18 +526,6 @@ impl SessionActor {
}
}
}
#[allow(clippy::items_after_statements)]
struct AuthManagerBearerResolver(std::sync::Arc<crate::auth::AuthManager>);
impl std::fmt::Debug for AuthManagerBearerResolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthManagerBearerResolver").finish()
}
}
impl kigi_sampler::BearerResolver for AuthManagerBearerResolver {
fn current_bearer(&self) -> Option<String> {
self.0.current_or_expired().map(|a| a.key)
}
}
let cfg = self
.chat_state_handle
.get_sampling_config()
@@ -260,6 +537,7 @@ impl SessionActor {
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: std::num::NonZeroU64::new(256_000).unwrap(),
reasoning_effort: None,
@@ -268,10 +546,32 @@ impl SessionActor {
let creds = self.chat_state_handle.get_credentials().await;
let model_facts = self.model_auth_facts(cfg.model.as_str());
let auth_method = self.auth_method_id.load();
let gate =
SessionTokenAuthGate::new(auth_method.as_deref(), model_facts.byok, &cfg.base_url);
let gate = SessionTokenAuthGate::new(
auth_method.as_deref(),
model_facts.byok,
&cfg.base_url,
self.model_platform(cfg.model.as_str()),
&self.credential_authority(),
);
let use_bearer_resolver = gate.active();
self.log_auth_gate_unknown("reconstruct_full_config", gate, &cfg.base_url);
// Resolve the bearer from the ACTIVE model's OWN manager: a grok model
// wraps the xai-grok manager, never the Kimi one (captured before
// `cfg.model` is moved into the struct below). `None` when the gate is
// inactive or the oauth provider has no manager (fail-fast, no Kimi
// fallback).
let inference_auth_manager = if use_bearer_resolver {
self.auth_manager_for_endpoint(&cfg.model, &cfg.base_url)
} else {
None
};
// Claude Pro/Max OAuth Messages adaptation for THIS turn's model
// (captured before `cfg.model` is moved into the struct below).
let anthropic_oauth = self.model_is_anthropic_oauth(&cfg.model);
// GitHub Copilot editor-identity headers for THIS turn's model.
let github_copilot = self.model_is_github_copilot(&cfg.model);
// ChatGPT/Codex identity headers for THIS turn's model.
let openai_codex = self.model_is_openai_codex(&cfg.model);
let auth_scheme = model_facts.auth_scheme;
let mut extra_headers = cfg.extra_headers;
crate::agent::config::inject_url_derived_headers(
@@ -312,6 +612,10 @@ impl SessionActor {
top_p: cfg.top_p,
api_backend: cfg.api_backend,
auth_scheme,
anthropic_oauth,
github_copilot,
openai_codex,
chat_compat: cfg.chat_compat,
extra_headers,
context_window: cfg.context_window.get(),
reasoning_effort: cfg.reasoning_effort,
@@ -321,15 +625,7 @@ impl SessionActor {
idle_timeout_secs: None,
origin_client: self.origin_client.clone(),
attribution_callback: self.attribution_callback.clone(),
bearer_resolver: if use_bearer_resolver {
self.auth_manager
.as_ref()
.map(|am| -> kigi_sampler::SharedBearerResolver {
std::sync::Arc::new(AuthManagerBearerResolver(am.clone()))
})
} else {
None
},
bearer_resolver: inference_auth_manager.map(auth_manager_bearer_resolver),
supports_backend_search: self.supports_backend_search.get(),
compactions_remaining: self.compactions_remaining.get(),
compaction_at_tokens: self.compaction_at_tokens.get(),
@@ -459,17 +755,27 @@ impl SessionActor {
slug: &str,
) -> Option<kigi_sampler::SamplerConfig> {
let creds = self.chat_state_handle.get_credentials().await;
let session_key = self
.auth_manager
.as_ref()
.and_then(|am| am.current_or_expired().map(|a| a.key.clone()));
let models = self.models_manager.models();
// Resolve the aux token by the aux model's OWN platform AND endpoint: a
// grok (oauth-platform) aux model draws its pooled grok token or `None`,
// and an API-key registry platform draws NOTHING — NEVER the primary
// Kimi session token (which `resolve_credentials` would otherwise stamp
// onto an api.x.ai / api.deepseek.com request). The first-party
// subscription channel still gets the primary (byte-identical).
// M6: ONE lookup. The platform AND the base URL the rule is applied to
// both come from `credential_for_slug`'s single resolution of `slug`
// against this catalog, so the platform and the endpoint can no longer
// disagree (they were previously resolved by two different lookups).
// Aux slugs are not the session's selection, so no `current_key`.
let session_key = self
.credential_authority()
.credential_for_slug(&models, None, slug);
let endpoints = self.models_manager.endpoints();
crate::agent::config::resolve_aux_model_sampling_config(
slug,
&models,
&endpoints,
session_key.as_deref(),
session_key.as_ref(),
creds.alpha_test_key.clone(),
)
}
@@ -484,9 +790,15 @@ impl SessionActor {
) -> Option<(kigi_sampler::SamplingClient, String)> {
let active_session_config = self.reconstruct_full_config().await;
let mut cfg = self.resolve_aux_sampler_config(slug).await?;
// LEAK 1b: the aux classifier must NOT inherit the SESSION model's
// (Kimi) bearer_resolver — the resolver is decided by the AUX model's
// own platform + endpoint at the chokepoint and passed in explicitly,
// so there is no "copy then remember to re-point" step to forget.
let aux_resolver = self.aux_bearer_resolver(slug, &cfg.base_url);
crate::agent::config::stamp_session_local_sampler_fields(
&mut cfg,
&active_session_config,
aux_resolver,
Some(self.max_retries),
);
let model = cfg.model.clone();
@@ -634,6 +946,7 @@ impl SessionActor {
session_id = % self.session_info.id.0, is_session_based = gate
.is_session_based, model_byok = gate.model_byok.as_str(),
endpoint_is_first_party = gate.endpoint_is_first_party,
credential_class = gate.credential_class.as_str(),
"auth recovery: sampler 401 not refreshable (api-key auth) — surfacing 401",
);
kigi_log::unified_log::warn(
@@ -643,7 +956,8 @@ impl SessionActor {
{ "kind" : error.kind.as_str(), "status_code" : error
.status_code, "is_session_based" : gate.is_session_based,
"model_byok" : gate.model_byok.as_str(),
"endpoint_is_first_party" : gate.endpoint_is_first_party, }
"endpoint_is_first_party" : gate.endpoint_is_first_party,
"credential_class" : gate.credential_class.as_str(), }
)),
);
}
@@ -659,29 +973,40 @@ impl SessionActor {
)),
);
}
if auth_recovery_eligible && let Some(ref am) = self.auth_manager {
if am.try_recover_unauthorized().await {
tracing::info!(
// Recover via the ACTIVE model's OWN manager: a grok 401 recovers the
// xai-grok session via the xai-grok manager, never the Kimi one. For a
// Kimi / non-oauth model this resolves to the primary — byte-identical.
if auth_recovery_eligible {
let (recovery_model, recovery_base_url) = self
.chat_state_handle
.get_sampling_config()
.await
.map(|c| (c.model, c.base_url))
.unwrap_or_default();
if let Some(am) = self.auth_manager_for_endpoint(&recovery_model, &recovery_base_url) {
if am.try_recover_unauthorized().await {
tracing::info!(
session_id = % self.session_info.id.0,
"auth recovery: sampler 401, recovered, retrying"
);
kigi_log::unified_log::info(
"auth recovery: sampler 401, recovered, retrying",
Some(self.session_info.id.0.as_ref()),
None,
);
self.prepare_sampler_for_turn().await;
return Ok(SamplerFailureRecovery::RefreshAuthAndResubmit);
}
tracing::warn!(
session_id = % self.session_info.id.0,
"auth recovery: sampler 401, recovered, retrying"
"auth recovery: sampler 401, refresh failed"
);
kigi_log::unified_log::info(
"auth recovery: sampler 401, recovered, retrying",
kigi_log::unified_log::warn(
"auth recovery: sampler 401, refresh failed",
Some(self.session_info.id.0.as_ref()),
None,
);
self.prepare_sampler_for_turn().await;
return Ok(SamplerFailureRecovery::RefreshAuthAndResubmit);
}
tracing::warn!(
session_id = % self.session_info.id.0,
"auth recovery: sampler 401, refresh failed"
);
kigi_log::unified_log::warn(
"auth recovery: sampler 401, refresh failed",
Some(self.session_info.id.0.as_ref()),
None,
);
}
if matches!(error.kind, SamplingErrorKind::IdleTimeout) {
self.signals_handle().record_idle_timeout();
@@ -844,14 +1169,17 @@ impl SessionActor {
}
/// Proactively refresh the auth token if near expiry.
pub(super) async fn refresh_token_if_expired(&self) {
if let Some(ref am) = self.auth_manager {
let (model_id, base_url) = self
.chat_state_handle
.get_sampling_config()
.await
.map(|c| (c.model, c.base_url))
.unwrap_or_default();
// Refresh the ACTIVE model's OWN manager: a grok model refreshes the
// xai-grok token via the xai-grok manager, never the Kimi one. For a
// Kimi / non-oauth model this resolves to the primary — byte-identical.
if let Some(am) = self.auth_manager_for_endpoint(&model_id, &base_url) {
let creds = self.chat_state_handle.get_credentials().await;
let (model_id, base_url) = self
.chat_state_handle
.get_sampling_config()
.await
.map(|c| (c.model, c.base_url))
.unwrap_or_default();
if self.auth_gate(&model_id, &base_url).active()
&& let Ok(key) = am.get_valid_token().await
{
@@ -882,6 +1210,25 @@ impl SessionActor {
.map(|c| c.model)
.unwrap_or_default();
let Some(ref key) = current_key else { return };
// M7/M9: a registry-platform model's key normally comes from that
// platform's credential resolved into its catalog entry, so with the
// session gate now inactive for every API-key platform those turns all
// fell through to here and paid a `load_effective_config()` disk read
// PER TURN, then logged a permanently false "Model not found in
// config.toml [model.*]" warning.
//
// But a `[model."deepseek/deepseek-chat"]` override DOES keep the base
// entry's `info.id` (`ConfigModelOverride::apply`), so "has a platform"
// does NOT imply "has no `[model.*]` block" — skipping on the platform
// alone would freeze an on-disk key rotation for the whole session.
// Skip only when the catalog entry carries no own credential at all,
// which is exactly the "key came from the platform, not from config"
// case the disk read cannot improve on.
if self.model_platform(&current_model_id).is_some()
&& !self.model_has_own_credential(&current_model_id)
{
return;
}
let Some(new_key) = self.reload_api_key_from_config(&current_model_id) else {
return;
};
@@ -896,6 +1243,15 @@ impl SessionActor {
creds.api_key = Some(new_key);
self.chat_state_handle.update_credentials(creds);
}
/// Whether the live catalog entry for `slug` carries its own credential —
/// an `api_key`/`env_key` from a `[model.*]` block, which a config edit can
/// rotate mid-session. A platform entry whose key came from the platform
/// credential has none.
fn model_has_own_credential(&self, slug: &str) -> bool {
let models = self.models_manager.models();
crate::agent::config::find_model_by_id(&models, slug)
.is_some_and(crate::agent::config::ModelEntry::has_own_credentials)
}
fn reload_api_key_from_config(&self, current_model_id: &str) -> Option<String> {
let raw_config = crate::config::load_effective_config()
.map_err(|e| tracing::warn!(error = % e, "Failed to reload config"))
@@ -973,3 +1329,47 @@ impl SessionActor {
.push_assistant_response(assistant_item);
}
}
#[cfg(test)]
mod bearer_resolver_tests {
use super::AuthManagerBearerResolver;
use kigi_sampler::BearerResolver;
/// LEAK 1b: the shared `AuthManagerBearerResolver` resolves the LIVE bearer
/// of the manager it wraps. So an aux bearer_resolver built over grok's OWN
/// (oauth) pooled manager yields grok's token (or `None`) — NEVER the Kimi
/// session token that a Kimi-manager resolver would. The
/// [`CredentialAuthority::bearer_resolver_for`](crate::auth::credential_authority::CredentialAuthority::bearer_resolver_for)
/// wraps exactly this grok manager for a grok aux model.
#[tokio::test]
async fn resolver_resolves_the_wrapped_manager_never_kimi() {
let dir = tempfile::tempdir().unwrap();
let kimi = std::sync::Arc::new(crate::auth::AuthManager::new(
dir.path(),
crate::auth::KimiCodeConfig::default(),
));
kimi.hot_swap(crate::auth::KimiAuth {
key: "kimi-tok".to_string(),
auth_mode: crate::auth::AuthMode::OAuth,
..crate::auth::KimiAuth::test_default()
});
// The Kimi-manager resolver yields the Kimi bearer.
assert_eq!(
AuthManagerBearerResolver(kimi.clone()).current_bearer(),
Some("kimi-tok".to_string()),
);
// The grok (oauth) pooled manager is distinct — its resolver never
// yields the Kimi bearer (grok's own token, or None).
let oauth = kigi_models::PlatformId::XaiGrok
.oauth()
.expect("xai-grok carries an OAuthConfig");
let grok = crate::auth::oauth_registry::global_manager_for(
&crate::auth::oauth_registry::pool_home(),
oauth,
);
assert_ne!(
AuthManagerBearerResolver(grok).current_bearer(),
Some("kimi-tok".to_string()),
"a grok aux bearer_resolver must never resolve the Kimi session token",
);
}
}
@@ -346,6 +346,7 @@ pub(crate) async fn spawn_session_actor(
temperature: sampling_config.temperature,
top_p: sampling_config.top_p,
api_backend: sampling_config.api_backend.clone(),
chat_compat: sampling_config.chat_compat,
extra_headers: sampling_config.extra_headers.clone(),
context_window: context_window_override.unwrap_or(baseline_context_window),
reasoning_effort: sampling_config.reasoning_effort,
@@ -979,10 +980,21 @@ pub(crate) async fn spawn_session_actor(
}
};
let doom_loop_recovery = effective_config.resolve_doom_loop_recovery();
let session_model_id_for_actor = session_model_id.clone();
let session = Arc::new_cyclic(|weak: &std::sync::Weak<SessionActor>| SessionActor {
session_info: session_info.clone(),
auth_method_id,
model_auth_facts: std::cell::RefCell::new(None),
// H4: seed the session's OWN selected catalog key from the model it was
// spawned with, resolved through the picker's lookup. Never the
// process-global `current_model_id()`. H-c: the rule lives in
// `selected_catalog_key_for_spawn` so it is covered by a test.
selected_catalog_key: std::cell::RefCell::new(
crate::agent::models::selected_catalog_key_for_spawn(
&models_manager.models(),
&session_model_id_for_actor,
),
),
attribution_callback,
auth_manager,
state,
@@ -485,23 +485,44 @@ async fn no_legacy_hint_for_oidc_auth() {
#[test]
fn session_token_auth_gate_truth_table() {
use crate::agent::auth_method::{ModelByok, session_token_auth_gate as gate};
use crate::auth::credential_authority::CredentialClass;
// Non-session methods never refresh, regardless of BYOK status or endpoint.
// `Pooled` (an OAuth platform's own pool) and `Primary` (kimi-code, or a
// bare / [model.*] model on the session's own endpoint) behave identically
// here: each names a credential that IS refreshable on that host.
for fp in [false, true] {
assert!(!gate(false, ModelByok::NotByok, fp));
assert!(!gate(false, ModelByok::Byok, fp));
assert!(!gate(false, ModelByok::Unknown, fp));
// Session method: a definite classification ignores the endpoint —
// NotByok always refreshes (only ever routes to the session endpoint),
// a genuine per-model Byok never does.
assert!(gate(true, ModelByok::NotByok, fp));
assert!(!gate(true, ModelByok::Byok, fp));
for class in [CredentialClass::Pooled, CredentialClass::Primary] {
assert!(!gate(false, ModelByok::NotByok, fp, class));
assert!(!gate(false, ModelByok::Byok, fp, class));
assert!(!gate(false, ModelByok::Unknown, fp, class));
// Session method on an endpoint that DOES take a session
// credential: a definite classification ignores the endpoint —
// NotByok refreshes, a genuine per-model Byok never does.
assert!(gate(true, ModelByok::NotByok, fp, class));
assert!(!gate(true, ModelByok::Byok, fp, class));
}
// …and an API-key registry platform endpoint (`CredentialClass::None`)
// is refused on every arm, first-party flag included: the leak guard.
assert!(!gate(true, ModelByok::NotByok, fp, CredentialClass::None));
assert!(!gate(true, ModelByok::Byok, fp, CredentialClass::None));
assert!(!gate(true, ModelByok::Unknown, fp, CredentialClass::None));
}
// Session method + Unknown BYOK: refresh only against a first-party xAI
// host, so a transiently-unclassifiable config can't demote a live session
// (the stale-token 401 regression) yet the session token never leaks to a
// third-party BYOK endpoint. This arm was unconditionally `false` pre-fix.
assert!(gate(true, ModelByok::Unknown, true));
assert!(!gate(true, ModelByok::Unknown, false));
assert!(gate(
true,
ModelByok::Unknown,
true,
CredentialClass::Primary
));
assert!(!gate(
true,
ModelByok::Unknown,
false,
CredentialClass::Primary
));
}
/// Pre-fix, the gate read `auth_type` and skipped recovery here, 401'ing every
@@ -847,7 +868,11 @@ async fn set_session_model_invalidates_byok_memo_for_same_model_id() {
temperature: None,
top_p: None,
api_backend: crate::sampling::ApiBackend::ChatCompletions,
chat_compat: Default::default(),
auth_scheme: Default::default(),
anthropic_oauth: false,
github_copilot: false,
openai_codex: false,
extra_headers: Default::default(),
context_window: 256_000,
force_http1: false,
@@ -865,7 +890,7 @@ async fn set_session_model_invalidates_byok_memo_for_same_model_id() {
header_injector: None,
};
let _ = actor
.handle_set_session_model(cfg, false, false, true, 85)
.handle_set_session_model(cfg, None, false, false, true, 85)
.await;
assert!(
@@ -44,7 +44,11 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
auth_scheme: Default::default(),
anthropic_oauth: false,
github_copilot: false,
openai_codex: false,
extra_headers: Default::default(),
context_window: 100_000,
force_http1: false,
@@ -87,6 +91,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: std::num::NonZeroU64::new(100_000).unwrap(),
reasoning_effort: None,
@@ -104,6 +109,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
session_info,
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
selected_catalog_key: std::cell::RefCell::new(None),
attribution_callback: None,
auth_manager: None,
state: TokioMutex::new(State {
@@ -338,7 +344,11 @@ async fn first_turn_memory_injection_persists_to_chat_history() {
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
auth_scheme: Default::default(),
anthropic_oauth: false,
github_copilot: false,
openai_codex: false,
context_window: 100_000,
force_http1: false,
max_retries: None,
@@ -381,6 +391,7 @@ async fn first_turn_memory_injection_persists_to_chat_history() {
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: std::num::NonZeroU64::new(100_000).unwrap(),
reasoning_effort: None,
@@ -466,7 +477,11 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
auth_scheme: Default::default(),
anthropic_oauth: false,
github_copilot: false,
openai_codex: false,
context_window: 100_000,
force_http1: false,
max_retries: None,
@@ -513,6 +528,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: std::num::NonZeroU64::new(100_000).unwrap(),
reasoning_effort: None,
@@ -547,6 +563,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
session_info: session_info.clone(),
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
selected_catalog_key: std::cell::RefCell::new(None),
attribution_callback: None,
auth_manager: None,
state: TokioMutex::new(State {
@@ -810,6 +827,7 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() {
},
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
selected_catalog_key: std::cell::RefCell::new(None),
attribution_callback: None,
auth_manager: None,
state,
@@ -1732,7 +1750,11 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
temperature: None,
top_p: None,
api_backend: kigi_sampler::ApiBackend::Responses,
chat_compat: Default::default(),
auth_scheme: Default::default(),
anthropic_oauth: false,
github_copilot: false,
openai_codex: false,
extra_headers: Default::default(),
context_window: 100_000,
force_http1: false,
@@ -1798,6 +1820,7 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
},
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
selected_catalog_key: std::cell::RefCell::new(None),
attribution_callback: None,
auth_manager: None,
state,
@@ -104,6 +104,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: std::num::NonZeroU64::new(200_000).unwrap(),
reasoning_effort: None,
@@ -127,6 +128,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
attribution_callback: None,
auth_method_id: test_auth_method_id("cached_token"),
model_auth_facts: std::cell::RefCell::new(None),
selected_catalog_key: std::cell::RefCell::new(None),
auth_manager: {
let dir = tempfile::tempdir().unwrap();
let mgr = std::sync::Arc::new(crate::auth::AuthManager::new(
@@ -52,6 +52,7 @@ async fn create_test_actor(
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: std::num::NonZeroU64::new(context_window)
.expect("test context_window must be non-zero"),
@@ -71,6 +72,7 @@ async fn create_test_actor(
rebuild_spec: crate::session::agent_rebuild::test_rebuild_spec_default(),
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
selected_catalog_key: std::cell::RefCell::new(None),
attribution_callback: None,
auth_manager: None,
state,
@@ -486,6 +488,7 @@ async fn create_test_actor_with_memory(
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: std::num::NonZeroU64::new(context_window)
.expect("test context_window must be non-zero"),
@@ -509,6 +512,7 @@ async fn create_test_actor_with_memory(
rebuild_spec: crate::session::agent_rebuild::test_rebuild_spec_default(),
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
selected_catalog_key: std::cell::RefCell::new(None),
attribution_callback: None,
auth_manager: None,
state,
@@ -1240,6 +1244,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: std::num::NonZeroU64::new(200_000).unwrap(),
reasoning_effort: None,
@@ -1263,6 +1268,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
rebuild_spec: crate::session::agent_rebuild::test_rebuild_spec_default(),
auth_method_id: test_auth_method_id("cached_token"),
model_auth_facts: std::cell::RefCell::new(None),
selected_catalog_key: std::cell::RefCell::new(None),
auth_manager: {
let dir = tempfile::tempdir().unwrap();
let mgr = std::sync::Arc::new(crate::auth::AuthManager::new(
@@ -104,6 +104,7 @@ async fn create_test_actor_with_memory(
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: std::num::NonZeroU64::new(context_window)
.expect("test context_window must be non-zero"),
@@ -126,6 +127,7 @@ async fn create_test_actor_with_memory(
},
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
selected_catalog_key: std::cell::RefCell::new(None),
attribution_callback: None,
auth_manager: None,
state,
@@ -78,6 +78,7 @@ pub(super) async fn make_replay_send_update_fixture() -> ReplaySendUpdateFixture
},
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
selected_catalog_key: std::cell::RefCell::new(None),
attribution_callback: None,
auth_manager: None,
state,
@@ -0,0 +1,990 @@
//! LEAK GUARD, part 2: the model→platform lookup (H5) and the AUX resolver
//! decision (H3/H4). Shares the fixtures in
//! [`super::session_bearer_leak_tests`]; see that module's header for the chain
//! and the storage-discipline contract.
use super::session_bearer_leak_tests::{
KIMI_TOKEN, actor_on_managed_model, actor_with_catalog, managed_entry,
};
use super::*;
use kigi_sampler::BearerResolver;
use kigi_test_support::EnvGuard;
use std::sync::Arc;
/// The host BOTH halves of the `anthropic` / `claude-pro-max` collision route
/// to, derived from the registry (as the sibling at
/// [`oauth_platform_models_keep_a_live_resolver_from_their_own_pool`] does) so
/// the fixture cannot drift, with the twin agreement asserted rather than
/// assumed — the collision is only a collision because both platforms serve the
/// same host.
fn anthropic_collision_host() -> String {
let oauth_host = kigi_models::PlatformId::ClaudeProMax.base_url();
assert_eq!(
kigi_models::PlatformId::Anthropic.base_url(),
oauth_host,
"the API-key platform and its subscription-OAuth twin must serve the same host, \
or this fixture is not testing the dual-credential collision"
);
oauth_host
}
/// Ambient BYOK env unset. `resolve_model_auth_facts` probes `std::env::var` at
/// call time, so a developer (or CI) holding `ANTHROPIC_API_KEY` flips the
/// fixture to `Byok` and switches off the session-token gate for a reason that
/// has nothing to do with the platform lookup under test. Every holder must be
/// `#[serial]`.
fn anthropic_collision_env_guard() -> [EnvGuard; 2] {
[
EnvGuard::unset("ANTHROPIC_API_KEY"),
EnvGuard::unset("KIGI_CODE_BASE_URL"),
]
}
#[tokio::test(flavor = "current_thread")]
async fn dual_credential_slug_collision_resolves_the_selected_oauth_platform() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
// (api-key twin, oauth twin, shared slug, host)
for (api_key_twin, oauth_twin, slug, base_url) in [
(
"xai/grok-4.5",
"xai-grok/grok-4.5",
"grok-4.5",
"https://api.x.ai/v1",
),
(
"anthropic/claude-opus-4-8",
"claude-pro-max/claude-opus-4-8",
"claude-opus-4-8",
"https://api.anthropic.com/v1",
),
(
"openai/gpt-5.5-codex",
"openai-codex/gpt-5.5-codex",
"gpt-5.5-codex",
"https://chatgpt.com/backend-api/codex",
),
] {
// API-key platform FIRST, exactly as `PlatformId::ALL` orders them.
let catalog = vec![
managed_entry(api_key_twin, slug, base_url),
managed_entry(oauth_twin, slug, base_url),
];
let (_dir, actor, _rx) = actor_with_catalog(catalog, oauth_twin, "unused").await;
let cfg = actor.reconstruct_full_config().await;
let resolver = cfg.bearer_resolver.as_ref().unwrap_or_else(|| {
panic!(
"{oauth_twin}: selecting the OAuth twin must keep a LIVE bearer_resolver \
(mid-session refresh); resolving {api_key_twin} instead drops it"
)
});
assert_ne!(
resolver.current_bearer(),
Some(KIMI_TOKEN.to_string()),
"{oauth_twin}: the resolver must read its OWN pool, never the Kimi primary"
);
let platform = kigi_models::parse_managed_model_key(oauth_twin)
.expect("managed key")
.0;
assert_eq!(
cfg.anthropic_oauth,
platform.wire_api() == kigi_models::PlatformWireApi::Messages,
"{oauth_twin}: the Claude OAuth Messages adaptation must follow the \
SELECTED platform"
);
assert_eq!(
cfg.openai_codex,
platform.sends_codex_responses_headers(),
"{oauth_twin}: the Codex identity headers must follow the SELECTED platform"
);
assert_eq!(
cfg.github_copilot,
platform.sends_copilot_editor_headers(),
"{oauth_twin}: the Copilot editor headers must follow the SELECTED platform"
);
}
})
.await;
}
/// The other half of H5: selecting the API-KEY twin of a colliding slug must
/// still resolve the API-key platform — no bearer_resolver, no adaptations. The
/// unified lookup must not simply prefer OAuth.
#[tokio::test(flavor = "current_thread")]
async fn dual_credential_slug_collision_resolves_the_selected_api_key_platform() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let catalog = vec![
managed_entry(
"anthropic/claude-opus-4-8",
"claude-opus-4-8",
"https://api.anthropic.com/v1",
),
managed_entry(
"claude-pro-max/claude-opus-4-8",
"claude-opus-4-8",
"https://api.anthropic.com/v1",
),
];
let (_dir, actor, _rx) =
actor_with_catalog(catalog, "anthropic/claude-opus-4-8", "sk-ant-byok").await;
let cfg = actor.reconstruct_full_config().await;
assert!(
cfg.bearer_resolver.is_none(),
"selecting the API-key twin must get NO session bearer resolver"
);
assert!(
!cfg.anthropic_oauth,
"the API-key Anthropic Messages request must stay byte-identical"
);
assert_eq!(cfg.api_key.as_deref(), Some("sk-ant-byok"));
})
.await;
}
/// MANDATORY counterpart: the subscription-OAuth platforms have NON-first-party
/// base URLs, so the fix must not disable their resolver. Each must still get a
/// LIVE `bearer_resolver` — and it must read THAT platform's own pooled
/// `AuthManager`, never the Kimi primary. The pooled managers are empty here (a
/// TempDir pool home), which is what makes `current_bearer() == None` a proof
/// that the Kimi bearer cannot be what they resolve.
#[tokio::test(flavor = "current_thread")]
async fn oauth_platform_models_keep_a_live_resolver_from_their_own_pool() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
// The base URL is the platform's OWN registry host, exactly as
// `models_fetch::platform_fetch_base` builds every fetched entry —
// derived here rather than hard-coded so the fixture cannot drift
// from the registry (L10 compares against precisely this).
for (catalog_key, slug) in [
("claude-pro-max/claude-opus-4-8", "claude-opus-4-8"),
("github-copilot/gpt-4.1", "gpt-4.1"),
("xai-grok/grok-4-latest", "grok-4-latest"),
("openai-codex/gpt-5.5", "gpt-5.5"),
] {
let base_url = kigi_models::parse_managed_model_key(catalog_key)
.expect("managed key")
.0
.base_url();
let base_url = base_url.as_str();
let (_dir, actor, _rx) =
actor_on_managed_model(catalog_key, slug, base_url, "unused").await;
let cfg = actor.reconstruct_full_config().await;
let resolver = cfg.bearer_resolver.as_ref().unwrap_or_else(|| {
panic!("{catalog_key}: must keep a live bearer_resolver for refresh")
});
assert_ne!(
resolver.current_bearer(),
Some(KIMI_TOKEN.to_string()),
"{catalog_key}: the Kimi bearer must never be what it resolves"
);
// The resolver is LIVE over that platform's pooled manager: a
// token rotated inside the pool is observed by the
// already-built resolver (this is what mid-session refresh
// does). The pool is read here, never mutated.
let pooled = actor
.credential_authority()
.manager_for(
kigi_models::parse_managed_model_key(catalog_key).map(|(p, _)| p),
base_url,
)
.expect("an OAuth platform on its own host always resolves a manager");
assert!(
!Arc::ptr_eq(
&pooled,
actor.auth_manager.as_ref().expect("primary is present")
),
"{catalog_key}: must route to its OWN pooled manager, not the Kimi primary"
);
assert_eq!(
resolver.current_bearer(),
pooled.current_or_expired().map(|a| a.key),
"{catalog_key}: the resolver must read THIS platform's pooled manager"
);
}
})
.await;
}
/// H3/H4 — the stamped AUX paths (image-describe, the auto-mode classifier and
/// the session-summary client all funnel through
/// `CredentialAuthority::bearer_resolver_for`). `SamplingClient::post` REPLACES
/// the request's auth header from the resolver, so an API-key-platform aux model
/// would have its own key overwritten by the Kimi bearer ON THE AUX HOST.
///
/// Revert-to-red: make `CredentialAuthority::governing_manager`'s
/// `Some(platform) => None` arm return `self.primary.clone()` and the deepseek /
/// openai / moonshot rows resolve `KIMI_TOKEN`.
#[test]
fn aux_bearer_resolver_clears_the_session_resolver_off_a_third_party_aux_host() {
let dir = tempfile::tempdir().expect("tempdir");
let primary = Arc::new(crate::auth::AuthManager::new(
dir.path(),
crate::auth::KimiCodeConfig::default(),
));
primary.hot_swap(crate::auth::KimiAuth {
key: KIMI_TOKEN.to_string(),
auth_mode: crate::auth::AuthMode::OAuth,
..crate::auth::KimiAuth::test_default()
});
let authority = crate::auth::credential_authority::CredentialAuthority::new(
crate::agent::config::EndpointsConfig::default(),
Some(primary),
);
let platform =
|key: &str| kigi_models::parse_managed_model_key(key).map(|(platform, _)| platform);
// Cleared: every API-key registry platform, and a `[model.*]` aux model
// pointed at a third-party host.
for (key, base_url) in [
("deepseek/deepseek-chat", "https://api.deepseek.com/v1"),
("openai/gpt-5-mini", "https://api.openai.com/v1"),
(
"moonshot-cn/kimi-k2-turbo-preview",
"https://api.moonshot.cn/v1",
),
] {
assert!(
authority
.bearer_resolver_for(platform(key), base_url)
.is_none(),
"LEAK: an aux model on {base_url} must not inherit the session bearer resolver"
);
}
assert!(
authority
.bearer_resolver_for(None, "https://api.openai.com/v1")
.is_none(),
"LEAK: a [model.*] aux model on a third-party host must not inherit it either"
);
// Kept (byte-identical): the first-party subscription channel and a
// platform-less aux model on the session's own endpoint.
for (key, base_url) in [
(
Some("kimi-code/kimi-for-coding"),
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
),
(None, kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url),
(None, "http://127.0.0.1:4141/v1"),
] {
let resolved = authority
.bearer_resolver_for(key.and_then(platform), base_url)
.unwrap_or_else(|| panic!("{key:?} @ {base_url} must keep the session resolver"));
assert_eq!(resolved.current_bearer(), Some(KIMI_TOKEN.to_string()));
}
// Re-pointed: an OAuth aux model on ITS OWN host gets a LIVE resolver over
// its own pool (empty here), never the Kimi primary. L10: the same model
// redirected to a third-party host gets NOTHING.
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime for the pooled manager's refresh task");
rt.block_on(async {
for key in [
"xai-grok/grok-4-latest",
"claude-pro-max/claude-opus-4-8",
"github-copilot/gpt-4.1",
"openai-codex/gpt-5.5",
] {
let p = platform(key).expect("managed key");
let resolved = authority
.bearer_resolver_for(Some(p), &p.base_url())
.unwrap_or_else(|| panic!("{key} must keep a live resolver from its own pool"));
assert_ne!(
resolved.current_bearer(),
Some(KIMI_TOKEN.to_string()),
"{key}: the aux resolver must never resolve the Kimi session bearer"
);
assert!(
authority
.bearer_resolver_for(Some(p), "https://example.invalid/v1")
.is_none(),
"LEAK ({key}): an OAuth aux model redirected off its own host gets nothing"
);
}
});
}
/// H4 — TWO CONCURRENT SESSIONS on a colliding slug. The model→platform lookup
/// used to key on `ModelsManager::current_model_id()`, a single PROCESS-GLOBAL
/// `RwLock<acp::ModelId>` written by whichever session switched last. With one
/// session on `xai-grok/grok-4.5` and another on `xai/grok-4.5` — same routing
/// slug, by design — the loser resolved the OTHER session's platform:
/// the subscription session lost its live resolver (unrecoverable 401 ~1h in)
/// and the API-key session got the pooled OAuth bearer stamped over its own
/// `sk-…` key, which the provider rejects.
///
/// Here the global cell is deliberately set to the API-key twin for BOTH
/// sessions (last writer wins, and it was the API-key one). Each session must
/// still resolve ITS OWN selection.
///
/// Revert-to-red: replace `self.selected_catalog_key()` in
/// `SessionActor::model_platform` with
/// `Some(self.models_manager.current_model_id().0.as_ref())`. Under THIS
/// fixture both of the subscription session's assertions fail. (L: the fixture
/// is what makes that true — `managed_entry` carries `api_key: None` and
/// `actor_with_catalog` pins `NotByok`, which is exactly what a FETCHED registry
/// entry resolves to. A user who additionally sets `ANTHROPIC_API_KEY` /
/// `[model.*] env_key` classifies `Byok`, the gate is inactive for that reason
/// alone, and only the `anthropic_oauth` assertion would still catch the
/// mis-resolution — hence the env guard below.)
#[tokio::test(flavor = "current_thread")]
#[serial_test::serial]
async fn concurrent_sessions_on_a_colliding_slug_each_resolve_their_own_platform() {
let _env = anthropic_collision_env_guard();
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let api_key_twin = "anthropic/claude-opus-4-8";
let oauth_twin = "claude-pro-max/claude-opus-4-8";
let slug = "claude-opus-4-8";
let host = &anthropic_collision_host();
let catalog = || {
vec![
// API-key platform FIRST, exactly as `PlatformId::ALL` orders them.
managed_entry(api_key_twin, slug, host),
managed_entry(oauth_twin, slug, host),
]
};
let (_d1, subscription, _r1) =
actor_with_catalog(catalog(), oauth_twin, "unused").await;
let (_d2, api_key, _r2) =
actor_with_catalog(catalog(), api_key_twin, "sk-ant-user").await;
// The other session switched last: the process-global cell now names
// the API-key twin for BOTH.
for actor in [&subscription, &api_key] {
actor
.models_manager
.set_current_model_id(acp::ModelId::new(api_key_twin.to_string()));
}
let sub_cfg = subscription.reconstruct_full_config().await;
let resolver = sub_cfg.bearer_resolver.as_ref().expect(
"the subscription session must keep its own live bearer_resolver even when \
another session switched the process-global model last",
);
assert_ne!(
resolver.current_bearer(),
Some(KIMI_TOKEN.to_string()),
"it must read the claude-pro-max pool, never the Kimi primary"
);
assert!(
sub_cfg.anthropic_oauth,
"the Claude OAuth Messages adaptation must follow the SUBSCRIPTION session"
);
let api_cfg = api_key.reconstruct_full_config().await;
assert!(
api_cfg.bearer_resolver.is_none(),
"LEAK: the API-key session must get no session bearer_resolver"
);
assert!(
!api_cfg.anthropic_oauth,
"the API-key session must not get the OAuth Messages adaptation"
);
assert_eq!(
api_cfg.api_key.as_deref(),
Some("sk-ant-user"),
"the API-key session keeps its own provider key"
);
})
.await;
}
/// H4 in LEADER mode, where `agent/handlers/model_switch.rs` skips
/// `set_current_model_id` ENTIRELY, so the process-global cell is frozen at the
/// startup default for the whole process lifetime. `platform_for_slug` then
/// fell through to the `.rev()` scan, which returns the LAST match — the OAuth
/// twin — so a Leader-mode session on the API-KEY twin was handed the pooled
/// OAuth bearer plus the Messages adaptation, and Anthropic rejects both.
///
/// Revert-to-red: same edit as above; with the global cell naming the startup
/// default (not in this catalog) the `.rev()` fallback resolves
/// `claude-pro-max/*` and both assertions fail.
#[tokio::test(flavor = "current_thread")]
#[serial_test::serial]
async fn leader_mode_session_resolves_its_own_platform_without_the_global_cell() {
let _env = anthropic_collision_env_guard();
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let slug = "claude-opus-4-8";
let host = &anthropic_collision_host();
let (_dir, actor, _rx) = actor_with_catalog(
vec![
managed_entry("anthropic/claude-opus-4-8", slug, host),
managed_entry("claude-pro-max/claude-opus-4-8", slug, host),
],
"anthropic/claude-opus-4-8",
"sk-ant-user",
)
.await;
// Leader mode never writes the global cell: it still names the
// startup default, which is not in this catalog at all.
assert!(
!actor
.models_manager
.models()
.contains_key(actor.models_manager.current_model_id().0.as_ref()),
"precondition: the process-global model id is stale (Leader mode)"
);
let cfg = actor.reconstruct_full_config().await;
assert!(
cfg.bearer_resolver.is_none(),
"LEAK: a Leader-mode API-key session must get no session bearer_resolver"
);
assert!(
!cfg.anthropic_oauth,
"a Leader-mode API-key session must not get the OAuth Messages adaptation"
);
})
.await;
}
/// L13 — a user whose ACP auth method is an API-KEY registry platform (e.g.
/// `deepseek`) can still SELECT a subscription-OAuth model, and the chokepoint
/// hands it that platform's pooled bearer as the request's `api_key`. The gate
/// keys on the primary method, which is not session-based, so the config used
/// to carry NO `bearer_resolver`: the pooled token froze at selection time and
/// the session died with an unrecoverable 401 once it expired (~1h).
///
/// The model's own credential now makes the gate session-based, confined to
/// that platform's own host by the gate's `credential_class` conjunct.
#[tokio::test(flavor = "current_thread")]
async fn oauth_model_under_an_api_key_auth_method_keeps_its_pooled_resolver() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let base_url = kigi_models::PlatformId::ClaudeProMax.base_url();
let (_dir, actor, _rx) = actor_with_catalog(
vec![managed_entry(
"claude-pro-max/claude-opus-4-8",
"claude-opus-4-8",
&base_url,
)],
"claude-pro-max/claude-opus-4-8",
"unused",
)
.await;
// The PRIMARY ACP method is an API-key registry platform login.
actor
.auth_method_id
.store(Some(std::sync::Arc::new(acp::AuthMethodId::new(
"deepseek",
))));
let cfg = actor.reconstruct_full_config().await;
let resolver = cfg.bearer_resolver.as_ref().expect(
"a subscription-OAuth model keeps a live resolver whatever the primary \
ACP auth method is, or it cannot refresh mid-session",
);
assert_ne!(
resolver.current_bearer(),
Some(KIMI_TOKEN.to_string()),
"and it reads the claude-pro-max pool, never the primary"
);
// An API-key-platform model under the same method stays resolver-free.
let (_d2, deepseek, _r2) = actor_with_catalog(
vec![managed_entry(
"deepseek/deepseek-chat",
"deepseek-chat",
"https://api.deepseek.com/v1",
)],
"deepseek/deepseek-chat",
"sk-deepseek",
)
.await;
deepseek
.auth_method_id
.store(Some(std::sync::Arc::new(acp::AuthMethodId::new(
"deepseek",
))));
assert!(
deepseek
.reconstruct_full_config()
.await
.bearer_resolver
.is_none(),
"LEAK: an API-key-platform model must never get a session resolver"
);
})
.await;
}
/// H-b — a `None` or STALE per-session catalog key must REFUSE, not degrade to
/// the subscription-OAuth twin.
///
/// `model_platform` falls through to `resolve_catalog_key`'s `.rev()` scan when
/// the session's own key does not name the slug, and that scan returns the LAST
/// match — the OAuth twin, because `PlatformId::ALL` orders every API-key
/// platform first. Combined with the L13 disjunct (a model whose own credential
/// is a pooled OAuth session is session-based BY ITSELF), an API-KEY session on
/// `anthropic/claude-opus-4-8` with no per-session key got
/// `is_session_based = true`, `credential_class = Pooled` (same
/// host) and, at `NotByok`, an ACTIVE gate — so `manager_for` handed it the
/// Claude POOLED manager, whose `bearer_resolver` REPLACES the user's own
/// `sk-ant-…` on the wire, plus the OAuth Messages adaptation. Anthropic rejects
/// both. This is exactly what H4 prevents, reached through the `None` path.
///
/// A key that is absent or names a different model is not evidence for either
/// twin: resolve to NO platform, which the chokepoint then decides purely by the
/// ENDPOINT (the OAuth host is not this session's coding endpoint ⇒ nothing
/// rides).
///
/// Revert-to-red (production, compiles): delete the
/// `platform.oauth().is_some() && !disambiguated && slug_collides_across_platforms(..)`
/// refusal from `crate::agent::models::platform_for_slug` and every
/// `bearer_resolver` / `anthropic_oauth` assertion below fails.
#[tokio::test(flavor = "current_thread")]
#[serial_test::serial]
async fn a_missing_or_stale_session_key_refuses_instead_of_guessing_the_oauth_twin() {
let _env = anthropic_collision_env_guard();
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let api_key_twin = "anthropic/claude-opus-4-8";
let slug = "claude-opus-4-8";
let host = anthropic_collision_host();
let catalog = || {
vec![
// API-key platform FIRST, exactly as `PlatformId::ALL` orders them.
managed_entry(api_key_twin, slug, &host),
managed_entry("claude-pro-max/claude-opus-4-8", slug, &host),
]
};
for (case, stale_key) in [
// No key at all: a session spawned on a model that left the
// catalog, or one an older build never seeded.
("absent", None),
// Stale: an `OverrideModelName` rename, or a key naming a model
// this session is no longer on.
("stale", Some("claude-pro-max/some-other-model".to_string())),
] {
let (_dir, actor, _rx) =
actor_with_catalog(catalog(), api_key_twin, "sk-ant-user").await;
*actor.selected_catalog_key.borrow_mut() = stale_key;
let cfg = actor.reconstruct_full_config().await;
assert!(
cfg.bearer_resolver.is_none(),
"{case}: LEAK — an unresolvable selection must get NO session bearer \
resolver; the pooled OAuth bearer would REPLACE the user's own key"
);
assert!(
!cfg.anthropic_oauth,
"{case}: nor the Claude OAuth Messages adaptation"
);
assert_eq!(
cfg.api_key.as_deref(),
Some("sk-ant-user"),
"{case}: the user's own provider key must survive untouched"
);
assert!(
actor
.credential_authority()
.manager_for(
crate::agent::models::platform_for_slug(
&actor.models_manager.models(),
actor.selected_catalog_key().as_deref(),
slug,
),
&host,
)
.is_none(),
"{case}: and no manager either — refuse, never guess"
);
}
// …while a session that DID select the OAuth twin still gets its
// pooled resolver: the refusal is about the guess, not the platform.
let (_dir, selected, _rx) =
actor_with_catalog(catalog(), "claude-pro-max/claude-opus-4-8", "unused").await;
let cfg = selected.reconstruct_full_config().await;
assert!(
cfg.bearer_resolver.is_some() && cfg.anthropic_oauth,
"a DELIBERATE subscription selection keeps its pooled resolver and \
adaptation (this is what makes the refusals above meaningful)"
);
})
.await;
}
/// H-c — coverage for the FIRST of the two production writers of
/// `selected_catalog_key`: the spawn seed
/// (`crate::agent::models::selected_catalog_key_for_spawn`, called from
/// `spawn.rs`). Every other test in this module sets the field by hand, so a
/// wrong seed was silent.
///
/// Both spawn shapes are covered: a FRESH session, spawned on the catalog key
/// the picker resolved, and a RESUME/LOAD, which spawns with the RAW persisted
/// `summary.current_model_id` — a BARE routing slug after any `SetSessionModel`,
/// since `handle_set_session_model` persists `sampling_config.model`. This seed
/// is where that slug becomes a key. The assertion is end-to-end: the seeded key
/// is fed to the very function the auth layer keys on.
#[test]
fn spawn_seeds_the_session_key_the_auth_layer_keys_on() {
let slug = "claude-opus-4-8";
let host = anthropic_collision_host();
let api_key_twin = "anthropic/claude-opus-4-8";
let oauth_twin = "claude-pro-max/claude-opus-4-8";
let models: indexmap::IndexMap<String, crate::agent::config::ModelEntry> = [
managed_entry(api_key_twin, slug, &host),
managed_entry(oauth_twin, slug, &host),
]
.into_iter()
.collect();
// FRESH: spawned on the catalog key. Idempotent, and it disambiguates.
for selected in [api_key_twin, oauth_twin] {
let seeded = crate::agent::models::selected_catalog_key_for_spawn(
&models,
&acp::ModelId::new(selected.to_string()),
);
assert_eq!(
seeded.as_deref(),
Some(selected),
"a fresh session must record the catalog key it was spawned with"
);
assert_eq!(
crate::agent::models::platform_for_slug(&models, seeded.as_deref(), slug),
kigi_models::parse_managed_model_key(selected).map(|(p, _)| p),
"…and that key must resolve THIS session's own platform for the bare slug"
);
}
// RESUME/LOAD: `acp_agent::load_session` spawns with the RAW persisted id,
// which after any model switch is the bare routing slug. THIS seed is what
// turns it into a key — the picker's `.rev()` answer, which is the resume
// default for a collided slug.
let resumed = crate::agent::models::selected_catalog_key_for_spawn(
&models,
&acp::ModelId::new(slug.to_string()),
);
assert_eq!(
resumed.as_deref(),
Some(oauth_twin),
"a bare persisted slug resolves through the picker's own lookup"
);
// A model that is no longer in the catalog seeds NOTHING, which (H-b) then
// refuses rather than guessing a twin.
assert_eq!(
crate::agent::models::selected_catalog_key_for_spawn(
&models,
&acp::ModelId::new("gone/model".to_string()),
),
None,
"a model that left the catalog must not seed a key"
);
}
/// H-c — coverage for the SECOND production writer: `SetSessionModel`
/// (`handle_set_session_model`), the picker's own path. The existing test
/// through this handler passes `None`, so a handler that dropped the key on the
/// floor stayed green.
///
/// End-to-end: after the switch the session's per-turn config must carry the
/// SELECTED twin's pooled resolver and adaptation, even though the bare slug in
/// the config is ambiguous.
#[tokio::test(flavor = "current_thread")]
#[serial_test::serial]
async fn set_session_model_records_the_key_the_next_turn_resolves_on() {
let _env = anthropic_collision_env_guard();
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let slug = "claude-opus-4-8";
let host = anthropic_collision_host();
let api_key_twin = "anthropic/claude-opus-4-8";
let oauth_twin = "claude-pro-max/claude-opus-4-8";
let (_dir, actor, _rx) = actor_with_catalog(
vec![
managed_entry(api_key_twin, slug, &host),
managed_entry(oauth_twin, slug, &host),
],
api_key_twin,
"sk-ant-user",
)
.await;
// Switch to the SUBSCRIPTION twin, exactly as
// `agent/handlers/model_switch.rs` does: the ambiguous slug in the
// sampler config plus the catalog KEY the picker resolved.
let models = actor.models_manager.models();
let entry = models.get(oauth_twin).expect("catalog entry");
let sampler = crate::agent::config::sampling_config_for_model(
entry,
crate::agent::config::resolve_credentials(entry, None),
None,
);
actor
.handle_set_session_model(
sampler,
Some(oauth_twin.to_string()),
false,
false,
true,
85,
)
.await
.expect("model switch");
assert_eq!(
actor.selected_catalog_key().as_deref(),
Some(oauth_twin),
"SetSessionModel must record the picker's catalog key"
);
let cfg = actor.reconstruct_full_config().await;
assert!(
cfg.bearer_resolver.is_some(),
"the switched-to subscription model must keep a live pooled resolver"
);
assert!(
cfg.anthropic_oauth,
"…and the Claude OAuth Messages adaptation"
);
// And back: switching to the API-key twin must UNDO both.
let entry = models.get(api_key_twin).expect("catalog entry");
let sampler = crate::agent::config::sampling_config_for_model(
entry,
crate::agent::config::resolve_credentials(entry, None),
None,
);
actor
.handle_set_session_model(
sampler,
Some(api_key_twin.to_string()),
false,
false,
true,
85,
)
.await
.expect("model switch");
let cfg = actor.reconstruct_full_config().await;
assert!(
cfg.bearer_resolver.is_none() && !cfg.anthropic_oauth,
"LEAK: switching back to the API-key twin must drop the pooled resolver \
and the OAuth adaptation"
);
})
.await;
}
/// H-c — `OverrideModelName` is the one command that rewrites
/// `SamplingConfig::model` WITHOUT going through `SetSessionModel`, so it used
/// to leave `selected_catalog_key` naming a model the session is no longer on.
/// It must keep the field consistent: KEEP when the key still names the new
/// routing name, CLEAR otherwise — never re-resolve, which would put the
/// `.rev()` guess into the field the rule treats as a deliberate selection.
#[tokio::test(flavor = "current_thread")]
#[serial_test::serial]
async fn override_model_name_keeps_the_session_key_consistent() {
let _env = anthropic_collision_env_guard();
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let slug = "claude-opus-4-8";
let host = anthropic_collision_host();
let oauth_twin = "claude-pro-max/claude-opus-4-8";
let (_dir, actor, _rx) = actor_with_catalog(
vec![
managed_entry("anthropic/claude-opus-4-8", slug, &host),
managed_entry(oauth_twin, slug, &host),
],
oauth_twin,
"unused",
)
.await;
// A rename to the SAME model's routing slug (or to its catalog key)
// keeps the selection.
for same in [slug, oauth_twin] {
actor.retain_selected_catalog_key_for(same);
assert_eq!(
actor.selected_catalog_key().as_deref(),
Some(oauth_twin),
"{same}: still names the selected entry — keep it"
);
}
// A rename to a DIFFERENT name makes the key stale: clear it, so the
// collided slug refuses (H-b) instead of resolving the old model.
actor.retain_selected_catalog_key_for("some-harness-model-name");
assert_eq!(
actor.selected_catalog_key(),
None,
"a stale key must be cleared, not carried into the next turn's \
platform lookup"
);
})
.await;
}
/// M3 — the FIRST-PARTY aux case must honour the session gate, which is what
/// the old shape did implicitly.
///
/// `stamp_session_local_sampler_fields` used to copy
/// `active_session_config.bearer_resolver`, and that field is `None` whenever
/// the gate is inactive. Re-pointing the aux resolver at the chokepoint (the
/// LEAK 1b fix) made it `Some(primary)` for the session's own coding endpoint
/// REGARDLESS of the gate — so a BYOK / api-key session with a `[model.*]` aux
/// entry carrying its own key on that endpoint had that key REPLACED by the
/// primary bearer on every image-describe / auto-mode-classifier / summary
/// request (`SamplingClient::post` overrides the auth header from the resolver).
///
/// Revert-to-red (production, compiles): delete the
/// `if is_primary_channel && !SessionTokenAuthGate::new(…).active()` early
/// return from `sampler_turn::aux_bearer_resolver_for` and the first two rows
/// below resolve `KIMI_TOKEN`.
#[tokio::test(flavor = "current_thread")]
async fn first_party_aux_resolver_honours_the_session_gate() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let coding_host = kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url;
let aux_slug = "kigi-aux";
let mut info = crate::agent::config::ModelInfo::fallback(aux_slug);
info.id = None; // a `[model.kigi-aux]` block, not a registry entry
info.base_url = coding_host.to_string();
let aux_entry = crate::agent::config::ModelEntry {
info,
api_key: None,
env_key: None,
api_base_url: None,
};
// (case, ACP auth method, the aux model's own BYOK status, expected)
for (case, auth_method, byok, expect_resolver) in [
(
"an API-key session: the aux model's own key must survive",
"deepseek",
crate::agent::auth_method::ModelByok::NotByok,
false,
),
(
"a BYOK aux entry under a session method: its env_key wins",
"cached_token",
crate::agent::auth_method::ModelByok::Byok,
false,
),
(
"the first-party subscription aux channel: byte-identical",
"cached_token",
crate::agent::auth_method::ModelByok::NotByok,
true,
),
] {
let (_dir, actor, _rx) = actor_with_catalog(
vec![(aux_slug.to_string(), aux_entry.clone())],
aux_slug,
"",
)
.await;
actor
.auth_method_id
.store(Some(Arc::new(acp::AuthMethodId::new(auth_method))));
actor.model_auth_facts.replace(Some((
aux_slug.to_string(),
crate::agent::config::ModelAuthFacts {
byok,
auth_scheme: Default::default(),
},
)));
let resolved = actor.aux_bearer_resolver(aux_slug, coding_host);
assert_eq!(
resolved.is_some(),
expect_resolver,
"{case}: aux resolver presence on the session's own endpoint"
);
if let Some(resolver) = resolved {
assert_eq!(
resolver.current_bearer(),
Some(KIMI_TOKEN.to_string()),
"{case}: and when it IS kept it is the primary's, live"
);
}
}
})
.await;
}
/// M-aux (REGRESSION this remediation introduced) — an aux call must NOT evict
/// the SESSION model's memoized auth facts.
///
/// `SessionActor::model_auth_facts` is a SINGLE slot. When `aux_bearer_resolver`
/// began asking it about the AUX slug, a definite result overwrote the session
/// model's entry, and:
/// (a) the next `reconstruct_full_config` re-paid `load_effective_config()` +
/// `resolve_model_list()` — the per-turn disk read M7/M9 removed — on top
/// of the one the aux call itself paid; and
/// (b) the memo's documented purpose (a transient `Unknown` falling back to
/// the last DEFINITE value FOR THE SAME model_id) was defeated: with the
/// aux slug in the slot, the session model's `Unknown` degrades to
/// `endpoint_is_first_party`, which is `false` for every
/// subscription-OAuth host — the session loses its `bearer_resolver` and
/// 401s unrecoverably ~1h in, the failure L13 exists to prevent.
///
/// Round 3's deleted `repoint_aux_bearer_resolver` never touched the memo.
///
/// Revert-to-red (production, compiles): make `SessionActor::aux_bearer_resolver`
/// call `self.model_auth_facts(slug)` instead of `self.aux_model_auth_facts(slug)`
/// — the slot then names the aux slug and both assertions below fail.
#[tokio::test(flavor = "current_thread")]
#[serial_test::serial]
async fn an_aux_call_does_not_evict_the_session_models_auth_facts() {
let _env = anthropic_collision_env_guard();
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let session_slug = "claude-opus-4-8";
let host = anthropic_collision_host();
let oauth_twin = "claude-pro-max/claude-opus-4-8";
let (_dir, actor, _rx) = actor_with_catalog(
vec![managed_entry(oauth_twin, session_slug, &host)],
oauth_twin,
"unused",
)
.await;
// The session model's DEFINITE facts, as a turn would have memoized
// them.
actor.model_auth_facts.replace(Some((
session_slug.to_string(),
crate::agent::config::ModelAuthFacts {
byok: crate::agent::auth_method::ModelByok::NotByok,
auth_scheme: Default::default(),
},
)));
// An aux turn: the auto-mode classifier / image-describe slug, which
// is NOT the session's model.
let _ = actor.aux_bearer_resolver("kigi-aux-classifier", &host);
let memo = actor.model_auth_facts.borrow();
let (cached_id, facts) = memo
.as_ref()
.expect("the session model's memo must survive an aux call");
assert_eq!(
cached_id, session_slug,
"an aux call evicted the SESSION model's memo: the next turn re-reads \
config from disk, and a transient Unknown loses its definite fallback"
);
assert_eq!(facts.byok, crate::agent::auth_method::ModelByok::NotByok);
})
.await;
}
@@ -0,0 +1,437 @@
//! LEAK GUARD (bearer_resolver channel): the primary (Kimi) subscription bearer
//! must never be stamped on a request to a host that does not own it.
//!
//! Chain the guard closes: a session-based ACP method (`cached_token` /
//! `kimi-code` / any OAuth platform) + a selected API-key-platform model
//! classifies `ModelByok::NotByok` (the model carries no `[model.*]` key), the
//! pre-fix `session_token_auth_gate` returned `true` unconditionally on that
//! arm, the manager lookup fell through to the primary Kimi manager for a
//! non-OAuth platform, and `SamplingClient::post` then REPLACED the correctly
//! resolved provider key with the Kimi bearer on the wire.
//!
//! The `api_key` half of the same defect (the config never even gets the
//! provider key, because `resolve_credentials` stamps the session token) is
//! pinned in `agent/mvp_agent/tests/api_key_channel_leak_tests.rs`, which drives
//! the real `prepare_sampling_config_for_model` resolution path. These tests
//! deliberately do NOT hand-stamp a provider key except where the assertion is
//! about the resolver overwriting one that already resolved correctly.
//!
//! The counterpart contract these tests also pin: the four subscription-OAuth
//! platforms have non-first-party base URLs but MUST keep a live
//! `bearer_resolver` drawn from their OWN pooled `AuthManager`, or they lose
//! mid-session token refresh.
//!
//! STORAGE DISCIPLINE (H6/M8): nothing here touches the developer's real
//! `~/.kigi` and nothing hot-swaps the process-global OAuth pool. Under
//! `cfg(test)` `oauth_registry::pool_home()` is a per-process temp path that is
//! never created, so every pooled manager is empty — exactly what the
//! assertions need (a live resolver that is provably NOT the Kimi one) — and
//! the binary leaves nothing behind.
use super::support::*;
use super::*;
use crate::agent::auth_method::ModelByok;
use crate::agent::config::{ModelAuthFacts, ModelEntry, ModelInfo};
use crate::auth::{AuthManager, AuthMode, KimiAuth, KimiCodeConfig};
use kigi_sampler::BearerResolver;
use std::sync::Arc;
use tokio::sync::mpsc;
/// The primary session bearer. Any occurrence of this string in an outgoing
/// request to a third-party host is the defect.
pub(super) const KIMI_TOKEN: &str = "kimi-subscription-token-DO-NOT-LEAK";
/// `(tempdir, manager)` standing in for the session's primary Kimi
/// `AuthManager`, holding a live (unexpired) OAuth session bearer.
fn kimi_primary() -> (tempfile::TempDir, Arc<AuthManager>) {
let dir = tempfile::tempdir().expect("tempdir");
let am = Arc::new(AuthManager::new(dir.path(), KimiCodeConfig::default()));
am.hot_swap(KimiAuth {
key: KIMI_TOKEN.to_string(),
auth_mode: AuthMode::OAuth,
refresh_token: Some("rt".into()),
expires_at: Some(chrono::Utc::now() + chrono::Duration::hours(1)),
..KimiAuth::test_default()
});
(dir, am)
}
/// One catalog entry: catalog key `catalog_key`, routing slug `slug`, routed at
/// `base_url`, carrying no credential of its own (the shape every fetched
/// registry model has).
pub(super) fn managed_entry(catalog_key: &str, slug: &str, base_url: &str) -> (String, ModelEntry) {
let mut info = ModelInfo::fallback(slug);
info.id = Some(catalog_key.to_string());
info.base_url = base_url.to_string();
(
catalog_key.to_string(),
ModelEntry {
info,
api_key: None,
env_key: None,
api_base_url: None,
},
)
}
/// A `SessionActor` on a session-based ACP method with a live Kimi primary,
/// whose live catalog holds `catalog` and whose SELECTED model is the catalog
/// key `selected` (the picker's own notion of "current"). `wire_key` is the
/// already-correctly-resolved provider credential sitting in chat state.
///
/// The per-model BYOK memo is pinned to `NotByok` on purpose: that is what a
/// fetched registry model actually resolves to (`resolve_model_auth_facts` only
/// ever sees `default_models.json` + `[model.*]`), and pinning it keeps the test
/// independent of the developer's on-disk `~/.kigi/config.toml`.
pub(super) async fn actor_with_catalog(
catalog: Vec<(String, ModelEntry)>,
selected: &str,
wire_key: &str,
) -> (
tempfile::TempDir,
Arc<SessionActor>,
mpsc::UnboundedReceiver<PersistenceMsg>,
) {
let (dir, am) = kimi_primary();
let (gateway_tx, _gateway_rx) = mpsc::unbounded_channel();
let (persistence_tx, persistence_rx) = mpsc::unbounded_channel();
let mut actor = create_test_actor(50_000, 200_000, 85, gateway_tx, persistence_tx).await;
actor.auth_manager = Some(am);
actor.auth_method_id = test_auth_method_id("cached_token");
let mut selected_entry = None;
for (key, entry) in catalog {
if key == selected {
selected_entry = Some(entry.clone());
}
actor.models_manager.insert_test_entry(key, entry);
}
let selected_entry = selected_entry.expect("the selected key must be in the catalog");
// H4: the SESSION owns its selection. The process-global
// `ModelsManager::current_model_id()` is deliberately left UNSET (it still
// names the startup default, which is not in this catalog) — exactly what
// Leader mode produces, since `agent/handlers/model_switch.rs` never calls
// `set_current_model_id` there, and what a second concurrent session on a
// colliding slug produces (last writer wins). Every assertion below
// therefore rides the per-session key, not the global cell.
*actor.selected_catalog_key.borrow_mut() = Some(selected.to_string());
let slug = selected_entry.info().model.clone();
actor
.chat_state_handle
.update_sampling_config(kigi_sampling_types::SamplingConfig {
base_url: selected_entry.info().base_url.clone(),
model: slug.clone(),
max_completion_tokens: None,
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: std::num::NonZeroU64::new(200_000).unwrap(),
reasoning_effort: None,
stream_tool_calls: None,
});
actor
.chat_state_handle
.update_credentials(kigi_chat_state::Credentials {
api_key: Some(wire_key.to_string()),
auth_type: kigi_chat_state::AuthType::SessionToken,
..Default::default()
});
actor.model_auth_facts.replace(Some((
slug,
ModelAuthFacts {
byok: ModelByok::NotByok,
auth_scheme: Default::default(),
},
)));
(dir, Arc::new(actor), persistence_rx)
}
/// Single-entry convenience over [`actor_with_catalog`].
pub(super) async fn actor_on_managed_model(
catalog_key: &str,
slug: &str,
base_url: &str,
wire_key: &str,
) -> (
tempfile::TempDir,
Arc<SessionActor>,
mpsc::UnboundedReceiver<PersistenceMsg>,
) {
actor_with_catalog(
vec![managed_entry(catalog_key, slug, base_url)],
catalog_key,
wire_key,
)
.await
}
/// THE leak test, at the wire. A `deepseek/deepseek-chat` turn on a session
/// (`cached_token`) method with a live Kimi primary must send DeepSeek's own key
/// — the Kimi subscription bearer must not appear anywhere in the request.
///
/// Revert-to-red: dropping the `credential_class` conjunct from
/// `session_token_auth_gate` puts `Bearer <KIMI_TOKEN>` on this request.
#[tokio::test(flavor = "multi_thread")]
async fn deepseek_turn_under_a_kimi_session_sends_no_kimi_bearer_on_the_wire() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/chat/completions"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "cmpl-1",
"object": "chat.completion",
"created": 0,
"model": "deepseek-chat",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": "ok" },
"finish_reason": "stop"
}]
})),
)
.mount(&server)
.await;
let uri = server.uri();
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (_dir, actor, _rx) = actor_on_managed_model(
"deepseek/deepseek-chat",
"deepseek-chat",
&uri,
"sk-deepseek-provider-key",
)
.await;
let cfg = actor.reconstruct_full_config().await;
assert!(
cfg.bearer_resolver.is_none(),
"an API-key platform model must get NO session bearer resolver"
);
let client =
kigi_sampler::SamplingClient::new(cfg).expect("sampling client must construct");
let _ = client
.chat_completion(kigi_sampling_types::ChatCompletionRequest::new(
"deepseek-chat",
vec![kigi_sampling_types::ChatRequestMessage::user("hi")],
))
.await;
})
.await;
let requests = server
.received_requests()
.await
.expect("wiremock records requests");
assert_eq!(requests.len(), 1, "exactly one inference request was sent");
let auth = requests[0]
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.expect("the request must carry an Authorization header")
.to_string();
assert!(
!auth.contains(KIMI_TOKEN),
"the Kimi subscription bearer must never reach a third-party inference host"
);
assert_eq!(
auth, "Bearer sk-deepseek-provider-key",
"the correctly-resolved provider key must survive to the wire"
);
}
/// The same guard for every other API-key registry platform shape: OpenAI
/// (Responses), Anthropic (x-api-key/Messages), Groq, Together and Z.AI CN — all
/// classify `NotByok`, all route to a non-first-party host, none may receive a
/// session bearer resolver.
#[tokio::test(flavor = "current_thread")]
async fn api_key_platform_models_get_no_session_bearer_resolver() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
for (catalog_key, slug, base_url) in [
("openai/gpt-5.2", "gpt-5.2", "https://api.openai.com/v1"),
(
"anthropic/claude-opus-4-8",
"claude-opus-4-8",
"https://api.anthropic.com/v1",
),
("groq/llama-4", "llama-4", "https://api.groq.com/openai/v1"),
("together/qwen-3", "qwen-3", "https://api.together.xyz/v1"),
(
"zai-coding-cn/glm-5",
"glm-5",
"https://open.bigmodel.cn/api/paas/v4",
),
] {
let (_dir, actor, _rx) =
actor_on_managed_model(catalog_key, slug, base_url, "sk-provider-key").await;
let cfg = actor.reconstruct_full_config().await;
assert!(
cfg.bearer_resolver.is_none(),
"{catalog_key}: an API-key platform must get no session bearer resolver"
);
assert_eq!(
cfg.api_key.as_deref(),
Some("sk-provider-key"),
"{catalog_key}: the provider key must stay on the config"
);
}
})
.await;
}
/// C2 at the resolver channel: a `[model.*]` entry has NO platform
/// (`info.id == None`), which used to be a blanket allow. Pointed at a
/// third-party host it must get no session resolver; pointed at the session's
/// own coding endpoint (a config.toml `[endpoints] coding_api_base_url`
/// deployment, a `KIGI_CODE_BASE_URL` override, or a local dev proxy) it must
/// keep one — that is why the predicate is not `is_first_party_url`.
///
/// Revert-to-red: make `CredentialAuthority::is_session_coding_endpoint` return
/// `true` unconditionally and a Kimi resolver lands on the openai.com config.
#[tokio::test(flavor = "current_thread")]
async fn config_model_entry_takes_a_session_resolver_only_on_its_own_endpoint() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
for base_url in ["https://api.openai.com/v1", "https://api.deepseek.com/v1"] {
let mut info = ModelInfo::fallback("gpt-4o");
info.id = None; // a `[model.gpt-4o]` block
info.base_url = base_url.to_string();
let entry = ModelEntry {
info,
api_key: None,
// An env_key that is NOT set: `has_own_credentials()` probes
// `std::env::var` at call time, so this classifies NotByok.
env_key: None,
api_base_url: None,
};
let (_dir, actor, _rx) =
actor_with_catalog(vec![("gpt-4o".to_string(), entry)], "gpt-4o", "").await;
let cfg = actor.reconstruct_full_config().await;
assert!(
cfg.bearer_resolver.is_none(),
"LEAK: a [model.*] block at {base_url} must get no session bearer resolver"
);
}
for base_url in [
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
"http://127.0.0.1:4141/v1",
] {
let mut info = ModelInfo::fallback("kigi-4.5");
info.id = None;
info.base_url = base_url.to_string();
let entry = ModelEntry {
info,
api_key: None,
env_key: None,
api_base_url: None,
};
let (_dir, actor, _rx) =
actor_with_catalog(vec![("kigi-4.5".to_string(), entry)], "kigi-4.5", "").await;
let resolver = actor
.reconstruct_full_config()
.await
.bearer_resolver
.expect("the session's own endpoint keeps the session resolver");
assert_eq!(
resolver.current_bearer(),
Some(KIMI_TOKEN.to_string()),
"{base_url}: a custom deployment / dev proxy is unchanged"
);
}
})
.await;
}
/// The Kimi / first-party subscription channel must be BYTE-IDENTICAL: the
/// session model keeps its live bearer_resolver AND the pre-flight refresh
/// still heals a stale buffered key. This is also what proves the Kimi bearer is
/// live in every LEAK assertion in this module — it WOULD leak if the guard
/// were missing.
///
/// (L12: the slug-collision commentary that used to sit here belongs to the
/// collision tests in `session_bearer_leak_platform_tests`, which is where its
/// revert-to-red actually reproduces; on this first-party test it never could.)
#[tokio::test(flavor = "current_thread")]
async fn kimi_first_party_model_still_rides_the_primary_session_bearer() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (_dir, actor, _rx) = actor_on_managed_model(
"kimi-code/kimi-for-coding",
"kimi-for-coding",
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
"stale-buffered-token",
)
.await;
let cfg = actor.reconstruct_full_config().await;
let resolver = cfg
.bearer_resolver
.as_ref()
.expect("the subscription model must keep the live session resolver");
assert_eq!(
resolver.current_bearer(),
Some(KIMI_TOKEN.to_string()),
"the first-party model resolves the primary session bearer"
);
actor.refresh_token_if_expired().await;
assert_eq!(
actor
.chat_state_handle
.get_credentials()
.await
.api_key
.as_deref(),
Some(KIMI_TOKEN),
"the first-party pre-flight refresh must still heal the stale key"
);
})
.await;
}
/// The persistence half of the defect: `refresh_token_if_expired` used to write
/// the Kimi session token into `chat_state` `creds.api_key` for ANY
/// session-method turn, from where it propagated to subagents and aux configs.
/// A deepseek turn must leave the provider key untouched.
///
/// M7 rides along: a registry-platform model must not fall into
/// `reload_api_key_from_config` at all (a `load_effective_config()` disk read
/// per turn plus a permanently false "not found in config.toml" warning), so
/// the key is left exactly as resolved.
#[tokio::test(flavor = "current_thread")]
async fn preflight_refresh_never_writes_the_kimi_token_into_a_platform_credential() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (_dir, actor, _rx) = actor_on_managed_model(
"deepseek/deepseek-chat",
"deepseek-chat",
"https://api.deepseek.com/v1",
"sk-deepseek-provider-key",
)
.await;
actor.refresh_token_if_expired().await;
assert_eq!(
actor
.chat_state_handle
.get_credentials()
.await
.api_key
.as_deref(),
Some("sk-deepseek-provider-key"),
"the Kimi session token must never overwrite a platform credential"
);
})
.await;
}
@@ -168,6 +168,7 @@ pub(crate) async fn create_test_actor_ex(
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: std::num::NonZeroU64::new(context_window)
.expect("test context_window must be non-zero"),
@@ -187,6 +188,7 @@ pub(crate) async fn create_test_actor_ex(
},
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
selected_catalog_key: std::cell::RefCell::new(None),
attribution_callback: None,
auth_manager: None,
state,
@@ -145,6 +145,13 @@ pub enum SessionCommand {
},
SetSessionModel {
sampling_config: kigi_sampler::SamplerConfig,
/// The catalog KEY the picker resolved (`{platform}/{model}` for a
/// registry model), which `sampling_config.model` — the bare routing
/// slug — cannot express when an API-key platform and its
/// subscription-OAuth twin list the same id. The session stores it as
/// its OWN selection instead of reading the process-global
/// `ModelsManager::current_model_id()` (H4).
catalog_key: Option<String>,
use_concise: bool,
/// When `false`, skip the system prompt rewrite (concise/default swap).
/// Set to `false` for forked sessions so mid-session model switches
@@ -2149,6 +2149,7 @@ mod inline_auto_compact_flow_tests {
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
extra_headers: Default::default(),
context_window: std::num::NonZeroU64::new(context_window)
.expect("test context_window must be non-zero"),
@@ -2167,6 +2168,7 @@ mod inline_auto_compact_flow_tests {
},
auth_method_id: test_auth_method_id("test-auth"),
model_auth_facts: std::cell::RefCell::new(None),
selected_catalog_key: std::cell::RefCell::new(None),
attribution_callback: None,
auth_manager: None,
state,
@@ -594,10 +594,10 @@ async fn write_patch_file_atomic(path: &Path, body: &str) -> std::io::Result<()>
.unwrap_or("goal-classifier.patch");
let tmp = dir.join(format!(".{file_name}.{}.tmp", uuid::Uuid::now_v7()));
tokio::fs::write(&tmp, body).await?;
if let Err(err) = tokio::fs::rename(&tmp, path).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(err);
}
let dest = path.to_path_buf();
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &dest))
.await
.map_err(std::io::Error::other)??;
Ok(())
}
@@ -895,7 +895,8 @@ impl GoalTracker {
};
let dest = goal_dir.join(name);
let _ = std::fs::create_dir_all(&goal_dir);
if std::fs::rename(&src, &dest).is_ok() || copy_no_follow(&src, &dest).is_ok() {
if crate::util::fs::replace_file(&src, &dest).is_ok() || copy_no_follow(&src, &dest).is_ok()
{
append_skeptic_reports(&scratch_root, &dest);
o.last_classifier_details_path = Some(dest.to_string_lossy().into_owned());
}
@@ -120,7 +120,7 @@ pub fn project(dir: &Path, state: &GraphOrchestration) -> std::io::Result<()> {
f.write_all(&buf)?;
f.sync_all()?;
}
std::fs::rename(&tmp, &target)
crate::util::fs::replace_file(&tmp, &target)
}
/// Load the projected graph, `Ok(None)` when absent. Malformed content
@@ -1588,7 +1588,11 @@ mod reasoning_compaction_regression_tests {
temperature: Some(0.7),
top_p: None,
api_backend: ApiBackend::ChatCompletions,
chat_compat: Default::default(),
auth_scheme: Default::default(),
anthropic_oauth: false,
github_copilot: false,
openai_codex: false,
extra_headers: Default::default(),
context_window: 256_000,
force_http1: false,
@@ -879,7 +879,14 @@ pub struct Summary {
/// `None` for sessions created before this field existed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox_profile: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
/// Lenient on read: an unknown token written by a newer kigi (grown
/// effort vocabulary) drops to `None` rather than hiding the whole
/// session from listings / failing resume after a version rollback.
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "kigi_sampling_types::lenient_reasoning_effort_opt"
)]
pub reasoning_effort: Option<ReasoningEffort>,
}
@@ -1002,6 +1009,22 @@ mod is_hidden_tests {
}
}
#[test]
fn summary_unknown_reasoning_effort_token_degrades_to_none() {
// A summary written by a NEWER kigi with a grown effort vocabulary
// must not vanish from listings or fail resume on this binary.
let mut s = summary_with_kind(None);
s.reasoning_effort = Some(ReasoningEffort::Max);
let json = serde_json::to_string(&s).unwrap();
let future = json.replace("\"max\"", "\"hypermax\"");
assert_ne!(json, future, "fixture must actually carry the token");
let back: Summary = serde_json::from_str(&future).expect("record must survive");
assert_eq!(back.reasoning_effort, None);
// Known tokens (including post-split "max") still round-trip.
let back: Summary = serde_json::from_str(&json).unwrap();
assert_eq!(back.reasoning_effort, Some(ReasoningEffort::Max));
}
#[test]
fn summary_round_trips_and_defaults_reasoning_effort() {
let mut s = summary_with_kind(None);
@@ -98,7 +98,7 @@ pub fn truncate_if_needed(cwd: &str) -> io::Result<()> {
}
}
std::fs::rename(temp_path, path)?;
crate::util::fs::replace_file(&temp_path, &path)?;
Ok(())
}
@@ -13,6 +13,16 @@ use std::fs::OpenOptions;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use tokio::io::AsyncWriteExt;
/// Commit `tmp` over `target` with the shared Windows-safe replace
/// (`util::fs::replace_file`): a bare async rename silently lost session
/// state — including the switched model — on Windows whenever AV/indexer
/// held the destination open.
async fn replace_file_async(tmp: PathBuf, target: PathBuf) -> io::Result<()> {
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &target))
.await
.unwrap_or_else(|e| Err(io::Error::other(e)))
}
/// How the adapter resolves the session directory on disk.
///
/// - `FromRoot` (default): computes `{root}/sessions/{urlencoded(cwd)}/{session_id}/`
@@ -289,7 +299,7 @@ impl JsonlStorageAdapter {
}
let tmp = path.with_extension("jsonl.tmp");
tokio::fs::write(&tmp, &content).await?;
tokio::fs::rename(&tmp, &path).await
replace_file_async(tmp, path).await
}
fn read_jsonl<T: serde::de::DeserializeOwned>(&self, path: PathBuf) -> io::Result<Vec<T>> {
if !path.exists() {
@@ -378,7 +388,7 @@ impl JsonlStorageAdapter {
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let tmp = summary_path.with_extension("json.tmp");
std::fs::write(&tmp, &bytes)?;
std::fs::rename(&tmp, &summary_path)
crate::util::fs::replace_file(&tmp, &summary_path)
}
fn read_summary_sync(&self, info: &Info) -> io::Result<Summary> {
let path = self.summary_file(info);
@@ -1057,7 +1067,7 @@ impl StorageAdapter for JsonlStorageAdapter {
let target = self.plan_mode_state_file(info);
let tmp = target.with_extension("json.tmp");
tokio::fs::write(&tmp, json).await?;
tokio::fs::rename(&tmp, &target).await
replace_file_async(tmp, target).await
}
async fn write_signals(
&self,
@@ -1069,7 +1079,7 @@ impl StorageAdapter for JsonlStorageAdapter {
let target = self.signals_file(info);
let tmp = target.with_extension("json.tmp");
tokio::fs::write(&tmp, signals_json).await?;
tokio::fs::rename(&tmp, &target).await
replace_file_async(tmp, target).await
}
async fn write_announcement_state(
&self,
@@ -1081,7 +1091,7 @@ impl StorageAdapter for JsonlStorageAdapter {
let target = self.announcement_state_file(info);
let tmp = target.with_extension("json.tmp");
tokio::fs::write(&tmp, json).await?;
tokio::fs::rename(&tmp, &target).await
replace_file_async(tmp, target).await
}
async fn write_goal_mode_state(
&self,
@@ -1096,7 +1106,7 @@ impl StorageAdapter for JsonlStorageAdapter {
}
let tmp = target.with_extension("json.tmp");
tokio::fs::write(&tmp, json).await?;
tokio::fs::rename(&tmp, &target).await
replace_file_async(tmp, target).await
}
async fn write_graph_mode_state(
&self,
@@ -1119,7 +1129,7 @@ impl StorageAdapter for JsonlStorageAdapter {
}
let tmp = target.with_extension("json.tmp");
tokio::fs::write(&tmp, json).await?;
tokio::fs::rename(&tmp, &target).await
replace_file_async(tmp, target).await
}
async fn load_session(&self, info: &Info) -> io::Result<PersistedData> {
let summary = self.read_summary_sync(info)?;
@@ -230,7 +230,10 @@ fn write_summary_atomic(summary_path: &Path, summary: &Summary) -> io::Result<()
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let tmp = summary_path.with_extension("json.tmp");
std::fs::write(&tmp, &bytes)?;
std::fs::rename(&tmp, summary_path)
// Windows-safe replace: this is the write that persists a session's
// CURRENT MODEL — a bare rename made a switched model silently revert
// on resume whenever AV/indexer held summary.json open on Windows.
crate::util::fs::replace_file(&tmp, summary_path)
}
#[cfg(test)]
@@ -44,7 +44,11 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap<String, bool>) -> SubagentSpawnCon
temperature: None,
top_p: None,
api_backend: Default::default(),
chat_compat: Default::default(),
auth_scheme: Default::default(),
anthropic_oauth: false,
github_copilot: false,
openai_codex: false,
extra_headers: Default::default(),
context_window: 256_000,
force_http1: false,
@@ -1044,7 +1044,9 @@ pub async fn resolve_api_key(explicit: Option<&str>, kigi_home: &Path) -> Result
if let Some(k) = explicit.map(str::trim).filter(|s| !s.is_empty()) {
return Ok(k.to_owned());
}
let env_key = std::env::var("XAI_API_KEY").ok();
// Canonical house-key resolution: KIGI_API_KEY, then the back-compat
// XAI_API_KEY / KIGI_CODE_XAI_API_KEY (see `read_xai_api_key_env`).
let env_key = crate::agent::auth_method::read_xai_api_key_env().ok();
if let Some(k) = env_key.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
return Ok(k.to_owned());
}
@@ -1052,7 +1054,7 @@ pub async fn resolve_api_key(explicit: Option<&str>, kigi_home: &Path) -> Result
return Ok(key);
}
Err(anyhow!(
"no API key: pass --api-key, set XAI_API_KEY, or run `kigi login` to populate \
"no API key: pass --api-key, set KIGI_API_KEY, or run `kigi login` to populate \
<kigi-home>/auth.json. An expired OIDC token is auto-refreshed when a refresh_token \
is present; if not, re-login is required."
))
@@ -2344,7 +2346,7 @@ mod tests {
.await;
assert!(
msg.contains("--api-key")
&& msg.contains("XAI_API_KEY")
&& msg.contains("KIGI_API_KEY")
&& msg.contains("kigi login")
&& msg.contains("auth.json"),
"error names all three sources: {msg}",
@@ -2445,7 +2447,9 @@ mod tests {
/// refresher that could mint credentials independent of the
/// fixture.
const ISOLATED_ENV_KEYS: &[&str] = &[
"KIGI_API_KEY",
"XAI_API_KEY",
"KIGI_CODE_XAI_API_KEY",
"KIGI_AUTH",
"KIGI_AUTH_PATH",
"KIGI_AUTH_PROVIDER_COMMAND",
@@ -114,9 +114,7 @@ fn dismiss_campaign_ids_at(
let nonce = DISMISS_TMP_NONCE.fetch_add(1, Ordering::Relaxed);
let tmp = path.with_extension(format!("json.{}.{}.tmp", std::process::id(), nonce));
std::fs::write(&tmp, &json)?;
std::fs::rename(&tmp, &path).inspect_err(|_| {
let _ = std::fs::remove_file(&tmp);
})
crate::util::fs::replace_file(&tmp, &path)
}
/// `KIGI_CAMPAIGNS_OVERRIDE` JSON array replaces all sources (`[]` = none; beats
@@ -367,7 +367,9 @@ pub async fn save_mcp_disabled_tools(server_name: &str, disabled_tools: &[String
let _ = tokio::fs::create_dir_all(parent).await;
}
tokio::fs::write(&tmp, &toml_str).await?;
tokio::fs::rename(&tmp, &path).await?;
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &path))
.await
.map_err(std::io::Error::other)??;
Ok(())
}
@@ -419,7 +421,9 @@ pub async fn save_mcp_server_enabled(server_name: &str, enabled: bool) -> Result
let _ = tokio::fs::create_dir_all(parent).await;
}
tokio::fs::write(&tmp, &toml_str).await?;
tokio::fs::rename(&tmp, &path).await?;
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &path))
.await
.map_err(std::io::Error::other)??;
Ok(())
}
@@ -476,7 +480,10 @@ pub async fn save_mcp_server_config_at(
let _ = tokio::fs::create_dir_all(parent).await;
}
tokio::fs::write(&tmp, &toml_str).await?;
tokio::fs::rename(&tmp, &path).await?;
let dest = path.to_path_buf();
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &dest))
.await
.map_err(std::io::Error::other)??;
Ok(())
}
@@ -553,7 +560,12 @@ pub async fn delete_mcp_server_config_at(
let _ = tokio::fs::create_dir_all(parent).await;
}
tokio::fs::write(&tmp, &toml_str).await?;
tokio::fs::rename(&tmp, &path).await?;
{
let dest = path.to_path_buf();
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &dest))
.await
.map_err(std::io::Error::other)??;
}
// Clean up OAuth credentials for the deleted server.
if let Ok(mut cred_store) = kigi_mcp::credentials::McpCredentialStore::load_default() {
@@ -88,17 +88,15 @@ pub async fn save_config(config: &Config) -> Result<()> {
}
let _ = prior_mode;
tokio::fs::rename(&tmp, &path).await?;
// Windows-safe replace (delete-first + retry on sharing violations) —
// a bare rename made `/model` persistence silently fail on Windows
// whenever AV/indexer/cloud-sync held config.toml open.
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &path))
.await
.map_err(|e| anyhow::anyhow!("config replace task: {e}"))??;
Ok(())
}
/// Acquire the `config.toml` write lock used by [`save_config`], so callers that
/// mutate the file directly (marketplace add/remove) can't interleave with a
/// settings save and clobber it.
pub(crate) async fn lock_config_writes() -> tokio::sync::MutexGuard<'static, ()> {
SAVE_LOCK.lock().await
}
/// Read a file, treating only `NotFound` as empty. Hard read errors (EACCES,
/// EIO) propagate so callers don't clobber an unreadable file on the next write.
pub(crate) fn read_to_string_or_empty(path: &std::path::Path) -> std::io::Result<String> {
@@ -145,11 +143,8 @@ pub(crate) fn atomic_write_string(path: &std::path::Path, content: &str) -> std:
}
let _ = prior_mode;
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
Ok(())
// Windows-safe replace; cleans up the tmp file on failure itself.
crate::util::fs::replace_file(&tmp, path)
}
/// Merge `[toolset.ask_user_question]` into the root table. `[toolset]` is
@@ -38,6 +38,10 @@ pub fn test_sampler_config(
top_p: None,
api_backend,
auth_scheme: Default::default(),
anthropic_oauth: false,
github_copilot: false,
openai_codex: false,
chat_compat: Default::default(),
extra_headers: extra_headers
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
@@ -433,7 +433,7 @@ fn git_rebase_refresh_storm_e2e() {
std::env::set_var("KIGI_SHARE_DIR", kigi_home.path());
std::env::set_var("KIGI_CODE_BASE_URL", server.url());
std::env::set_var("KIGI_API_BASE_URL", server.url());
std::env::set_var("XAI_API_KEY", "test-key-for-ci");
std::env::set_var("KIGI_API_KEY", "test-key-for-ci");
std::env::set_var("KIGI_TELEMETRY_ENABLED", "false");
std::env::set_var("KIGI_FEEDBACK_ENABLED", "false");
std::env::set_var("KIGI_TRACE_UPLOAD", "false");

Some files were not shown because too many files have changed in this diff Show More