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