diff --git a/AGENTS.md b/AGENTS.md index a66ccb0..e71514d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,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, and `GET /coding/v1/models` discovery. | -| `src/index.ts` | Plugin entry. Wires `auth` hook (login + loader) and `chat.params` hook. | +| `src/index.ts` | Plugin entry. Wires `auth` (login + loader) plus the Kimi chat hooks/body rewrite. | Data flow on a chat request: @@ -44,7 +44,7 @@ Data flow on a chat request: 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 → lazily discovers `/coding/v1/models` when needed → sets Authorization + the seven `X-Msh-*` headers → on 401 refreshes once and retries. -5. Separately, opencode runs the `chat.params` hook and writes `thinking`, `reasoning_effort`, `prompt_cache_key` into `output.options`. opencode wraps those as `{ [providerID]: options }` and the openai-compatible SDK forwards them as top-level body fields. That is why those keys must use **exactly** the wire names (`prompt_cache_key`, `reasoning_effort`, `thinking`). +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 @@ -52,7 +52,7 @@ These are the invariants that, if broken, silently degrade K2.6 → K2.5 or prod 1. **`X-Msh-Version` and `User-Agent` must track `kimi-cli`.** Bumping involves exactly one line in `src/constants.ts`. See upstream `research/kimi-cli/src/kimi_cli/constant.py`. The UA prefix is `KimiCLI/` (not `KimiCodeCLI/`) — Moonshot's `kimi-for-coding` backend 403s with `access_terminated_error: only available for Coding Agents such as Kimi CLI, Claude Code, Roo Code…` on any other prefix. Likewise, `X-Msh-Device-Model` must mirror kimi-cli's `_device_model()` shape, including the Darwin/Windows special cases (`macOS `, `Windows 10/11 `, Linux `"{system} {release} {machine}"`) — NOT just `{arch}` — and `X-Msh-Os-Version` is the kernel build string from `os.version()`, NOT `"{type} {release}"`. Tested live against `api.kimi.com/coding/v1` on 2026-04-17 — any of those three fields off-spec → 403. 2. **`X-Msh-Device-Id` must be stable across runs.** Never regenerate a fresh UUID at import time. `getDeviceId()` reads/writes `~/.kimi/device_id`; that path is shared with `kimi-cli` on purpose. -3. **`Authorization` header is owned by `loader.fetch`.** Anything else (opencode core, the SDK, future hooks) must be overridden. Our `loader` deletes both `authorization` and `Authorization` before setting its own. +3. **`Authorization` header is owned by `loader.fetch`.** Anything else (opencode core, the SDK, future hooks) must be overridden. Our `loader` deletes both `authorization` and `Authorization` before setting its own. The private `x-opencode-kimi-*` transport headers are also consumed and stripped there; they must never leak upstream. 4. **Effort ↔ fields mapping** (kimi-cli `llm.py` / `kosong/chat_provider/kimi.py`): | Effort | `reasoning_effort` | `thinking` | @@ -63,9 +63,9 @@ These are the invariants that, if broken, silently degrade K2.6 → K2.5 or prod | `medium` | `"medium"` | `{type:"enabled"}` | | `high` | `"high"` | `{type:"enabled"}` | - `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. + `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. Compute this from `input.model.options` plus `input.model.variants[input.message.model.variant]`, not from `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`. +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. 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). @@ -83,7 +83,7 @@ These are the invariants that, if broken, silently degrade K2.6 → K2.5 or prod ### What not to do -- ❌ Don't add heuristics that look at the model id outside of `chat.params`. The `auth.loader` fetch is already scoped to this provider; the only place that needs to match on `kimi-for-coding` is the params hook. +- ❌ Don't add heuristics that look at the model id outside of the Kimi chat hooks / `loader.fetch`. The auth loader is already scoped to this provider; only the chat hooks and the body rewrite need to match on `kimi-for-coding`. - ❌ Don't rename the provider id back to `kimi-for-coding` or to anything else listed in models.dev. See rule 8. - ❌ Don't add new header values that kimi-cli doesn't send. The fingerprint matters. - ❌ Don't call out to other files to "share" the kimi-cli credentials. Different OAuth consumers must have independent refresh-token chains or one will invalidate the other. diff --git a/package.json b/package.json index ff0838b..7148968 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-kimi-full", - "version": "1.2.0", + "version": "1.2.1", "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 1431793..c5146b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,108 @@ type ModelDiscovery = { model_display?: string } +type ThinkingType = "enabled" | "disabled" + +type KimiBodyFields = { + prompt_cache_key?: string + thinking?: { type: ThinkingType } + reasoning_effort?: string +} + +type KimiHookInput = { + sessionID: string + model: { + providerID: string + id: string + options?: Record + variants?: Record> + } + message: { + model: { + variant?: string + } + } +} + +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 asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return + return value as Record +} + +function asThinking(value: unknown): KimiBodyFields["thinking"] | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return + const type = (value as { type?: unknown }).type + if (type !== "enabled" && type !== "disabled") return + return { type } +} + +function pickEffort(options: Record | undefined) { + const effort = options?.reasoning_effort ?? options?.reasoningEffort + return typeof effort === "string" ? effort : undefined +} + +function resolveKimiBodyFields(input: KimiHookInput): KimiBodyFields | undefined { + if (input.model.providerID !== PROVIDER_ID) return + if (input.model.id !== MODEL_ID) return + + const modelOptions = asRecord(input.model.options) + const variantOptions = input.message.model.variant + ? asRecord(input.model.variants?.[input.message.model.variant]) + : undefined + + const fields: KimiBodyFields = { prompt_cache_key: input.sessionID } + const thinking = asThinking(variantOptions?.thinking) ?? asThinking(modelOptions?.thinking) + const effort = pickEffort(variantOptions) ?? pickEffort(modelOptions) + + if (effort === "auto") return fields + if (effort === "off") { + fields.thinking = { type: "disabled" } + return fields + } + if (effort) fields.reasoning_effort = effort + fields.thinking = thinking ?? { type: "enabled" } + return fields +} + +function applyKimiBodyFields(target: Record, fields: KimiBodyFields) { + target.prompt_cache_key = fields.prompt_cache_key + if (fields.reasoning_effort) { + target.reasoning_effort = fields.reasoning_effort + } else { + delete target.reasoning_effort + } + delete target.reasoningEffort + if (fields.thinking) { + target.thinking = fields.thinking + return + } + delete target.thinking +} + +function consumeInternalKimiBodyFields(headers: Headers): KimiBodyFields { + const fields: KimiBodyFields = {} + const promptCacheKey = headers.get(INTERNAL_PROMPT_CACHE_KEY_HEADER) + if (promptCacheKey) fields.prompt_cache_key = promptCacheKey + const reasoningEffort = headers.get(INTERNAL_REASONING_EFFORT_HEADER) + if (reasoningEffort) fields.reasoning_effort = reasoningEffort + const thinkingType = headers.get(INTERNAL_THINKING_TYPE_HEADER) + if (thinkingType === "enabled" || thinkingType === "disabled") { + fields.thinking = { type: thinkingType } + } + headers.delete(INTERNAL_PROMPT_CACHE_KEY_HEADER) + headers.delete(INTERNAL_REASONING_EFFORT_HEADER) + headers.delete(INTERNAL_THINKING_TYPE_HEADER) + return fields +} + +function hasKimiBodyFields(fields: KimiBodyFields) { + return Boolean(fields.prompt_cache_key || fields.reasoning_effort || fields.thinking) +} + function pickModelInfo(models: KimiModelInfo[]): ModelDiscovery { const picked = models.find((m) => m.id === MODEL_ID) ?? models[0] if (!picked) return {} @@ -83,11 +185,13 @@ 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.params` — adds the Kimi-specific request body fields the model - * actually needs: `thinking.type`, `reasoning_effort`, and - * `prompt_cache_key`. These are placed under the SDK-scoped - * options bag so `@ai-sdk/openai-compatible` forwards them - * verbatim as top-level JSON body fields. + * 3. `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 + * forward-compat if opencode fixes its current + * openai-compatible providerOptions namespace mismatch. */ const plugin: Plugin = async ({ client }) => { // --- helpers --------------------------------------------------------------- @@ -182,6 +286,12 @@ const plugin: Plugin = async ({ client }) => { new Headers(init?.headers).forEach((value, key) => { headers.set(key, value) }) + // opencode currently namespaces providerOptions for + // @ai-sdk/openai-compatible under the provider id, while the SDK + // reads them back under the human provider name. Carry Kimi-only + // body fields through private headers instead so the wire request + // stays correct regardless of that upstream mismatch. + const kimiBodyFields = consumeInternalKimiBodyFields(headers) // Strip anything the upstream SDK put on. Our values win. headers.delete("authorization") headers.delete("Authorization") @@ -213,11 +323,16 @@ const plugin: Plugin = async ({ client }) => { .text() .catch(() => undefined) : undefined - if (targetModel && targetModel !== MODEL_ID && originalBody) { + if (((targetModel && targetModel !== MODEL_ID) || hasKimiBodyFields(kimiBodyFields)) && originalBody) { try { const parsed = JSON.parse(originalBody) - if (parsed && typeof parsed === "object" && parsed.model === MODEL_ID) { - parsed.model = targetModel + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + if (targetModel && targetModel !== MODEL_ID && parsed.model === MODEL_ID) { + parsed.model = targetModel + } + if (hasKimiBodyFields(kimiBodyFields)) { + applyKimiBodyFields(parsed as Record, kimiBodyFields) + } newInit = { ...init, body: JSON.stringify(parsed) } } } catch { @@ -296,62 +411,32 @@ const plugin: Plugin = async ({ client }) => { ], }, + "chat.headers": async (input, output) => { + const fields = resolveKimiBodyFields(input as KimiHookInput) + if (!fields) return + if (fields.prompt_cache_key) { + output.headers[INTERNAL_PROMPT_CACHE_KEY_HEADER] = fields.prompt_cache_key + } + if (fields.reasoning_effort) { + output.headers[INTERNAL_REASONING_EFFORT_HEADER] = fields.reasoning_effort + } + if (fields.thinking) { + output.headers[INTERNAL_THINKING_TYPE_HEADER] = fields.thinking.type + } + }, + /** - * Inject Kimi-specific body fields. + * Mirror Kimi-specific body fields into providerOptions when possible. * - * kimi-cli sends BOTH `reasoning_effort` and a `thinking` object at the - * top level. The @ai-sdk/openai-compatible SDK forwards unknown keys in - * `providerOptions[]` as top-level body fields, which is exactly - * what we need. The sdkKey for @ai-sdk/openai-compatible is the - * providerID, so we write to `output.options` which opencode then wraps - * as `{ [providerID]: options }` via ProviderTransform.providerOptions. + * The real load-bearing path is `chat.headers` → `loader.fetch`, because + * current opencode/openai-compatible builds disagree on the providerOptions + * namespace. We still normalize `output.options` so the plugin keeps + * working if upstream aligns those keys later. */ "chat.params": async (input, output) => { - // Gate on model.providerID (the field opencode's llm.ts actually - // populates — `input.provider` is the flat `ProviderConfig` passed by - // `packages/opencode/src/session/llm.ts::stream`, so `input.provider.id` - // works too, but the @opencode-ai/plugin type for `ProviderContext` - // claims `.info.id` exists — the runtime shape disagrees. Using - // `input.model.providerID` is what every first-party plugin does - // (cloudflare.ts, codex.ts, github-copilot/copilot.ts). - if (input.model.providerID !== PROVIDER_ID) return - if (input.model.id !== MODEL_ID) return - - // `prompt_cache_key` — stable per conversation so the backend can reuse - // its KV cache across turns. opencode's sessionID is exactly that. - output.options.prompt_cache_key = input.sessionID - - // Thinking / reasoning effort. We mirror kimi-cli's mapping: - // - effort `off` → no reasoning_effort, thinking.type = "disabled" - // - effort `auto` → omit both; let Moonshot pick dynamically - // - effort ∈ {low, medium, high} → reasoning_effort = effort, - // thinking.type = "enabled" - // - // Effort is read from opencode's options bag. It may be present as: - // - `reasoning_effort` (wire shape; what model.variants typically set) - // - `reasoningEffort` (opencode camelCase passthrough) - // - `reasoning.effort` / `providerOptions..reasoningEffort` (rare) - // We accept the first two, normalize, and leave any caller-supplied - // `thinking` object alone if they already set one. - const effort = output.options.reasoning_effort ?? output.options.reasoningEffort - if (effort === "auto") { - // Explicit "auto" variant: remove both knobs so the server picks. - delete output.options.reasoning_effort - delete output.options.reasoningEffort - delete output.options.thinking - } else if (typeof effort === "string" && effort !== "off") { - output.options.reasoning_effort = effort - delete output.options.reasoningEffort - output.options.thinking = output.options.thinking ?? { type: "enabled" } - } else if (effort === "off") { - delete output.options.reasoning_effort - delete output.options.reasoningEffort - output.options.thinking = { type: "disabled" } - } else if (!output.options.thinking) { - // No effort set at all → thinking enabled, no reasoning_effort - // (server picks). Matches kimi-cli's "nothing passed" default. - output.options.thinking = { type: "enabled" } - } + const fields = resolveKimiBodyFields(input as KimiHookInput) + if (!fields) return + applyKimiBodyFields(output.options, fields) }, } } diff --git a/test/plugin.test.ts b/test/plugin.test.ts index aeb5f3e..4c9da4f 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -38,37 +38,83 @@ async function getHooks() { return { hooks, writes } } -// ---------- chat.params ----------------------------------------------------- +// ---------- chat hooks ------------------------------------------------------ -// Minimal shape for input/output we care about in the params hook. -// Mirrors the runtime shape opencode actually passes (see +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" + +// Minimal shape for the chat hook input/output we care about. Mirrors the +// runtime shape opencode actually passes (see // research/opencode/packages/opencode/src/session/llm.ts::stream — `model` -// has `.providerID` and `.id`; `provider` is the flat ProviderConfig). -type ParamsInput = { +// has `.providerID`, `.id`, `.options`, `.variants`; the selected variant +// rides on `message.model.variant`). +type HookInput = { + agent: string provider: { id: string } - model: { providerID: string; id: string } + model: { + providerID: string + id: string + options?: Record + variants?: Record> + } + message: { model: { variant?: string } } sessionID: string } type ParamsOutput = { options: Record } +type HeadersOutput = { headers: Record } + +type HookInputOptions = { + providerID?: string + modelID?: string + sessionID?: string + modelOptions?: Record + variants?: Record> + variant?: string +} + +function makeHookInput(options: HookInputOptions = {}): HookInput { + const providerID = options.providerID ?? PROVIDER_ID + return { + agent: "test-agent", + provider: { id: providerID }, + model: { + providerID, + id: options.modelID ?? MODEL_ID, + options: options.modelOptions, + variants: options.variants, + }, + message: { + model: { + variant: options.variant, + }, + }, + sessionID: options.sessionID ?? "sess-1", + } +} + function callParams( - hook: (i: ParamsInput, o: ParamsOutput) => Promise | void, - providerID: string, - modelID: string, + hook: (i: HookInput, o: ParamsOutput) => Promise | void, + input: HookInputOptions = {}, options: Record = {}, - sessionID = "sess-1", ) { const output: ParamsOutput = { options: { ...options } } - const res = hook( - { provider: { id: providerID }, model: { providerID, id: modelID }, sessionID }, - output, - ) + const res = hook(makeHookInput(input), output) + return { res, output } +} + +function callHeaders(hook: (i: HookInput, o: HeadersOutput) => Promise | void, input: HookInputOptions = {}) { + const output: HeadersOutput = { headers: {} } + const res = hook(makeHookInput(input), output) return { res, output } } test("chat.params: no-op for other providers (AGENTS.md rule: gated on PROVIDER_ID)", async () => { const { hooks } = await getHooks() const hook = hooks["chat.params"]! - const { output } = callParams(hook, "some-other-provider", MODEL_ID, { reasoning_effort: "high" }) + const { output } = callParams(hook, { providerID: "some-other-provider", modelOptions: { reasoning_effort: "high" } }, { + reasoning_effort: "high", + }) // Untouched — no prompt_cache_key, no thinking added. expect(output.options).toEqual({ reasoning_effort: "high" }) }) @@ -76,7 +122,7 @@ test("chat.params: no-op for other providers (AGENTS.md rule: gated on PROVIDER_ test("chat.params: no-op for other models under our provider (rule 5 gating)", async () => { const { hooks } = await getHooks() const hook = hooks["chat.params"]! - const { output } = callParams(hook, PROVIDER_ID, "kimi-something-else") + const { output } = callParams(hook, { modelID: "kimi-something-else" }) expect(output.options.prompt_cache_key).toBeUndefined() expect(output.options.thinking).toBeUndefined() }) @@ -84,7 +130,7 @@ test("chat.params: no-op for other models under our provider (rule 5 gating)", a test("chat.params: attaches prompt_cache_key = sessionID for kimi-for-coding only", async () => { const { hooks } = await getHooks() const hook = hooks["chat.params"]! - const { output } = await callParams(hook, PROVIDER_ID, MODEL_ID, {}, "sess-42") + const { output } = await callParams(hook, { sessionID: "sess-42" }) expect(output.options.prompt_cache_key).toBe("sess-42") }) @@ -106,9 +152,11 @@ const EFFORT_MATRIX: Array<{ test("chat.params: effort=auto → no reasoning_effort, no thinking (server picks dynamically)", async () => { const { hooks } = await getHooks() - const { output } = await callParams(hooks["chat.params"]!, PROVIDER_ID, MODEL_ID, { - reasoning_effort: "auto", - }) + const { output } = await callParams( + hooks["chat.params"]!, + { modelOptions: { reasoning_effort: "auto" } }, + { reasoning_effort: "auto" }, + ) expect(output.options.reasoning_effort).toBeUndefined() expect(output.options.reasoningEffort).toBeUndefined() expect(output.options.thinking).toBeUndefined() @@ -116,7 +164,7 @@ test("chat.params: effort=auto → no reasoning_effort, no thinking (server pick for (const row of EFFORT_MATRIX) { test(`chat.params: effort=${JSON.stringify(row.in)} → effort=${row.effort}, thinking=${row.thinkingType}`, async () => { const { hooks } = await getHooks() - const { output } = await callParams(hooks["chat.params"]!, PROVIDER_ID, MODEL_ID, row.in) + const { output } = await callParams(hooks["chat.params"]!, { modelOptions: row.in }, row.in) expect(output.options.reasoning_effort).toBe(row.effort) expect(output.options.thinking).toEqual({ type: row.thinkingType }) }) @@ -125,12 +173,51 @@ for (const row of EFFORT_MATRIX) { test("chat.params: `reasoningEffort` (camelCase) input also drives the mapping", async () => { // opencode may use camelCase upstream; the plugin accepts either. const { hooks } = await getHooks() - const { output } = await callParams(hooks["chat.params"]!, PROVIDER_ID, MODEL_ID, { reasoningEffort: "off" }) + const { output } = await callParams( + hooks["chat.params"]!, + { modelOptions: { reasoningEffort: "off" } }, + { reasoningEffort: "off" }, + ) expect(output.options.thinking).toEqual({ type: "disabled" }) expect(output.options.reasoning_effort).toBeUndefined() expect(output.options.reasoningEffort).toBeUndefined() }) +test("chat.headers: default request enables thinking and carries prompt_cache_key", async () => { + const { hooks } = await getHooks() + const { output } = await callHeaders(hooks["chat.headers"]!) + expect(output.headers[INTERNAL_PROMPT_CACHE_KEY_HEADER]).toBe("sess-1") + expect(output.headers[INTERNAL_THINKING_TYPE_HEADER]).toBe("enabled") + expect(output.headers[INTERNAL_REASONING_EFFORT_HEADER]).toBeUndefined() +}) + +test("chat.headers: selected variant overrides model options for the wire effort mapping", async () => { + const { hooks } = await getHooks() + const { output } = await callHeaders(hooks["chat.headers"]!, { + modelOptions: { reasoning_effort: "high" }, + variants: { + auto: { reasoning_effort: "auto" }, + off: { reasoning_effort: "off" }, + low: { reasoning_effort: "low" }, + }, + variant: "off", + }) + expect(output.headers[INTERNAL_PROMPT_CACHE_KEY_HEADER]).toBe("sess-1") + expect(output.headers[INTERNAL_THINKING_TYPE_HEADER]).toBe("disabled") + expect(output.headers[INTERNAL_REASONING_EFFORT_HEADER]).toBeUndefined() +}) + +test("chat.headers: effort=auto omits both thinking and reasoning_effort", async () => { + const { hooks } = await getHooks() + const { output } = await callHeaders(hooks["chat.headers"]!, { + variants: { auto: { reasoning_effort: "auto" } }, + variant: "auto", + }) + expect(output.headers[INTERNAL_PROMPT_CACHE_KEY_HEADER]).toBe("sess-1") + expect(output.headers[INTERNAL_THINKING_TYPE_HEADER]).toBeUndefined() + expect(output.headers[INTERNAL_REASONING_EFFORT_HEADER]).toBeUndefined() +}) + // ---------- auth.loader ----------------------------------------------------- function jwt() { @@ -187,6 +274,69 @@ test("auth.loader: owns Authorization and strips any caller-supplied value (rule expect(h["x-msh-device-id"]).toMatch(/^[0-9a-f]{32}$/) }) +test("auth.loader: injects default thinking via private headers and strips them upstream", async () => { + const { hooks } = await getHooks() + const { output: headerOutput } = await callHeaders(hooks["chat.headers"]!, { + sessionID: "sess-default", + }) + 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: [] }), + }) + const upstream = mock.calls[1]! + expect(upstream.headers[INTERNAL_PROMPT_CACHE_KEY_HEADER]).toBeUndefined() + expect(upstream.headers[INTERNAL_REASONING_EFFORT_HEADER]).toBeUndefined() + expect(upstream.headers[INTERNAL_THINKING_TYPE_HEADER]).toBeUndefined() + expect(JSON.parse(upstream.body as string)).toEqual({ + model: MODEL_ID, + messages: [], + prompt_cache_key: "sess-default", + thinking: { type: "enabled" }, + }) +}) + +test("auth.loader: injects selected reasoning_effort from private headers into the wire body", async () => { + const { hooks } = await getHooks() + const { output: headerOutput } = await callHeaders(hooks["chat.headers"]!, { + sessionID: "sess-high", + variants: { high: { reasoning_effort: "high" } }, + variant: "high", + }) + 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-high", + reasoning_effort: "high", + thinking: { type: "enabled" }, + }) +}) + 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 })