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