feat: add Kimi Code OAuth support (import from Kimi CLI)

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
This commit is contained in:
SPANISH FLU 2026-01-28 20:50:32 +01:00
parent 6044bf3637
commit 425c9a941a
7 changed files with 117 additions and 3 deletions

View File

@ -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,

View File

@ -52,7 +52,7 @@ export function registerOnboardCommand(program: Command) {
.option("--mode <mode>", "Wizard mode: local|remote")
.option(
"--auth-choice <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 <id>",

View File

@ -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",

View File

@ -14,6 +14,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
"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",

View File

@ -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",

View File

@ -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"

View File

@ -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<OAuthCredentials> {
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,
};
}