From 7aeb8d36fa1119ca659a52781cc38839c0de0766 Mon Sep 17 00:00:00 2001 From: lemon07r Date: Sun, 19 Apr 2026 02:20:10 -0400 Subject: [PATCH] Add OAuth request timeouts and release checks --- .github/workflows/release.yml | 3 ++ AGENTS.md | 1 + package.json | 2 +- src/index.ts | 4 +-- src/oauth.ts | 8 ++++- test/_util/fetchMock.ts | 9 +++++- test/bun-test.d.ts | 5 +++ test/oauth.test.ts | 5 ++- test/plugin.test.ts | 59 ++++++++++++++++------------------- tsconfig.tests.json | 4 +++ 10 files changed, 62 insertions(+), 38 deletions(-) create mode 100644 test/bun-test.d.ts create mode 100644 tsconfig.tests.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09b30ed..a34e9f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,9 @@ jobs: - name: Type-check run: bunx tsc --noEmit + - name: Type-check tests + run: bunx tsc --noEmit --project tsconfig.tests.json + - name: Syntax check (no bundle) run: bun build --target=node --no-bundle src/index.ts > /dev/null diff --git a/AGENTS.md b/AGENTS.md index 0f9aaf3..81039cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,7 @@ Offline: ```sh bunx tsc --noEmit # type-check +bunx tsc --noEmit --project tsconfig.tests.json # type-check tests/helpers bun build --target=node --no-bundle src/index.ts # syntax check bun test # offline unit tests ``` diff --git a/package.json b/package.json index 2d64d05..c8d394e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-kimi-full", - "version": "1.2.5", + "version": "1.2.6", "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": { diff --git a/src/index.ts b/src/index.ts index 44afc4f..0994b99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises" import os from "node:os" import path from "node:path" import type { Plugin } from "@opencode-ai/plugin" -import { MODEL_ID, PROVIDER_ID, REFRESH_SAFETY_WINDOW_MS } from "./constants.ts" +import { API_BASE_URL, MODEL_ID, PROVIDER_ID, REFRESH_SAFETY_WINDOW_MS } from "./constants.ts" import { kimiHeaders } from "./headers.ts" import { type KimiModelInfo, listModels, pollDeviceToken, refreshToken, startDeviceAuth } from "./oauth.ts" @@ -291,7 +291,7 @@ function buildConfigBlock(info: { model_id: string; display?: string }) { [PROVIDER_ID]: { npm: "@ai-sdk/openai-compatible", name: "Kimi For Coding (OAuth)", - options: { baseURL: "https://api.kimi.com/coding/v1" }, + options: { baseURL: API_BASE_URL }, models: { [MODEL_ID]: { name, diff --git a/src/oauth.ts b/src/oauth.ts index 63a68db..49fab84 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -27,6 +27,9 @@ export type TokenResponse = { const REFRESH_RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]) const REFRESH_MAX_RETRIES = 3 +// Mirror kimi-cli's default aiohttp session timeout +// (research/kimi-cli/src/kimi_cli/utils/aiohttp.py). +const REQUEST_TIMEOUT_MS = 120_000 function formBody(params: Record): string { return new URLSearchParams(params).toString() @@ -41,6 +44,7 @@ async function postForm(url: string, params: Record): Promise Accept: "application/json", }, body: formBody(params), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }) const text = await res.text() let json: any @@ -73,7 +77,7 @@ export async function startDeviceAuth(): Promise { * and `slow_down` per RFC 8628. */ export async function pollDeviceToken(device: DeviceAuth): Promise { - let interval = Math.max(1, device.interval || 5) * 1000 + let interval = Math.max(1, device.interval ?? 5) * 1000 const deadline = Date.now() + device.expires_in * 1000 while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, interval)) @@ -113,6 +117,7 @@ export async function refreshToken(refresh: string): Promise { refresh_token: refresh, grant_type: OAUTH_REFRESH_GRANT, }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }) const text = await res.text() let json: any = {} @@ -196,6 +201,7 @@ export async function listModels(accessToken: string): Promise Authorization: `Bearer ${accessToken}`, Accept: "application/json", }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }) const text = await res.text() if (!res.ok) { diff --git a/test/_util/fetchMock.ts b/test/_util/fetchMock.ts index b77f37a..3e10c5b 100644 --- a/test/_util/fetchMock.ts +++ b/test/_util/fetchMock.ts @@ -7,6 +7,7 @@ export type FetchCall = { method: string headers: Record body: string | undefined + hasSignal: boolean } type MaybePromise = T | Promise @@ -41,7 +42,13 @@ export function installFetchMock(responder: Responder) { : init?.body == null ? undefined : String(init.body) - const call: FetchCall = { url, method: (init?.method ?? request?.method ?? "GET").toUpperCase(), headers, body } + const call: FetchCall = { + url, + method: (init?.method ?? request?.method ?? "GET").toUpperCase(), + headers, + body, + hasSignal: init?.signal != null || request?.signal != null, + } calls.push(call) const r = await responder(call, calls.length - 1) const status = r.status ?? 200 diff --git a/test/bun-test.d.ts b/test/bun-test.d.ts new file mode 100644 index 0000000..e51ef6c --- /dev/null +++ b/test/bun-test.d.ts @@ -0,0 +1,5 @@ +declare module "bun:test" { + export const test: (...args: any[]) => any + export const expect: any + export const afterEach: (...args: any[]) => any +} diff --git a/test/oauth.test.ts b/test/oauth.test.ts index 67db11e..e3ac643 100644 --- a/test/oauth.test.ts +++ b/test/oauth.test.ts @@ -37,6 +37,7 @@ test("startDeviceAuth posts client_id+scope as form-encoded to the device endpoi const call = mock.calls[0]! expect(call.url).toBe(OAUTH_DEVICE_AUTH_URL) expect(call.method).toBe("POST") + expect(call.hasSignal).toBe(true) expect(call.headers["content-type"]).toBe("application/x-www-form-urlencoded") // Fingerprint headers must be present on every oauth call, not just chat. expect(call.headers["x-msh-version"]).toBeDefined() @@ -55,6 +56,7 @@ test("refreshToken posts grant_type=refresh_token and returns normalized shape", expect(t).toEqual({ access_token: "a2", refresh_token: "r2", token_type: "Bearer", expires_in: 900 }) const call = mock.calls[0]! expect(call.url).toBe(OAUTH_TOKEN_URL) + expect(call.hasSignal).toBe(true) expect(parseForm(call.body)).toEqual({ client_id: OAUTH_CLIENT_ID, refresh_token: "r1", @@ -86,7 +88,7 @@ test("refreshToken throws a clear error when a non-retryable response is non-JSO }) test("pollDeviceToken honors authorization_pending and returns on approval", async () => { - // pollDeviceToken clamps with `device.interval || 5` then max(1, …)*1000, + // pollDeviceToken clamps with `device.interval ?? 5` then max(1, …)*1000, // so the effective poll wait is max(1, interval) seconds. Use interval=1 // and a single pending cycle to keep the test ~2s. const device = { @@ -103,6 +105,7 @@ test("pollDeviceToken honors authorization_pending and returns on approval", asy const t = await pollDeviceToken(device) expect(t.access_token).toBe("A") expect(mock.calls).toHaveLength(2) + expect(mock.calls[0]!.hasSignal).toBe(true) // Sends device_code + the RFC 8628 grant type. expect(parseForm(mock.calls[0]!.body)).toEqual({ client_id: OAUTH_CLIENT_ID, diff --git a/test/plugin.test.ts b/test/plugin.test.ts index fb233bd..2e32277 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -80,25 +80,11 @@ 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`, `.id`, `.options`, `.variants`; the selected variant -// rides on `message.model.variant`). -type HookInput = { - agent: string - provider: { id: string } - model: { - providerID: string - id: string - options?: Record - variants?: Record> - } - message: { model: { variant?: string } } - sessionID: string -} -type ParamsOutput = { options: Record } -type HeadersOutput = { headers: Record } +type Hooks = Awaited> +type ChatParamsHook = NonNullable +type ChatHeadersHook = NonNullable +type ParamsOutput = Parameters[1] +type HeadersOutput = Parameters[1] type HookInputOptions = { providerID?: string @@ -109,7 +95,7 @@ type HookInputOptions = { variant?: string } -function makeHookInput(options: HookInputOptions = {}): HookInput { +function makeHookInput(options: HookInputOptions = {}) { const providerID = options.providerID ?? PROVIDER_ID return { agent: "test-agent", @@ -129,28 +115,36 @@ function makeHookInput(options: HookInputOptions = {}): HookInput { } } -function callParams( - hook: (i: HookInput, o: ParamsOutput) => Promise | void, +async function callParams( + hook: ChatParamsHook, input: HookInputOptions = {}, options: Record = {}, ) { - const output: ParamsOutput = { options: { ...options } } - const res = hook(makeHookInput(input), output) - return { res, output } + const output: ParamsOutput = { + temperature: 0, + topP: 1, + topK: 0, + maxOutputTokens: undefined, + options: { ...options }, + } + await hook(makeHookInput(input) as any, output) + return { output } } -function callHeaders(hook: (i: HookInput, o: HeadersOutput) => Promise | void, input: HookInputOptions = {}) { +async function callHeaders(hook: ChatHeadersHook, input: HookInputOptions = {}) { const output: HeadersOutput = { headers: {} } - const res = hook(makeHookInput(input), output) - return { res, output } + await hook(makeHookInput(input) as any, output) + return { 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, { providerID: "some-other-provider", modelOptions: { reasoning_effort: "high" } }, { - reasoning_effort: "high", - }) + const { output } = await 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" }) }) @@ -158,7 +152,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, { modelID: "kimi-something-else" }) + const { output } = await callParams(hook, { modelID: "kimi-something-else" }) expect(output.options.prompt_cache_key).toBeUndefined() expect(output.options.thinking).toBeUndefined() }) @@ -320,6 +314,7 @@ test("provider.models: fills limit.context from discovery when config still has const { hooks, writes } = await getHooks() const provider = makeProviderState() const next = await hooks.provider!.models!(provider as any, { auth: validAuth() } as any) + expect(mock.calls[0]!.hasSignal).toBe(true) 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) diff --git a/tsconfig.tests.json b/tsconfig.tests.json new file mode 100644 index 0000000..3104f33 --- /dev/null +++ b/tsconfig.tests.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts", "test/**/*.ts", "test/**/*.d.ts"] +}