From 425c9a941a007f68b2374bdbc459ee0a8f446ddb Mon Sep 17 00:00:00 2001 From: SPANISH FLU Date: Wed, 28 Jan 2026 20:50:32 +0100 Subject: [PATCH] feat: add Kimi Code OAuth support (import from Kimi CLI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add native Kimi Code OAuth support, allowing users to authenticate with their Kimi subscription instead of requiring a static API key. Changes: - New kimi-code-oauth.ts: token refresh via auth.kimi.com/api/oauth/token - Auth dispatch: route kimi-code provider to custom refresh (same pattern as qwen-portal and chutes) - New 'kimi-code-oauth' onboarding auth choice: imports OAuth credentials from Kimi CLI (~/.kimi/credentials/kimi-code.json) - Handles expires_at conversion (Kimi uses seconds, Moltbot uses ms) Usage: moltbot onboard --auth-choice kimi-code-oauth Requires Kimi CLI to be logged in first (kimi login). 🤖 AI-assisted (Claude Opus 4.5 + Kimi K2.5 sub-agent) Testing: endpoint + refresh verified via curl; compiles clean; lint clean; not yet tested e2e through Moltbot --- src/agents/auth-profiles/oauth.ts | 8 ++- src/cli/program/register.onboard.ts | 2 +- src/commands/auth-choice-options.ts | 7 ++- .../auth-choice.preferred-provider.ts | 1 + .../local/auth-choice.ts | 46 ++++++++++++++++ src/commands/onboard-types.ts | 1 + src/providers/kimi-code-oauth.ts | 55 +++++++++++++++++++ 7 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 src/providers/kimi-code-oauth.ts diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 590a752a3..ddd2d84d4 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -3,6 +3,7 @@ import lockfile from "proper-lockfile"; import type { MoltbotConfig } from "../../config/config.js"; import { refreshChutesTokens } from "../chutes-oauth.js"; +import { refreshKimiCodeCredentials } from "../../providers/kimi-code-oauth.js"; import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js"; import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js"; import { formatAuthDoctorHint } from "./doctor.js"; @@ -62,7 +63,12 @@ async function refreshOAuthTokenWithLock(params: { const newCredentials = await refreshQwenPortalCredentials(cred); return { apiKey: newCredentials.access, newCredentials }; })() - : await getOAuthApiKey(cred.provider as OAuthProvider, oauthCreds); + : String(cred.provider) === "kimi-code" + ? await (async () => { + const newCredentials = await refreshKimiCodeCredentials(cred); + return { apiKey: newCredentials.access, newCredentials }; + })() + : await getOAuthApiKey(cred.provider as OAuthProvider, oauthCreds); if (!result) return null; store.profiles[params.profileId] = { ...cred, diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 8f31635f0..677f0ed3e 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -52,7 +52,7 @@ export function registerOnboardCommand(program: Command) { .option("--mode ", "Wizard mode: local|remote") .option( "--auth-choice ", - "Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip", + "Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|kimi-code-oauth|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip", ) .option( "--token-provider ", diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 6b49ff17b..6463bf01e 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -99,7 +99,7 @@ const AUTH_CHOICE_GROUP_DEFS: { value: "moonshot", label: "Moonshot AI", hint: "Kimi K2 + Kimi Code", - choices: ["moonshot-api-key", "kimi-code-api-key"], + choices: ["moonshot-api-key", "kimi-code-api-key", "kimi-code-oauth"], }, { value: "zai", @@ -141,6 +141,11 @@ export function buildAuthChoiceOptions(params: { }); options.push({ value: "moonshot-api-key", label: "Moonshot AI API key" }); options.push({ value: "kimi-code-api-key", label: "Kimi Code API key" }); + options.push({ + value: "kimi-code-oauth", + label: "Kimi Code OAuth (import from Kimi CLI)", + hint: "Reads credentials from ~/.kimi/credentials/", + }); options.push({ value: "synthetic-api-key", label: "Synthetic API key" }); options.push({ value: "venice-api-key", diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index 6fe26b59a..e1e4923a0 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -14,6 +14,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { "ai-gateway-api-key": "vercel-ai-gateway", "moonshot-api-key": "moonshot", "kimi-code-api-key": "kimi-code", + "kimi-code-oauth": "kimi-code", "gemini-api-key": "google", "google-antigravity": "google-antigravity", "google-gemini-cli": "google-gemini-cli", diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 7d952730c..b519def95 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -252,6 +252,52 @@ export async function applyNonInteractiveAuthChoice(params: { return applyMoonshotConfig(nextConfig); } + if (authChoice === "kimi-code-oauth") { + const os = await import("node:os"); + const path = await import("node:path"); + const fs = await import("node:fs"); + const kimiCredPath = path.join(os.homedir(), ".kimi", "credentials", "kimi-code.json"); + if (!fs.existsSync(kimiCredPath)) { + runtime.error( + `Kimi CLI credentials not found at ${kimiCredPath}.\n` + + "Please log in with Kimi CLI first: kimi login", + ); + runtime.exit(1); + return null; + } + let raw: { access_token?: string; refresh_token?: string; expires_at?: number }; + try { + raw = JSON.parse(fs.readFileSync(kimiCredPath, "utf-8")); + } catch { + runtime.error(`Failed to parse Kimi CLI credentials at ${kimiCredPath}.`); + runtime.exit(1); + return null; + } + if (!raw.access_token || !raw.refresh_token) { + runtime.error("Kimi CLI credentials file is missing access_token or refresh_token."); + runtime.exit(1); + return null; + } + upsertAuthProfile({ + profileId: "kimi-code:default", + credential: { + type: "oauth", + provider: "kimi-code", + access: raw.access_token, + refresh: raw.refresh_token, + // Kimi stores expires_at in seconds; Moltbot uses milliseconds + expires: raw.expires_at ? raw.expires_at * 1000 : 0, + }, + }); + runtime.log("Imported Kimi Code OAuth credentials from Kimi CLI."); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "kimi-code:default", + provider: "kimi-code", + mode: "oauth", + }); + return applyKimiCodeConfig(nextConfig); + } + if (authChoice === "kimi-code-api-key") { const resolved = await resolveNonInteractiveApiKey({ provider: "kimi-code", diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index aa1d9afe0..408c47bd5 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -15,6 +15,7 @@ export type AuthChoice = | "ai-gateway-api-key" | "moonshot-api-key" | "kimi-code-api-key" + | "kimi-code-oauth" | "synthetic-api-key" | "venice-api-key" | "codex-cli" diff --git a/src/providers/kimi-code-oauth.ts b/src/providers/kimi-code-oauth.ts new file mode 100644 index 000000000..b074a7351 --- /dev/null +++ b/src/providers/kimi-code-oauth.ts @@ -0,0 +1,55 @@ +import type { OAuthCredentials } from "@mariozechner/pi-ai"; + +const KIMI_CODE_OAUTH_HOST = "https://auth.kimi.com"; +const KIMI_CODE_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"; + +export async function refreshKimiCodeCredentials( + credentials: OAuthCredentials, +): Promise { + if (!credentials.refresh?.trim()) { + throw new Error("Kimi Code OAuth refresh token missing; re-authenticate."); + } + + const host = process.env.KIMI_CODE_OAUTH_HOST || KIMI_CODE_OAUTH_HOST; + + const response = await fetch(`${host}/api/oauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: credentials.refresh, + client_id: KIMI_CODE_CLIENT_ID, + }), + }); + + if (!response.ok) { + const text = await response.text(); + if (response.status === 401 || response.status === 403) { + throw new Error( + `Kimi Code OAuth refresh token expired or revoked. Re-authenticate with ` + + `\`moltbot onboard --auth-choice kimi-code-oauth\`.`, + ); + } + throw new Error(`Kimi Code OAuth refresh failed: ${text || response.statusText}`); + } + + const payload = (await response.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + + if (!payload.access_token || !payload.expires_in) { + throw new Error("Kimi Code OAuth refresh response missing access token."); + } + + return { + ...credentials, + access: payload.access_token, + refresh: payload.refresh_token || credentials.refresh, + expires: Date.now() + payload.expires_in * 1000, + }; +}