mirror of
https://github.com/lemon07r/opencode-kimi-full.git
synced 2026-07-18 08:05:52 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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" }
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user