mirror of
https://github.com/lemon07r/opencode-kimi-full.git
synced 2026-07-18 08:05:52 +02:00
Fix auth discovery and refresh parity
This commit is contained in:
@@ -43,14 +43,14 @@ Data flow on a chat request:
|
||||
1. opencode asks the `@ai-sdk/openai-compatible` provider for a language model.
|
||||
2. Before instantiating it, opencode calls our `auth.loader`. We return `{ apiKey, fetch }`.
|
||||
3. The SDK uses our `fetch` for every HTTP call (models, chat, whatever).
|
||||
4. Our `fetch` calls `ensureFresh()` → maybe refreshes → sets Authorization + the seven `X-Msh-*` headers → on 401 refreshes once and retries.
|
||||
4. Our `fetch` calls `ensureFresh()` → maybe refreshes → lazily discovers `/coding/v1/models` when needed → sets Authorization + the seven `X-Msh-*` headers → on 401 refreshes once and retries.
|
||||
5. Separately, opencode runs the `chat.params` hook and writes `thinking`, `reasoning_effort`, `prompt_cache_key` into `output.options`. opencode wraps those as `{ [providerID]: options }` and the openai-compatible SDK forwards them as top-level body fields. That is why those keys must use **exactly** the wire names (`prompt_cache_key`, `reasoning_effort`, `thinking`).
|
||||
|
||||
### Contracts to keep intact
|
||||
|
||||
These are the invariants that, if broken, silently degrade K2.6 → K2.5 or produce fingerprint-based throttling. Do not "clean them up" without reading the linked upstream.
|
||||
|
||||
1. **`X-Msh-Version` and `User-Agent` must track `kimi-cli`.** Bumping involves exactly one line in `src/constants.ts`. See upstream `research/kimi-cli/src/kimi_cli/constant.py`. The UA prefix is `KimiCLI/` (not `KimiCodeCLI/`) — Moonshot's `kimi-for-coding` backend 403s with `access_terminated_error: only available for Coding Agents such as Kimi CLI, Claude Code, Roo Code…` on any other prefix. Likewise, `X-Msh-Device-Model` is `"{system} {release} {machine}"` (e.g. `Linux 7.0.0 x86_64`) — NOT just `{arch}` — and `X-Msh-Os-Version` is the kernel build string from `os.version()`, NOT `"{type} {release}"`. Tested live against `api.kimi.com/coding/v1` on 2026-04-17 — any of those three fields off-spec → 403.
|
||||
1. **`X-Msh-Version` and `User-Agent` must track `kimi-cli`.** Bumping involves exactly one line in `src/constants.ts`. See upstream `research/kimi-cli/src/kimi_cli/constant.py`. The UA prefix is `KimiCLI/` (not `KimiCodeCLI/`) — Moonshot's `kimi-for-coding` backend 403s with `access_terminated_error: only available for Coding Agents such as Kimi CLI, Claude Code, Roo Code…` on any other prefix. Likewise, `X-Msh-Device-Model` must mirror kimi-cli's `_device_model()` shape, including the Darwin/Windows special cases (`macOS <version> <arch>`, `Windows 10/11 <arch>`, Linux `"{system} {release} {machine}"`) — NOT just `{arch}` — and `X-Msh-Os-Version` is the kernel build string from `os.version()`, NOT `"{type} {release}"`. Tested live against `api.kimi.com/coding/v1` on 2026-04-17 — any of those three fields off-spec → 403.
|
||||
2. **`X-Msh-Device-Id` must be stable across runs.** Never regenerate a fresh UUID at import time. `getDeviceId()` reads/writes `~/.kimi/device_id`; that path is shared with `kimi-cli` on purpose.
|
||||
3. **`Authorization` header is owned by `loader.fetch`.** Anything else (opencode core, the SDK, future hooks) must be overridden. Our `loader` deletes both `authorization` and `Authorization` before setting its own.
|
||||
4. **Effort ↔ fields mapping** (kimi-cli `llm.py` / `kosong/chat_provider/kimi.py`):
|
||||
@@ -66,8 +66,8 @@ These are the invariants that, if broken, silently degrade K2.6 → K2.5 or prod
|
||||
`auto` is the "let the server decide dynamically" variant — neither field is sent, matching kimi-cli's "nothing passed" default. When no effort is set at all, the plugin still emits `thinking: {type: "enabled"}` because the model is a reasoner. Gate the hook on `input.model.providerID` — NOT `input.provider.info.id`. The `@opencode-ai/plugin` `ProviderContext` type claims `.info.id` exists, but the runtime shape opencode passes (see `research/opencode/packages/opencode/src/session/llm.ts::stream`, ~line 168, `provider: item`) is the flat `ProviderConfig` (`.id`). `input.model.providerID` is what every first-party plugin uses (cloudflare.ts, codex.ts, github-copilot/copilot.ts) and it avoids the runtime crash "undefined is not an object (evaluating 'input.provider.info.id')". Tested live 2026-04-17.
|
||||
|
||||
5. **`prompt_cache_key` only for `kimi-for-coding`.** Never attach it to unrelated models. The check is `input.model.id === MODEL_ID` in `chat.params`.
|
||||
6. **Wire model id comes from `/coding/v1/models`, not from user config.** The opencode-side model id is a stable alias (`MODEL_ID = "kimi-for-coding"`); the plugin calls `GET /coding/v1/models` at login and on every token refresh (mirroring kimi-cli's `refresh_managed_models` in `research/kimi-cli/src/kimi_cli/auth/platforms.py`), caches the first returned `{id, context_length, display_name}` in `auth.json` as `model_id`/`context_length`/`model_display`, and rewrites the JSON body `model` field inside `loader.fetch` whenever the cached id differs from `MODEL_ID` (the K2.5 case — server may return `k2p5` instead). K2.6 accounts see `id: "kimi-for-coding"` and the rewrite is a no-op. Do not strip the `kimi-` prefix; send whatever the server returned. Discovery failures are non-fatal (stale cached id still works; 401 retry flushes broken tokens).
|
||||
7. **Auth store is opencode's, not kimi-cli's.** We use `client.auth.get/set` against the `kimi-for-coding-oauth` provider id. Do not read/write `~/.kimi/credentials/kimi-code.json`; that's kimi-cli's file and sharing it across independent apps causes token-race bugs.
|
||||
6. **Wire model id comes from `/coding/v1/models`, not from user config.** The opencode-side model id is a stable alias (`MODEL_ID = "kimi-for-coding"`); the plugin calls `GET /coding/v1/models` at login and on every token refresh (mirroring kimi-cli's `refresh_managed_models` in `research/kimi-cli/src/kimi_cli/auth/platforms.py`), caches the first returned `{id, context_length, display_name}` in loader memory, and rewrites the JSON body `model` field inside `loader.fetch` whenever the discovered id differs from `MODEL_ID` (the K2.5 case — server may return `k2p5` instead). A new loader instance re-discovers on first use if needed. K2.6 accounts see `id: "kimi-for-coding"` and the rewrite is a no-op. Do not strip the `kimi-` prefix; send whatever the server returned. Discovery failures are non-fatal (warm cached id still works; 401 retry flushes broken tokens).
|
||||
7. **Auth store is opencode's, not kimi-cli's.** We use opencode's auth store for tokens under the `kimi-for-coding-oauth` provider id. Do not read/write `~/.kimi/credentials/kimi-code.json`; that's kimi-cli's file and sharing it across independent apps causes token-race bugs. Also note that opencode's SDK auth schema only persists the standard oauth fields, so model discovery metadata cannot be stored there durably.
|
||||
8. **Provider id must not collide with any id in the [models.dev](https://models.dev) catalog.** models.dev publishes `kimi-for-coding` (static `KIMI_API_KEY` → `@ai-sdk/anthropic` → K2.5). If we registered under that same id, `opencode auth login kimi-for-coding` would surface two methods under one entry and users picking the API-key one would silently land on K2.5. We deliberately use `kimi-for-coding-oauth` instead; `MODEL_ID` on the wire stays `kimi-for-coding` (rule 6).
|
||||
9. **`src/index.ts` must have exactly one export — the default plugin function.** opencode's plugin loader (`research/opencode/packages/opencode/src/plugin/index.ts` → `getLegacyPlugins`) iterates every export of the plugin module and throws `Plugin export is not a function` if any named export is not callable. The failure mode is silent in the CLI (the provider just doesn't appear in `opencode auth login`); the error only surfaces in `~/.local/share/opencode/log/*.log`. Keep constants in `src/constants.ts` and import them in `src/index.ts` rather than re-exporting. `test/exports.test.ts` guards this.
|
||||
|
||||
@@ -103,7 +103,7 @@ bun test # offline unit tests
|
||||
|
||||
Online (requires a real Kimi-for-coding account):
|
||||
|
||||
1. `cd ~/.opencode && bun add /path/to/this/repo`
|
||||
1. Install the local checkout via opencode's plugin flow (`opencode plugin /path/to/this/repo --global`) or point the `plugin` array in your opencode config at the repo root, as shown in `README.md`.
|
||||
2. Paste the provider block from `README.md` into your opencode config.
|
||||
3. `opencode auth login kimi-for-coding-oauth` — confirm a token lands in opencode's `auth.json` with `type: "oauth"`, a JWT `access`, and `expires` ~15 min in the future.
|
||||
4. Start opencode, select `kimi-for-coding-oauth/kimi-for-coding`, and ask the model to self-identify. It should claim to be K2.6 / `kimi-for-coding`.
|
||||
|
||||
@@ -99,7 +99,7 @@ Two identifiers are load-bearing:
|
||||
opencode auth login -p kimi-for-coding-oauth
|
||||
```
|
||||
|
||||
The plugin returns a verification URL and user code. After browser approval it polls the device-auth endpoint, queries `/coding/v1/models` to discover the current model id and context length for your account, prints a config snippet with that context length filled in, and stores the token plus discovered model metadata in opencode's auth store. Model discovery runs again on every token refresh. Access tokens refresh automatically, and the loader retries once after a `401`.
|
||||
The plugin returns a verification URL and user code. After browser approval it polls the device-auth endpoint, queries `/coding/v1/models` to discover the current model id and context length for your account, prints a config snippet with that context length filled in, and stores the token in opencode's auth store. Model discovery runs again on every token refresh, and a fresh loader instance will re-query `/coding/v1/models` on first use if it needs the current wire model id. Access tokens refresh automatically, and the loader retries once after a `401`.
|
||||
|
||||
### Use
|
||||
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencode-kimi-full",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"description": "OpenCode plugin that adds first-class support for Kimi K2.6 (kimi-for-coding) via the official Kimi OAuth device flow, matching the upstream kimi-cli 1:1.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -38,6 +38,9 @@
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/plugin": ">=1.4.6"
|
||||
},
|
||||
"engines": {
|
||||
"opencode": ">=1.4.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/plugin": "^1.4.7",
|
||||
"typescript": "^5.6.0",
|
||||
|
||||
+65
-8
@@ -2,6 +2,7 @@ import os from "node:os"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import crypto from "node:crypto"
|
||||
import childProcess from "node:child_process"
|
||||
import { KIMI_CLI_VERSION, USER_AGENT } from "./constants.ts"
|
||||
|
||||
// kimi-cli persists its device id at `~/.kimi/device_id` as a plain UUIDv4
|
||||
@@ -27,10 +28,67 @@ export function getDeviceId(): string {
|
||||
}
|
||||
|
||||
// Non-ASCII characters in HTTP headers will be rejected by Node's undici
|
||||
// fetch (`TypeError: Invalid character in header content`). kimi-cli does the
|
||||
// same sanitization in oauth._ascii_header_value.
|
||||
function ascii(value: string): string {
|
||||
return value.replace(/[^\x20-\x7e]/g, "?")
|
||||
// fetch (`TypeError: Invalid character in header content`). kimi-cli strips
|
||||
// non-ASCII bytes in oauth._ascii_header_value; we do the same while also
|
||||
// dropping control characters to stay within Node's header rules.
|
||||
export function asciiHeaderValue(value: string, fallback = "unknown"): string {
|
||||
const sanitized = value.replace(/[^\x20-\x7e]/g, "").trim()
|
||||
return sanitized || fallback
|
||||
}
|
||||
|
||||
let cachedMacVersion: string | undefined
|
||||
|
||||
function macProductVersion(): string | undefined {
|
||||
if (process.platform !== "darwin") return undefined
|
||||
if (cachedMacVersion !== undefined) return cachedMacVersion || undefined
|
||||
try {
|
||||
cachedMacVersion = childProcess.execFileSync("sw_vers", ["-productVersion"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim()
|
||||
} catch {
|
||||
cachedMacVersion = ""
|
||||
}
|
||||
return cachedMacVersion || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors kimi-cli's `_device_model()` logic in research/kimi-cli/src/
|
||||
* kimi_cli/auth/oauth.py, including the Darwin/Windows special cases.
|
||||
*/
|
||||
export function kimiDeviceModel(input?: {
|
||||
system?: string
|
||||
release?: string
|
||||
machine?: string
|
||||
macVersion?: string
|
||||
}) {
|
||||
const system = input?.system ?? os.type()
|
||||
const release = input?.release ?? os.release()
|
||||
const machine = input?.machine ?? os.machine?.() ?? os.arch()
|
||||
|
||||
if (system === "Darwin") {
|
||||
const version = input?.macVersion ?? macProductVersion() ?? release
|
||||
if (version && machine) return `macOS ${version} ${machine}`
|
||||
if (version) return `macOS ${version}`
|
||||
return `macOS ${machine}`.trim()
|
||||
}
|
||||
|
||||
if (system === "Windows_NT") {
|
||||
const parts = release.split(".")
|
||||
const build = Number(parts[2] ?? "")
|
||||
const label = parts[0] === "10" ? (Number.isFinite(build) && build >= 22000 ? "11" : "10") : release
|
||||
if (label && machine) return `Windows ${label} ${machine}`
|
||||
if (label) return `Windows ${label}`
|
||||
return `Windows ${machine}`.trim()
|
||||
}
|
||||
|
||||
if (system) {
|
||||
if (release && machine) return `${system} ${release} ${machine}`
|
||||
if (release) return `${system} ${release}`
|
||||
return `${system} ${machine}`.trim()
|
||||
}
|
||||
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,14 +104,13 @@ function ascii(value: string): string {
|
||||
* - platform.version() → os.version() (kernel build string on Linux)
|
||||
*/
|
||||
export function kimiHeaders(): Record<string, string> {
|
||||
const machine = os.machine?.() || os.arch()
|
||||
return {
|
||||
"User-Agent": USER_AGENT,
|
||||
"X-Msh-Platform": "kimi_cli",
|
||||
"X-Msh-Version": KIMI_CLI_VERSION,
|
||||
"X-Msh-Device-Name": ascii(os.hostname() || "unknown"),
|
||||
"X-Msh-Device-Model": ascii(`${os.type()} ${os.release()} ${machine}`),
|
||||
"X-Msh-Device-Name": asciiHeaderValue(os.hostname() || "unknown"),
|
||||
"X-Msh-Device-Model": asciiHeaderValue(kimiDeviceModel()),
|
||||
"X-Msh-Device-Id": getDeviceId(),
|
||||
"X-Msh-Os-Version": ascii(os.version?.() || `${os.type()} ${os.release()}`),
|
||||
"X-Msh-Os-Version": asciiHeaderValue(os.version?.() || `${os.type()} ${os.release()}`),
|
||||
}
|
||||
}
|
||||
|
||||
+77
-60
@@ -1,7 +1,7 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin"
|
||||
import { MODEL_ID, PROVIDER_ID, REFRESH_SAFETY_WINDOW_MS } from "./constants.ts"
|
||||
import { kimiHeaders } from "./headers.ts"
|
||||
import { listModels, pollDeviceToken, refreshToken, startDeviceAuth } from "./oauth.ts"
|
||||
import { type KimiModelInfo, listModels, pollDeviceToken, refreshToken, startDeviceAuth } from "./oauth.ts"
|
||||
|
||||
// IMPORTANT: this module must have exactly ONE export — the default plugin
|
||||
// function. opencode's plugin loader (packages/opencode/src/plugin/index.ts →
|
||||
@@ -14,20 +14,24 @@ type OAuthAuth = {
|
||||
refresh: string
|
||||
access: string
|
||||
expires: number
|
||||
// Discovered from `GET /coding/v1/models` at login and on every refresh.
|
||||
// Mirrors kimi-cli's `refresh_managed_models`. Absent on auth records
|
||||
// created before v1.1.0; fallback to MODEL_ID and no context-length hint.
|
||||
//
|
||||
// `model_id` is the slug Moonshot actually expects on the wire for this
|
||||
// account. In practice every current tier returns `kimi-for-coding`, but
|
||||
// the field exists so a future server-side slug change doesn't require a
|
||||
// plugin update. `context_length` is surfaced to users in the post-login
|
||||
// config block so their opencode config matches their real entitlement.
|
||||
}
|
||||
|
||||
type ModelDiscovery = {
|
||||
model_id?: string
|
||||
context_length?: number
|
||||
model_display?: string
|
||||
}
|
||||
|
||||
function pickModelInfo(models: KimiModelInfo[]): ModelDiscovery {
|
||||
const picked = models.find((m) => m.id === MODEL_ID) ?? models[0]
|
||||
if (!picked) return {}
|
||||
return {
|
||||
model_id: picked.id,
|
||||
context_length: picked.context_length,
|
||||
model_display: picked.display_name,
|
||||
}
|
||||
}
|
||||
|
||||
function buildConfigBlock(info: { model_id: string; context_length?: number; display?: string }) {
|
||||
const name = info.display ?? "Kimi For Coding"
|
||||
const ctx = info.context_length ?? 0
|
||||
@@ -46,6 +50,7 @@ function buildConfigBlock(info: { model_id: string; context_length?: number; dis
|
||||
[MODEL_ID]: {
|
||||
name,
|
||||
reasoning: true,
|
||||
options: {},
|
||||
...(ctx > 0 ? { limit: { context: ctx } } : {}),
|
||||
variants: {
|
||||
off: { reasoning_effort: "off" },
|
||||
@@ -75,7 +80,9 @@ function buildConfigBlock(info: { model_id: string; context_length?: number; dis
|
||||
* a custom `fetch` that (a) refreshes the access token when
|
||||
* it is about to expire, (b) injects the seven X-Msh-* / UA
|
||||
* headers on every upstream call (models list, chat, etc.),
|
||||
* and (c) retries once with a forced refresh on 401.
|
||||
* (c) lazily discovers the current wire model id from
|
||||
* `GET /coding/v1/models`, and (d) retries once with a forced
|
||||
* refresh on 401.
|
||||
* 3. `chat.params` — adds the Kimi-specific request body fields the model
|
||||
* actually needs: `thinking.type`, `reasoning_effort`, and
|
||||
* `prompt_cache_key`. These are placed under the SDK-scoped
|
||||
@@ -108,40 +115,50 @@ const plugin: Plugin = async ({ client }) => {
|
||||
* this loader callback. Writes still go through `client.auth.set`.
|
||||
*/
|
||||
loader: async (readAuth) => {
|
||||
const ensureFresh = async (force = false): Promise<OAuthAuth> => {
|
||||
const current = (await readAuth()) as OAuthAuth | undefined
|
||||
let discovery: ModelDiscovery = {}
|
||||
|
||||
const discoverModelInfo = async (access: string): Promise<ModelDiscovery> => {
|
||||
// opencode's SDK auth schema only persists the standard oauth fields
|
||||
// (`refresh`/`access`/`expires`) on `client.auth.set`, so discovery
|
||||
// cannot live durably in auth.json across refresh writes. Cache it in
|
||||
// this loader instance instead, and repopulate lazily on startup.
|
||||
const next = pickModelInfo(await listModels(access))
|
||||
if (next.model_id) discovery = next
|
||||
return discovery
|
||||
}
|
||||
|
||||
const ensureDiscovered = async (auth: OAuthAuth & Partial<ModelDiscovery>) => {
|
||||
if (!discovery.model_id && auth.model_id) {
|
||||
discovery = {
|
||||
model_id: auth.model_id,
|
||||
context_length: auth.context_length,
|
||||
model_display: auth.model_display,
|
||||
}
|
||||
}
|
||||
if (discovery.model_id) return { ...auth, ...discovery }
|
||||
try {
|
||||
return { ...auth, ...(await discoverModelInfo(auth.access)) }
|
||||
} catch {
|
||||
return { ...auth, ...discovery }
|
||||
}
|
||||
}
|
||||
|
||||
const ensureFresh = async (force = false): Promise<OAuthAuth & ModelDiscovery> => {
|
||||
const current = (await readAuth()) as (OAuthAuth & Partial<ModelDiscovery>) | undefined
|
||||
if (!current || current.type !== "oauth")
|
||||
throw new Error(
|
||||
"kimi-for-coding-oauth: not logged in — run `opencode auth login kimi-for-coding-oauth`",
|
||||
)
|
||||
if (!force && !isExpiring(current)) return current
|
||||
if (!force && !isExpiring(current)) return ensureDiscovered(current)
|
||||
const tokens = await refreshToken(current.refresh)
|
||||
// kimi-cli re-runs `refresh_managed_models` on every successful
|
||||
// refresh — we mirror that so entitlement changes (e.g. an
|
||||
// account gaining/losing K2.6 access) are picked up without a
|
||||
// full re-login. Failures here must not block the refresh: a
|
||||
// stale cached model_id still works for the common case, and
|
||||
// warm in-memory discovery still works for the common case, and
|
||||
// the request-path 401 retry will flush a broken access token.
|
||||
let discovered: Pick<OAuthAuth, "model_id" | "context_length" | "model_display"> = {
|
||||
model_id: current.model_id,
|
||||
context_length: current.context_length,
|
||||
model_display: current.model_display,
|
||||
}
|
||||
try {
|
||||
const models = await listModels(tokens.access_token)
|
||||
// Prefer the canonical `kimi-for-coding` id when the server
|
||||
// offers it; otherwise take the first entry. Mirrors kimi-cli's
|
||||
// behavior of treating `/models` as authoritative while keeping
|
||||
// a stable slug when possible, so users see the same id across
|
||||
// tiers in practice.
|
||||
const picked = models.find((m) => m.id === MODEL_ID) ?? models[0]
|
||||
if (picked) {
|
||||
discovered = {
|
||||
model_id: picked.id,
|
||||
context_length: picked.context_length,
|
||||
model_display: picked.display_name,
|
||||
}
|
||||
}
|
||||
await discoverModelInfo(tokens.access_token)
|
||||
} catch {
|
||||
/* keep previous discovery */
|
||||
}
|
||||
@@ -150,10 +167,9 @@ const plugin: Plugin = async ({ client }) => {
|
||||
refresh: tokens.refresh_token,
|
||||
access: tokens.access_token,
|
||||
expires: Date.now() + tokens.expires_in * 1000,
|
||||
...discovered,
|
||||
}
|
||||
await persistAuth(next)
|
||||
return next
|
||||
return { ...next, ...discovery }
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -161,8 +177,11 @@ const plugin: Plugin = async ({ client }) => {
|
||||
// requires a truthy apiKey to wire things up; use a sentinel.
|
||||
apiKey: "kimi-for-coding-oauth",
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const doRequest = async (auth: OAuthAuth) => {
|
||||
const headers = new Headers(init?.headers)
|
||||
const doRequest = async (auth: OAuthAuth & ModelDiscovery) => {
|
||||
const headers = new Headers(input instanceof Request ? input.headers : undefined)
|
||||
new Headers(init?.headers).forEach((value, key) => {
|
||||
headers.set(key, value)
|
||||
})
|
||||
// Strip anything the upstream SDK put on. Our values win.
|
||||
headers.delete("authorization")
|
||||
headers.delete("Authorization")
|
||||
@@ -185,9 +204,18 @@ const plugin: Plugin = async ({ client }) => {
|
||||
// exactly the id it got back from `/models`.
|
||||
let newInit = init
|
||||
const targetModel = auth.model_id
|
||||
if (targetModel && targetModel !== MODEL_ID && init?.body && typeof init.body === "string") {
|
||||
const originalBody =
|
||||
typeof init?.body === "string"
|
||||
? init.body
|
||||
: input instanceof Request && init?.body === undefined
|
||||
? await input
|
||||
.clone()
|
||||
.text()
|
||||
.catch(() => undefined)
|
||||
: undefined
|
||||
if (targetModel && targetModel !== MODEL_ID && originalBody) {
|
||||
try {
|
||||
const parsed = JSON.parse(init.body)
|
||||
const parsed = JSON.parse(originalBody)
|
||||
if (parsed && typeof parsed === "object" && parsed.model === MODEL_ID) {
|
||||
parsed.model = targetModel
|
||||
newInit = { ...init, body: JSON.stringify(parsed) }
|
||||
@@ -231,31 +259,21 @@ const plugin: Plugin = async ({ client }) => {
|
||||
// after approval (mirrors kimi-cli's login flow).
|
||||
// Failures here degrade gracefully — the plugin still
|
||||
// works; users just don't see the config-block hint and
|
||||
// the next token refresh will re-attempt discovery.
|
||||
let discovered: Pick<OAuthAuth, "model_id" | "context_length" | "model_display"> = {}
|
||||
// the loader will re-attempt discovery before the first
|
||||
// model-rewrite that needs it.
|
||||
try {
|
||||
const models = await listModels(tokens.access_token)
|
||||
// Same preference as the loader: canonical id wins,
|
||||
// else first. In practice every tier currently returns
|
||||
// `kimi-for-coding`; the fallback exists only so a
|
||||
// future server change can't brick the plugin.
|
||||
const picked = models.find((m) => m.id === MODEL_ID) ?? models[0]
|
||||
if (picked) {
|
||||
discovered = {
|
||||
model_id: picked.id,
|
||||
context_length: picked.context_length,
|
||||
model_display: picked.display_name,
|
||||
}
|
||||
const discovered = pickModelInfo(await listModels(tokens.access_token))
|
||||
if (discovered.model_id) {
|
||||
// Print a ready-to-paste config block. opencode shows
|
||||
// this next to the "Authorized" message.
|
||||
const block = buildConfigBlock({
|
||||
model_id: picked.id,
|
||||
context_length: picked.context_length,
|
||||
display: picked.display_name,
|
||||
model_id: discovered.model_id,
|
||||
context_length: discovered.context_length,
|
||||
display: discovered.model_display,
|
||||
})
|
||||
console.log(
|
||||
`\n✓ Authorized for Kimi For Coding (model: ${picked.id}${
|
||||
picked.context_length ? `, context ${picked.context_length}` : ""
|
||||
`\n✓ Authorized for Kimi For Coding (model: ${discovered.model_id}${
|
||||
discovered.context_length ? `, context ${discovered.context_length}` : ""
|
||||
})\n\nAdd this to your opencode config (~/.config/opencode/opencode.json) if you haven't already:\n\n${block}\n`,
|
||||
)
|
||||
}
|
||||
@@ -267,7 +285,6 @@ const plugin: Plugin = async ({ client }) => {
|
||||
refresh: tokens.refresh_token,
|
||||
access: tokens.access_token,
|
||||
expires: Date.now() + tokens.expires_in * 1000,
|
||||
...discovered,
|
||||
}
|
||||
} catch {
|
||||
return { type: "failed" }
|
||||
|
||||
+67
-5
@@ -25,6 +25,9 @@ export type TokenResponse = {
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
const REFRESH_RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504])
|
||||
const REFRESH_MAX_RETRIES = 3
|
||||
|
||||
function formBody(params: Record<string, string>): string {
|
||||
return new URLSearchParams(params).toString()
|
||||
}
|
||||
@@ -95,11 +98,70 @@ export async function pollDeviceToken(device: DeviceAuth): Promise<TokenResponse
|
||||
}
|
||||
|
||||
export async function refreshToken(refresh: string): Promise<TokenResponse> {
|
||||
return postForm<TokenResponse>(OAUTH_TOKEN_URL, {
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
refresh_token: refresh,
|
||||
grant_type: OAUTH_REFRESH_GRANT,
|
||||
})
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt < REFRESH_MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const res = await fetch(OAUTH_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...kimiHeaders(),
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: formBody({
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
refresh_token: refresh,
|
||||
grant_type: OAUTH_REFRESH_GRANT,
|
||||
}),
|
||||
})
|
||||
const text = await res.text()
|
||||
let json: any = {}
|
||||
try {
|
||||
json = text ? JSON.parse(text) : {}
|
||||
} catch {
|
||||
if (REFRESH_RETRYABLE_STATUSES.has(res.status)) {
|
||||
const err = new Error(
|
||||
`kimi oauth refresh transient ${res.status}: non-JSON response: ${text.slice(0, 200)}`,
|
||||
) as Error & { status?: number }
|
||||
err.status = res.status
|
||||
throw err
|
||||
}
|
||||
const err = new Error(
|
||||
`kimi oauth: non-JSON response from ${OAUTH_TOKEN_URL} (status ${res.status}): ${text.slice(0, 200)}`,
|
||||
) as Error & { status?: number }
|
||||
err.status = res.status
|
||||
throw err
|
||||
}
|
||||
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
const err = new Error(`kimi oauth ${json.error ?? res.status}: ${json.error_description ?? text}`) as Error & {
|
||||
status?: number
|
||||
}
|
||||
err.status = res.status
|
||||
throw err
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = new Error(`kimi oauth ${json.error ?? res.status}: ${json.error_description ?? text}`) as Error & {
|
||||
code?: string
|
||||
status?: number
|
||||
}
|
||||
err.code = json.error
|
||||
err.status = res.status
|
||||
throw err
|
||||
}
|
||||
|
||||
return json as TokenResponse
|
||||
} catch (err) {
|
||||
const status = (err as { status?: number }).status
|
||||
const retryable = status === undefined || REFRESH_RETRYABLE_STATUSES.has(status)
|
||||
lastError = err
|
||||
if (!retryable || attempt === REFRESH_MAX_RETRIES - 1) throw err
|
||||
await new Promise((r) => setTimeout(r, 2 ** attempt * 1000))
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error("kimi oauth: token refresh failed")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+18
-4
@@ -18,14 +18,28 @@ export function installFetchMock(responder: Responder) {
|
||||
const calls: FetchCall[] = []
|
||||
const original = globalThis.fetch
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url
|
||||
const request = input instanceof Request ? input : undefined
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url
|
||||
const headers: Record<string, string> = {}
|
||||
const hs = new Headers(init?.headers)
|
||||
const hs = new Headers(request?.headers)
|
||||
new Headers(init?.headers).forEach((v, k) => {
|
||||
hs.set(k, v)
|
||||
})
|
||||
hs.forEach((v, k) => {
|
||||
headers[k] = v
|
||||
})
|
||||
const body = typeof init?.body === "string" ? init.body : init?.body == null ? undefined : String(init.body)
|
||||
const call: FetchCall = { url, method: (init?.method ?? "GET").toUpperCase(), headers, body }
|
||||
const body =
|
||||
typeof init?.body === "string"
|
||||
? init.body
|
||||
: request && init?.body === undefined
|
||||
? await request
|
||||
.clone()
|
||||
.text()
|
||||
.catch(() => undefined)
|
||||
: init?.body == null
|
||||
? undefined
|
||||
: String(init.body)
|
||||
const call: FetchCall = { url, method: (init?.method ?? request?.method ?? "GET").toUpperCase(), headers, body }
|
||||
calls.push(call)
|
||||
const r = responder(call, calls.length - 1)
|
||||
const status = r.status ?? 200
|
||||
|
||||
+21
-14
@@ -3,7 +3,7 @@ import fs from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { KIMI_CLI_VERSION, USER_AGENT } from "../src/constants.ts"
|
||||
import { getDeviceId, kimiHeaders } from "../src/headers.ts"
|
||||
import { asciiHeaderValue, getDeviceId, kimiDeviceModel, kimiHeaders } from "../src/headers.ts"
|
||||
|
||||
// Note: getDeviceId() reads/writes ~/.kimi/device_id. That file is shared
|
||||
// with kimi-cli on purpose (AGENTS.md rule 2) and the function is
|
||||
@@ -41,6 +41,12 @@ test("All header values are ASCII-only (undici rejects non-ASCII)", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("asciiHeaderValue strips non-ASCII bytes like kimi-cli and falls back when empty", () => {
|
||||
expect(asciiHeaderValue(" hoste ")).toBe("hoste")
|
||||
expect(asciiHeaderValue("hést")).toBe("hst")
|
||||
expect(asciiHeaderValue("你好")).toBe("unknown")
|
||||
})
|
||||
|
||||
test("Device id is a 32-char lowercase hex string (kimi-cli UUIDv4 no-dashes format)", () => {
|
||||
expect(getDeviceId()).toMatch(/^[0-9a-f]{32}$/)
|
||||
})
|
||||
@@ -61,20 +67,21 @@ test("Device id is also present and matches in the headers map", () => {
|
||||
|
||||
// Regression guard: prior to v1.0.3 we were sending `X-Msh-Device-Model =
|
||||
// <arch>` and `X-Msh-Os-Version = <type release>`. That didn't match kimi-cli
|
||||
// (which uses `platform.system() + release() + machine()` for the model and
|
||||
// `platform.version()` — the kernel build string — for the os version) and
|
||||
// caused Moonshot to 403 every request from this plugin with
|
||||
// "access_terminated_error". Keep these shape-asserts strict so a future
|
||||
// "cleanup" of headers.ts can't silently regress the fingerprint.
|
||||
test("X-Msh-Device-Model matches kimi-cli _device_model() shape (system release machine)", () => {
|
||||
// and caused Moonshot to 403 every request from this plugin with
|
||||
// "access_terminated_error". Keep this tied to the helper so Linux, macOS,
|
||||
// and Windows all stay on the same parity path.
|
||||
test("X-Msh-Device-Model matches the kimi-cli parity helper", () => {
|
||||
const h = kimiHeaders()
|
||||
const sys = os.type()
|
||||
const rel = os.release()
|
||||
const mach = os.machine?.() || os.arch()
|
||||
expect(h["X-Msh-Device-Model"]).toBe(`${sys} ${rel} ${mach}`)
|
||||
// Must contain whitespace-separated release and machine — i.e. more than a
|
||||
// bare arch string.
|
||||
expect(h["X-Msh-Device-Model"]).toContain(" ")
|
||||
expect(h["X-Msh-Device-Model"]).toBe(asciiHeaderValue(kimiDeviceModel()))
|
||||
})
|
||||
|
||||
test("kimiDeviceModel mirrors kimi-cli Darwin and Windows special cases", () => {
|
||||
expect(kimiDeviceModel({ system: "Darwin", release: "23.6.0", machine: "arm64", macVersion: "15.4.1" })).toBe(
|
||||
"macOS 15.4.1 arm64",
|
||||
)
|
||||
expect(kimiDeviceModel({ system: "Windows_NT", release: "10.0.26100", machine: "x64" })).toBe("Windows 11 x64")
|
||||
expect(kimiDeviceModel({ system: "Windows_NT", release: "10.0.19045", machine: "x64" })).toBe("Windows 10 x64")
|
||||
expect(kimiDeviceModel({ system: "Linux", release: "6.8.0", machine: "x86_64" })).toBe("Linux 6.8.0 x86_64")
|
||||
})
|
||||
|
||||
test("X-Msh-Os-Version matches os.version() (kernel build string on Linux)", () => {
|
||||
|
||||
+12
-2
@@ -62,6 +62,16 @@ test("refreshToken posts grant_type=refresh_token and returns normalized shape",
|
||||
})
|
||||
})
|
||||
|
||||
test("refreshToken retries transient 5xx and non-JSON responses before succeeding", async () => {
|
||||
mock = installFetchMock((_, i) => {
|
||||
if (i === 0) return { status: 502, bodyText: "<html>gateway</html>" }
|
||||
return { body: { access_token: "a3", refresh_token: "r3", token_type: "Bearer", expires_in: 900 } }
|
||||
})
|
||||
const t = await refreshToken("retry-me")
|
||||
expect(t.access_token).toBe("a3")
|
||||
expect(mock.calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("postForm wraps non-OK responses with error.code from the JSON body", async () => {
|
||||
mock = installFetchMock(() => ({
|
||||
status: 400,
|
||||
@@ -70,8 +80,8 @@ test("postForm wraps non-OK responses with error.code from the JSON body", async
|
||||
await expect(refreshToken("bad")).rejects.toThrow(/invalid_grant/)
|
||||
})
|
||||
|
||||
test("postForm throws a clear error when the server returns non-JSON", async () => {
|
||||
mock = installFetchMock(() => ({ status: 502, bodyText: "<html>gateway</html>" }))
|
||||
test("refreshToken throws a clear error when a non-retryable response is non-JSON", async () => {
|
||||
mock = installFetchMock(() => ({ status: 400, bodyText: "<html>bad request</html>" }))
|
||||
await expect(refreshToken("x")).rejects.toThrow(/non-JSON response/)
|
||||
})
|
||||
|
||||
|
||||
+89
-13
@@ -163,15 +163,23 @@ test("auth.loader: apiKey sentinel is returned (opencode requires truthy)", asyn
|
||||
})
|
||||
|
||||
test("auth.loader: owns Authorization and strips any caller-supplied value (rule 3)", async () => {
|
||||
mock = installFetchMock(() => ({ body: { ok: true } }))
|
||||
mock = installFetchMock((call) => {
|
||||
if (call.url.endsWith("/coding/v1/models")) {
|
||||
return { body: { data: [{ id: MODEL_ID, context_length: 262144 }] } }
|
||||
}
|
||||
return { body: { ok: true } }
|
||||
})
|
||||
const { fetch: f } = await getLoaderFetch(async () => validAuth({ access: jwt() }))
|
||||
await f("https://api.kimi.com/coding/v1/chat", {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer SHOULD-BE-OVERRIDDEN", authorization: "lower-also" },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(mock.calls).toHaveLength(1)
|
||||
const h = mock.calls[0]!.headers
|
||||
expect(mock.calls.map((c) => c.url)).toEqual([
|
||||
"https://api.kimi.com/coding/v1/models",
|
||||
"https://api.kimi.com/coding/v1/chat",
|
||||
])
|
||||
const h = mock.calls[1]!.headers
|
||||
expect(h["authorization"]).toBe(`Bearer ${jwt()}`)
|
||||
// Seven kimi-cli fingerprint headers are attached on every request.
|
||||
expect(h["x-msh-platform"]).toBe("kimi_cli")
|
||||
@@ -210,8 +218,10 @@ test("auth.loader: refreshes when expiry is within safety window", async () => {
|
||||
expect(writes[0]!.id).toBe(PROVIDER_ID)
|
||||
const persisted = writes[0]!.body as { access: string; model_id?: string; context_length?: number }
|
||||
expect(persisted.access).toBe("access-2")
|
||||
expect(persisted.model_id).toBe("kimi-for-coding")
|
||||
expect(persisted.context_length).toBe(262144)
|
||||
// opencode's SDK auth schema persists only the standard oauth fields; model
|
||||
// discovery is cached in-memory by the loader.
|
||||
expect(persisted.model_id).toBeUndefined()
|
||||
expect(persisted.context_length).toBeUndefined()
|
||||
expect(reads).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
@@ -238,8 +248,8 @@ test("auth.loader: prefers the canonical MODEL_ID slug when /models returns mult
|
||||
const { writes, fetch: f } = await getLoaderFetch(async () => current)
|
||||
await f("https://api.kimi.com/coding/v1/chat")
|
||||
const persisted = writes[0]!.body as { model_id?: string; context_length?: number }
|
||||
expect(persisted.model_id).toBe(MODEL_ID)
|
||||
expect(persisted.context_length).toBe(262144)
|
||||
expect(persisted.model_id).toBeUndefined()
|
||||
expect(persisted.context_length).toBeUndefined()
|
||||
})
|
||||
|
||||
test("auth.loader: model discovery failure does not break refresh (graceful)", async () => {
|
||||
@@ -259,6 +269,48 @@ test("auth.loader: model discovery failure does not break refresh (graceful)", a
|
||||
expect((writes[0]!.body as { model_id?: string }).model_id).toBeUndefined()
|
||||
})
|
||||
|
||||
test("auth.loader: discovers /models on first request when auth storage only has bare oauth fields", async () => {
|
||||
mock = installFetchMock((call) => {
|
||||
if (call.url.endsWith("/coding/v1/models")) {
|
||||
return { body: { data: [{ id: "k2p5", display_name: "Kimi Code", context_length: 131072 }] } }
|
||||
}
|
||||
return { body: { ok: true } }
|
||||
})
|
||||
const { fetch: f } = await getLoaderFetch(async () => validAuth())
|
||||
await f("https://api.kimi.com/coding/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ model: MODEL_ID, messages: [] }),
|
||||
})
|
||||
expect(mock.calls.map((c) => c.url)).toEqual([
|
||||
"https://api.kimi.com/coding/v1/models",
|
||||
"https://api.kimi.com/coding/v1/chat/completions",
|
||||
])
|
||||
const sentBody = JSON.parse(mock.calls[1]!.body as string)
|
||||
expect(sentBody.model).toBe("k2p5")
|
||||
})
|
||||
|
||||
test("auth.loader: caches discovered model info for subsequent requests in the same loader", async () => {
|
||||
mock = installFetchMock((call) => {
|
||||
if (call.url.endsWith("/coding/v1/models")) {
|
||||
return { body: { data: [{ id: "k2p5", display_name: "Kimi Code", context_length: 131072 }] } }
|
||||
}
|
||||
return { body: { ok: true } }
|
||||
})
|
||||
const { fetch: f } = await getLoaderFetch(async () => validAuth())
|
||||
await f("https://api.kimi.com/coding/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ model: MODEL_ID, turn: 1 }),
|
||||
})
|
||||
await f("https://api.kimi.com/coding/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ model: MODEL_ID, turn: 2 }),
|
||||
})
|
||||
expect(mock.calls.filter((c) => c.url.endsWith("/coding/v1/models"))).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("auth.loader: rewrites wire `model` to the discovered server id (Option A)", async () => {
|
||||
// Persisted auth already carries a discovered model_id different from the
|
||||
// opencode-side MODEL_ID placeholder — this is the K2.5 account case.
|
||||
@@ -292,6 +344,30 @@ test("auth.loader: leaves body untouched when discovered id equals MODEL_ID (K2.
|
||||
expect(mock.calls[0]!.body).toBe(originalBody)
|
||||
})
|
||||
|
||||
test("auth.loader: preserves Request input headers while still rewriting Authorization and model", async () => {
|
||||
mock = installFetchMock(() => ({ body: { ok: true } }))
|
||||
const { fetch: f } = await getLoaderFetch(
|
||||
async () =>
|
||||
({
|
||||
...validAuth(),
|
||||
model_id: "k2p5",
|
||||
}) as unknown as ReturnType<typeof validAuth>,
|
||||
)
|
||||
const req = new Request("https://api.kimi.com/coding/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-extra": "keep-me",
|
||||
Authorization: "Bearer stale",
|
||||
},
|
||||
body: JSON.stringify({ model: MODEL_ID, messages: [] }),
|
||||
})
|
||||
await f(req)
|
||||
expect(mock.calls[0]!.headers["x-extra"]).toBe("keep-me")
|
||||
expect(mock.calls[0]!.headers["authorization"]).toBe("Bearer access-1")
|
||||
expect(JSON.parse(mock.calls[0]!.body as string).model).toBe("k2p5")
|
||||
})
|
||||
|
||||
test("auth.loader: 401 triggers exactly one forced refresh + retry (no infinite loop)", async () => {
|
||||
let current = validAuth({ access: "stale" })
|
||||
const readAuth = async () => current
|
||||
@@ -311,15 +387,17 @@ test("auth.loader: 401 triggers exactly one forced refresh + retry (no infinite
|
||||
const res = await f("https://api.kimi.com/coding/v1/chat")
|
||||
expect(res.status).toBe(401)
|
||||
const urls = mock.calls.map((c) => c.url)
|
||||
// Expected order: stale call → refresh → /models discovery → retry with fresh token → STOP.
|
||||
// Expected order: startup discovery with stale token → stale call → refresh
|
||||
// → /models discovery with fresh token → retry with fresh token → STOP.
|
||||
expect(urls).toEqual([
|
||||
"https://api.kimi.com/coding/v1/models",
|
||||
"https://api.kimi.com/coding/v1/chat",
|
||||
"https://auth.kimi.com/api/oauth/token",
|
||||
"https://api.kimi.com/coding/v1/models",
|
||||
"https://api.kimi.com/coding/v1/chat",
|
||||
])
|
||||
expect(mock.calls[0]!.headers["authorization"]).toBe("Bearer stale")
|
||||
expect(mock.calls[3]!.headers["authorization"]).toBe("Bearer fresh")
|
||||
expect(mock.calls[1]!.headers["authorization"]).toBe("Bearer stale")
|
||||
expect(mock.calls[4]!.headers["authorization"]).toBe("Bearer fresh")
|
||||
})
|
||||
|
||||
// ---------- auth.methods (device flow wiring) -------------------------------
|
||||
@@ -353,9 +431,6 @@ test("auth.methods[0].authorize returns URL + instructions + async callback", as
|
||||
expect(cb.access).toBe("A")
|
||||
expect(cb.refresh).toBe("R")
|
||||
expect(typeof cb.expires).toBe("number")
|
||||
// Discovered fields are persisted alongside the token (Option A+B).
|
||||
expect(cb.model_id).toBe("kimi-for-coding")
|
||||
expect(cb.context_length).toBe(262144)
|
||||
})
|
||||
|
||||
test("auth callback prints a config snippet with top-level model variants", async () => {
|
||||
@@ -411,6 +486,7 @@ test("auth callback prints a config snippet with top-level model variants", asyn
|
||||
}
|
||||
const model = parsed.provider[PROVIDER_ID]!.models[MODEL_ID]!
|
||||
expect(model.limit?.context).toBe(262144)
|
||||
expect(model.options).toEqual({})
|
||||
expect(model.variants?.off).toEqual({ reasoning_effort: "off" })
|
||||
expect(model.variants?.auto).toEqual({ reasoning_effort: "auto" })
|
||||
expect(model.options?.variants).toBeUndefined()
|
||||
|
||||
Reference in New Issue
Block a user