Fix Kimi provider metadata discovery

This commit is contained in:
lemon07r
2026-04-18 07:47:25 -04:00
parent a802ff0bac
commit 14d93a6bf3
4 changed files with 206 additions and 16 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "opencode-kimi-full",
"version": "1.2.1",
"version": "1.2.2",
"description": "OpenCode plugin that adds first-class support for Kimi K2.6 (kimi-for-coding) via the official Kimi OAuth device flow, matching the upstream kimi-cli 1:1.",
"license": "MIT",
"repository": {
+93 -14
View File
@@ -30,6 +30,12 @@ type KimiBodyFields = {
reasoning_effort?: string
}
type ModelWithContextLimit = {
limit?: {
context?: number
}
}
type KimiHookInput = {
sessionID: string
model: {
@@ -49,6 +55,17 @@ const INTERNAL_PROMPT_CACHE_KEY_HEADER = "x-opencode-kimi-prompt-cache-key"
const INTERNAL_REASONING_EFFORT_HEADER = "x-opencode-kimi-reasoning-effort"
const INTERNAL_THINKING_TYPE_HEADER = "x-opencode-kimi-thinking-type"
function isOAuthAuth(value: unknown): value is OAuthAuth {
if (!value || typeof value !== "object" || Array.isArray(value)) return false
const auth = value as Partial<OAuthAuth>
return (
auth.type === "oauth" &&
typeof auth.access === "string" &&
typeof auth.refresh === "string" &&
typeof auth.expires === "number"
)
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return
return value as Record<string, unknown>
@@ -134,6 +151,29 @@ function pickModelInfo(models: KimiModelInfo[]): ModelDiscovery {
}
}
function withDiscoveredContext<T extends ModelWithContextLimit>(model: T, contextLength: number | undefined): T {
if (!contextLength || contextLength <= 0) return model
if ((model.limit?.context ?? 0) > 0) return model
return {
...model,
limit: {
...model.limit,
context: contextLength,
},
}
}
function applyDiscoveryToModels<T extends Record<string, ModelWithContextLimit>>(models: T, discovery: ModelDiscovery): T {
const current = models[MODEL_ID]
if (!current) return models
const next = withDiscoveredContext(current, discovery.context_length)
if (next === current) return models
return {
...models,
[MODEL_ID]: next,
}
}
function buildConfigBlock(info: { model_id: string; context_length?: number; display?: string }) {
const name = info.display ?? "Kimi For Coding"
const ctx = info.context_length ?? 0
@@ -185,26 +225,72 @@ function buildConfigBlock(info: { model_id: string; context_length?: number; dis
* (c) lazily discovers the current wire model id from
* `GET /coding/v1/models`, and (d) retries once with a forced
* refresh on 401.
* 3. `chat.headers` — computes the Kimi-specific request body fields the
* 3. `provider.models` — discovers `context_length` early enough to patch
* opencode's model metadata when the user's config still has
* the default zero context window.
* 4. `chat.headers` — computes the Kimi-specific request body fields the
* model actually needs (`thinking.type`,
* `reasoning_effort`, `prompt_cache_key`) and passes them to
* `loader.fetch` via private headers.
* 4. `chat.params` — mirrors the same fields into `output.options` for
* 5. `chat.params` — mirrors the same fields into `output.options` for
* forward-compat if opencode fixes its current
* openai-compatible providerOptions namespace mismatch.
*/
const plugin: Plugin = async ({ client }) => {
// --- helpers ---------------------------------------------------------------
let cachedDiscovery: ModelDiscovery = {}
const persistAuth = async (auth: OAuthAuth) => {
await client.auth.set({ path: { id: PROVIDER_ID }, body: auth })
}
const isExpiring = (auth: OAuthAuth) => auth.expires - Date.now() < REFRESH_SAFETY_WINDOW_MS
const rememberDiscovery = (discovery: ModelDiscovery) => {
if (discovery.model_id) cachedDiscovery = discovery
return cachedDiscovery
}
const refreshAuth = async (auth: OAuthAuth) => {
const tokens = await refreshToken(auth.refresh)
const next: OAuthAuth = {
type: "oauth",
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + tokens.expires_in * 1000,
}
await persistAuth(next)
return next
}
// --- return hooks ----------------------------------------------------------
return {
provider: {
id: PROVIDER_ID,
models: async (provider, ctx) => {
if (!isOAuthAuth(ctx.auth)) return provider.models
const discover = async (auth: OAuthAuth) =>
applyDiscoveryToModels(provider.models, rememberDiscovery(pickModelInfo(await listModels(auth.access))))
const current = ctx.auth
let auth = current
try {
if (isExpiring(auth)) auth = await refreshAuth(auth)
return await discover(auth)
} catch (error) {
if (auth !== current || (error as { status?: number }).status !== 401) return provider.models
}
try {
return await discover(await refreshAuth(current))
} catch {
return provider.models
}
},
},
auth: {
provider: PROVIDER_ID,
@@ -219,15 +305,14 @@ const plugin: Plugin = async ({ client }) => {
* this loader callback. Writes still go through `client.auth.set`.
*/
loader: async (readAuth) => {
let discovery: ModelDiscovery = {}
let discovery: ModelDiscovery = cachedDiscovery
const discoverModelInfo = async (access: string): Promise<ModelDiscovery> => {
// opencode's SDK auth schema only persists the standard oauth fields
// (`refresh`/`access`/`expires`) on `client.auth.set`, so discovery
// cannot live durably in auth.json across refresh writes. Cache it in
// this loader instance instead, and repopulate lazily on startup.
const next = pickModelInfo(await listModels(access))
if (next.model_id) discovery = next
discovery = rememberDiscovery(pickModelInfo(await listModels(access)))
return discovery
}
@@ -238,6 +323,7 @@ const plugin: Plugin = async ({ client }) => {
context_length: auth.context_length,
model_display: auth.model_display,
}
cachedDiscovery = discovery
}
if (discovery.model_id) return { ...auth, ...discovery }
try {
@@ -254,7 +340,7 @@ const plugin: Plugin = async ({ client }) => {
"kimi-for-coding-oauth: not logged in — run `opencode auth login kimi-for-coding-oauth`",
)
if (!force && !isExpiring(current)) return ensureDiscovered(current)
const tokens = await refreshToken(current.refresh)
const next = await refreshAuth(current)
// kimi-cli re-runs `refresh_managed_models` on every successful
// refresh — we mirror that so entitlement changes (e.g. an
// account gaining/losing K2.6 access) are picked up without a
@@ -262,17 +348,10 @@ const plugin: Plugin = async ({ client }) => {
// warm in-memory discovery still works for the common case, and
// the request-path 401 retry will flush a broken access token.
try {
await discoverModelInfo(tokens.access_token)
await discoverModelInfo(next.access)
} catch {
/* keep previous discovery */
}
const next: OAuthAuth = {
type: "oauth",
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + tokens.expires_in * 1000,
}
await persistAuth(next)
return { ...next, ...discovery }
}
+3 -1
View File
@@ -199,7 +199,9 @@ export async function listModels(accessToken: string): Promise<KimiModelInfo[]>
})
const text = await res.text()
if (!res.ok) {
throw new Error(`kimi list-models ${res.status}: ${text.slice(0, 200)}`)
const err = new Error(`kimi list-models ${res.status}: ${text.slice(0, 200)}`) as Error & { status?: number }
err.status = res.status
throw err
}
let json: any
try {
+109
View File
@@ -239,6 +239,86 @@ async function getLoaderFetch(readAuth: () => Promise<unknown>) {
return { fetch: (res as { fetch: typeof fetch }).fetch, apiKey: (res as { apiKey: string }).apiKey, writes }
}
function makeProviderState(context = 0) {
return {
id: PROVIDER_ID,
models: {
[MODEL_ID]: {
name: "Kimi For Coding",
reasoning: true,
options: {},
limit: { context },
variants: {
auto: { reasoning_effort: "auto" },
},
},
"some-other-model": {
name: "Other",
reasoning: false,
options: {},
limit: { context: 1234 },
},
},
}
}
// ---------- provider.models -------------------------------------------------
test("provider.models: fills limit.context from discovery when config still has zero", async () => {
mock = installFetchMock((call) => {
if (call.url.endsWith("/coding/v1/models")) {
return { body: { data: [{ id: MODEL_ID, context_length: 262144 }] } }
}
return { body: { ok: true } }
})
const { hooks, writes } = await getHooks()
const provider = makeProviderState()
const next = await hooks.provider!.models!(provider as any, { auth: validAuth() } as any)
expect(next[MODEL_ID]!.limit?.context).toBe(262144)
expect(next["some-other-model"]!.limit?.context).toBe(1234)
expect(provider.models[MODEL_ID]!.limit?.context).toBe(0)
expect(writes).toHaveLength(0)
})
test("provider.models: preserves an explicit user context limit", async () => {
mock = installFetchMock((call) => {
if (call.url.endsWith("/coding/v1/models")) {
return { body: { data: [{ id: MODEL_ID, context_length: 262144 }] } }
}
return { body: { ok: true } }
})
const { hooks } = await getHooks()
const provider = makeProviderState(8192)
const next = await hooks.provider!.models!(provider as any, { auth: validAuth() } as any)
expect(next[MODEL_ID]!.limit?.context).toBe(8192)
})
test("provider.models: retries once with a refreshed token after 401", async () => {
mock = installFetchMock((call) => {
if (call.url.endsWith("/coding/v1/models") && call.headers["authorization"] === "Bearer stale") {
return { status: 401, body: { error: "unauthorized" } }
}
if (call.url.includes("/oauth/token")) {
return { body: { access_token: "fresh", refresh_token: "refresh-2", token_type: "Bearer", expires_in: 900 } }
}
if (call.url.endsWith("/coding/v1/models") && call.headers["authorization"] === "Bearer fresh") {
return { body: { data: [{ id: MODEL_ID, context_length: 131072 }] } }
}
return { body: { ok: true } }
})
const { hooks, writes } = await getHooks()
const provider = makeProviderState()
const next = await hooks.provider!.models!(provider as any, { auth: validAuth({ access: "stale" }) } as any)
expect(mock.calls.map((c) => c.url)).toEqual([
"https://api.kimi.com/coding/v1/models",
"https://auth.kimi.com/api/oauth/token",
"https://api.kimi.com/coding/v1/models",
])
expect(mock.calls[2]!.headers["authorization"]).toBe("Bearer fresh")
expect(next[MODEL_ID]!.limit?.context).toBe(131072)
expect((writes[0]!.body as { access: string }).access).toBe("fresh")
})
test("auth.loader: refuses to run when no credentials are persisted", async () => {
const { fetch: f } = await getLoaderFetch(async () => undefined)
await expect(f("https://api.kimi.com/coding/v1/models")).rejects.toThrow(/not logged in/)
@@ -337,6 +417,35 @@ test("auth.loader: injects selected reasoning_effort from private headers into t
})
})
test("auth.loader: effort=auto injects only prompt_cache_key and never synthesizes temperature", async () => {
const { hooks } = await getHooks()
const { output: headerOutput } = await callHeaders(hooks["chat.headers"]!, {
sessionID: "sess-auto",
variants: { auto: { reasoning_effort: "auto" } },
variant: "auto",
})
mock = installFetchMock((call) => {
if (call.url.endsWith("/coding/v1/models")) {
return { body: { data: [{ id: MODEL_ID, context_length: 262144 }] } }
}
return { body: { ok: true } }
})
const { fetch: f } = await getLoaderFetch(async () => validAuth())
await f("https://api.kimi.com/coding/v1/chat/completions", {
method: "POST",
headers: {
"content-type": "application/json",
...headerOutput.headers,
},
body: JSON.stringify({ model: MODEL_ID, messages: [] }),
})
expect(JSON.parse(mock.calls[1]!.body as string)).toEqual({
model: MODEL_ID,
messages: [],
prompt_cache_key: "sess-auto",
})
})
test("auth.loader: refreshes when expiry is within safety window", async () => {
let reads = 0
const initial = validAuth({ expires: Date.now() + REFRESH_SAFETY_WINDOW_MS / 2 })