Repository navigation
feat(kimi): add China-region Kimi Code provider - #400
Conversation
Kimi Code is a subscription product separate from the Moonshot Open Platform. Its CLI, desktop app, and the older kimi-cli all authorize with the same OAuth client over the RFC 8628 device grant and talk to https://api.kimi.com/coding/v1, so the gateway can hold that same login. - device-code login through the console, plus credential import from the file the Kimi Code client already writes under ~/.kimi-code - token refresh five minutes before expiry, single-flighted per account and persisted because Kimi rotates the refresh token - model discovery from GET /models: context window, thinking mode and efforts, image input, and tool support, all taken from the declaration - Chat Completions with Kimi's wire deviations (top-level thinking, max_completion_tokens, temperature 0.6/1.0, developer to system, 64-char tool ids), which serves /v1/chat/completions, /v1/responses, and /v1/messages through the shared gateway translation - readiness from /models, profile from /me, and quota from /usages (5-hour, 7-day, monthly, monthly-code windows) - plan-gated models classify as model_not_available instead of auth, so a healthy account stays ready and only the model is evicted Verified against a live China-region subscription: sign-in, model discovery, chat, streaming, tool calls, thinking deltas, vision, and all three public APIs.
There was a problem hiding this comment.
Thanks for this — it's a really solid first cut of an in-process provider. The package split matches the existing adapter pattern, OAuth stays on the public Kimi Code client, the catalog/quota/chat deviations are documented and tested, and the live-acceptance table is more honest than most new-provider PRs. CI is also green.
A few of the behaviors described in the PR summary don't quite land yet, and one of them is load-bearing because Kimi rotates the refresh token. Could you take a look at the notes below? Happy to re-review as soon as they're in.
It would also help to merge current main first (AGENTS.md asks for this). The branch is mergeable but a bit behind, and a couple of the console files this change needs to touch have moved.
Please take a look
-
Console reasoning default looks wired, but the setting never actually sticks.
AGENTS.mdsays the console value is a default only when the client omits a level. The adapter already hasstoredReasoning(), but:internal/control/settings.goUpdateModelSettingonly persistsreasoning_effortfortrae/workbuddy.frontend/src/pages/ProvidersPage.tsxonReasoningChangereturns early for any other provider, so the Kimi dropdown is currently a no-op.internal/console/models.gomodelSettingResponsealso only returnsreasoning_effortfor those two providers (thekimi/prefix was added to path splitting, not to the setting payload).resolveLevelclamps the request before reading the stored default.ResolveReasoningLevel("", caps)already fills the catalog default, sostoredReasoning()is dead even after a PATCH lands.
WorkBuddy's order is the one we want: empty client level → stored console default → then clamp. Could you extend the settings PATCH,modelSettingResponse, and the Providers page allow-list tokimias well?
-
New Kimi accounts still drop caller system prompts.
The PR text and thechooseProvidercomment say Kimi keeps system prompts because there is no upstream screening to work around.accountOptions()still sendsdrop_system_prompt: truefor every non-WorkBuddy provider (showDropSystem ? dropSystemPrompt : true), sosetDropSystemPrompt(option?.provider !== 'kimi')doesn't change what gets stored. Either persistfalsefor Kimi on create, or we can drop that claim — either is fine. -
A failed persist after refresh can burn the account.
Kimi rotates the refresh token on every refresh.credential()/forceRefresh()currently doif err := saveCredential(...); err != nil { return refreshed, nil }. The in-flight request keeps going with the new access token, but the next request reloads the old refresh token and the account falls intoKindAuth. Could you surface (or retry) the persist error here? Swallowing it is much more expensive on this provider than on ones that reuse the same refresh token. -
RFC 8628
slow_downdoesn't actually back off.
pollSlowDownStepis documented but unused.pollDeviceTokentreatsslow_downasauthorization_pendingwithout raisingloginState.interval, so a polite server is still polled at the original cadence.PollLoginalready throttles onstate.interval— bumping it onslow_downshould be enough.
Nits (non-blocking)
supportsToolsdefaults totruewhensupports_tool_useis absent. That invents a capability the catalog did not declare;falseis safer unless a live payload actually omits the field on tool-capable models.ExportreturnsErrUnsupportedwhile the descriptor advertisesImportExport. Totally fine if import-only is intentional — a one-line note in the changelog/docs would help so the empty console export looks expected.- Device-code login, entitlement-vs-auth classification, temperature omit-not-clamp, 64-char tool ids,
/usages404 → unknown, and the httptest flow coverage are all in good shape. Please keep those.
Thanks again for the live-account work on this — that's the hard part. Looking forward to the follow-up.
| return Credential{}, err | ||
| } | ||
| if err := c.saveCredential(ctx, accountID, refreshed); err != nil { | ||
| return refreshed, nil |
There was a problem hiding this comment.
Kimi rotates the refresh token on every successful refresh, so this persist really has to land.
If SaveCredentialPayload fails here, the current request continues with refreshed in memory, but the next loadCredential still has the previous refresh token — which the server has already invalidated. That turns into a spurious KindAuth / re-login.
Could you return (or retry) the write error instead of swallowing it? forceRefresh in chat.go has the same pattern.
| } | ||
| level := providers.ResolveReasoningLevel(requested, caps) | ||
| if level == "" || len(caps.ReasoningOptions) == 0 { | ||
| level = providers.ResolveReasoningLevel(c.storedReasoning(ctx, reqModel), caps) |
There was a problem hiding this comment.
providers.ResolveReasoningLevel("", caps) already returns ReasoningDefault or ReasoningOptions[0] when the model declares a ladder. That means this if level == "" branch never reads storedReasoning for a real Kimi thinking model, so the console default can't apply even after settings persistence is wired.
WorkBuddy does the order AGENTS.md asks for: requested level, else stored console default, then clamp. Could you match that here?
| return devicePollResult{}, fmt.Errorf("kimi token polling server error (HTTP %d): %s", status, apiMessage(body, "unknown error")) | ||
| } | ||
| switch payload.Error { | ||
| case "authorization_pending", "slow_down": |
There was a problem hiding this comment.
slow_down is currently classified as pending, but loginState.interval never increases and pollSlowDownStep is unused. RFC 8628 asks the client to back off on slow_down.
PollLogin already throttles on state.interval — bumping it here (or in the pending branch of PollLogin) should be enough for the next console poll to wait longer.
| setTab(option?.provider === 'zhipu' ? 'pat' : 'browser') | ||
| // Kimi Code has no content screening that would need caller system prompts | ||
| // stripped, so a new Kimi account keeps them by default. | ||
| setDropSystemPrompt(option?.provider !== 'kimi') |
There was a problem hiding this comment.
setDropSystemPrompt(option?.provider !== 'kimi') doesn't currently change what gets persisted. showDropSystem is WorkBuddy-only, and accountOptions() sends drop_system_prompt: showDropSystem ? dropSystemPrompt : true, so a new Kimi account still stores true.
The PR summary says Kimi keeps caller system prompts by default. Either sending false on create for provider === 'kimi', or dropping this comment, would make the two match — right now this is dead code that contradicts the stored flag.
Review follow-up for caigee-cmd#400. - Persist, read, decorate, and PATCH the Kimi reasoning default. The console value now reaches the request: priority is the client value, then the saved console default, then the catalog default, and only then the clamp (previously the clamp ran first, so the saved value was dead). - New Kimi accounts keep caller system prompts. The wizard sends drop_system_prompt=false for Kimi instead of relying on a switch Kimi does not expose. - Fail the request when a rotated refresh token cannot be persisted. Kimi invalidates the previous refresh token on every rotation, so continuing with an in-memory-only token left the stored credential already dead. - Back off the device-token poll interval on RFC 8628 slow_down, capped so a still-pending code finishes inside its window. - Document that Kimi Code is import-only, and pin the tool-support default to the observed catalog behavior: the live payload omits supports_tool_use on every model and those models do complete tool calls. Live re-verification: catalog shows the saved level, a request without a level is sent at that level, an explicit client level still wins, clearing the setting restores the catalog default, a system prompt round-trips, and streaming plus tool calls still work.
TestSaturatedPoolKeepsFiveSecondRetryAfter failed once on CI while passing locally (15 runs before the retrigger, plus 30 runs at GOMAXPROCS=2 and two shuffled full-package runs). The test does not touch any file this branch changes, so retrigger rather than change pool behavior.
|
Thanks for the detailed review — the refresh-token point especially, that one was load-bearing and I had it wrong. Follow-up pushed as On merging Blockers1. The reasoning default now actually sticks.
Tests: Live, against the real account: PATCH 2. New Kimi accounts keep system prompts. Live: an account created through the API the way the wizard does it stores and reads back 3. A failed persist after rotation now fails the request. Tests: 4. Tests: Nits
Verification
Unchanged by design: device-code login, entitlement-vs-auth classification, temperature omit-not-clamp, 64-char tool ids, Ready for another look when you have a moment — happy to split any of this further if you'd prefer it smaller. |
caigee-cmd
left a comment
There was a problem hiding this comment.
Re-reviewed on 5dab128. The four blocking items from the previous review are addressed:
- Reasoning default —
UpdateModelSetting/ReadModelSetting/modelSettingResponse/ Providers page /DecorateModelsWithContextall includekimi;resolveLevelapplies client → console default → catalog clamp. - System prompts kept —
accountOptions()setsdrop_system_prompt: falsefor new Kimi accounts. - Refresh write failure fails hard —
persistRefreshedretries once then returnsKindUnavailable;credential()andforceRefresh()never return an unpersisted rotated token. slow_downbackoff — distinct poll kind +backOffLogin(+5s, cap 60s) soPollLoginthrottles correctly.
CI is green on all six checks (go, frontend, worker, container, secrets, windows-host). LGTM — not merging from this review.
Summary
Adds a China-region Kimi Code provider (subscription product, not the Moonshot Open Platform API-key product). Kimi Code's CLI, desktop app, and the older
kimi-cliall authorize with the same public OAuth client over the RFC 8628 device grant and talk tohttps://api.kimi.com/coding/v1, so the gateway can hold the login the user already completed and serve it through the existing endpoint set.Two credential universes stay separate: this provider is OAuth-only against
auth.kimi.com; ansk-…Open Platform key is not accepted here.~/.kimi-code/credentials/kimi-code.json(or{"format":"kimi-oauth-v1","import_from":"local"}to read that file on the gateway host). Account readiness comes fromGET /models;GET /mefills the display profile. Import only — the descriptor advertisesimport_exportbecause it covers the import side, but export is deliberately not offered: the credential is a bearer token pair, so exporting it would hand out the account.GET /modelsis the single source of truth for the model list and every capability: context window, declared thinking mode (only/both/none) and effort ladder, image input, and tool support. Nothing is invented; the legacy flat field shape is accepted too.thinkingobject (neverreasoning_effort), onlymax_completion_tokensis accepted,temperatureonly accepts0.6(thinking off) or1.0(on) so any other value is omitted rather than clamped, anddeveloperis rewritten tosystem. Tool-call ids are capped at 64 characters consistently across calls and results. Streams are returned to the gateway unchanged, so/v1/chat/completions,/v1/responses, and/v1/messagesall work through the existing shared translation.GET /usagesmaps the subscription windows (5-hour, 7-day, monthly, monthly-code) intoQuotaInfo.Windows; an account without the managed API (404) reports unknown instead of zero. TheboosterWalletblock is intentionally not surfaced — the console has no prepaid-balance slot, so it stays an unverified metric rather than being forced into a percentage window./modelslist is not scoped to the subscription tier: it lists models the plan cannot call, and the call then fails with an authentication-shaped"your current subscription does not have access to <model>". That is a model entitlement, not an account failure, so it classifies asmodel_not_availableand the executor evicts the model for that account while the account stays ready. The chat path also skips the token refresh for that shape instead of spending a refresh round.Wiring follows the existing provider pattern: descriptor in
internal/providers/registry.go, adapter registered ininternal/app/app.go,kimi/prefix added to the executor and console model-prefix lists, console parity (account-type labels in both locales, brand mark), plus one bilingual changelog fragment.Review follow-up (1a24bcd)
Addresses every blocking item from the review.
origin/mainisf16d802, which is exactly this branch's base, so there was nothing to merge.UpdateModelSetting/ReadModelSetting/UpdateProviderModelSettingacceptkimi(withmax_moderejected, because Kimi has one context tier),modelSettingResponsereturns the reasoning payload forkimi,DecorateModelsWithContextdecorateskimiliketrae/workbuddyso the catalog shows the saved value, and the Providers page sends the PATCH forkimi.resolveLevelnow applies the order fromAGENTS.md: client value → saved console default → catalog default, and only then the clamp (previouslyResolveReasoningLevel("", caps)filled the catalog default first, sostoredReasoningwas dead).TestUpdateModelSettingPersistsKimiReasoningDefault,TestUpdateModelSettingRejectsKimiMaxMode,TestModelSettingResponseKimiCarriesReasoningOnly,kimiadded toTestDecorateProviderSettingsRestoresDefaultReasoningState, andTestReasoningPriority(client wins, saved default wins over catalog, both are clamped).low→/api/modelsshowsreasoning_effort: low; a request with no level logslevel="low"; a request withreasoning_effort: maxlogslevel="max"; clearing the setting restores the catalog default.accountOptions()now sendsdrop_system_prompt: falseforkimi; the deadsetDropSystemPromptline is gone. Other providers keep the historicaltruedefault, WorkBuddy keeps its switch.drop_system_prompt=falseand reads backfalse; a chat with asystemmessage ("answer with exactly one uppercase word") returnedHELLO, which proves the prompt reached the model.credential()andforceRefresh()go throughpersistRefreshed, which retries the write once and then returns aKindUnavailableerror naming the problem; it never returns the unpersisted credential. The account fails over instead of falling into a spuriousKindAuthon its next request.TestRefreshPersistFailureFailsTheRequest(no chat request is made, stored credential is untouched),TestForceRefreshPersistFailureSurfaces(401 → refresh → write failure surfaces),TestConcurrentRefreshIsSingleFlight(8 goroutines, exactly one refresh call, one persisted pair).slow_downnow backs off.pollDeviceTokenreports a distinctslow_downkind,PollLoginraisesloginState.intervalby 5 s per occurrence (capped at 60 s so a pending code still finishes inside its window), and the existing throttle then makes the next console poll wait.TestDeviceLoginSlowDownBacksOff(two consecutive slow_downs widen 5 s → 10 s → 15 s, the throttled poll does not hit the token endpoint, then success) andTestDeviceLoginPendingDoesNotWidenInterval.Nits:
supportsToolsdefault. I checked the live payload before changing anything:GET /modelson this account omitssupports_tool_useon all six models, and those same models complete real tool calls (finish_reason: tool_calls, arguments round-tripped). Kimi Code's own client also treats an absent field as supported. Defaulting tofalsewould therefore hide a capability that demonstrably works, so the default staystrue, the reasoning is documented incatalog.go, and an explicitsupports_tool_use: falseis still honored (covered by a fixture inTestParseCatalogJSONCurrentShape).doc.goand in the changelog fragment.Test plan
go test ./...(new and touched packages green; the same pre-existing Windows-only failures asmainremain ininternal/api,internal/executor,internal/providers/qoder,internal/runtime, andinternal/store— file-mode and path-separator assertions that also fail on a cleanmaincheckout)go vet ./...cd frontend && npm run build && npm run lint(0 errors, warnings unchanged) andnpm run syncpython scripts/release-notes.py validategit diff --checkchangelog/unreleased/kimi-code-provider.mdcd worker && npm test— not touched by this PRgo test -race ./...— could not run locally (no cgo toolchain on the Windows host); CI covers itAll six CI checks pass on
5dab128:go,frontend,worker,container,secrets, andwindows-host. An earlier run of1a24bcdfailed only thegojob, onTestSaturatedPoolKeepsFiveSecondRetryAfter— a pool-saturation test this branch does not touch. It passed on the same code in thewindows-hostjob of that very run, on the rerun, 15 local runs before the retrigger, and 30 local runs atGOMAXPROCS=2; the retrigger commit exists only to get a fresh green run.Live acceptance (one real China-region subscription)
Run against a locally built binary on a fresh data dir, through the public endpoints only:
login/devicereturned the verification URL,login/statusreachedok, account becameready/hotGET /modelssucceeded;/mefilledremote_uidand the plan labelkimi-for-coding(1,048,576 ctx, thinkingonly, effortslow/high/max, defaultmax, image in),k3,k3-256k,k3.1-pro,k3.1-pro-highspeed,kimi-for-coding-highspeedkimi/kimi-for-codinganswered, usage reportedreasoning_contentand final usage (cached_tokenspresent)finish_reason: tool_callswith{"city":"Chongqing"}; a second request replayingassistant.tool_calls+toolresult produced the final answerreasoning_effort: maxproduced 115 reasoning deltas and a 91-reasoning-token completion/v1/responsesresponse.completedwith usage)/v1/messagesmessage_start…message_delta)kimi/k3returnedmodel_not_available; the account stayedready/hot; the second request failed fast from the learned model state; the entitled model kept workingScope of this account:
kimi-for-codingandk3-256kare callable;k3,k3.1-pro,k3.1-pro-highspeed, andkimi-for-coding-highspeedare advertised by/modelsbut the subscription does not include them, which is exactly the entitlement case above.Multi-account
Only one real account exists, so multi-account behavior is covered by automated tests only: independent credentials per account, per-account catalog cache, per-account refresh single-flight (a rotation on account B does not touch account A), and per-account login state.
Known limitations
auth.kimi.com/api.kimi.com/coding/v1); the global region is not part of this change./modelsis not plan-scoped, so an unentitled model is only hidden after its first attempted call (the executor's existing model eviction then handles it).boosterWallet(prepaid booster balance) is not rendered in the console yet.slow_downpath and the persist-failure path are only covered by the httptest suite, because neither can be triggered on the production endpoint on demand.