Wire opencode auth/loader/chat.params hooks for kimi-for-coding

This commit is contained in:
lemon07r
2026-04-16 23:24:46 -04:00
parent 2bf6c4b605
commit c1fc4688a4
+175
View File
@@ -0,0 +1,175 @@
import type { Plugin } from "@opencode-ai/plugin"
import { MODEL_ID, REFRESH_SAFETY_WINDOW_MS } from "./constants.ts"
import { kimiHeaders } from "./headers.ts"
import { pollDeviceToken, refreshToken, startDeviceAuth } from "./oauth.ts"
// Provider id the user must use in their opencode config. Keep it in sync with
// README.md → "Configure opencode".
export const PROVIDER_ID = "kimi-for-coding"
type OAuthAuth = {
type: "oauth"
refresh: string
access: string
expires: number
}
/**
* Plugin entry point.
*
* Responsibilities, in order of execution:
* 1. `auth` — register device-flow OAuth login under the `kimi-for-coding`
* provider id. opencode persists the returned tokens in its
* own auth.json; we never touch disk for credentials.
* 2. `loader` — runs every time opencode instantiates the provider. Returns
* a custom `fetch` that (a) refreshes the access token when
* it is about to expire, (b) injects the seven X-Msh-* / UA
* headers on every upstream call (models list, chat, etc.),
* and (c) 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.
*/
const plugin: Plugin = async ({ client }) => {
// --- helpers ---------------------------------------------------------------
const readAuth = async (): Promise<OAuthAuth | undefined> => {
const res = await client.auth.get({ path: { id: PROVIDER_ID } })
const data: any = (res as any).data ?? res
return data?.type === "oauth" ? (data as OAuthAuth) : undefined
}
const persistAuth = async (auth: OAuthAuth) => {
await client.auth.set({ path: { id: PROVIDER_ID }, body: auth })
}
const isExpiring = (auth: OAuthAuth) => auth.expires - Date.now() < REFRESH_SAFETY_WINDOW_MS
const ensureFresh = async (force = false): Promise<OAuthAuth> => {
const current = await readAuth()
if (!current) throw new Error("kimi-for-coding: not logged in — run `opencode auth login kimi-for-coding`")
if (!force && !isExpiring(current)) return current
const tokens = await refreshToken(current.refresh)
const next: OAuthAuth = {
type: "oauth",
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + tokens.expires_in * 1000,
}
await persistAuth(next)
return next
}
// --- return hooks ----------------------------------------------------------
return {
auth: {
provider: PROVIDER_ID,
/**
* Called every time opencode creates an `@ai-sdk/openai-compatible`
* instance for this provider. We inject a `fetch` that owns all auth
* and header concerns so no other hook has to worry about them.
*/
loader: async () => {
return {
// We own the Authorization header entirely, but opencode still
// requires a truthy apiKey to wire things up; use a sentinel.
apiKey: "kimi-for-coding-oauth",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
const doRequest = async (auth: OAuthAuth) => {
const headers = new Headers(init?.headers)
// Strip anything the upstream SDK put on. Our values win.
headers.delete("authorization")
headers.delete("Authorization")
for (const [k, v] of Object.entries(kimiHeaders())) headers.set(k, v)
headers.set("Authorization", `Bearer ${auth.access}`)
return fetch(input, { ...init, headers })
}
let auth = await ensureFresh()
let res = await doRequest(auth)
if (res.status === 401) {
// Token might have been invalidated server-side before its
// nominal expiry. Force a refresh and retry exactly once.
auth = await ensureFresh(true)
res = await doRequest(auth)
}
return res
},
}
},
methods: [
{
type: "oauth",
label: "Kimi Code (device flow)",
authorize: async () => {
const device = await startDeviceAuth()
const url = device.verification_uri_complete ?? device.verification_uri
return {
url,
instructions: `Open the URL above and approve code ${device.user_code}. This window will continue automatically.`,
method: "auto",
callback: async () => {
try {
const tokens = await pollDeviceToken(device)
return {
type: "success",
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + tokens.expires_in * 1000,
}
} catch {
return { type: "failed" }
}
},
}
},
},
],
},
/**
* Inject Kimi-specific body fields.
*
* 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.
*/
"chat.params": async (input, output) => {
if (input.provider.info.id !== 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 ∈ {low, medium, high} → reasoning_effort = effort,
// thinking.type = "enabled"
// If the user (or a higher-level hook) already set one of these, leave
// their value alone.
const effort = output.options.reasoning_effort ?? output.options.reasoningEffort
if (typeof effort === "string" && effort !== "off") {
output.options.reasoning_effort = effort
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) {
// Default: thinking on, let the server pick effort (don't send one).
output.options.thinking = { type: "enabled" }
}
},
}
}
export default plugin