Kimi Code joins as an OAuth plan provider: RFC 8628 device flow, verified against the live endpoints

- New oauth/kimi-code-flow.ts mirrors the official kimi-cli: form-encoded device_authorization + token polling at auth.kimi.com with the required X-Msh-* device headers, pure poll interpreter, 15-minute tokens refreshed via refresh_token grant
- Agent loop runs chat completions at https://api.kimi.com/coding/v1 with the Bearer access token; default model kimi-for-coding (kimi-for-coding-highspeed for Allegretto+ plans)
- Live model listing via /coding/v1/models; credential refresh wired into the turn credential resolver
- Migration 057 seeds the provider (disabled) into existing organizations
- Wire protocol probed live: device code issued, authorization_pending poll observed, 401 envelope on the coding API confirmed
This commit is contained in:
ZacharyZhang-NY committed 2026-07-10 17:18:31 -04:00
1 parent 813276de71
commit 327de38e78
14 files changed
+281 -2

No files matched your search

+189
View File
@@ -0,0 +1,189 @@
/**
* Kimi Code (kimi.com plan) OAuth — RFC 8628 device-code flow.
*
* Mirrors the official MoonshotAI/kimi-cli implementation: form-encoded
* requests against auth.kimi.com carrying the X-Msh-* device headers the
* backend requires, then Bearer access tokens against the coding endpoint.
*/
import { createHash } from "node:crypto";
import { hostname, platform as osPlatform, release as osRelease, userInfo } from "node:os";
import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-types";
// Published public client id of the official Kimi CLI; reused by third-party
// coding agents per Kimi's device-flow integration guidance.
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
const OAUTH_HOST = "https://auth.kimi.com";
export const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
const TOKEN_EXPIRY_MARGIN_MS = 5 * 60 * 1000;
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
const DEFAULT_DEVICE_CODE_LIFETIME_SECONDS = 900;
/**
* Stable per-machine device identity (32 hex chars, matching the CLI's
* uuid4().hex format). Derived instead of stored so every call site — login,
* refresh — reports the same device without a new persistence surface.
*/
function kimiDeviceId(): string {
return createHash("sha256")
.update(`agentcompany|${hostname()}|${osPlatform()}|${userInfo().username}`)
.digest("hex")
.slice(0, 32);
}
function asciiHeaderValue(value: string, fallback: string): string {
const sanitized = value.replace(/[^\x20-\x7e]/g, "").trim();
return sanitized || fallback;
}
/** The auth backend rejects requests that omit these device headers. */
function deviceHeaders(): Record<string, string> {
return {
"X-Msh-Platform": "agentcompany",
"X-Msh-Version": "1.0",
"X-Msh-Device-Name": asciiHeaderValue(hostname(), "unknown"),
"X-Msh-Device-Model": asciiHeaderValue(osPlatform(), "unknown"),
"X-Msh-Os-Version": asciiHeaderValue(osRelease(), "unknown"),
"X-Msh-Device-Id": kimiDeviceId(),
};
}
async function postForm(path: string, form: Record<string, string>): Promise<{ status: number; data: Record<string, unknown> }> {
const response = await fetch(`${OAUTH_HOST}${path}`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
...deviceHeaders(),
},
body: new URLSearchParams(form),
});
const text = await response.text();
let data: unknown;
try {
data = JSON.parse(text);
} catch {
throw new Error(`Kimi OAuth endpoint returned a non-JSON response: ${response.status} ${text.slice(0, 200)}`);
}
if (typeof data !== "object" || data === null || Array.isArray(data)) {
throw new Error(`Kimi OAuth endpoint returned an unexpected payload: ${response.status}`);
}
return { status: response.status, data: data as Record<string, unknown> };
}
interface DeviceAuthorization {
device_code: string;
user_code: string;
verification_uri_complete: string;
expires_in: number;
interval: number;
}
async function requestDeviceAuthorization(): Promise<DeviceAuthorization> {
const { status, data } = await postForm("/api/oauth/device_authorization", { client_id: CLIENT_ID });
if (status !== 200) throw new Error(`Kimi device authorization failed: ${status} ${JSON.stringify(data)}`);
const deviceCode = data.device_code;
const userCode = data.user_code;
const verificationUriComplete = data.verification_uri_complete;
if (typeof deviceCode !== "string" || typeof userCode !== "string" || typeof verificationUriComplete !== "string") {
throw new Error("Invalid device authorization response from Kimi.");
}
return {
device_code: deviceCode,
user_code: userCode,
verification_uri_complete: verificationUriComplete,
expires_in: typeof data.expires_in === "number" && data.expires_in > 0 ? data.expires_in : DEFAULT_DEVICE_CODE_LIFETIME_SECONDS,
interval: typeof data.interval === "number" && data.interval > 0 ? data.interval : DEFAULT_POLL_INTERVAL_SECONDS,
};
}
function credentialsFromToken(raw: Record<string, unknown>): OAuthCredentials {
const access = raw.access_token;
const refresh = raw.refresh_token;
const expiresIn = raw.expires_in;
if (typeof access !== "string" || typeof refresh !== "string" || typeof expiresIn !== "number") {
throw new Error("Kimi token response was missing required fields.");
}
return { access, refresh, expires: Date.now() + expiresIn * 1000 - TOKEN_EXPIRY_MARGIN_MS };
}
export type KimiPollDecision =
| { kind: "token"; credentials: OAuthCredentials }
| { kind: "pending" }
| { kind: "expired" }
| { kind: "error"; message: string };
/** Pure interpreter of a device-token poll response (unit-tested). */
export function interpretKimiPollResponse(status: number, raw: Record<string, unknown>): KimiPollDecision {
if (status === 200 && typeof raw.access_token === "string") {
return { kind: "token", credentials: credentialsFromToken(raw) };
}
const error = typeof raw.error === "string" ? raw.error : "";
if (error === "expired_token") return { kind: "expired" };
if (error === "access_denied") {
const description = typeof raw.error_description === "string" && raw.error_description ? `: ${raw.error_description}` : "";
return { kind: "error", message: `Kimi sign-in was denied${description}` };
}
// The reference CLI keeps polling on every other status (authorization_pending,
// slow_down, transient 5xx); the device-code deadline bounds the wait.
return { kind: "pending" };
}
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Login cancelled"));
return;
}
const timeout = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timeout);
reject(new Error("Login cancelled"));
},
{ once: true },
);
});
}
async function pollForToken(device: DeviceAuthorization, signal?: AbortSignal): Promise<OAuthCredentials> {
const deadline = Date.now() + device.expires_in * 1000;
const intervalMs = Math.max(1000, device.interval * 1000);
while (Date.now() < deadline) {
if (signal?.aborted) throw new Error("Login cancelled");
await abortableSleep(Math.min(intervalMs, deadline - Date.now()), signal);
const { status, data } = await postForm("/api/oauth/token", {
client_id: CLIENT_ID,
device_code: device.device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
});
const decision = interpretKimiPollResponse(status, data);
if (decision.kind === "token") return decision.credentials;
if (decision.kind === "expired") throw new Error("Kimi device code expired. Please try signing in again.");
if (decision.kind === "error") throw new Error(decision.message);
}
throw new Error("Kimi device flow timed out. Please try signing in again.");
}
/** Refresh an expired Kimi Code access token (they live ~15 minutes). */
export async function refreshKimiCode(refreshToken: string): Promise<OAuthCredentials> {
const { status, data } = await postForm("/api/oauth/token", {
client_id: CLIENT_ID,
grant_type: "refresh_token",
refresh_token: refreshToken,
});
if (status !== 200) throw new Error(`Kimi token refresh failed: ${status} ${JSON.stringify(data)}`);
return credentialsFromToken(data);
}
export const kimiCodeFlow: OAuthFlow = {
providerKey: "kimi-code",
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
const device = await requestDeviceAuthorization();
callbacks.onAuth({
url: device.verification_uri_complete,
instructions: `Approve code ${device.user_code} in your browser to link your Kimi plan.`,
});
return pollForToken(device, callbacks.signal);
},
};
+2
View File
@@ -2,6 +2,7 @@ import { githubCopilotFlow } from "./github-copilot-flow";
import { openaiCodexFlow } from "./openai-codex-flow";
import { antigravityFlow } from "./google-antigravity-flow";
import { anthropicClaudeFlow } from "./anthropic-claude-flow";
import { kimiCodeFlow } from "./kimi-code-flow";
import type { OAuthFlow } from "./oauth-types";
/** Provider keys (from the catalog) mapped to their native OAuth login flow. */
@@ -10,6 +11,7 @@ const OAUTH_FLOWS: Record<string, OAuthFlow> = {
"gpt-account": openaiCodexFlow,
"google-antigravity": antigravityFlow,
"claude-pro": anthropicClaudeFlow,
"kimi-code": kimiCodeFlow,
};
export function hasOAuthFlow(providerKey: string): boolean {
+7
View File
@@ -24,6 +24,7 @@ import { backgroundUnsupported, retrieveUnsupported } from "./provider-backgroun
import { authorizeWithParser, refreshUnsupported, revokeLocalProfile } from "./provider-auth-lifecycle";
import { getJson, postEventStream, postJson, type JsonRecord } from "./provider-http";
import { fetchAnthropicModels, fetchOpenAiModels } from "./provider-models-fetch";
import { KIMI_CODE_BASE_URL } from "./oauth/kimi-code-flow";
const ANTHROPIC_VERSION = "2023-06-01";
const CLAUDE_PRO_BETA = "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14";
@@ -233,6 +234,12 @@ export const providerAdapters: ProviderAdapterRegistry = {
"anthropic-beta": CLAUDE_PRO_BETA,
}),
},
"kimi-code": {
...browserTokenAdapter("kimi-code", PROVIDER_AUTH_TYPE.OAUTH_PKCE),
listModels: (ctx) => fetchOpenAiModels(`${KIMI_CODE_BASE_URL}/models`, {
authorization: `Bearer ${requireAccessTokenProfile(ctx.authProfile).accessToken}`,
}),
},
"azure-openai": {
providerKey: "azure-openai",
authType: PROVIDER_AUTH_TYPE.CLOUD,
@@ -28,6 +28,7 @@ import { refreshCodex } from "../oauth/openai-codex-flow";
import { refreshClaudePro } from "../oauth/anthropic-claude-flow";
import { refreshAntigravity } from "../oauth/google-antigravity-flow";
import { refreshGitHubCopilotToken } from "../oauth/github-copilot-flow";
import { refreshKimiCode } from "../oauth/kimi-code-flow";
interface StoredOAuth {
access: string;
@@ -63,6 +64,8 @@ function refreshOAuth(providerKey: string, creds: StoredOAuth): Promise<OAuthCre
return refreshAntigravity(creds.refresh, creds.projectId);
case "github-copilot":
return refreshGitHubCopilotToken(creds.refresh, creds.enterpriseUrl ?? undefined);
case "kimi-code":
return refreshKimiCode(creds.refresh);
default:
throw new Error(`${providerKey} cannot refresh its credential.`);
}
@@ -10,6 +10,7 @@ import {
runCopilotTurn,
runCustomAnthropicTurn,
runCustomOpenAiTurn,
runKimiCodeTurn,
runOpenAiResponsesTurn,
runVertexTurn,
} from "./turns";
@@ -29,6 +30,7 @@ const TURNS: Record<string, ToolTurnFn> = {
"github-copilot": runCopilotTurn,
"google-antigravity": runAntigravityTurn,
"claude-pro": runClaudeProTurn,
"kimi-code": runKimiCodeTurn,
"custom-openai": runCustomOpenAiTurn,
"custom-anthropic": runCustomAnthropicTurn,
"azure-openai": runAzureTurn,
@@ -14,6 +14,7 @@ import { bedrockToolConfig, parseBedrockOutput, toBedrockMessages } from "./bedr
import { signAwsRequest } from "./aws-sigv4";
import { getGcpAccessToken } from "./gcp-auth";
import { COPILOT_HEADERS, copilotBaseUrl } from "../oauth/github-copilot-flow";
import { KIMI_CODE_BASE_URL } from "../oauth/kimi-code-flow";
const ANTIGRAVITY_VERSION = "1.21.9";
const ANTIGRAVITY_ENDPOINT = "https://cloudcode-pa.googleapis.com";
@@ -108,6 +109,22 @@ export async function runClaudeProTurn(input: ProviderToolTurnInput): Promise<Ag
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
}
// ---- Kimi Code (OAuth plan, chat completions at the coding endpoint) ----
export async function runKimiCodeTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
const access = requireAccessToken(input.credential, "Kimi Code");
const body: JsonRecord = {
model: input.model,
messages: toChatMessages(input.instructions, input.messages),
tools: chatToolDefs(input.tools),
tool_choice: "auto",
stream: false,
};
const response = await postJson(`${KIMI_CODE_BASE_URL}/chat/completions`, { authorization: `Bearer ${access}` }, body);
const parsed = parseChatChoice(response.data);
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
}
// ---- GPT account / OpenAI Codex (OAuth, SSE) ----
export async function runCodexTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {