Fix Kimi request body injection

This commit is contained in:
lemon07r
2026-04-17 15:10:14 -04:00
parent 8f17119356
commit a802ff0bac
4 changed files with 324 additions and 89 deletions
+6 -6
View File
@@ -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 <version> <arch>`, `Windows 10/11 <arch>`, 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.
+1 -1
View File
@@ -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": {
+145 -60
View File
@@ -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<string, unknown>
variants?: Record<string, Record<string, unknown>>
}
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<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return
return value as Record<string, unknown>
}
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<string, unknown> | 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<string, unknown>, 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<string, unknown>, 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[<sdkKey>]` 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.<id>.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)
},
}
}
+172 -22
View File
@@ -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<string, unknown>
variants?: Record<string, Record<string, unknown>>
}
message: { model: { variant?: string } }
sessionID: string
}
type ParamsOutput = { options: Record<string, unknown> }
type HeadersOutput = { headers: Record<string, string> }
type HookInputOptions = {
providerID?: string
modelID?: string
sessionID?: string
modelOptions?: Record<string, unknown>
variants?: Record<string, Record<string, unknown>>
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> | void,
providerID: string,
modelID: string,
hook: (i: HookInput, o: ParamsOutput) => Promise<void> | void,
input: HookInputOptions = {},
options: Record<string, unknown> = {},
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> | 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 })