Release v1.1.0: auto-discover model id + context length from /coding/v1/models

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, ...}].
This commit is contained in:
lemon07r
2026-04-17 04:53:27 -04:00
parent 194da3cc9b
commit 6549be2f3d
6 changed files with 264 additions and 16 deletions
+2 -2
View File
@@ -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.
+3 -4
View File
@@ -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
+1 -1
View File
@@ -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": {
+137 -2
View File
@@ -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<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)
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<OAuthAuth, "model_id" | "context_length" | "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,
}
// 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" }
+48
View File
@@ -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<TokenResponse> {
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<KimiModelInfo[]> {
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[]
}
+73 -7
View File
@@ -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<typeof validAuth>
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<typeof validAuth>
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)
})