diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 78c2026d..39e50067 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -20,6 +20,7 @@ - Added `contentText` for extracting joined text from message content ([#6840](https://github.com/earendil-works/pi/pull/6840) by [@xl0](https://github.com/xl0)). - Added a shared `uuidv7` utility for time-ordered identifiers ([#6834](https://github.com/earendil-works/pi/pull/6834) by [@xl0](https://github.com/xl0)). - Added optional usage metadata to tool result messages ([#6671](https://github.com/earendil-works/pi/pull/6671) by [@davidbrai](https://github.com/davidbrai)). +- Added Kimi Code subscription OAuth login (device authorization grant) for the `kimi-coding` provider, with token refresh and `KIMI_CODE_OAUTH_HOST`/`KIMI_OAUTH_HOST` host overrides. ### Changed diff --git a/packages/ai/src/auth/oauth/kimi-coding.ts b/packages/ai/src/auth/oauth/kimi-coding.ts new file mode 100644 index 00000000..93e7062d --- /dev/null +++ b/packages/ai/src/auth/oauth/kimi-coding.ts @@ -0,0 +1,302 @@ +/** + * Kimi Code (subscription) OAuth flow + * + * RFC 8628 device authorization grant against https://auth.kimi.com with JSON + * responses. The access token authenticates requests to + * https://api.kimi.com/coding as an `Authorization: Bearer` header. + */ + +import { getProviderEnvValue } from "../../utils/provider-env.ts"; +import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts"; +import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; + +const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"; +const DEFAULT_OAUTH_HOST = "https://auth.kimi.com"; +const DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60; +const DEFAULT_POLL_INTERVAL_SECONDS = 5; +const REQUEST_TIMEOUT_MS = 30 * 1000; +const REFRESH_MAX_RETRIES = 3; + +type DeviceAuthorization = { + deviceCode: string; + userCode: string; + verificationUri: string; + verificationUriComplete: string; + intervalSeconds: number; + expiresInSeconds: number; +}; + +type TokenResponse = { + access: string; + refresh: string; + expires: number; +}; + +function getOauthHost(): string { + const override = getProviderEnvValue("KIMI_CODE_OAUTH_HOST") || getProviderEnvValue("KIMI_OAUTH_HOST"); + return (override || DEFAULT_OAUTH_HOST).replace(/\/+$/, ""); +} + +function requestSignal(signal?: AbortSignal): AbortSignal { + return AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...(signal ? [signal] : [])]); +} + +function formUrlEncode(fields: Record): string { + return new URLSearchParams(fields).toString(); +} + +async function readJson(response: Response): Promise | null> { + try { + const json = await response.json(); + return json && typeof json === "object" ? (json as Record) : null; + } catch { + return null; + } +} + +/** The verification URI is opened in the user's browser; only http(s) URLs are trusted. */ +function trustedHttpUrl(value: unknown): string | null { + if (typeof value !== "string" || !value) return null; + try { + const url = new URL(value); + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + return url.href; + } catch { + return null; + } +} + +async function startDeviceAuthorization(oauthHost: string, signal?: AbortSignal): Promise { + const response = await fetch(`${oauthHost}/api/oauth/device_authorization`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: formUrlEncode({ client_id: CLIENT_ID }), + signal: requestSignal(signal), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Kimi Code device authorization failed with status ${response.status}${text ? `: ${text}` : ""}`); + } + + const json = await readJson(response); + const deviceCode = json?.device_code; + const userCode = json?.user_code; + const verificationUri = json?.verification_uri; + const verificationUriComplete = json?.verification_uri_complete; + if ( + typeof deviceCode !== "string" || + typeof userCode !== "string" || + typeof verificationUri !== "string" || + typeof verificationUriComplete !== "string" || + !trustedHttpUrl(verificationUriComplete) || + !trustedHttpUrl(verificationUri) + ) { + throw new Error(`Invalid Kimi Code device authorization response: ${JSON.stringify(json)}`); + } + + const interval = json?.interval; + const expiresIn = json?.expires_in; + return { + deviceCode, + userCode, + verificationUri, + verificationUriComplete, + intervalSeconds: + typeof interval === "number" && Number.isFinite(interval) && interval > 0 + ? interval + : DEFAULT_POLL_INTERVAL_SECONDS, + expiresInSeconds: + typeof expiresIn === "number" && Number.isFinite(expiresIn) && expiresIn > 0 + ? expiresIn + : DEVICE_CODE_TIMEOUT_SECONDS, + }; +} + +function parseTokenResponse(json: Record | null, operation: string): TokenResponse { + const accessToken = json?.access_token; + const refreshToken = json?.refresh_token; + const expiresIn = json?.expires_in; + if ( + typeof accessToken !== "string" || + !accessToken || + typeof refreshToken !== "string" || + !refreshToken || + typeof expiresIn !== "number" || + !Number.isFinite(expiresIn) || + expiresIn <= 0 + ) { + throw new Error(`Kimi Code token ${operation} response missing fields: ${JSON.stringify(json)}`); + } + return { + access: accessToken, + refresh: refreshToken, + expires: Date.now() + expiresIn * 1000, + }; +} + +async function pollForToken( + oauthHost: string, + device: DeviceAuthorization, + signal?: AbortSignal, +): Promise { + return pollOAuthDeviceCodeFlow({ + intervalSeconds: device.intervalSeconds, + expiresInSeconds: device.expiresInSeconds, + waitBeforeFirstPoll: true, + signal, + poll: async () => { + const response = await fetch(`${oauthHost}/api/oauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: formUrlEncode({ + client_id: CLIENT_ID, + device_code: device.deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + signal: requestSignal(signal), + }); + + if (response.status >= 500) { + const text = await response.text().catch(() => ""); + return { + status: "failed", + message: `Kimi Code device token request failed with status ${response.status}${text ? `: ${text}` : ""}`, + }; + } + + const json = await readJson(response); + if (response.ok && typeof json?.access_token === "string") { + try { + return { status: "complete", value: parseTokenResponse(json, "poll") }; + } catch (error) { + return { status: "failed", message: error instanceof Error ? error.message : String(error) }; + } + } + + const error = json?.error; + const description = typeof json?.error_description === "string" ? `: ${json.error_description}` : ""; + if (error === "authorization_pending") { + return { status: "pending" }; + } + if (error === "slow_down") { + const interval = json?.interval; + return { + status: "slow_down", + intervalSeconds: typeof interval === "number" && interval > 0 ? interval : undefined, + }; + } + if (error === "expired_token") { + return { status: "failed", message: "Kimi Code device authorization expired. Please restart login." }; + } + if (error === "access_denied") { + return { status: "failed", message: "Kimi Code login was denied." }; + } + return { + status: "failed", + message: `Kimi Code device token request failed (status ${response.status})${typeof error === "string" ? `: ${error}${description}` : ""}`, + }; + }, + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRetryableRefreshFailure(response: Response): boolean { + return response.status === 429 || response.status >= 500; +} + +async function refreshToken( + oauthHost: string, + refreshTokenValue: string, + signal?: AbortSignal, +): Promise { + let lastError: Error | undefined; + for (let attempt = 0; attempt <= REFRESH_MAX_RETRIES; attempt++) { + if (attempt > 0) { + await sleep(1000 * 2 ** (attempt - 1)); + } + if (signal?.aborted) { + throw new Error("Kimi Code token refresh aborted"); + } + + let response: Response; + try { + response = await fetch(`${oauthHost}/api/oauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: formUrlEncode({ + client_id: CLIENT_ID, + grant_type: "refresh_token", + refresh_token: refreshTokenValue, + }), + signal: requestSignal(signal), + }); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + continue; + } + + const json = await readJson(response); + if (response.ok) { + return parseTokenResponse(json, "refresh"); + } + + // Unauthorized: the stored credential is dead; Models clears it and prompts re-login. + if (response.status === 401 || response.status === 403 || json?.error === "invalid_grant") { + const description = typeof json?.error_description === "string" ? `: ${json.error_description}` : ""; + throw new Error(`Kimi Code token refresh unauthorized (status ${response.status})${description}`); + } + + if (isRetryableRefreshFailure(response) && attempt < REFRESH_MAX_RETRIES) { + lastError = new Error(`Kimi Code token refresh failed with status ${response.status}`); + continue; + } + + const text = JSON.stringify(json); + throw new Error(`Kimi Code token refresh failed with status ${response.status}${text ? `: ${text}` : ""}`); + } + + throw lastError ?? new Error("Kimi Code token refresh failed"); +} + +async function loginKimiCoding(interaction: AuthInteraction): Promise { + const oauthHost = getOauthHost(); + const device = await startDeviceAuthorization(oauthHost, interaction.signal); + interaction.notify({ + type: "device_code", + userCode: device.userCode, + verificationUri: device.verificationUriComplete, + intervalSeconds: device.intervalSeconds, + expiresInSeconds: device.expiresInSeconds, + }); + const token = await pollForToken(oauthHost, device, interaction.signal); + return { type: "oauth", access: token.access, refresh: token.refresh, expires: token.expires }; +} + +export const kimiCodingOAuth: OAuthAuth = { + name: "Kimi Code (subscription)", + loginLabel: "Sign in with Kimi Code", + + login: loginKimiCoding, + + refresh: async (credential, signal) => { + const token = await refreshToken(getOauthHost(), credential.refresh, signal); + return { type: "oauth", access: token.access, refresh: token.refresh, expires: token.expires }; + }, + + async toAuth(credential) { + return { headers: { Authorization: `Bearer ${credential.access}` } }; + }, +}; diff --git a/packages/ai/src/auth/oauth/load.ts b/packages/ai/src/auth/oauth/load.ts index ca1c87d2..011e93e1 100644 --- a/packages/ai/src/auth/oauth/load.ts +++ b/packages/ai/src/auth/oauth/load.ts @@ -15,6 +15,7 @@ type OAuthFlowLoaders = { anthropic: () => OAuthAuth | Promise; openaiCodex: () => OAuthAuth | Promise; githubCopilot: () => OAuthAuth | Promise; + kimiCoding: () => OAuthAuth | Promise; xai: () => OAuthAuth | Promise; radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise; }; @@ -41,6 +42,11 @@ export const loadGitHubCopilotOAuth = async (): Promise => { return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth; }; +export const loadKimiCodingOAuth = async (): Promise => { + if (bundledLoaders) return bundledLoaders.kimiCoding(); + return ((await importOAuthModule("./kimi-coding.ts")) as { kimiCodingOAuth: OAuthAuth }).kimiCodingOAuth; +}; + export const loadXaiOAuth = async (): Promise => { if (bundledLoaders) return bundledLoaders.xai(); return ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth; diff --git a/packages/ai/src/bun-oauth.ts b/packages/ai/src/bun-oauth.ts index 31cdec98..8c3be5be 100644 --- a/packages/ai/src/bun-oauth.ts +++ b/packages/ai/src/bun-oauth.ts @@ -1,5 +1,6 @@ import { anthropicOAuth } from "./auth/oauth/anthropic.ts"; import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts"; +import { kimiCodingOAuth } from "./auth/oauth/kimi-coding.ts"; import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts"; import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts"; import { createRadiusOAuth } from "./auth/oauth/radius.ts"; @@ -11,6 +12,7 @@ export function registerBunOAuthFlows(): void { anthropic: () => anthropicOAuth, openaiCodex: () => openaiCodexOAuth, githubCopilot: () => githubCopilotOAuth, + kimiCoding: () => kimiCodingOAuth, xai: () => xaiOAuth, radius: createRadiusOAuth, }); diff --git a/packages/ai/src/providers/kimi-coding.ts b/packages/ai/src/providers/kimi-coding.ts index 865ae28c..ecd87d56 100644 --- a/packages/ai/src/providers/kimi-coding.ts +++ b/packages/ai/src/providers/kimi-coding.ts @@ -1,5 +1,6 @@ import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts"; -import { envApiKeyAuth } from "../auth/helpers.ts"; +import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; +import { loadKimiCodingOAuth } from "../auth/oauth/load.ts"; import { createProvider, type Provider } from "../models.ts"; import { KIMI_CODING_MODELS } from "./kimi-coding.models.ts"; @@ -8,7 +9,14 @@ export function kimiCodingProvider(): Provider<"anthropic-messages"> { id: "kimi-coding", name: "Kimi For Coding", baseUrl: "https://api.kimi.com/coding", - auth: { apiKey: envApiKeyAuth("Kimi API key", ["KIMI_API_KEY"]) }, + auth: { + apiKey: envApiKeyAuth("Kimi API key", ["KIMI_API_KEY"]), + oauth: lazyOAuth({ + name: "Kimi Code (subscription)", + loginLabel: "Sign in with Kimi Code", + load: loadKimiCodingOAuth, + }), + }, models: Object.values(KIMI_CODING_MODELS), api: anthropicMessagesApi(), }); diff --git a/packages/ai/test/kimi-coding-oauth.test.ts b/packages/ai/test/kimi-coding-oauth.test.ts new file mode 100644 index 00000000..07c56360 --- /dev/null +++ b/packages/ai/test/kimi-coding-oauth.test.ts @@ -0,0 +1,260 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { kimiCodingOAuth } from "../src/auth/oauth/kimi-coding.ts"; +import type { AuthInteraction } from "../src/auth/types.ts"; + +const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"; +const OAUTH_HOST = "https://auth.kimi.com"; + +function jsonResponse(body: unknown, status: number = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function getUrl(input: unknown): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + if (input instanceof Request) return input.url; + throw new Error(`Unsupported fetch input: ${String(input)}`); +} + +function deviceAuthorizationResponse(overrides?: Record): Response { + return jsonResponse({ + user_code: "ABCD-1234", + device_code: "device-code-123", + verification_uri: "https://www.kimi.com/code", + verification_uri_complete: "https://www.kimi.com/code?user_code=ABCD-1234", + interval: 5, + expires_in: 600, + ...overrides, + }); +} + +function createInteraction(events: Array>): AuthInteraction { + return { + prompt: async () => { + throw new Error("Kimi Code login should not prompt"); + }, + notify: (event) => events.push(event), + }; +} + +describe("Kimi Code OAuth", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.useRealTimers(); + }); + + it("logs in with the device authorization flow", async () => { + vi.useFakeTimers(); + const startTime = new Date("2026-07-20T00:00:00Z"); + vi.setSystemTime(startTime); + + const events: Array> = []; + const pollResponses = [ + jsonResponse({ error: "authorization_pending" }, 400), + jsonResponse({ access_token: "access-token", refresh_token: "refresh-token", expires_in: 3600 }), + ]; + const pollTimes: number[] = []; + + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const url = getUrl(input); + if (url === `${OAUTH_HOST}/api/oauth/device_authorization`) { + expect(init?.method).toBe("POST"); + expect(init?.headers).toMatchObject({ + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }); + expect(new URLSearchParams(String(init?.body)).get("client_id")).toBe(CLIENT_ID); + return deviceAuthorizationResponse(); + } + if (url === `${OAUTH_HOST}/api/oauth/token`) { + pollTimes.push(Date.now()); + const params = new URLSearchParams(String(init?.body)); + expect(params.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:device_code"); + expect(params.get("client_id")).toBe(CLIENT_ID); + expect(params.get("device_code")).toBe("device-code-123"); + const response = pollResponses.shift(); + if (!response) throw new Error("Unexpected extra token poll"); + return response; + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + const credentialPromise = kimiCodingOAuth.login(createInteraction(events)); + for (let i = 0; i < 5 && events.length === 0; i++) { + await vi.advanceTimersByTimeAsync(0); + } + expect(events).toEqual([ + { + type: "device_code", + userCode: "ABCD-1234", + verificationUri: "https://www.kimi.com/code?user_code=ABCD-1234", + intervalSeconds: 5, + expiresInSeconds: 600, + }, + ]); + + // waitBeforeFirstPoll: first poll happens after the 5s interval. + await vi.advanceTimersByTimeAsync(4999); + expect(pollTimes).toEqual([]); + await vi.advanceTimersByTimeAsync(1); + expect(pollTimes).toEqual([startTime.getTime() + 5000]); + + await vi.advanceTimersByTimeAsync(5000); + await expect(credentialPromise).resolves.toEqual({ + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: startTime.getTime() + 10000 + 3600 * 1000, + }); + expect(pollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 10000]); + }); + + it("fails when the device code expires", async () => { + vi.useFakeTimers(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + if (url === `${OAUTH_HOST}/api/oauth/device_authorization`) { + return deviceAuthorizationResponse(); + } + if (url === `${OAUTH_HOST}/api/oauth/token`) { + return jsonResponse({ error: "expired_token" }, 400); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + const credentialPromise = kimiCodingOAuth.login(createInteraction([])); + const assertion = expect(credentialPromise).rejects.toThrow("expired"); + await vi.advanceTimersByTimeAsync(5000); + await assertion; + }); + + it("fails when the user denies the login", async () => { + vi.useFakeTimers(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + if (url === `${OAUTH_HOST}/api/oauth/device_authorization`) { + return deviceAuthorizationResponse(); + } + if (url === `${OAUTH_HOST}/api/oauth/token`) { + return jsonResponse({ error: "access_denied" }, 400); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + const credentialPromise = kimiCodingOAuth.login(createInteraction([])); + const assertion = expect(credentialPromise).rejects.toThrow("denied"); + await vi.advanceTimersByTimeAsync(5000); + await assertion; + }); + + it("honors the KIMI_CODE_OAUTH_HOST override", async () => { + vi.useFakeTimers(); + vi.stubEnv("KIMI_CODE_OAUTH_HOST", "https://auth.example.com/"); + + const urls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + urls.push(url); + if (url === "https://auth.example.com/api/oauth/device_authorization") { + return deviceAuthorizationResponse({ interval: 1 }); + } + if (url === "https://auth.example.com/api/oauth/token") { + return jsonResponse({ access_token: "a", refresh_token: "r", expires_in: 60 }); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + const credentialPromise = kimiCodingOAuth.login(createInteraction([])); + await vi.advanceTimersByTimeAsync(1000); + await expect(credentialPromise).resolves.toMatchObject({ access: "a", refresh: "r" }); + expect(urls).toEqual([ + "https://auth.example.com/api/oauth/device_authorization", + "https://auth.example.com/api/oauth/token", + ]); + }); + + it("refreshes tokens and returns a Bearer header for requests", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const url = getUrl(input); + expect(url).toBe(`${OAUTH_HOST}/api/oauth/token`); + const params = new URLSearchParams(String(init?.body)); + expect(params.get("grant_type")).toBe("refresh_token"); + expect(params.get("refresh_token")).toBe("old-refresh"); + expect(params.get("client_id")).toBe(CLIENT_ID); + return jsonResponse({ access_token: "new-access", refresh_token: "new-refresh", expires_in: 3600 }); + }), + ); + + const before = Date.now(); + const credential = await kimiCodingOAuth.refresh({ + type: "oauth", + access: "old-access", + refresh: "old-refresh", + expires: before, + }); + expect(credential).toEqual({ + type: "oauth", + access: "new-access", + refresh: "new-refresh", + expires: expect.any(Number), + }); + expect(credential.expires).toBeGreaterThanOrEqual(before + 3600 * 1000); + + await expect(kimiCodingOAuth.toAuth(credential)).resolves.toEqual({ + headers: { Authorization: "Bearer new-access" }, + }); + }); + + it("retries refresh on 429 and fails unauthorized on invalid_grant", async () => { + vi.useFakeTimers(); + + // 429 once, then success. + let calls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (): Promise => { + calls += 1; + if (calls === 1) return jsonResponse({ error: "temporarily_unavailable" }, 429); + return jsonResponse({ access_token: "a", refresh_token: "r", expires_in: 60 }); + }), + ); + + const refreshPromise = kimiCodingOAuth.refresh({ + type: "oauth", + access: "old", + refresh: "old", + expires: 0, + }); + await vi.advanceTimersByTimeAsync(1000); + await expect(refreshPromise).resolves.toMatchObject({ access: "a" }); + expect(calls).toBe(2); + + // invalid_grant is not retried. + vi.stubGlobal( + "fetch", + vi.fn(async (): Promise => jsonResponse({ error: "invalid_grant" }, 400)), + ); + await expect( + kimiCodingOAuth.refresh({ type: "oauth", access: "old", refresh: "old", expires: 0 }), + ).rejects.toThrow("unauthorized"); + }); +});