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
29 KiB
Kigi — agent/developer notes
Single source of truth for how this repository is organized and the constraints every change must respect. Update this file whenever the tech stack or product direction changes.
What this is
Kigi is an unofficial Kimi Code CLI community build: a hard fork of
xai-org/grok-build (Apache-2.0, Rust) re-targeted at the Kimi Code
subscription API and the Moonshot open platform. It coexists with the
official kimi CLI: binary kigi, config dir ~/.kigi
(KIGI_SHARE_DIR override), keyring service kigi, env prefix KIGI_*.
Never read or write ~/.kimi (except the explicit one-time read-only
import) or any KIMI_* env var.
Hard constraints
- Zero egress: outbound connections are limited to
auth.kimi.com,api.kimi.com,api.moonshot.cn,api.moonshot.ai, GitHub Releases domains, user-configured MCP servers, the endpoints of provider platforms the user has credentialed, andmodels.dev(model metadata refresh — reached ONLY when an enabled platform's/modelswire lacks metadata,wire_serves_metadata=false; Kimi/Moonshot never trigger it;KIGI_MODELS_DEV_URL=0disables). No telemetry, no analytics, ever.crates/codegen/kigi-envis 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,cargo clippy --workspace --all-targets(zero warnings),cargo fmt --all --check,cargo deny check advisories. - Observability is local:
kigi-log(unified session log,--debugfirehose, subsystem file logs, opt-in instrumentation) writes under~/.kigionly. Its zero-network property is a contract. - Atomic file replace goes through
util::fs::replace_file(tmp+rename commit step; async callers wrap inspawn_blocking). Never inline a barefs::renamereplace: WindowsMoveFileExW(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 leastwarn!— neverlet _ =. - The root
Cargo.tomlis hand-maintained (upstream's generator is not in this repo). Members sorted; versions inherited fromworkspace.package.version— the single source of truth for the release version (kigi_version::VERSIONderives from it; the release workflow gates thev*tag against it).
Layout
crates/codegen/— the bulk of the application (62kigi-*crates). Key ones:kigi-bin(binarykigi),kigi-tui(full-screen TUI + headless-pmode +acp/mcpcommands),kigi-shell(agent runtime, leader-follower IPC, sessions),kigi-sampler(inference client; ChatCompletions/Responses/Messages backends),kigi-auth(credentials),kigi-config(config layering,~/.kigipaths),kigi-env(endpoints),kigi-tools(tool implementations incl. codex/opencode ports — see its THIRD_PARTY_NOTICES.md),kigi-workspace(FS/VCS/exec/permissions, checkpoint/worktree),kigi-log(local observability).crates/common/,crates/build/,prod/mc/— shared libs, proto build, proxy wire types (the latter to be redefined against Kimi in M1).third_party/— vendored Mermaid rendering stack (untouched policy).bin/protoc— dotslash launcher used by proto codegen.
Storage discipline
- Tests that touch the filesystem MUST use
tempfile::TempDir(drop cleans up) — never barestd::env::temp_dir()+create_dir_all, which leaks directories into the OS temp root forever. target/grows past 150GB across repeated full-workspace builds (incremental is already off); runcargo cleanwhen it exceeds ~50GB and at milestone boundaries.- Graph node worktrees are removed right after a successful merge-back; only FAILED nodes keep theirs for postmortem.
Test seams
Cross-crate test hooks are behind the test-support cargo feature
(kigi-workspace, kigi-pager-render, kigi-config, kigi-tui), enabled via
dependents' [dev-dependencies]. Don't expose new test seams as plain
#[cfg(test)] items across crate boundaries.
Graph mode (/graph, post-0.1.x — plan.md in the parent dir)
A deterministic DAG scheduler layered over the goal engine: /graph <objective> decomposes the objective into nodes (graph planner subagent
→ Agentproof-style static gate in graph_plan.rs), then executes each
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.
- Enabled by default (
KIGI_GRAPH=0is 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; reusesGoalStatus/GoalPhase/GoalPauseReason),session/graph_plan.rs(planner-JSON contract + validation + fnv id canonicalization),session/graph_planner.rs(planner runner, reuses the goal planner spawn plumbing),session/acp_session_impl/graph.rs(orchestration seam). - Seam points:
handle_promptintercepts GraphSet/GraphResume; the in-turn loop'sEndTurnarm callsrun_graph_round_end()to advance nodes within the same turn; goal auto-pauses cascade to the graph inauto_pause_goal_if_active_inner; node goals are armed with the REMAINING graph budget soenforce_goal_token_budgetcascades trips. - Persistence:
PersistenceMsg::GraphModeState(Option<..>)→<session_dir>/graph/state.json(Nonetombstones after clear); immutable per-version baselinesgraph/graph.baseline.v{N}.json; per-node goal artifacts archived tograph/<node_id>/. Restore demotesActive→UserPausedandRunning→Ready(re-run is safe: the verifier gates completion). /goaland/graphare mutually exclusive while the graph owns the engine; e2e suite:acp_session_tests/graph/graph_e2e_tests.rs.- Parallel fan-out (G1): with
KIGI_GRAPH_CONCURRENCY > 1(default 3, clamp [1,8]) and ≥2Readynodes,drive_graphruns batches viaacp_session_impl/graph_workers.rs— per node a bounded worker↔verifier subagent loop (KIGI_GRAPH_NODE_ROUNDS, default 3;general-purposechildren; worktree isolation on round 1, resume keeps context+worktree on later rounds;NODE_RESULT:/NODE_VERDICT:terminal contracts parsed fail-closed), then SEQUENTIAL merge-back viakigi_workspace::worktree::apply_worktree(ApplyMode::Merge); a conflict fails the node and blocks its dependents while other chains continue.gn-finalalways runs serially on the full goal engine. Concurrency=1 is byte-identical to the serial G0 path. Ceiling: a worker round exceeding the foreground subagent await budget (600s) is cancelled and retried via resume. - G2:
BudgetLimitedis resumable — a budget trip demotes in-flight nodes toReady(resource stop, not a verdict) and/graph resume --budget <tokens>re-arms with fresh headroom. The pager shows a graph status chip driven by theGraphUpdatedwire variant (extensions/notification.rs), emitted from the singlepersist_graph_statechokepoint (checkpoint ⇔ badge tick); old pagers degrade via#[serde(other)] Unknown. PTY scenarios:graph_slash_presession{,_disabled}.yaml. - G3: dynamic replan.
DISCOVERED: <text>line markers (fence-stripped, placeholder-filtered) from workers/verifiers/the serial node's final text queue aspending_discoveries; at each dispatch boundarymaybe_replan_graph(acp_session_impl/graph_replan.rs) runs a replanner subagent producing an APPEND-ONLY appendix (validate_replan: existing-id deps allowed, edges ontogn-finalrejected — they would cycle after the final-gating extension), bumpsplan_version, freezesgraph.baseline.v{N}.json, and regatesgn-final(Ready→Waiting). Bounded byKIGI_GRAPH_REPLAN_CAP(default 3, 0 = off); past the cap — and after the final node has achieved — discoveries drain to history only. Replan failure degrades (history + notice); it never pauses a working graph. - G4: the graph follows the repo. Every checkpoint projects to
.kigi/graph.jsonlat the git root (session/graph_project.rs, header line + one node per line, atomic write); single writer via an fs2 flock sidecar; other instances get read-only/graph status. Fresh sessions revive via/graph resume(load UNDER the lock, from_snapshot demotions apply). All lock-then-mutate sites identity-check the projectedgraph_id; kigi never commits the file. - G5:
/graph showrenders box-drawing DAG art (session/graph_render.rs, Sugiyama-lite: longest-path layers, dummy pass-throughs, barycenter ordering, bus lanes). Wider than 120 cols degrades to the status tree. - G6: plan-boundary topology optimizer
(
acp_session_impl/graph_optimize.rs;KIGI_GRAPH_OPTIMIZER=0disables). Restricted ops (remove_dep/reorder/merge/split) validated bygraph_plan::apply_optimization: pending-only targets, immutable nodes byte-identical in the result, terminal gate rebuilt, whole-graph acyclicity. Applied passes bumpplan_versionand share 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 +ALLentry +spec()arm + row; registry tests enforce completeness/uniqueness/row shape). - API-key resolution precedence, per platform: platform env var(s) >
auth.jsonscope 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_keymode) — 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-lessAction::Login(auto-login, 401 re-auth) resolves the first interactive method./loginopens the picker (Action::OpenLoginPicker), never a flow directly; mid-session its last row is Cancel, not Quit. _meta.connectedon an advertised method is DISPLAY state (green badge on the picker): stamped atinitialize()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_oauthplatform carryingoauth: Some(&OAuthConfig)(client id / auth host / start+token paths /token_host/scope/scope_key/ optional extra device field /flow/token_body) drives a scope-keyedAuthManager::new_oauth_provider+refresh::GenericDeviceRefresher(selected bybuild_refresherviaoauth_config_for_scope_key; the refresher dispatches the refresh body bytoken_body: form →auth::oauth_device, JSON →auth::oauth_pkce,GithubCopilotExchange→auth::github_copilotcopilot-token re-mint). Kimi Code keepsoauth: Noneand its bespoke path unchanged. The interactive login is dispatched byOAuthConfig.flow(inrun_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, baseapi.x.ai/v1, form token body, same wire as the API-keyxairow).OAuthFlow::PkceLocalhost { redirect_port, redirect_path }→auth::oauth_pkce(authorization-code + PKCE S256,127.0.0.1:redirect_port{redirect_path}loopback with STRICTstatevalidation + manual-paste fallback, authorize host possibly ≠ token host). Thetoken_bodyselects the login dialect:Json= claude (state == verifier, JSON exchange carryingstate),Form= codex (fresh-randomstate, FORM exchange withoutstateviaexchange_code_form).OAuthConfig.authorize_extraappends provider-only authorize params (empty for all but codex). Providers:claude-pro-max(scope_key oauth/claude-pro-max, port 53692/callback, JSON body, baseapi.anthropic.com/v1, Anthropic Messages + listing wire reached with an OAuthsk-ant-oat…Bearer). Its Messages requests take the OAuth adaptation —anthropic-beta claude-code-…,oauth-…claude-cliUA +x-app cli+ the required "You are Claude Code…" system prefix — gated onSamplerConfig.anthropic_oauth(claude-pro-max only), so API-keyanthropic/minimaxMessages requests stay byte-identical. Its/v1/modelslisting rides the same Bearer + oauth-beta headers. THINKING REPLAY (prune_replayed_thinking, all Messages requests): Anthropic validates every replayedthinkingblock (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 "Invalidsignatureinthinkingblock".
- CROSS-PROVIDER REPLAY POLICY (Pi
transform-messagespattern): 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 nativers_*id (foreign capture is id "") and provenance-gates whole turns viatransform_items_for_responses(AssistantItem.model_idvs the request model: foreign Reasoning dropped, foreign BackendToolCall demoted to itstext_summary); the codex adapter additionally drops barers_*references (stateless backend); tool-call ids pass through ONE shared ASCIIsanitize_tool_call_idsymmetrically on call+result on BOTH the Messages and Responses legs; Messages image sources go throughparse_base64_image_data_uri(raster whitelist, nodata:url sources) and empty user turns get a placeholder. Dangling tool calls are already repaired item-level byrepair_dangling_tool_callson 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_compatexplicitly; BYOK/custom entries default toPassthrough(vanilla OpenAI semantics) EXCEPT entries pointed at the house/Kimi coding endpoint, which keep theKimidialect (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; persistedmistralvalues 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 hostauth.openai.com, clientapp_EMoam…, scopeopenid profile email offline_access, the 3 authorize-extra paramsid_token_add_organizations/codex_cli_simplified_flow/originator=codex_cli_rs). Refresh is a plainrefresh_tokenFORM grant (the generic refresher'sFormpath →auth::oauth_device). The mintedaccess_tokenis 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 basechatgpt.com/backend-api/codex(→{base}/responses) with a Codex-gated adaptation (SamplerConfig.openai_codex/PlatformId::sends_codex_responses_headers()): headerschatgpt-account-id(per-request from the JWT),originator codex_cli_rs,OpenAI-Beta responses=experimental, a codexUser-Agent;store:falseis the shared Responses default. BODY adaptation (adapt_body_for_codex_backend, same gate): the backend 400srole:systeminput ("System messages are not allowed") — system items are hoisted into the top-levelinstructionsfield — and stateless reasoning replay requiresinclude:["reasoning.encrypted_content"]. API-keyopenaiResponses requests carry NONE of this (byte-identical, pinned by a control wire test).reasoning.effortcarries the thinking level (incl. the codex-onlyultra). NO websocket, NO base_instructions. CATALOG is HARDCODED (PlatformId::hardcoded_catalog→openai_codex_wire_models, mapped through the SAMEplatform_wire_model_to_entryoutput): exactly the 4visibility=list&&supported_in_api=truemodels (gpt-5.6-sol/terra/luna,gpt-5.5, ctx 272000, per-model efforts) — NO live/modelsfetch, NO codex-CLI /~/.codexdependency;gpt-5.3-codex-spark(api=false) andgpt-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, baseapi.individual.githubcopilot.com, ChatCompletions wire). Stage 1 is an RFC-8628 device flow ongithub.com(clientIv1.b507a08c87ecfe98, scoperead:user) whose errors ride a200body (not4xx) — it mints the DURABLE github token. Stage 2 (GET api.github.com/copilot_internal/v2/tokenwithcopilot_exchange+ editor headers) re-mints the SHORT-LIVED copilot token. Persisted asKimiAuth.key = copilot token,refresh_token = github token,expires_at = copilot expiry; the "refresh" is a copilot-token RE-MINT (GET, not arefresh_tokengrant). Every/modelslisting AND/chat/completionsrequest carries the VS Code editor-identity headers (User-Agent GitHubCopilotChat/…,Editor-Version,Editor-Plugin-Version,Copilot-Integration-Id;+X-GitHub-Api-Versionon/models,+X-Initiator useron inference) — gated onSamplerConfig.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 iffmodel_picker_enabled&&policy.state != "disabled"&&tool_calls != falseAND the id is NOT aclaude-(haiku|sonnet|opus)-[45](anthropic-messages) orgpt-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 yet403at 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 afterkimi-code(AuthMethodKind::OAuthPlatform, inPlatformId::ALLorder: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 routesplatform.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 probesmodels_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 stampmeta.provider(platform display name) for the client's/modelpicker; the picker itself lists the fetched catalog, which is by construction the credentialed providers' models. - INFERENCE-AUTH CHOKEPOINT (security):
auth::credential_authority::CredentialAuthorityis the ONE authority answering "WHICH credential — if any — may ride this request?". It holds the session's effectiveEndpointsConfigand its primaryAuthManagerPRIVATELY and answers only(platform, base_url)questions:credential_class→CredentialClass::{Pooled, Primary, None}(the outer term ofauth_method::session_token_auth_gate),manager_for(the governing manager for refresh / 401 recovery),credential_for(the request'sapi_key) andbearer_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 pooledAuthManager(so it keeps a livebearer_resolverand mid-session refresh despite a non-first-party base URL) and ONLY at its ownplatform.base_url()— a[model."claude-pro-max/x"]override keepsinfo.idbut can pointbase_urlanywhere.kimi-codeand 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_urlfrom config.toml — what the managed-config sync writes — overKIGI_CODE_BASE_URL),models_base_url, loopback, or the compiled production endpoint. Never a blanket allow: BYOK ishas_own_credentials(), which probesstd::env::varat call time, so a[model.*]block with an unsetenv_keyclassifiesNotByok. Every API-key registry platform rides NOTHING. STRUCTURAL ENFORCEMENT: the authority is the only producer ofSessionCredential, 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) takesOption<&SessionCredential>rather thanOption<&str>— a new call site cannot express the leak.stamp_session_local_sampler_fieldslikewise takes the auxbearer_resolverexplicitly instead of copying the session's and relying on the caller to re-point it, andsampler_turn::aux_bearer_resolver_foris the ONE definition of the aux/summary resolver rule (the session actor andMvpAgent::build_summary_clientboth 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" istruefor a subscription-OAuth platform at its own host — where the credential that may ride is that platform's POOLED token, never the primary. Askcredential_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 oncredential_classrather than reach for a boolean. There is deliberately no second, similarly-named predicate to pick wrongly. The sharedsampling_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(thecached_token/kimi.com/oidclogin handlers and thenew_session/load_sessionseed), which askscredential_for; and thexai.api_keyhandler inacp_agent.rs, which stamps the houseKIGI_API_KEYread from the environment — a credential the authority does not own — only whencredential_classisPrimary, the class the house key rides and the one every OAuth platform's own host is NOT. - MODEL→PLATFORM LOOKUP (security):
SamplingConfig::modelis 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 inPlatformId::ALL. The auth layer therefore resolves the platform from the catalog KEY the picker selected, held PER SESSION inSessionActor::selected_catalog_key(seeded at spawn byagent::models::selected_catalog_key_for_spawn, rewritten bySetSessionModel, CLEARED byOverrideModelNamewhen the rename makes it stale), viaagent::models::entry_for_slug/platform_for_slug. NEVERModelsManager::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 sharedMvpAgent::sampling_configis BUILT from that cell — but ONCE, at startup, and never rebuilt, while the cell moves on every non-Leader switch. Its guards therefore readMvpAgent::sampling_config_platform, the platform captured WITH the config by the sameModelsManager::sampling_config()call, never a fresh lookup against the live cell: once the two drift, re-resolving the config's bare slug falls through toresolve_catalog_key's.rev()scan, answers the API-key twin, and a post-expirykigi loginsilently 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_slugreturnsNonefor a slug that collides across platforms rather than trustingresolve_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.Nonethen 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 ownresolve_catalog_key, andconfig::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 livebearer_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_entrystampssupported_in_api = platform != KimiCode.ModelInfo::visible_for_authreads 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. Onlykimi-coderides 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 byscripts/gen_enrichment_snapshot.py, single Rust transformparse_api_jsonfor 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 (maxsplit fromxhigh2026-07;ultrais codex-only, abovemax, surfaced only via a model's server-declared effort menu; Kimi wire spells its top tiermax, kimi_compat renames).
Milestones (PRD §8.3)
- M0 (done): rename, deletions (voice/telemetry/announcements/marketplace/ relay-gateway), toolchain, gates.
- M1: Kimi device-flow auth (F1), Moonshot API-key channel (F2), inference
via ChatCompletions (F3), dynamic model sync (F4). The auth stack in
kigi-shell/src/auth+kigi-authgets rewritten here; transitional grok.com references live only there and inkigi-sampler/proxy types. - M2: server-side search/fetch (F5), command parity with kimi-cli 1.49.0
(F6), one-time
~/.kimi/config.tomlimport (F7), F9 smoke list, perf CI. - M3: GitHub Releases distribution, install scripts, self-update (F8).