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

@@ -0,0 +1,8 @@
export const kimiCodeProviderSchema = `
insert or ignore into providers(id, org_id, provider_key, label, auth_type, model, credential_id, enabled, created_at)
select 'prv_' || lower(hex(randomblob(16))), organizations.id, 'kimi-code', 'Kimi Code', 'oauth_pkce', 'kimi-for-coding', null, 0, datetime('now')
from organizations;
insert into schema_migrations(version, name, applied_at)
values (57, 'kimi_code_provider', datetime('now'));
`;
+2
View File
@@ -55,6 +55,7 @@ import { autonomousPauseSchema } from "./migrations/053-autonomous-pause";
import { closeStaleWakeTasksSchema } from "./migrations/054-close-stale-wake-tasks";
import { taskOriginSchema } from "./migrations/055-task-origin";
import { kimiProvidersSchema } from "./migrations/056-kimi-providers";
import { kimiCodeProviderSchema } from "./migrations/057-kimi-code-provider";
interface Migration {
version: number;
@@ -119,6 +120,7 @@ const MIGRATIONS: Migration[] = [
{ version: 54, name: "close_stale_wake_tasks", sql: closeStaleWakeTasksSchema },
{ version: 55, name: "task_origin", sql: taskOriginSchema },
{ version: 56, name: "kimi_providers", sql: kimiProvidersSchema },
{ version: 57, name: "kimi_code_provider", sql: kimiCodeProviderSchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
+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> {
+9
View File
@@ -119,6 +119,15 @@ const RAW_CATALOG: Omit<ProviderCatalogEntry, "models" | "supportsLiveModels">[]
signInUrl: "https://chatgpt.com/codex",
credentialHelp: "Sign in to ChatGPT Plus/Pro in the browser; AgentCompany finishes the handshake.",
},
{
key: "kimi-code",
label: "Kimi Code",
authType: PROVIDER_AUTH_TYPE.OAUTH_PKCE,
defaultModel: "kimi-for-coding",
capabilityTags: ["account", "coding"],
signInUrl: "https://www.kimi.com/code",
credentialHelp: "Sign in with your Kimi plan account in the browser; AgentCompany finishes the device-code handshake.",
},
{
key: "azure-openai",
label: "Azure OpenAI",
+4
View File
@@ -226,6 +226,10 @@ export const PROVIDER_MODELS: Record<string, ProviderModelEntry[]> = {
],
kimi: KIMI_MODELS,
"kimi-cn": KIMI_MODELS,
"kimi-code": [
{ id: "kimi-for-coding", name: "Kimi for Coding" },
{ id: "kimi-for-coding-highspeed", name: "Kimi for Coding Highspeed" },
],
openrouter: [
{ id: "anthropic/claude-sonnet-4.5", name: "Anthropic: Claude Sonnet 4.5" },
{ id: "anthropic/claude-opus-4.5", name: "Anthropic: Claude Opus 4.5" },
@@ -20,6 +20,7 @@ const PROVIDER_SECRET_SAMPLES: Record<string, string> = {
openrouter: "sk-or-live-provider",
kimi: "sk-live-provider",
"kimi-cn": "sk-live-provider",
"kimi-code": "kimi-session-token",
"github-copilot": "ghu_live_provider",
"google-antigravity": "antigravity-session",
"gpt-account": "chatgpt-session",
+1 -1
View File
@@ -24,7 +24,7 @@ describe("domain services", () => {
const connection = createConnection();
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56]);
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57]);
connection.close();
});
+35
View File
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import { describe, expect, test } from "vitest";
import { interpretPollResponse } from "../../src/main/services/oauth/github-copilot-flow";
import { interpretKimiPollResponse } from "../../src/main/services/oauth/kimi-code-flow";
import { extractAccountId } from "../../src/main/services/oauth/openai-codex-flow";
import { readProjectId, resolveAntigravityClientSecret } from "../../src/main/services/oauth/google-antigravity-flow";
import { generatePkce } from "../../src/main/services/oauth/pkce";
@@ -33,6 +34,40 @@ describe("github copilot device-code poll interpreter", () => {
});
});
describe("kimi code device-code poll interpreter", () => {
test("returns credentials with an expiry margin when the token arrives", () => {
const before = Date.now();
const decision = interpretKimiPollResponse(200, { access_token: "kimi_access", refresh_token: "kimi_refresh", expires_in: 900 });
expect(decision.kind).toBe("token");
if (decision.kind === "token") {
expect(decision.credentials.access).toBe("kimi_access");
expect(decision.credentials.refresh).toBe("kimi_refresh");
expect(decision.credentials.expires).toBeGreaterThan(before);
expect(decision.credentials.expires).toBeLessThan(before + 900 * 1000);
}
});
test("rejects a token response missing required fields", () => {
expect(() => interpretKimiPollResponse(200, { access_token: "kimi_access" })).toThrow("missing required fields");
});
test("keeps polling while authorization is pending", () => {
expect(interpretKimiPollResponse(400, { error: "authorization_pending" })).toEqual({ kind: "pending" });
expect(interpretKimiPollResponse(400, { error: "slow_down" })).toEqual({ kind: "pending" });
expect(interpretKimiPollResponse(500, {})).toEqual({ kind: "pending" });
});
test("stops on an expired device code", () => {
expect(interpretKimiPollResponse(400, { error: "expired_token" })).toEqual({ kind: "expired" });
});
test("fails when the user denies the request", () => {
const decision = interpretKimiPollResponse(400, { error: "access_denied", error_description: "user declined" });
expect(decision.kind).toBe("error");
if (decision.kind === "error") expect(decision.message).toContain("user declined");
});
});
describe("openai codex account id extraction", () => {
test("reads the chatgpt account id from the token claims", () => {
const token = makeJwt({ "https://api.openai.com/auth": { chatgpt_account_id: "acct_42" } });
+1 -1
View File
@@ -186,7 +186,7 @@ describe("aws sigv4", () => {
describe("tool-turn router", () => {
test("supports every agent-capable provider (incl. cloud and custom) and rejects unknown ones", () => {
for (const key of ["openai", "anthropic", "xai", "groq", "mistral", "cerebras", "openrouter", "kimi", "kimi-cn", "gpt-account", "github-copilot", "google-antigravity", "claude-pro", "custom-openai", "custom-anthropic", "azure-openai", "aws-bedrock", "vertex-ai"]) {
for (const key of ["openai", "anthropic", "xai", "groq", "mistral", "cerebras", "openrouter", "kimi", "kimi-cn", "gpt-account", "github-copilot", "google-antigravity", "claude-pro", "kimi-code", "custom-openai", "custom-anthropic", "azure-openai", "aws-bedrock", "vertex-ai"]) {
expect(supportsToolTurn(key)).toBe(true);
expect(typeof selectToolTurn(key)).toBe("function");
}