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:
parent
6044bf3637
commit
425c9a941a
@ -3,6 +3,7 @@ import lockfile from "proper-lockfile";
|
|||||||
|
|
||||||
import type { MoltbotConfig } from "../../config/config.js";
|
import type { MoltbotConfig } from "../../config/config.js";
|
||||||
import { refreshChutesTokens } from "../chutes-oauth.js";
|
import { refreshChutesTokens } from "../chutes-oauth.js";
|
||||||
|
import { refreshKimiCodeCredentials } from "../../providers/kimi-code-oauth.js";
|
||||||
import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js";
|
import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js";
|
||||||
import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js";
|
import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js";
|
||||||
import { formatAuthDoctorHint } from "./doctor.js";
|
import { formatAuthDoctorHint } from "./doctor.js";
|
||||||
@ -62,7 +63,12 @@ async function refreshOAuthTokenWithLock(params: {
|
|||||||
const newCredentials = await refreshQwenPortalCredentials(cred);
|
const newCredentials = await refreshQwenPortalCredentials(cred);
|
||||||
return { apiKey: newCredentials.access, newCredentials };
|
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;
|
if (!result) return null;
|
||||||
store.profiles[params.profileId] = {
|
store.profiles[params.profileId] = {
|
||||||
...cred,
|
...cred,
|
||||||
|
|||||||
@ -52,7 +52,7 @@ export function registerOnboardCommand(program: Command) {
|
|||||||
.option("--mode <mode>", "Wizard mode: local|remote")
|
.option("--mode <mode>", "Wizard mode: local|remote")
|
||||||
.option(
|
.option(
|
||||||
"--auth-choice <choice>",
|
"--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(
|
.option(
|
||||||
"--token-provider <id>",
|
"--token-provider <id>",
|
||||||
|
|||||||
@ -99,7 +99,7 @@ const AUTH_CHOICE_GROUP_DEFS: {
|
|||||||
value: "moonshot",
|
value: "moonshot",
|
||||||
label: "Moonshot AI",
|
label: "Moonshot AI",
|
||||||
hint: "Kimi K2 + Kimi Code",
|
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",
|
value: "zai",
|
||||||
@ -141,6 +141,11 @@ export function buildAuthChoiceOptions(params: {
|
|||||||
});
|
});
|
||||||
options.push({ value: "moonshot-api-key", label: "Moonshot AI API key" });
|
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-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: "synthetic-api-key", label: "Synthetic API key" });
|
||||||
options.push({
|
options.push({
|
||||||
value: "venice-api-key",
|
value: "venice-api-key",
|
||||||
|
|||||||
@ -14,6 +14,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
|
|||||||
"ai-gateway-api-key": "vercel-ai-gateway",
|
"ai-gateway-api-key": "vercel-ai-gateway",
|
||||||
"moonshot-api-key": "moonshot",
|
"moonshot-api-key": "moonshot",
|
||||||
"kimi-code-api-key": "kimi-code",
|
"kimi-code-api-key": "kimi-code",
|
||||||
|
"kimi-code-oauth": "kimi-code",
|
||||||
"gemini-api-key": "google",
|
"gemini-api-key": "google",
|
||||||
"google-antigravity": "google-antigravity",
|
"google-antigravity": "google-antigravity",
|
||||||
"google-gemini-cli": "google-gemini-cli",
|
"google-gemini-cli": "google-gemini-cli",
|
||||||
|
|||||||
@ -252,6 +252,52 @@ export async function applyNonInteractiveAuthChoice(params: {
|
|||||||
return applyMoonshotConfig(nextConfig);
|
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") {
|
if (authChoice === "kimi-code-api-key") {
|
||||||
const resolved = await resolveNonInteractiveApiKey({
|
const resolved = await resolveNonInteractiveApiKey({
|
||||||
provider: "kimi-code",
|
provider: "kimi-code",
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export type AuthChoice =
|
|||||||
| "ai-gateway-api-key"
|
| "ai-gateway-api-key"
|
||||||
| "moonshot-api-key"
|
| "moonshot-api-key"
|
||||||
| "kimi-code-api-key"
|
| "kimi-code-api-key"
|
||||||
|
| "kimi-code-oauth"
|
||||||
| "synthetic-api-key"
|
| "synthetic-api-key"
|
||||||
| "venice-api-key"
|
| "venice-api-key"
|
||||||
| "codex-cli"
|
| "codex-cli"
|
||||||
|
|||||||
55
src/providers/kimi-code-oauth.ts
Normal file
55
src/providers/kimi-code-oauth.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user