diff --git a/package.json b/package.json index 7148968..59ecb1b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-kimi-full", - "version": "1.2.1", + "version": "1.2.2", "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 c5146b0..9dc8969 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,12 @@ type KimiBodyFields = { reasoning_effort?: string } +type ModelWithContextLimit = { + limit?: { + context?: number + } +} + type KimiHookInput = { sessionID: string model: { @@ -49,6 +55,17 @@ 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" +function isOAuthAuth(value: unknown): value is OAuthAuth { + if (!value || typeof value !== "object" || Array.isArray(value)) return false + const auth = value as Partial + return ( + auth.type === "oauth" && + typeof auth.access === "string" && + typeof auth.refresh === "string" && + typeof auth.expires === "number" + ) +} + function asRecord(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return return value as Record @@ -134,6 +151,29 @@ function pickModelInfo(models: KimiModelInfo[]): ModelDiscovery { } } +function withDiscoveredContext(model: T, contextLength: number | undefined): T { + if (!contextLength || contextLength <= 0) return model + if ((model.limit?.context ?? 0) > 0) return model + return { + ...model, + limit: { + ...model.limit, + context: contextLength, + }, + } +} + +function applyDiscoveryToModels>(models: T, discovery: ModelDiscovery): T { + const current = models[MODEL_ID] + if (!current) return models + const next = withDiscoveredContext(current, discovery.context_length) + if (next === current) return models + return { + ...models, + [MODEL_ID]: next, + } +} + function buildConfigBlock(info: { model_id: string; context_length?: number; display?: string }) { const name = info.display ?? "Kimi For Coding" const ctx = info.context_length ?? 0 @@ -185,26 +225,72 @@ function buildConfigBlock(info: { model_id: string; context_length?: number; dis * (c) lazily discovers the current wire model id from * `GET /coding/v1/models`, and (d) retries once with a forced * refresh on 401. - * 3. `chat.headers` — computes the Kimi-specific request body fields the + * 3. `provider.models` — discovers `context_length` early enough to patch + * opencode's model metadata when the user's config still has + * the default zero context window. + * 4. `chat.headers` — computes the Kimi-specific request body fields the * model actually needs (`thinking.type`, * `reasoning_effort`, `prompt_cache_key`) and passes them to * `loader.fetch` via private headers. - * 4. `chat.params` — mirrors the same fields into `output.options` for + * 5. `chat.params` — mirrors the same fields into `output.options` for * forward-compat if opencode fixes its current * openai-compatible providerOptions namespace mismatch. */ const plugin: Plugin = async ({ client }) => { // --- helpers --------------------------------------------------------------- + let cachedDiscovery: ModelDiscovery = {} + const persistAuth = async (auth: OAuthAuth) => { await client.auth.set({ path: { id: PROVIDER_ID }, body: auth }) } const isExpiring = (auth: OAuthAuth) => auth.expires - Date.now() < REFRESH_SAFETY_WINDOW_MS + const rememberDiscovery = (discovery: ModelDiscovery) => { + if (discovery.model_id) cachedDiscovery = discovery + return cachedDiscovery + } + + const refreshAuth = async (auth: OAuthAuth) => { + 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 hooks ---------------------------------------------------------- return { + provider: { + id: PROVIDER_ID, + models: async (provider, ctx) => { + if (!isOAuthAuth(ctx.auth)) return provider.models + + const discover = async (auth: OAuthAuth) => + applyDiscoveryToModels(provider.models, rememberDiscovery(pickModelInfo(await listModels(auth.access)))) + + const current = ctx.auth + let auth = current + try { + if (isExpiring(auth)) auth = await refreshAuth(auth) + return await discover(auth) + } catch (error) { + if (auth !== current || (error as { status?: number }).status !== 401) return provider.models + } + + try { + return await discover(await refreshAuth(current)) + } catch { + return provider.models + } + }, + }, auth: { provider: PROVIDER_ID, @@ -219,15 +305,14 @@ const plugin: Plugin = async ({ client }) => { * this loader callback. Writes still go through `client.auth.set`. */ loader: async (readAuth) => { - let discovery: ModelDiscovery = {} + let discovery: ModelDiscovery = cachedDiscovery const discoverModelInfo = async (access: string): Promise => { // opencode's SDK auth schema only persists the standard oauth fields // (`refresh`/`access`/`expires`) on `client.auth.set`, so discovery // cannot live durably in auth.json across refresh writes. Cache it in // this loader instance instead, and repopulate lazily on startup. - const next = pickModelInfo(await listModels(access)) - if (next.model_id) discovery = next + discovery = rememberDiscovery(pickModelInfo(await listModels(access))) return discovery } @@ -238,6 +323,7 @@ const plugin: Plugin = async ({ client }) => { context_length: auth.context_length, model_display: auth.model_display, } + cachedDiscovery = discovery } if (discovery.model_id) return { ...auth, ...discovery } try { @@ -254,7 +340,7 @@ const plugin: Plugin = async ({ client }) => { "kimi-for-coding-oauth: not logged in — run `opencode auth login kimi-for-coding-oauth`", ) if (!force && !isExpiring(current)) return ensureDiscovered(current) - const tokens = await refreshToken(current.refresh) + const next = await refreshAuth(current) // 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 @@ -262,17 +348,10 @@ const plugin: Plugin = async ({ client }) => { // warm in-memory discovery still works for the common case, and // the request-path 401 retry will flush a broken access token. try { - await discoverModelInfo(tokens.access_token) + await discoverModelInfo(next.access) } catch { /* keep previous discovery */ } - 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, ...discovery } } diff --git a/src/oauth.ts b/src/oauth.ts index 7d78b34..63a68db 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -199,7 +199,9 @@ export async function listModels(accessToken: string): Promise }) const text = await res.text() if (!res.ok) { - throw new Error(`kimi list-models ${res.status}: ${text.slice(0, 200)}`) + const err = new Error(`kimi list-models ${res.status}: ${text.slice(0, 200)}`) as Error & { status?: number } + err.status = res.status + throw err } let json: any try { diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 4c9da4f..b37f4fa 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -239,6 +239,86 @@ async function getLoaderFetch(readAuth: () => Promise) { return { fetch: (res as { fetch: typeof fetch }).fetch, apiKey: (res as { apiKey: string }).apiKey, writes } } +function makeProviderState(context = 0) { + return { + id: PROVIDER_ID, + models: { + [MODEL_ID]: { + name: "Kimi For Coding", + reasoning: true, + options: {}, + limit: { context }, + variants: { + auto: { reasoning_effort: "auto" }, + }, + }, + "some-other-model": { + name: "Other", + reasoning: false, + options: {}, + limit: { context: 1234 }, + }, + }, + } +} + +// ---------- provider.models ------------------------------------------------- + +test("provider.models: fills limit.context from discovery when config still has zero", 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 { hooks, writes } = await getHooks() + const provider = makeProviderState() + const next = await hooks.provider!.models!(provider as any, { auth: validAuth() } as any) + expect(next[MODEL_ID]!.limit?.context).toBe(262144) + expect(next["some-other-model"]!.limit?.context).toBe(1234) + expect(provider.models[MODEL_ID]!.limit?.context).toBe(0) + expect(writes).toHaveLength(0) +}) + +test("provider.models: preserves an explicit user context limit", 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 { hooks } = await getHooks() + const provider = makeProviderState(8192) + const next = await hooks.provider!.models!(provider as any, { auth: validAuth() } as any) + expect(next[MODEL_ID]!.limit?.context).toBe(8192) +}) + +test("provider.models: retries once with a refreshed token after 401", async () => { + mock = installFetchMock((call) => { + if (call.url.endsWith("/coding/v1/models") && call.headers["authorization"] === "Bearer stale") { + return { status: 401, body: { error: "unauthorized" } } + } + if (call.url.includes("/oauth/token")) { + return { body: { access_token: "fresh", refresh_token: "refresh-2", token_type: "Bearer", expires_in: 900 } } + } + if (call.url.endsWith("/coding/v1/models") && call.headers["authorization"] === "Bearer fresh") { + return { body: { data: [{ id: MODEL_ID, context_length: 131072 }] } } + } + return { body: { ok: true } } + }) + const { hooks, writes } = await getHooks() + const provider = makeProviderState() + const next = await hooks.provider!.models!(provider as any, { auth: validAuth({ access: "stale" }) } as any) + expect(mock.calls.map((c) => c.url)).toEqual([ + "https://api.kimi.com/coding/v1/models", + "https://auth.kimi.com/api/oauth/token", + "https://api.kimi.com/coding/v1/models", + ]) + expect(mock.calls[2]!.headers["authorization"]).toBe("Bearer fresh") + expect(next[MODEL_ID]!.limit?.context).toBe(131072) + expect((writes[0]!.body as { access: string }).access).toBe("fresh") +}) + 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/) @@ -337,6 +417,35 @@ test("auth.loader: injects selected reasoning_effort from private headers into t }) }) +test("auth.loader: effort=auto injects only prompt_cache_key and never synthesizes temperature", async () => { + const { hooks } = await getHooks() + const { output: headerOutput } = await callHeaders(hooks["chat.headers"]!, { + sessionID: "sess-auto", + variants: { auto: { reasoning_effort: "auto" } }, + variant: "auto", + }) + 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()) + await f("https://api.kimi.com/coding/v1/chat/completions", { + method: "POST", + headers: { + "content-type": "application/json", + ...headerOutput.headers, + }, + body: JSON.stringify({ model: MODEL_ID, messages: [] }), + }) + expect(JSON.parse(mock.calls[1]!.body as string)).toEqual({ + model: MODEL_ID, + messages: [], + prompt_cache_key: "sess-auto", + }) +}) + test("auth.loader: refreshes when expiry is within safety window", async () => { let reads = 0 const initial = validAuth({ expires: Date.now() + REFRESH_SAFETY_WINDOW_MS / 2 })