mirror of
https://github.com/lemon07r/opencode-kimi-full.git
synced 2026-07-18 08:05:52 +02:00
64550fb057
Adds one test file per source file plus a shared fetch mock helper: - test/constants.test.ts: OAuth scope/client_id/PROVIDER_ID/MODEL_ID pins, rules 1, 6, 8. - test/headers.test.ts: seven fingerprint header keys, UA/X-Msh-Version track KIMI_CLI_VERSION, ASCII-only values, device-id format and stability across calls (rules 1, 2). - test/oauth.test.ts: form-encoded bodies for device-start/refresh, authorization_pending/expired_token/unknown-error branches, non-OK and non-JSON error shapes. - test/plugin.test.ts: chat.params provider+model gating, full effort matrix (rule 4), prompt_cache_key gating (rule 5), camelCase effort input, loader Authorization ownership (rule 3), refresh-on-expiry with persistAuth, 401 single-retry no-loop, device-flow authorize wiring. - test/_util/fetchMock.ts: zero-dep global-fetch swap returning canned Responses with call capture. Notes: - Tests use the real ~/.kimi/device_id; getDeviceId is idempotent and the file is shared with kimi-cli by design (AGENTS.md rule 2), so no HOME redirect is needed. Attempted monkey-patching os.homedir instead is fragile under Bun's eager import-binding resolution. - pollDeviceToken tests use interval=1 because `device.interval || 5` treats 0 as the default 5s and would push each iteration past the default bun-test timeout. Total: 35 tests, ~5s to run.
247 lines
10 KiB
TypeScript
247 lines
10 KiB
TypeScript
import { test, expect, afterEach } from "bun:test"
|
|
import plugin from "../src/index.ts"
|
|
import { MODEL_ID, PROVIDER_ID, REFRESH_SAFETY_WINDOW_MS } from "../src/constants.ts"
|
|
import { installFetchMock } from "./_util/fetchMock.ts"
|
|
|
|
// kimiHeaders() → getDeviceId() reads/writes ~/.kimi/device_id; that file is
|
|
// shared with kimi-cli by design and writes are idempotent — no HOME
|
|
// redirect needed.
|
|
|
|
let mock: ReturnType<typeof installFetchMock> | undefined
|
|
afterEach(() => {
|
|
mock?.restore()
|
|
mock = undefined
|
|
})
|
|
|
|
// Fake opencode plugin context. Only `client.auth.set` is used by the
|
|
// plugin's writes; reads go through the `readAuth` callback passed to
|
|
// `loader`, not through client.
|
|
function makeContext() {
|
|
const writes: Array<{ id: string; body: unknown }> = []
|
|
return {
|
|
writes,
|
|
ctx: {
|
|
client: {
|
|
auth: {
|
|
set: async ({ path, body }: { path: { id: string }; body: unknown }) => {
|
|
writes.push({ id: path.id, body })
|
|
},
|
|
},
|
|
},
|
|
} as unknown as Parameters<typeof plugin>[0],
|
|
}
|
|
}
|
|
|
|
async function getHooks() {
|
|
const { ctx, writes } = makeContext()
|
|
const hooks = await plugin(ctx)
|
|
return { hooks, writes }
|
|
}
|
|
|
|
// ---------- chat.params -----------------------------------------------------
|
|
|
|
// Minimal shape for input/output we care about in the params hook.
|
|
type ParamsInput = { provider: { info: { id: string } }; model: { id: string }; sessionID: string }
|
|
type ParamsOutput = { options: Record<string, unknown> }
|
|
function callParams(
|
|
hook: (i: ParamsInput, o: ParamsOutput) => Promise<void> | void,
|
|
providerID: string,
|
|
modelID: string,
|
|
options: Record<string, unknown> = {},
|
|
sessionID = "sess-1",
|
|
) {
|
|
const output: ParamsOutput = { options: { ...options } }
|
|
const res = hook({ provider: { info: { id: providerID } }, model: { id: modelID }, sessionID }, 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" })
|
|
// Untouched — no prompt_cache_key, no thinking added.
|
|
expect(output.options).toEqual({ reasoning_effort: "high" })
|
|
})
|
|
|
|
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")
|
|
expect(output.options.prompt_cache_key).toBeUndefined()
|
|
expect(output.options.thinking).toBeUndefined()
|
|
})
|
|
|
|
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")
|
|
expect(output.options.prompt_cache_key).toBe("sess-42")
|
|
})
|
|
|
|
// The effort matrix is the most load-bearing contract in AGENTS.md → rule 4.
|
|
// Off → thinking disabled, reasoning_effort stripped.
|
|
// low/medium/high → reasoning_effort kept, thinking enabled.
|
|
// unset → thinking enabled, no reasoning_effort (server-picks default).
|
|
const EFFORT_MATRIX: Array<{
|
|
in: Record<string, unknown>
|
|
effort: string | undefined
|
|
thinkingType: "enabled" | "disabled"
|
|
}> = [
|
|
{ in: { reasoning_effort: "off" }, effort: undefined, thinkingType: "disabled" },
|
|
{ in: { reasoning_effort: "low" }, effort: "low", thinkingType: "enabled" },
|
|
{ in: { reasoning_effort: "medium" }, effort: "medium", thinkingType: "enabled" },
|
|
{ in: { reasoning_effort: "high" }, effort: "high", thinkingType: "enabled" },
|
|
{ in: {}, effort: undefined, thinkingType: "enabled" },
|
|
]
|
|
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)
|
|
expect(output.options.reasoning_effort).toBe(row.effort)
|
|
expect(output.options.thinking).toEqual({ type: row.thinkingType })
|
|
})
|
|
}
|
|
|
|
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" })
|
|
expect(output.options.thinking).toEqual({ type: "disabled" })
|
|
expect(output.options.reasoning_effort).toBeUndefined()
|
|
expect(output.options.reasoningEffort).toBeUndefined()
|
|
})
|
|
|
|
// ---------- auth.loader -----------------------------------------------------
|
|
|
|
function jwt() {
|
|
return "header.payload.sig"
|
|
}
|
|
function validAuth(overrides: Partial<{ access: string; refresh: string; expires: number }> = {}) {
|
|
return {
|
|
type: "oauth" as const,
|
|
access: overrides.access ?? "access-1",
|
|
refresh: overrides.refresh ?? "refresh-1",
|
|
// Far enough in the future to skip the refresh-on-expiry path.
|
|
expires: overrides.expires ?? Date.now() + 10 * 60_000,
|
|
}
|
|
}
|
|
|
|
async function getLoaderFetch(readAuth: () => Promise<unknown>) {
|
|
const { hooks, writes } = await getHooks()
|
|
const res = await hooks.auth!.loader!(readAuth as any, {} as any)
|
|
return { fetch: (res as { fetch: typeof fetch }).fetch, apiKey: (res as { apiKey: string }).apiKey, writes }
|
|
}
|
|
|
|
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/)
|
|
})
|
|
|
|
test("auth.loader: apiKey sentinel is returned (opencode requires truthy)", async () => {
|
|
const { apiKey } = await getLoaderFetch(async () => validAuth())
|
|
expect(apiKey).toBe("kimi-for-coding-oauth")
|
|
})
|
|
|
|
test("auth.loader: owns Authorization and strips any caller-supplied value (rule 3)", async () => {
|
|
mock = installFetchMock(() => ({ body: { ok: true } }))
|
|
const { fetch: f } = await getLoaderFetch(async () => validAuth({ access: jwt() }))
|
|
await f("https://api.kimi.com/coding/v1/chat", {
|
|
method: "POST",
|
|
headers: { Authorization: "Bearer SHOULD-BE-OVERRIDDEN", authorization: "lower-also" },
|
|
body: JSON.stringify({}),
|
|
})
|
|
expect(mock.calls).toHaveLength(1)
|
|
const h = mock.calls[0]!.headers
|
|
expect(h["authorization"]).toBe(`Bearer ${jwt()}`)
|
|
// Seven kimi-cli fingerprint headers are attached on every request.
|
|
expect(h["x-msh-platform"]).toBe("kimi_cli")
|
|
expect(h["x-msh-version"]).toBeDefined()
|
|
expect(h["x-msh-device-id"]).toMatch(/^[0-9a-f]{32}$/)
|
|
})
|
|
|
|
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 })
|
|
let current: ReturnType<typeof validAuth> = initial
|
|
const readAuth = async () => {
|
|
reads++
|
|
return current
|
|
}
|
|
// First fetch call → token refresh, second → the actual request.
|
|
mock = installFetchMock((call) => {
|
|
if (call.url.includes("/oauth/token")) {
|
|
return { body: { access_token: "access-2", refresh_token: "refresh-2", token_type: "Bearer", expires_in: 900 } }
|
|
}
|
|
return { body: { ok: true } }
|
|
})
|
|
const { fetch: f, writes } = await getLoaderFetch(readAuth)
|
|
await f("https://api.kimi.com/coding/v1/chat")
|
|
// One refresh call + one API call.
|
|
expect(mock.calls.map((c) => c.url)).toEqual([
|
|
"https://auth.kimi.com/api/oauth/token",
|
|
"https://api.kimi.com/coding/v1/chat",
|
|
])
|
|
expect(mock.calls[1]!.headers["authorization"]).toBe("Bearer access-2")
|
|
// Persisted the refreshed token back to opencode's auth store.
|
|
expect(writes).toHaveLength(1)
|
|
expect(writes[0]!.id).toBe(PROVIDER_ID)
|
|
expect((writes[0]!.body as { access: string }).access).toBe("access-2")
|
|
expect(reads).toBeGreaterThan(0)
|
|
})
|
|
|
|
test("auth.loader: 401 triggers exactly one forced refresh + retry (no infinite loop)", async () => {
|
|
let current = validAuth({ access: "stale" })
|
|
const readAuth = async () => current
|
|
mock = installFetchMock((call) => {
|
|
if (call.url.includes("/oauth/token")) {
|
|
current = { ...current, access: "fresh" }
|
|
return { body: { access_token: "fresh", refresh_token: "refresh-2", token_type: "Bearer", expires_in: 900 } }
|
|
}
|
|
// First API call: stale → 401. Every subsequent API call: 401 as well.
|
|
// The loader must NOT loop; exactly one retry after refresh.
|
|
return { status: 401, body: { error: "unauthorized" } }
|
|
})
|
|
const { fetch: f } = await getLoaderFetch(readAuth)
|
|
const res = await f("https://api.kimi.com/coding/v1/chat")
|
|
expect(res.status).toBe(401)
|
|
const urls = mock.calls.map((c) => c.url)
|
|
// Expected order: stale call → refresh → retry with fresh token → STOP.
|
|
expect(urls).toEqual([
|
|
"https://api.kimi.com/coding/v1/chat",
|
|
"https://auth.kimi.com/api/oauth/token",
|
|
"https://api.kimi.com/coding/v1/chat",
|
|
])
|
|
expect(mock.calls[0]!.headers["authorization"]).toBe("Bearer stale")
|
|
expect(mock.calls[2]!.headers["authorization"]).toBe("Bearer fresh")
|
|
})
|
|
|
|
// ---------- auth.methods (device flow wiring) -------------------------------
|
|
|
|
test("auth.methods[0].authorize returns URL + instructions + async callback", async () => {
|
|
mock = installFetchMock((call) => {
|
|
if (call.url.includes("device_authorization")) {
|
|
return {
|
|
body: {
|
|
device_code: "dc",
|
|
user_code: "WXYZ-1234",
|
|
verification_uri: "https://auth.kimi.com/device",
|
|
verification_uri_complete: "https://auth.kimi.com/device?u=WXYZ-1234",
|
|
expires_in: 60,
|
|
interval: 1,
|
|
},
|
|
}
|
|
}
|
|
return { body: { access_token: "A", refresh_token: "R", token_type: "Bearer", expires_in: 900 } }
|
|
})
|
|
const { hooks } = await getHooks()
|
|
const method = hooks.auth!.methods![0] as { authorize: () => Promise<any> }
|
|
const r = await method.authorize()
|
|
expect(r.url).toBe("https://auth.kimi.com/device?u=WXYZ-1234")
|
|
expect(r.instructions).toContain("WXYZ-1234")
|
|
const cb = await r.callback()
|
|
expect(cb.type).toBe("success")
|
|
expect(cb.access).toBe("A")
|
|
expect(cb.refresh).toBe("R")
|
|
expect(typeof cb.expires).toBe("number")
|
|
})
|