Skip to content

Commit 7aeb8d3

Browse files
committed
Add OAuth request timeouts and release checks
1 parent efca301 commit 7aeb8d3

10 files changed

Lines changed: 62 additions & 38 deletions

File tree

.github/workflows/release.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ jobs:
4545
- name: Type-check
4646
run: bunx tsc --noEmit
4747

48+
- name: Type-check tests
49+
run: bunx tsc --noEmit --project tsconfig.tests.json
50+
4851
- name: Syntax check (no bundle)
4952
run: bun build --target=node --no-bundle src/index.ts > /dev/null
5053

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ Offline:
9999

100100
```sh
101101
bunx tsc --noEmit # type-check
102+
bunx tsc --noEmit --project tsconfig.tests.json # type-check tests/helpers
102103
bun build --target=node --no-bundle src/index.ts # syntax check
103104
bun test # offline unit tests
104105
```

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opencode-kimi-full",
3-
"version": "1.2.5",
3+
"version": "1.2.6",
44
"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.",
55
"license": "MIT",
66
"repository": {

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from "node:fs/promises"
22
import os from "node:os"
33
import path from "node:path"
44
import type { Plugin } from "@opencode-ai/plugin"
5-
import { MODEL_ID, PROVIDER_ID, REFRESH_SAFETY_WINDOW_MS } from "./constants.ts"
5+
import { API_BASE_URL, MODEL_ID, PROVIDER_ID, REFRESH_SAFETY_WINDOW_MS } from "./constants.ts"
66
import { kimiHeaders } from "./headers.ts"
77
import { type KimiModelInfo, listModels, pollDeviceToken, refreshToken, startDeviceAuth } from "./oauth.ts"
88

@@ -291,7 +291,7 @@ function buildConfigBlock(info: { model_id: string; display?: string }) {
291291
[PROVIDER_ID]: {
292292
npm: "@ai-sdk/openai-compatible",
293293
name: "Kimi For Coding (OAuth)",
294-
options: { baseURL: "https://api.kimi.com/coding/v1" },
294+
options: { baseURL: API_BASE_URL },
295295
models: {
296296
[MODEL_ID]: {
297297
name,

src/oauth.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export type TokenResponse = {
2727

2828
const REFRESH_RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504])
2929
const REFRESH_MAX_RETRIES = 3
30+
// Mirror kimi-cli's default aiohttp session timeout
31+
// (research/kimi-cli/src/kimi_cli/utils/aiohttp.py).
32+
const REQUEST_TIMEOUT_MS = 120_000
3033

3134
function formBody(params: Record<string, string>): string {
3235
return new URLSearchParams(params).toString()
@@ -41,6 +44,7 @@ async function postForm<T>(url: string, params: Record<string, string>): Promise
4144
Accept: "application/json",
4245
},
4346
body: formBody(params),
47+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
4448
})
4549
const text = await res.text()
4650
let json: any
@@ -73,7 +77,7 @@ export async function startDeviceAuth(): Promise<DeviceAuth> {
7377
* and `slow_down` per RFC 8628.
7478
*/
7579
export async function pollDeviceToken(device: DeviceAuth): Promise<TokenResponse> {
76-
let interval = Math.max(1, device.interval || 5) * 1000
80+
let interval = Math.max(1, device.interval ?? 5) * 1000
7781
const deadline = Date.now() + device.expires_in * 1000
7882
while (Date.now() < deadline) {
7983
await new Promise((r) => setTimeout(r, interval))
@@ -113,6 +117,7 @@ export async function refreshToken(refresh: string): Promise<TokenResponse> {
113117
refresh_token: refresh,
114118
grant_type: OAUTH_REFRESH_GRANT,
115119
}),
120+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
116121
})
117122
const text = await res.text()
118123
let json: any = {}
@@ -196,6 +201,7 @@ export async function listModels(accessToken: string): Promise<KimiModelInfo[]>
196201
Authorization: `Bearer ${accessToken}`,
197202
Accept: "application/json",
198203
},
204+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
199205
})
200206
const text = await res.text()
201207
if (!res.ok) {

test/_util/fetchMock.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export type FetchCall = {
77
method: string
88
headers: Record<string, string>
99
body: string | undefined
10+
hasSignal: boolean
1011
}
1112

1213
type MaybePromise<T> = T | Promise<T>
@@ -41,7 +42,13 @@ export function installFetchMock(responder: Responder) {
4142
: init?.body == null
4243
? undefined
4344
: String(init.body)
44-
const call: FetchCall = { url, method: (init?.method ?? request?.method ?? "GET").toUpperCase(), headers, body }
45+
const call: FetchCall = {
46+
url,
47+
method: (init?.method ?? request?.method ?? "GET").toUpperCase(),
48+
headers,
49+
body,
50+
hasSignal: init?.signal != null || request?.signal != null,
51+
}
4552
calls.push(call)
4653
const r = await responder(call, calls.length - 1)
4754
const status = r.status ?? 200

test/bun-test.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
declare module "bun:test" {
2+
export const test: (...args: any[]) => any
3+
export const expect: any
4+
export const afterEach: (...args: any[]) => any
5+
}

test/oauth.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ test("startDeviceAuth posts client_id+scope as form-encoded to the device endpoi
3737
const call = mock.calls[0]!
3838
expect(call.url).toBe(OAUTH_DEVICE_AUTH_URL)
3939
expect(call.method).toBe("POST")
40+
expect(call.hasSignal).toBe(true)
4041
expect(call.headers["content-type"]).toBe("application/x-www-form-urlencoded")
4142
// Fingerprint headers must be present on every oauth call, not just chat.
4243
expect(call.headers["x-msh-version"]).toBeDefined()
@@ -55,6 +56,7 @@ test("refreshToken posts grant_type=refresh_token and returns normalized shape",
5556
expect(t).toEqual({ access_token: "a2", refresh_token: "r2", token_type: "Bearer", expires_in: 900 })
5657
const call = mock.calls[0]!
5758
expect(call.url).toBe(OAUTH_TOKEN_URL)
59+
expect(call.hasSignal).toBe(true)
5860
expect(parseForm(call.body)).toEqual({
5961
client_id: OAUTH_CLIENT_ID,
6062
refresh_token: "r1",
@@ -86,7 +88,7 @@ test("refreshToken throws a clear error when a non-retryable response is non-JSO
8688
})
8789

8890
test("pollDeviceToken honors authorization_pending and returns on approval", async () => {
89-
// pollDeviceToken clamps with `device.interval || 5` then max(1, …)*1000,
91+
// pollDeviceToken clamps with `device.interval ?? 5` then max(1, …)*1000,
9092
// so the effective poll wait is max(1, interval) seconds. Use interval=1
9193
// and a single pending cycle to keep the test ~2s.
9294
const device = {
@@ -103,6 +105,7 @@ test("pollDeviceToken honors authorization_pending and returns on approval", asy
103105
const t = await pollDeviceToken(device)
104106
expect(t.access_token).toBe("A")
105107
expect(mock.calls).toHaveLength(2)
108+
expect(mock.calls[0]!.hasSignal).toBe(true)
106109
// Sends device_code + the RFC 8628 grant type.
107110
expect(parseForm(mock.calls[0]!.body)).toEqual({
108111
client_id: OAUTH_CLIENT_ID,

test/plugin.test.ts

Lines changed: 27 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -80,25 +80,11 @@ const INTERNAL_PROMPT_CACHE_KEY_HEADER = "x-opencode-kimi-prompt-cache-key"
8080
const INTERNAL_REASONING_EFFORT_HEADER = "x-opencode-kimi-reasoning-effort"
8181
const INTERNAL_THINKING_TYPE_HEADER = "x-opencode-kimi-thinking-type"
8282

83-
// Minimal shape for the chat hook input/output we care about. Mirrors the
84-
// runtime shape opencode actually passes (see
85-
// research/opencode/packages/opencode/src/session/llm.ts::stream — `model`
86-
// has `.providerID`, `.id`, `.options`, `.variants`; the selected variant
87-
// rides on `message.model.variant`).
88-
type HookInput = {
89-
agent: string
90-
provider: { id: string }
91-
model: {
92-
providerID: string
93-
id: string
94-
options?: Record<string, unknown>
95-
variants?: Record<string, Record<string, unknown>>
96-
}
97-
message: { model: { variant?: string } }
98-
sessionID: string
99-
}
100-
type ParamsOutput = { options: Record<string, unknown> }
101-
type HeadersOutput = { headers: Record<string, string> }
83+
type Hooks = Awaited<ReturnType<typeof plugin>>
84+
type ChatParamsHook = NonNullable<Hooks["chat.params"]>
85+
type ChatHeadersHook = NonNullable<Hooks["chat.headers"]>
86+
type ParamsOutput = Parameters<ChatParamsHook>[1]
87+
type HeadersOutput = Parameters<ChatHeadersHook>[1]
10288

10389
type HookInputOptions = {
10490
providerID?: string
@@ -109,7 +95,7 @@ type HookInputOptions = {
10995
variant?: string
11096
}
11197

112-
function makeHookInput(options: HookInputOptions = {}): HookInput {
98+
function makeHookInput(options: HookInputOptions = {}) {
11399
const providerID = options.providerID ?? PROVIDER_ID
114100
return {
115101
agent: "test-agent",
@@ -129,36 +115,44 @@ function makeHookInput(options: HookInputOptions = {}): HookInput {
129115
}
130116
}
131117

132-
function callParams(
133-
hook: (i: HookInput, o: ParamsOutput) => Promise<void> | void,
118+
async function callParams(
119+
hook: ChatParamsHook,
134120
input: HookInputOptions = {},
135121
options: Record<string, unknown> = {},
136122
) {
137-
const output: ParamsOutput = { options: { ...options } }
138-
const res = hook(makeHookInput(input), output)
139-
return { res, output }
123+
const output: ParamsOutput = {
124+
temperature: 0,
125+
topP: 1,
126+
topK: 0,
127+
maxOutputTokens: undefined,
128+
options: { ...options },
129+
}
130+
await hook(makeHookInput(input) as any, output)
131+
return { output }
140132
}
141133

142-
function callHeaders(hook: (i: HookInput, o: HeadersOutput) => Promise<void> | void, input: HookInputOptions = {}) {
134+
async function callHeaders(hook: ChatHeadersHook, input: HookInputOptions = {}) {
143135
const output: HeadersOutput = { headers: {} }
144-
const res = hook(makeHookInput(input), output)
145-
return { res, output }
136+
await hook(makeHookInput(input) as any, output)
137+
return { output }
146138
}
147139

148140
test("chat.params: no-op for other providers (AGENTS.md rule: gated on PROVIDER_ID)", async () => {
149141
const { hooks } = await getHooks()
150142
const hook = hooks["chat.params"]!
151-
const { output } = callParams(hook, { providerID: "some-other-provider", modelOptions: { reasoning_effort: "high" } }, {
152-
reasoning_effort: "high",
153-
})
143+
const { output } = await callParams(
144+
hook,
145+
{ providerID: "some-other-provider", modelOptions: { reasoning_effort: "high" } },
146+
{ reasoning_effort: "high" },
147+
)
154148
// Untouched — no prompt_cache_key, no thinking added.
155149
expect(output.options).toEqual({ reasoning_effort: "high" })
156150
})
157151

158152
test("chat.params: no-op for other models under our provider (rule 5 gating)", async () => {
159153
const { hooks } = await getHooks()
160154
const hook = hooks["chat.params"]!
161-
const { output } = callParams(hook, { modelID: "kimi-something-else" })
155+
const { output } = await callParams(hook, { modelID: "kimi-something-else" })
162156
expect(output.options.prompt_cache_key).toBeUndefined()
163157
expect(output.options.thinking).toBeUndefined()
164158
})
@@ -320,6 +314,7 @@ test("provider.models: fills limit.context from discovery when config still has
320314
const { hooks, writes } = await getHooks()
321315
const provider = makeProviderState()
322316
const next = await hooks.provider!.models!(provider as any, { auth: validAuth() } as any)
317+
expect(mock.calls[0]!.hasSignal).toBe(true)
323318
expect(next[MODEL_ID]!.limit?.context).toBe(262144)
324319
expect(next["some-other-model"]!.limit?.context).toBe(1234)
325320
expect(provider.models[MODEL_ID]!.limit?.context).toBe(0)

tsconfig.tests.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"include": ["src/**/*.ts", "test/**/*.ts", "test/**/*.d.ts"]
4+
}

0 commit comments

Comments
 (0)