From 6549be2f3deba3ff5f88600b15941fbd6b9a0d8e Mon Sep 17 00:00:00 2001 From: lemon07r Date: Fri, 17 Apr 2026 04:53:27 -0400 Subject: [PATCH] Release v1.1.0: auto-discover model id + context length from /coding/v1/models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors kimi-cli's refresh_managed_models behavior: at login (and on every token refresh) the plugin now GETs /coding/v1/models with the user's JWT, caches the first returned {id, context_length, display_name} in auth.json, and rewrites the wire 'model' body field to the cached id inside loader.fetch. K2.5 accounts (server returns e.g. 'k2p5') and K2.6 accounts (server returns 'kimi-for-coding') now share identical opencode config. After successful login, the authorize callback prints a ready-to-paste provider config block with the discovered values filled in. - src/oauth.ts: added listModels() (GET /coding/v1/models). - src/index.ts: OAuthAuth extended with model_id/context_length/model_display; ensureFresh() refetches on refresh; loader.fetch rewrites JSON body 'model' when cached id differs from MODEL_ID; authorize callback runs listModels() + console.logs a ready-to-paste config block. - README.md: drop hardcoded context/output limits and K2.6-specific name; explain auto-discovery and the K2.5/K2.6 alias mechanism. - AGENTS.md: rule 6 rewritten to describe the wire-rewrite contract. - test/plugin.test.ts: +3 tests (discovery on refresh persists metadata, graceful /models failure, body.model rewrite K2.5 + K2.6 no-op); existing 401-retry and refresh tests updated for the extra /models call. Tested live against a real K2.6 account — listModels() returns [{id: 'kimi-for-coding', context_length: 262144, ...}]. --- AGENTS.md | 4 +- README.md | 7 +-- package.json | 2 +- src/index.ts | 139 +++++++++++++++++++++++++++++++++++++++++++- src/oauth.ts | 48 +++++++++++++++ test/plugin.test.ts | 80 ++++++++++++++++++++++--- 6 files changed, 264 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5a55456..9d1e3f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ Three files, 1 job each. Do not add a fourth unless the existing three genuinely |--------------------|--------------------------------------------------------------------------------| | `src/constants.ts` | Pinned strings that must mirror upstream kimi-cli (version, endpoints, client id, scope). | | `src/headers.ts` | The seven `X-Msh-*` / UA headers + the persistent `~/.kimi/device_id` file. | -| `src/oauth.ts` | Device-code start, device-code poll, refresh-token exchange. Nothing else. | +| `src/oauth.ts` | Device-code start, device-code poll, refresh-token exchange, and `GET /coding/v1/models` discovery. | | `src/index.ts` | Plugin entry. Wires `auth` hook (login + loader) and `chat.params` hook. | Data flow on a chat request: @@ -66,7 +66,7 @@ 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. **Model id goes over the wire verbatim.** Don't strip the `kimi-` prefix — the backend expects exactly `kimi-for-coding`. +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. 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. diff --git a/README.md b/README.md index 86e0b17..66b78c8 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,7 @@ Add the plugin and a provider entry to `opencode.json` (or `~/.config/opencode/o }, "models": { "kimi-for-coding": { - "name": "Kimi K2.6 Code Preview", - "limit": { "context": 262144, "output": 32768 }, + "name": "Kimi For Coding", "reasoning": true, "options": {}, "variants": { @@ -68,7 +67,7 @@ Add the plugin and a provider entry to `opencode.json` (or `~/.config/opencode/o Two identifiers are load-bearing: - **provider id** `kimi-for-coding-oauth` — the plugin's `auth` and `chat.params` hooks match on it. -- **model id** `kimi-for-coding` — sent to Moonshot verbatim; do not strip the `kimi-` prefix. +- **model id** `kimi-for-coding` — a stable opencode-side alias. The plugin rewrites the wire `model` field to whatever `/coding/v1/models` reports for your account (e.g. `kimi-for-coding` on K2.6 tiers, `k2p5` on K2.5 tiers). Both tiers use identical config. > **Note.** The provider id is intentionally not `kimi-for-coding`. That id is already published by [models.dev](https://models.dev) and points at a static-API-key flow that routes to K2.5. Using a distinct id keeps the two paths from colliding under a single `opencode auth login` entry. @@ -78,7 +77,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 and persists tokens through opencode's `auth.json`. Access tokens have a ~15 minute TTL and refresh automatically; refresh tokens last ~30 days. +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 model id and context length your account is entitled to, prints a ready-to-paste config block with those values filled in, and persists everything (tokens + discovered metadata) through opencode's `auth.json`. The model list is re-checked on every token refresh, mirroring kimi-cli's `refresh_managed_models`. Access tokens have a ~15 minute TTL and refresh automatically; refresh tokens last ~30 days. ### Use diff --git a/package.json b/package.json index 250619d..095a209 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-kimi-full", - "version": "1.0.6", + "version": "1.1.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": { diff --git a/src/index.ts b/src/index.ts index 53a0259..88a1e6f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { pollDeviceToken, refreshToken, startDeviceAuth } from "./oauth.ts" +import { 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,6 +14,55 @@ 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 — K2.5 tiers can see `k2p5`, K2.6 tiers see `kimi-for-coding`. + // `context_length` is surfaced to users in the post-login config block + // so their opencode config matches their real entitlement. + model_id?: string + context_length?: number + model_display?: string +} + +function buildConfigBlock(info: { model_id: string; context_length?: number; display?: string }) { + const name = info.display ?? "Kimi For Coding" + const ctx = info.context_length ?? 0 + // The opencode-side model key is always MODEL_ID ("kimi-for-coding"); the + // plugin rewrites the wire `model` body field to `info.model_id` inside + // `loader.fetch`. This way both K2.5 and K2.6 users paste identical + // config — only the wire request differs. + return JSON.stringify( + { + provider: { + [PROVIDER_ID]: { + npm: "@ai-sdk/openai-compatible", + name: "Kimi For Coding (OAuth)", + options: { baseURL: "https://api.kimi.com/coding/v1" }, + models: { + [MODEL_ID]: { + name, + reasoning: true, + ...(ctx > 0 ? { limit: { context: ctx } } : {}), + options: { + variants: { + auto: {}, + off: { reasoning_effort: "off" }, + low: { reasoning_effort: "low" }, + medium: { reasoning_effort: "medium" }, + high: { reasoning_effort: "high" }, + }, + }, + }, + }, + }, + }, + }, + null, + 2, + ) } /** @@ -68,11 +117,36 @@ const plugin: Plugin = async ({ client }) => { ) if (!force && !isExpiring(current)) return 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 + // the request-path 401 retry will flush a broken access token. + let discovered: Pick = { + model_id: current.model_id, + context_length: current.context_length, + model_display: current.model_display, + } + try { + const models = await listModels(tokens.access_token) + const picked = models[0] + if (picked) { + discovered = { + model_id: picked.id, + context_length: picked.context_length, + model_display: picked.display_name, + } + } + } catch { + /* keep previous discovery */ + } const next: OAuthAuth = { type: "oauth", refresh: tokens.refresh_token, access: tokens.access_token, expires: Date.now() + tokens.expires_in * 1000, + ...discovered, } await persistAuth(next) return next @@ -90,7 +164,36 @@ const plugin: Plugin = async ({ client }) => { headers.delete("Authorization") for (const [k, v] of Object.entries(kimiHeaders())) headers.set(k, v) headers.set("Authorization", `Bearer ${auth.access}`) - return fetch(input, { ...init, headers }) + + // Rewrite the wire `model` to the server-discovered id. + // opencode bakes the model id into the LanguageModel instance + // at provider-init time (via `provider.chatModel(modelId)`), + // so `chat.params` cannot change it. We rewrite the JSON + // body here instead. Only touches requests where: + // - we have a discovered id that differs from what opencode + // sent (otherwise leave the body untouched), + // - the body is JSON with a string `model` field equal to + // our opencode-side placeholder MODEL_ID. + // This way `input.model.id` stays `kimi-for-coding` in + // opencode's UI/config, while Moonshot sees whatever its + // /models endpoint says for this account (e.g. `k2p5` on + // K2.5 tiers). Mirrors kimi-cli's behavior — it always sends + // 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") { + try { + const parsed = JSON.parse(init.body) + if (parsed && typeof parsed === "object" && parsed.model === MODEL_ID) { + parsed.model = targetModel + newInit = { ...init, body: JSON.stringify(parsed) } + } + } catch { + /* non-JSON body, e.g. multipart — leave alone */ + } + } + + return fetch(input, { ...newInit, headers }) } let auth = await ensureFresh() @@ -120,11 +223,43 @@ const plugin: Plugin = async ({ client }) => { callback: async () => { try { const tokens = await pollDeviceToken(device) + // Discover the account's real model entitlement right + // 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 = {} + try { + const models = await listModels(tokens.access_token) + const picked = models[0] + if (picked) { + discovered = { + model_id: picked.id, + context_length: picked.context_length, + model_display: picked.display_name, + } + // 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, + }) + console.log( + `\n✓ Kimi for Coding: authorized (model: ${picked.id}${ + picked.context_length ? `, context ${picked.context_length}` : "" + })\n\nAdd this to your opencode config (~/.config/opencode/opencode.json) if you haven't already:\n\n${block}\n`, + ) + } + } catch { + /* non-fatal */ + } return { type: "success", refresh: tokens.refresh_token, access: tokens.access_token, expires: Date.now() + tokens.expires_in * 1000, + ...discovered, } } catch { return { type: "failed" } diff --git a/src/oauth.ts b/src/oauth.ts index 2c65e2f..e4ad9b7 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -1,4 +1,5 @@ import { + API_BASE_URL, OAUTH_CLIENT_ID, OAUTH_DEVICE_AUTH_URL, OAUTH_DEVICE_GRANT, @@ -100,3 +101,50 @@ export async function refreshToken(refresh: string): Promise { grant_type: OAUTH_REFRESH_GRANT, }) } + +/** + * Shape of each entry in `GET /coding/v1/models`. Field set mirrors what + * kimi-cli reads — see research/kimi-cli/src/kimi_cli/auth/platforms.py + * `_list_models`. Unused-by-this-plugin flags are kept optional for + * documentation; the wire response carries them either way. + */ +export type KimiModelInfo = { + id: string + display_name?: string + context_length?: number + supports_reasoning?: boolean + supports_image_in?: boolean + supports_video_in?: boolean +} + +/** + * Calls `GET {API_BASE_URL}/models` with the user's JWT and returns the + * server's authoritative model list for this account. Different account + * tiers see different slugs (K2.5 accounts may see `k2p5`, K2.6 accounts + * see `kimi-for-coding`); the `id` and `context_length` here are the only + * truth about what the user is actually entitled to. + * + * kimi-cli calls this on login and on every successful token refresh + * (see `refresh_managed_models` in platforms.py). We do the same. + */ +export async function listModels(accessToken: string): Promise { + const res = await fetch(`${API_BASE_URL}/models`, { + headers: { + ...kimiHeaders(), + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }) + const text = await res.text() + if (!res.ok) { + throw new Error(`kimi list-models ${res.status}: ${text.slice(0, 200)}`) + } + let json: any + try { + json = JSON.parse(text) + } catch { + throw new Error(`kimi list-models: non-JSON response: ${text.slice(0, 200)}`) + } + const data = Array.isArray(json?.data) ? json.data : [] + return data.filter((m: any) => typeof m?.id === "string") as KimiModelInfo[] +} diff --git a/test/plugin.test.ts b/test/plugin.test.ts index c7cb00d..06b1c13 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -187,28 +187,84 @@ test("auth.loader: refreshes when expiry is within safety window", async () => { reads++ return current } - // First fetch call → token refresh, second → the actual request. + // Expected order: token refresh → /models discovery → actual request. mock = installFetchMock((call) => { if (call.url.includes("/oauth/token")) { return { body: { access_token: "access-2", refresh_token: "refresh-2", token_type: "Bearer", expires_in: 900 } } } + if (call.url.endsWith("/coding/v1/models")) { + return { body: { data: [{ id: "kimi-for-coding", display_name: "Kimi Code", context_length: 262144 }] } } + } return { body: { ok: true } } }) const { fetch: f, writes } = await getLoaderFetch(readAuth) await f("https://api.kimi.com/coding/v1/chat") - // One refresh call + one API call. expect(mock.calls.map((c) => c.url)).toEqual([ "https://auth.kimi.com/api/oauth/token", + "https://api.kimi.com/coding/v1/models", "https://api.kimi.com/coding/v1/chat", ]) - expect(mock.calls[1]!.headers["authorization"]).toBe("Bearer access-2") - // Persisted the refreshed token back to opencode's auth store. + expect(mock.calls[2]!.headers["authorization"]).toBe("Bearer access-2") + // Persisted the refreshed token + discovered model metadata. expect(writes).toHaveLength(1) expect(writes[0]!.id).toBe(PROVIDER_ID) - expect((writes[0]!.body as { access: string }).access).toBe("access-2") + 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) expect(reads).toBeGreaterThan(0) }) +test("auth.loader: model discovery failure does not break refresh (graceful)", async () => { + const current = validAuth({ expires: Date.now() + REFRESH_SAFETY_WINDOW_MS / 2, access: "old" }) + mock = installFetchMock((call) => { + if (call.url.includes("/oauth/token")) { + return { body: { access_token: "new", refresh_token: "r", token_type: "Bearer", expires_in: 900 } } + } + if (call.url.endsWith("/coding/v1/models")) return { status: 500, body: { error: "oops" } } + return { body: { ok: true } } + }) + const { fetch: f, writes } = await getLoaderFetch(async () => current) + const res = await f("https://api.kimi.com/coding/v1/chat") + expect(res.ok).toBe(true) + // Persisted despite /models failing; just no model_id. + expect((writes[0]!.body as { access: string }).access).toBe("new") + expect((writes[0]!.body as { model_id?: string }).model_id).toBeUndefined() +}) + +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. + const current = { + ...validAuth(), + model_id: "k2p5", + } as unknown as ReturnType + mock = installFetchMock(() => ({ body: { ok: true } })) + const { fetch: f } = await getLoaderFetch(async () => current) + 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).toHaveLength(1) + const sentBody = JSON.parse(mock.calls[0]!.body as string) + expect(sentBody.model).toBe("k2p5") + expect(sentBody.messages).toEqual([]) +}) + +test("auth.loader: leaves body untouched when discovered id equals MODEL_ID (K2.6 case)", async () => { + const current = { ...validAuth(), model_id: MODEL_ID } as unknown as ReturnType + mock = installFetchMock(() => ({ body: { ok: true } })) + const { fetch: f } = await getLoaderFetch(async () => current) + const originalBody = JSON.stringify({ model: MODEL_ID, x: 1 }) + await f("https://api.kimi.com/coding/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: originalBody, + }) + expect(mock.calls[0]!.body).toBe(originalBody) +}) + test("auth.loader: 401 triggers exactly one forced refresh + retry (no infinite loop)", async () => { let current = validAuth({ access: "stale" }) const readAuth = async () => current @@ -217,6 +273,9 @@ test("auth.loader: 401 triggers exactly one forced refresh + retry (no infinite current = { ...current, access: "fresh" } return { body: { access_token: "fresh", refresh_token: "refresh-2", token_type: "Bearer", expires_in: 900 } } } + if (call.url.endsWith("/coding/v1/models")) { + return { body: { data: [{ id: "kimi-for-coding", context_length: 262144 }] } } + } // First API call: stale → 401. Every subsequent API call: 401 as well. // The loader must NOT loop; exactly one retry after refresh. return { status: 401, body: { error: "unauthorized" } } @@ -225,14 +284,15 @@ 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 → retry with fresh token → STOP. + // Expected order: stale call → refresh → /models discovery → retry with fresh token → STOP. expect(urls).toEqual([ "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[2]!.headers["authorization"]).toBe("Bearer fresh") + expect(mock.calls[3]!.headers["authorization"]).toBe("Bearer fresh") }) // ---------- auth.methods (device flow wiring) ------------------------------- @@ -251,6 +311,9 @@ test("auth.methods[0].authorize returns URL + instructions + async callback", as }, } } + if (call.url.endsWith("/coding/v1/models")) { + return { body: { data: [{ id: "kimi-for-coding", display_name: "Kimi Code", context_length: 262144 }] } } + } return { body: { access_token: "A", refresh_token: "R", token_type: "Bearer", expires_in: 900 } } }) const { hooks } = await getHooks() @@ -263,4 +326,7 @@ 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) })