Harden OAuth refresh token recovery

This commit is contained in:
lemon07r
2026-04-19 01:39:56 -04:00
parent 60bfb23030
commit efca301079
5 changed files with 318 additions and 27 deletions
+3 -3
View File
@@ -43,7 +43,7 @@ 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 (sharing one in-flight refresh across concurrent callers so they don't race the same refresh token) → lazily discovers `/coding/v1/models` when needed → sets Authorization + the seven `X-Msh-*` headers → on 401 refreshes once and retries.
4. Our `fetch` calls `ensureFresh()`prefers the live opencode auth-store entry over stale `OPENCODE_AUTH_CONTENT` snapshots → maybe refreshes (sharing one in-flight promise in-process and a lock across plugin instances so they don't race the same refresh token) → lazily discovers `/coding/v1/models` when needed → sets Authorization + the seven `X-Msh-*` headers → on 401 refreshes once and retries.
5. Separately, opencode runs `chat.headers` and `chat.params`. `chat.headers` computes `thinking`, `reasoning_effort`, and `prompt_cache_key` from `input.model.options` plus the selected `input.message.model.variant`, then passes them to `loader.fetch` via private `x-opencode-kimi-*` headers. `loader.fetch` strips those headers and injects the wire fields into the JSON body. `chat.params` mirrors the same keys into `output.options` only as a forward-compat fallback if opencode later fixes its openai-compatible providerOptions namespace mismatch.
### Contracts to keep intact
@@ -67,11 +67,11 @@ These are the invariants that, if broken, silently degrade K2.6 → K2.5 or prod
5. **`prompt_cache_key` only for `kimi-for-coding`.** Never attach it to unrelated models. The check is `input.model.id === MODEL_ID` in the Kimi chat hooks, and the actual wire injection happens in `loader.fetch`.
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.
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. The plugin may live-read opencode's `auth.json` entry for this provider to bypass stale `OPENCODE_AUTH_CONTENT` workspace snapshots, but writes still go through opencode's auth store (`client.auth.set`). 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.
10. **The post-login config hint must not emit a partial `limit` object.** opencode's live config schema at `https://opencode.ai/config.json` requires both `limit.context` and `limit.output` whenever `limit` is present, while Kimi's `GET /coding/v1/models` only gives us `context_length`. Therefore `buildConfigBlock()` omits `limit` entirely and leaves `provider.models` to backfill `limit.context` at runtime. Do not invent `output` or set `input` heuristically; opencode's overflow logic treats `limit.input` as authoritative (`research/opencode/packages/opencode/src/session/overflow.ts`).
11. **Concurrent refreshes must collapse to one in-flight OAuth exchange.** `provider.models` and `auth.loader` can both notice an expiring token at about the same time. `refreshAuth()` in `src/index.ts` therefore shares one promise across overlapping callers so they do not race the same refresh token or double-write auth state. `test/plugin.test.ts` covers both loader-vs-loader and provider.models-vs-loader overlap.
11. **Concurrent refreshes must collapse to one in-flight OAuth exchange, even across plugin instances.** `provider.models` and `auth.loader` can both notice an expiring token at about the same time, and separate opencode workspace/plugin instances can inherit stale auth snapshots. `refreshAuth()` in `src/index.ts` therefore shares one promise across overlapping callers, takes a provider-scoped auth-store lock before refreshing, re-reads opencode's live auth-store entry under that lock, and treats a changed on-disk token chain as authoritative. `test/plugin.test.ts` covers loader-vs-loader, provider.models-vs-loader, cross-instance lock reuse, and the `invalid_grant` self-heal path where another process already rotated the refresh token.
### Working on this repo
+3 -2
View File
@@ -100,7 +100,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 ready-to-paste config snippet, and stores the token in opencode's auth store. The authorization message still includes the discovered context length; the snippet intentionally omits `limit` because opencode's schema requires `limit.output` too, and Kimi's models endpoint only exposes `context_length`. 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`.
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 ready-to-paste config snippet, and stores the token in opencode's auth store. The authorization message still includes the discovered context length; the snippet intentionally omits `limit` because opencode's schema requires `limit.output` too, and Kimi's models endpoint only exposes `context_length`. 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, the loader retries once after a `401`, and refreshes are coordinated through opencode's live auth store so concurrent workspaces do not burn a stale refresh token from an old `OPENCODE_AUTH_CONTENT` snapshot.
### Use
@@ -136,6 +136,7 @@ This plugin exists to bring the OAuth/device-flow `kimi-cli` path into opencode
- The seven `X-Msh-*` headers and a kimi-cli-shaped `User-Agent`.
- `~/.kimi/device_id` shared with a locally-installed kimi-cli.
- Tokens stored in opencode's auth store under a dedicated provider id, so the plugin and kimi-cli keep independent refresh-token chains and do not invalidate each other.
- Live auth-store rereads plus a provider-scoped refresh lock, so concurrent opencode workspaces converge on the latest refresh-token chain instead of tripping `invalid_grant`.
- Streaming, `reasoning_content` deltas, and tool-call schemas are handled upstream by `@ai-sdk/openai-compatible` — not reimplemented here.
</details>
@@ -168,7 +169,7 @@ Effort-to-field mapping used by the plugin:
| Path | Purpose |
|---|---|
| `~/.kimi/device_id` | Stable UUID used in `X-Msh-Device-Id`. Shared with kimi-cli. |
| opencode auth store (`auth.json` in opencode's XDG data dir; on Linux typically `~/.local/share/opencode/auth.json`) | Token storage, managed by opencode through `client.auth.*`. |
| opencode auth store (`auth.json` in opencode's XDG data dir; on Linux typically `~/.local/share/opencode/auth.json`) | Token storage, managed by opencode through `client.auth.*`; the plugin also live-reads this entry to avoid stale workspace auth snapshots during refresh. |
No other state is persisted. Credentials are never written to `~/.kimi/credentials/`; that path belongs to kimi-cli, and sharing it would cause refresh-token races between the two clients.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "opencode-kimi-full",
"version": "1.2.4",
"version": "1.2.5",
"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": {
+165 -20
View File
@@ -1,3 +1,6 @@
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import type { Plugin } from "@opencode-ai/plugin"
import { MODEL_ID, PROVIDER_ID, REFRESH_SAFETY_WINDOW_MS } from "./constants.ts"
import { kimiHeaders } from "./headers.ts"
@@ -54,6 +57,13 @@ type KimiHookInput = {
const INTERNAL_PROMPT_CACHE_KEY_HEADER = "x-opencode-kimi-prompt-cache-key"
const INTERNAL_REASONING_EFFORT_HEADER = "x-opencode-kimi-reasoning-effort"
const INTERNAL_THINKING_TYPE_HEADER = "x-opencode-kimi-thinking-type"
const REFRESH_LOCK_WAIT_MS = 15_000
const REFRESH_LOCK_POLL_MS = 100
const REFRESH_LOCK_STALE_MS = 120_000
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function isOAuthAuth(value: unknown): value is OAuthAuth {
if (!value || typeof value !== "object" || Array.isArray(value)) return false
@@ -71,6 +81,96 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
return value as Record<string, unknown>
}
function authStoreCandidates() {
const home = os.homedir()
if (process.env.XDG_DATA_HOME) {
return [path.join(process.env.XDG_DATA_HOME, "opencode", "auth.json")]
}
const candidates = new Set<string>()
candidates.add(path.join(home, ".local", "share", "opencode", "auth.json"))
if (process.platform === "darwin") {
candidates.add(path.join(home, "Library", "Application Support", "opencode", "auth.json"))
}
if (process.platform === "win32") {
const local = process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local")
candidates.add(path.join(local, "opencode", "auth.json"))
if (process.env.APPDATA) {
candidates.add(path.join(process.env.APPDATA, "opencode", "auth.json"))
}
}
return [...candidates]
}
async function resolveAuthStorePath() {
const candidates = authStoreCandidates()
for (const file of candidates) {
try {
await fs.access(file)
return file
} catch {}
}
return candidates[0]!
}
async function readAuthStoreEntry() {
for (const file of authStoreCandidates()) {
try {
const parsed = JSON.parse(await fs.readFile(file, "utf8")) as Record<string, unknown>
const entry = parsed[PROVIDER_ID] ?? parsed[`${PROVIDER_ID}/`]
if (isOAuthAuth(entry)) return entry
} catch {}
}
return
}
function sameAuth(left: OAuthAuth, right: OAuthAuth) {
return left.access === right.access && left.refresh === right.refresh && left.expires === right.expires
}
function withInvalidGrantHint(error: unknown) {
if (!(error instanceof Error) || !/invalid_grant/.test(error.message)) return error
const next = new Error(
`${error.message}. The token may have been rotated or revoked in another opencode session — run \`opencode auth login kimi-for-coding-oauth\` again if it does not self-heal.`,
) as Error & { code?: string; status?: number }
next.code = (error as Error & { code?: string }).code
next.status = (error as Error & { status?: number }).status
return next
}
async function withRefreshLock<T>(work: () => Promise<T>) {
const authFile = await resolveAuthStorePath()
const lockDir = `${authFile}.${PROVIDER_ID}.refresh.lock`
await fs.mkdir(path.dirname(lockDir), { recursive: true })
const deadline = Date.now() + REFRESH_LOCK_WAIT_MS
while (true) {
try {
await fs.mkdir(lockDir)
break
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code !== "EEXIST") throw error
try {
const stat = await fs.stat(lockDir)
if (Date.now() - stat.mtimeMs > REFRESH_LOCK_STALE_MS) {
await fs.rm(lockDir, { recursive: true, force: true })
continue
}
} catch {}
if (Date.now() >= deadline) {
throw new Error("kimi oauth: timed out waiting for the auth refresh lock")
}
await sleep(REFRESH_LOCK_POLL_MS)
}
}
try {
return await work()
} finally {
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined)
}
}
function asThinking(value: unknown): KimiBodyFields["thinking"] | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return
const type = (value as { type?: unknown }).type
@@ -220,7 +320,9 @@ function buildConfigBlock(info: { model_id: string; display?: string }) {
* Responsibilities, in order of execution:
* 1. `auth` — register device-flow OAuth login under the
* `kimi-for-coding-oauth` provider id. opencode persists the returned tokens in its
* own auth.json; we never touch disk for credentials.
* own auth.json; the plugin also live-reads that file so
* workspace auth snapshots do not strand stale refresh
* tokens.
* 2. `loader` — runs every time opencode instantiates the provider. Returns
* a custom `fetch` that (a) refreshes the access token when
* it is about to expire, (b) injects the seven X-Msh-* / UA
@@ -245,8 +347,19 @@ const plugin: Plugin = async ({ client }) => {
let cachedDiscovery: ModelDiscovery = {}
let refreshPromise: Promise<OAuthAuth> | undefined
const syncProcessAuthContent = (auth: OAuthAuth) => {
if (!process.env.OPENCODE_AUTH_CONTENT) return
try {
const parsed = JSON.parse(process.env.OPENCODE_AUTH_CONTENT) as Record<string, unknown>
delete parsed[`${PROVIDER_ID}/`]
parsed[PROVIDER_ID] = auth
process.env.OPENCODE_AUTH_CONTENT = JSON.stringify(parsed)
} catch {}
}
const persistAuth = async (auth: OAuthAuth) => {
await client.auth.set({ path: { id: PROVIDER_ID }, body: auth })
syncProcessAuthContent(auth)
}
const isExpiring = (auth: OAuthAuth) => auth.expires - Date.now() < REFRESH_SAFETY_WINDOW_MS
@@ -256,22 +369,52 @@ const plugin: Plugin = async ({ client }) => {
return cachedDiscovery
}
const refreshAuth = async (auth: OAuthAuth) => {
const readLiveAuth = async () => {
const auth = await readAuthStoreEntry()
if (auth) syncProcessAuthContent(auth)
return auth
}
const readCurrentAuth = async (readAuth?: () => Promise<unknown>) => {
const live = await readLiveAuth()
if (live) return live
if (!readAuth) return
const current = await readAuth()
if (!isOAuthAuth(current)) return
syncProcessAuthContent(current)
return current
}
const refreshAuth = async (auth: OAuthAuth, force = false) => {
// opencode can ask both `provider.models` and `loader.fetch` to refresh
// around the same time. Serialize those calls so one refresh token does
// not fan out into multiple concurrent refresh exchanges.
// around the same time, including from separate workspace processes that
// only inherited a stale `OPENCODE_AUTH_CONTENT` snapshot. Serialize
// refreshes through a lock and re-read opencode's live auth store before
// spending the refresh token.
if (refreshPromise) return refreshPromise
refreshPromise = (async () => {
try {
const tokens = await refreshToken(auth.refresh)
const next: OAuthAuth = {
type: "oauth",
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + tokens.expires_in * 1000,
}
await persistAuth(next)
return next
return await withRefreshLock(async () => {
const latest = await readLiveAuth()
const current = latest ?? auth
if (latest && !sameAuth(latest, auth) && !force && !isExpiring(latest)) return latest
if (!force && !isExpiring(current)) return current
try {
const tokens = await refreshToken(current.refresh)
const next: OAuthAuth = {
type: "oauth",
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + tokens.expires_in * 1000,
}
await persistAuth(next)
return next
} catch (error) {
const newest = await readLiveAuth()
if (newest && !sameAuth(newest, current)) return newest
throw withInvalidGrantHint(error)
}
})
} finally {
refreshPromise = undefined
}
@@ -290,7 +433,7 @@ const plugin: Plugin = async ({ client }) => {
const discover = async (auth: OAuthAuth) =>
applyDiscoveryToModels(provider.models, rememberDiscovery(pickModelInfo(await listModels(auth.access))))
const current = ctx.auth
const current = (await readCurrentAuth()) ?? ctx.auth
let auth = current
try {
if (isExpiring(auth)) auth = await refreshAuth(auth)
@@ -300,7 +443,7 @@ const plugin: Plugin = async ({ client }) => {
}
try {
return await discover(await refreshAuth(current))
return await discover(await refreshAuth(current, true))
} catch {
return provider.models
}
@@ -315,9 +458,11 @@ const plugin: Plugin = async ({ client }) => {
* and header concerns so no other hook has to worry about them.
*
* `readAuth` comes from opencode: it returns the currently persisted
* credentials for this provider id (opencode's `auth.json`). The SDK
* client intentionally does not expose a `get` — reading is scoped to
* this loader callback. Writes still go through `client.auth.set`.
* credentials for this provider id. opencode workspace processes may
* hydrate that from a stale `OPENCODE_AUTH_CONTENT` snapshot, so the
* loader prefers the live auth.json entry on disk and only falls back to
* `readAuth` when the file is absent. Writes still go through
* `client.auth.set`.
*/
loader: async (readAuth) => {
let discovery: ModelDiscovery = cachedDiscovery
@@ -349,13 +494,13 @@ const plugin: Plugin = async ({ client }) => {
}
const ensureFresh = async (force = false): Promise<OAuthAuth & ModelDiscovery> => {
const current = (await readAuth()) as (OAuthAuth & Partial<ModelDiscovery>) | undefined
const current = (await readCurrentAuth(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 ensureDiscovered(current)
const next = await refreshAuth(current)
const next = await refreshAuth(current, force)
// 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
+146 -1
View File
@@ -1,3 +1,6 @@
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { test, expect, afterEach } from "bun:test"
import plugin from "../src/index.ts"
import { MODEL_ID, PROVIDER_ID, REFRESH_SAFETY_WINDOW_MS } from "../src/constants.ts"
@@ -7,12 +10,45 @@ import { installFetchMock } from "./_util/fetchMock.ts"
// shared with kimi-cli by design and writes are idempotent — no HOME
// redirect needed.
const TEST_XDG_DATA_HOME = path.join(os.tmpdir(), `opencode-kimi-full-test-${process.pid}`)
process.env.XDG_DATA_HOME = TEST_XDG_DATA_HOME
delete process.env.OPENCODE_AUTH_CONTENT
let mock: ReturnType<typeof installFetchMock> | undefined
afterEach(() => {
afterEach(async () => {
mock?.restore()
mock = undefined
process.env.XDG_DATA_HOME = TEST_XDG_DATA_HOME
delete process.env.OPENCODE_AUTH_CONTENT
await fs.rm(TEST_XDG_DATA_HOME, { recursive: true, force: true })
})
async function withTempAuthStore<T>(entry: unknown, run: (root: string) => Promise<T>) {
const prev = process.env.XDG_DATA_HOME
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-kimi-full-"))
process.env.XDG_DATA_HOME = root
await writeAuthStore(root, entry)
try {
return await run(root)
} finally {
if (prev === undefined) {
delete process.env.XDG_DATA_HOME
} else {
process.env.XDG_DATA_HOME = prev
}
await fs.rm(root, { recursive: true, force: true })
}
}
function authStorePath(root: string) {
return path.join(root, "opencode", "auth.json")
}
async function writeAuthStore(root: string, entry: unknown) {
await fs.mkdir(path.dirname(authStorePath(root)), { recursive: true })
await fs.writeFile(authStorePath(root), JSON.stringify({ [PROVIDER_ID]: entry }), "utf8")
}
// Fake opencode plugin context. Only `client.auth.set` is used by the
// plugin's writes; reads go through the `readAuth` callback passed to
// `loader`, not through client.
@@ -329,6 +365,26 @@ test("provider.models: retries once with a refreshed token after 401", async ()
expect((writes[0]!.body as { access: string }).access).toBe("fresh")
})
test("provider.models: prefers the live auth store over a stale ctx.auth snapshot", async () => {
await withTempAuthStore(validAuth({ access: "fresh", refresh: "refresh-2" }), async () => {
mock = installFetchMock((call) => {
if (call.url.endsWith("/coding/v1/models") && call.headers["authorization"] === "Bearer fresh") {
return { body: { data: [{ id: MODEL_ID, context_length: 131072 }] } }
}
return { status: 401, body: { error: "unauthorized" } }
})
const { hooks } = await getHooks()
const provider = makeProviderState()
const next = await hooks.provider!.models!(
provider as any,
{ auth: validAuth({ access: "stale", refresh: "refresh-1" }) } as any,
)
expect(mock.calls).toHaveLength(1)
expect(mock.calls[0]!.headers["authorization"]).toBe("Bearer fresh")
expect(next[MODEL_ID]!.limit?.context).toBe(131072)
})
})
test("auth.loader: refuses to run when no credentials are persisted", async () => {
const { fetch: f } = await getLoaderFetch(async () => undefined)
await expect(f("https://api.kimi.com/coding/v1/models")).rejects.toThrow(/not logged in/)
@@ -339,6 +395,25 @@ test("auth.loader: apiKey sentinel is returned (opencode requires truthy)", asyn
expect(apiKey).toBe("kimi-for-coding-oauth")
})
test("auth.loader: prefers live auth.json over a stale readAuth snapshot", async () => {
await withTempAuthStore(validAuth({ access: "fresh", refresh: "refresh-2" }), async () => {
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: "stale", refresh: "refresh-1" }))
await f("https://api.kimi.com/coding/v1/chat")
expect(mock.calls.map((c) => c.url)).toEqual([
"https://api.kimi.com/coding/v1/models",
"https://api.kimi.com/coding/v1/chat",
])
expect(mock.calls[0]!.headers["authorization"]).toBe("Bearer fresh")
expect(mock.calls[1]!.headers["authorization"]).toBe("Bearer fresh")
})
})
test("auth.loader: owns Authorization and strips any caller-supplied value (rule 3)", async () => {
mock = installFetchMock((call) => {
if (call.url.endsWith("/coding/v1/models")) {
@@ -561,6 +636,45 @@ test("provider.models and auth.loader share one in-flight refresh exchange", asy
expect(writes).toHaveLength(1)
})
test("auth.loader: separate plugin instances share one refresh via the auth-store lock", async () => {
const stale = validAuth({ access: "stale", expires: Date.now() + REFRESH_SAFETY_WINDOW_MS / 2 })
await withTempAuthStore(stale, async (root) => {
const gate = deferred<void>()
mock = installFetchMock(async (call) => {
if (call.url.includes("/oauth/token")) {
await gate.promise
const next = validAuth({ access: "access-2", refresh: "refresh-2", expires: Date.now() + 15 * 60_000 })
await writeAuthStore(root, next)
return { body: { access_token: next.access, refresh_token: next.refresh, token_type: "Bearer", expires_in: 900 } }
}
if (call.url.endsWith("/coding/v1/models")) {
return { body: { data: [{ id: MODEL_ID, context_length: 262144 }] } }
}
return { body: { ok: true } }
})
const readAuth = async () => stale
const { fetch: f1 } = await getLoaderFetch(readAuth)
const { fetch: f2 } = await getLoaderFetch(readAuth)
const request = {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: MODEL_ID, messages: [] }),
}
const p1 = f1("https://api.kimi.com/coding/v1/chat/completions", request)
const p2 = f2("https://api.kimi.com/coding/v1/chat/completions", request)
await new Promise((r) => setTimeout(r, 0))
gate.resolve()
await Promise.all([p1, p2])
expect(mock.calls.filter((c) => c.url.includes("/oauth/token"))).toHaveLength(1)
expect(mock.calls.filter((c) => c.url.endsWith("/coding/v1/chat/completions")).map((c) => c.headers["authorization"])).toEqual([
"Bearer access-2",
"Bearer access-2",
])
})
})
test("auth.loader: prefers the canonical MODEL_ID slug when /models returns multiple", async () => {
// Server returns several entries; the canonical `kimi-for-coding` is not first.
// Selection must still prefer it over the first element.
@@ -605,6 +719,37 @@ 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: invalid_grant self-heals when the live auth store rotated mid-refresh", async () => {
const stale = validAuth({ access: "stale", expires: Date.now() + REFRESH_SAFETY_WINDOW_MS / 2 })
await withTempAuthStore(stale, async (root) => {
mock = installFetchMock(async (call) => {
if (call.url.includes("/oauth/token")) {
const next = validAuth({ access: "fresh", refresh: "refresh-2", expires: Date.now() + 15 * 60_000 })
await writeAuthStore(root, next)
return {
status: 400,
body: { error: "invalid_grant", error_description: "The provided authorization grant is invalid" },
}
}
if (call.url.endsWith("/coding/v1/models")) {
return { body: { data: [{ id: MODEL_ID, context_length: 262144 }] } }
}
return { body: { ok: true } }
})
const { fetch: f, writes } = await getLoaderFetch(async () => stale)
const res = await f("https://api.kimi.com/coding/v1/chat")
expect(res.ok).toBe(true)
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 fresh")
expect(mock.calls[2]!.headers["authorization"]).toBe("Bearer fresh")
expect(writes).toHaveLength(0)
})
})
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")) {