jeo-code 0.7.26 → 0.7.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/compaction.ts +24 -0
- package/src/agent/config-schema.ts +1 -0
- package/src/agent/engine.ts +20 -5
- package/src/agent/loop.ts +3 -0
- package/src/agent/state.ts +84 -5
- package/src/agent/task-tool.ts +5 -2
- package/src/ai/model-catalog.ts +23 -0
- package/src/ai/model-discovery.ts +6 -2
- package/src/ai/model-manager.ts +60 -13
- package/src/ai/providers/anthropic.ts +62 -18
- package/src/ai/providers/antigravity.ts +34 -22
- package/src/ai/providers/gemini.ts +81 -27
- package/src/ai/providers/kimi.ts +44 -5
- package/src/ai/providers/openai-responses.ts +36 -2
- package/src/ai/types.ts +10 -0
- package/src/auth/callback-server.ts +19 -5
- package/src/auth/flows/AGENTS.md +7 -6
- package/src/auth/flows/antigravity.ts +3 -0
- package/src/auth/flows/google-project.ts +8 -2
- package/src/auth/flows/google.ts +15 -1
- package/src/auth/flows/index.ts +10 -0
- package/src/auth/flows/kimi.ts +263 -0
- package/src/auth/flows/openai.ts +103 -3
- package/src/auth/oauth.ts +9 -0
- package/src/auth/refresh.ts +6 -1
- package/src/auth/storage.ts +5 -5
- package/src/commands/launch/input.ts +244 -9
- package/src/commands/launch/slash-views.ts +5 -2
- package/src/commands/launch.ts +64 -28
- package/src/commands/team.ts +3 -0
- package/src/tui/clipboard.ts +49 -0
- package/src/tui/components/welcome.ts +98 -39
- package/src/tui/terminal.ts +5 -3
- package/src/util/provider-error.ts +4 -12
- package/src/util/retry.ts +88 -31
|
@@ -27,6 +27,19 @@ export interface OAuthCallbackFlowOptions {
|
|
|
27
27
|
|
|
28
28
|
export type CallbackResult = { code: string; state: string };
|
|
29
29
|
|
|
30
|
+
/** Thrown when the fixed callback port cannot be bound and the provider forbids a
|
|
31
|
+
* port fallback (fixed redirect URI). Typed so callers (e.g. the OpenAI flow) can
|
|
32
|
+
* detect the bind failure and fall back to the device-code flow instead of
|
|
33
|
+
* string-matching the error message. */
|
|
34
|
+
export class CallbackPortUnavailableError extends Error {
|
|
35
|
+
readonly port: number;
|
|
36
|
+
constructor(port: number) {
|
|
37
|
+
super(`OAuth callback port ${port} unavailable and this provider requires a fixed redirect URI.`);
|
|
38
|
+
this.name = "CallbackPortUnavailableError";
|
|
39
|
+
this.port = port;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
// "jeo" wordmark — bold forged monospace lettering on a neon blue→violet→pink
|
|
31
44
|
// gradient (the mascot's synthwave palette), generated via god-tibo-imagen and
|
|
32
45
|
// matching jeo-pi's bold-wordmark typographic treatment. Embedded as a data URI
|
|
@@ -125,6 +138,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
125
138
|
Bun.serve({
|
|
126
139
|
hostname: this.callbackHostname,
|
|
127
140
|
port,
|
|
141
|
+
reusePort: false, // gjc parity: never share the port — a busy port must FAIL, not silently split traffic
|
|
128
142
|
fetch: req => this.#handle(req, expectedState),
|
|
129
143
|
});
|
|
130
144
|
try {
|
|
@@ -134,9 +148,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
134
148
|
return { server, redirectUri };
|
|
135
149
|
} catch {
|
|
136
150
|
if (this.fixedRedirectUri) {
|
|
137
|
-
throw new
|
|
138
|
-
`OAuth callback port ${this.preferredPort} unavailable and this provider requires a fixed redirect URI.`
|
|
139
|
-
);
|
|
151
|
+
throw new CallbackPortUnavailableError(this.preferredPort);
|
|
140
152
|
}
|
|
141
153
|
const server = serve(0);
|
|
142
154
|
const redirectUri = `http://${this.callbackHostname}:${server.port}${this.callbackPath}`;
|
|
@@ -172,7 +184,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
172
184
|
});
|
|
173
185
|
|
|
174
186
|
return new Response(renderHtml(ok, message), {
|
|
175
|
-
status: ok ? 200 : 400
|
|
187
|
+
status: ok ? 200 : 500, // gjc parity: provider/CSRF failures are server-side errors (500), not 400
|
|
176
188
|
headers: { "content-type": "text/html" },
|
|
177
189
|
});
|
|
178
190
|
}
|
|
@@ -206,7 +218,9 @@ export abstract class OAuthCallbackFlow {
|
|
|
206
218
|
.then(input => {
|
|
207
219
|
const parsed = parseCallbackInput(input);
|
|
208
220
|
if (!parsed.code) return null;
|
|
209
|
-
|
|
221
|
+
// gjc parity: a bare pasted code WITHOUT a state is accepted (the provider
|
|
222
|
+
// may strip it); only an explicitly MISMATCHED state is rejected.
|
|
223
|
+
if (expectedState && parsed.state && parsed.state !== expectedState) return null;
|
|
210
224
|
return { code: parsed.code, state: parsed.state ?? "" } as CallbackResult;
|
|
211
225
|
})
|
|
212
226
|
.catch(() => null),
|
package/src/auth/flows/AGENTS.md
CHANGED
|
@@ -9,12 +9,13 @@ Specific OAuth flow implementations for various providers.
|
|
|
9
9
|
## Key Files
|
|
10
10
|
| File | Description |
|
|
11
11
|
|------|-------------|
|
|
12
|
-
| `anthropic.ts` |
|
|
13
|
-
| `antigravity.ts` |
|
|
14
|
-
| `google-project.ts` |
|
|
15
|
-
| `google.ts` |
|
|
16
|
-
| `index.ts` |
|
|
17
|
-
| `
|
|
12
|
+
| `anthropic.ts` | Anthropic (claude.ai) PKCE OAuth |
|
|
13
|
+
| `antigravity.ts` | Google Antigravity OAuth (desktop-app client, dedicated project discovery) |
|
|
14
|
+
| `google-project.ts` | Cloud Code Assist project discovery/onboarding shared by gemini + antigravity |
|
|
15
|
+
| `google.ts` | Google Gemini CLI OAuth (authorization-code) |
|
|
16
|
+
| `index.ts` | OAUTH_FLOW_REGISTRY wiring flows to providers |
|
|
17
|
+
| `kimi.ts` | Kimi Code (Moonshot subscription) device-authorization OAuth — no callback server |
|
|
18
|
+
| `openai.ts` | OpenAI Codex PKCE OAuth + device-code fallback when port 1455 is busy |
|
|
18
19
|
|
|
19
20
|
## Subdirectories
|
|
20
21
|
*(None)*
|
|
@@ -56,6 +56,9 @@ export function discoverAntigravityProjectId(
|
|
|
56
56
|
return discoverGoogleProjectId(accessToken, {
|
|
57
57
|
metadata: { ...ANTIGRAVITY_DISCOVERY_METADATA },
|
|
58
58
|
extraHeaders: { "User-Agent": getAntigravityUserAgent() },
|
|
59
|
+
// gjc antigravity parity: a reported tier without a project ONBOARDS the
|
|
60
|
+
// default/legacy tier instead of throwing the workspace-project hint.
|
|
61
|
+
alwaysOnboard: true,
|
|
59
62
|
onProgress: opts.onProgress,
|
|
60
63
|
});
|
|
61
64
|
}
|
|
@@ -56,6 +56,11 @@ export interface DiscoverProjectOptions {
|
|
|
56
56
|
/** Per-request deadline; a stalled discovery fetch must NEVER hang the turn
|
|
57
57
|
* forever (round-5 #2 — this path runs BEFORE the manager's guarded call). */
|
|
58
58
|
requestTimeoutMs?: number;
|
|
59
|
+
/** Antigravity semantics (gjc google-antigravity discoverProject): when the account
|
|
60
|
+
* reports a tier but names no project, ONBOARD the default/legacy tier instead of
|
|
61
|
+
* demanding an explicit workspace project. The gemini-cli flow keeps the strict
|
|
62
|
+
* workspace hint (default false). */
|
|
63
|
+
alwaysOnboard?: boolean;
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
const DISCOVERY_REQUEST_TIMEOUT_MS = 30_000;
|
|
@@ -147,12 +152,13 @@ export async function discoverGoogleProjectId(
|
|
|
147
152
|
if (data.currentTier) {
|
|
148
153
|
const existing = readProjectId(data.cloudaicompanionProject) ?? envProjectId;
|
|
149
154
|
if (existing) return existing;
|
|
150
|
-
throw new Error(WORKSPACE_PROJECT_HINT);
|
|
155
|
+
if (!opts.alwaysOnboard) throw new Error(WORKSPACE_PROJECT_HINT);
|
|
156
|
+
// alwaysOnboard: fall through and provision the default/legacy tier (gjc antigravity parity).
|
|
151
157
|
}
|
|
152
158
|
|
|
153
159
|
// Not onboarded yet: provision the default tier (free tier needs no project).
|
|
154
160
|
const tierId = defaultTierId(data.allowedTiers) || TIER_FREE;
|
|
155
|
-
if (tierId !== TIER_FREE && tierId !== TIER_LEGACY && !envProjectId) {
|
|
161
|
+
if (!opts.alwaysOnboard && tierId !== TIER_FREE && tierId !== TIER_LEGACY && !envProjectId) {
|
|
156
162
|
throw new Error(WORKSPACE_PROJECT_HINT);
|
|
157
163
|
}
|
|
158
164
|
|
package/src/auth/flows/google.ts
CHANGED
|
@@ -9,8 +9,22 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { OAuthCallbackFlow } from "../callback-server";
|
|
11
11
|
import { discoverGoogleProjectId } from "./google-project";
|
|
12
|
+
import { getGeminiCliHeaders } from "../../ai/providers/gemini";
|
|
12
13
|
import type { OAuthController, OAuthCredentials } from "../types";
|
|
13
14
|
|
|
15
|
+
/** Login-time Cloud Code Assist project discovery for a gemini-cli token. Sends the
|
|
16
|
+
* gemini-cli identity headers (User-Agent + Client-Metadata) on loadCodeAssist/
|
|
17
|
+
* onboardUser, matching gjc's google-gemini-cli discovery requests. */
|
|
18
|
+
export function discoverGeminiProjectId(
|
|
19
|
+
accessToken: string,
|
|
20
|
+
opts: { onProgress?: (message: string) => void } = {},
|
|
21
|
+
): Promise<string> {
|
|
22
|
+
return discoverGoogleProjectId(accessToken, {
|
|
23
|
+
extraHeaders: getGeminiCliHeaders(),
|
|
24
|
+
onProgress: opts.onProgress,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
const decode = (s: string) => atob(s);
|
|
15
29
|
const CLIENT_ID = decode(
|
|
16
30
|
[
|
|
@@ -101,7 +115,7 @@ class GoogleOAuthFlow extends OAuthCallbackFlow {
|
|
|
101
115
|
// gjc parity: auto-discover (or provision) the Cloud Code Assist project so
|
|
102
116
|
// Antigravity models work straight after login — best-effort, never fails login.
|
|
103
117
|
try {
|
|
104
|
-
projectId = await
|
|
118
|
+
projectId = await discoverGeminiProjectId(data.access_token);
|
|
105
119
|
} catch {
|
|
106
120
|
projectId = undefined;
|
|
107
121
|
}
|
package/src/auth/flows/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { loginAnthropic, refreshAnthropicToken } from "./anthropic";
|
|
|
5
5
|
import { loginOpenAI, refreshOpenAIToken } from "./openai";
|
|
6
6
|
import { loginGoogle, refreshGoogleToken } from "./google";
|
|
7
7
|
import { loginAntigravity, refreshAntigravityToken } from "./antigravity";
|
|
8
|
+
import { loginKimi, refreshKimiToken } from "./kimi";
|
|
8
9
|
|
|
9
10
|
export interface OAuthFlow {
|
|
10
11
|
readonly provider: OAuthProvider;
|
|
@@ -52,9 +53,18 @@ export const OAUTH_FLOW_REGISTRY: Record<OAuthProvider, OAuthFlow> = {
|
|
|
52
53
|
verifiedEndToEnd: true,
|
|
53
54
|
note: "Antigravity desktop-app OAuth client; serves antigravity/* models (Gemini 3, Claude, GPT-OSS via Cloud Code Assist). The Google Cloud projectId is discovered automatically at login.",
|
|
54
55
|
},
|
|
56
|
+
kimi: {
|
|
57
|
+
provider: "kimi",
|
|
58
|
+
label: "Kimi Code (Moonshot subscription)",
|
|
59
|
+
login: loginKimi,
|
|
60
|
+
refresh: refreshKimiToken,
|
|
61
|
+
verifiedEndToEnd: true,
|
|
62
|
+
note: "Device-code OAuth (auth.kimi.com); the token is served via the Anthropic-compatible endpoint at api.kimi.com/coding. A KIMI_API_KEY, when set and no OAuth is stored, uses the OpenAI-compatible api.moonshot.ai/v1.",
|
|
63
|
+
},
|
|
55
64
|
};
|
|
56
65
|
|
|
57
66
|
export { loginAnthropic, refreshAnthropicToken } from "./anthropic";
|
|
58
67
|
export { loginOpenAI, refreshOpenAIToken } from "./openai";
|
|
59
68
|
export { loginGoogle, refreshGoogleToken } from "./google";
|
|
60
69
|
export { loginAntigravity, refreshAntigravityToken, discoverAntigravityProjectId, antigravityClientSecret } from "./antigravity";
|
|
70
|
+
export { loginKimi, refreshKimiToken, getKimiCommonHeaders } from "./kimi";
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kimi Code (Moonshot subscription) OAuth — device authorization grant.
|
|
3
|
+
* Faithful port of gjc's packages/ai/src/utils/oauth/kimi.ts.
|
|
4
|
+
*
|
|
5
|
+
* No callback server: the user opens `verification_uri_complete` (or enters the
|
|
6
|
+
* user code at `verification_uri`) and jeo polls the token endpoint until the
|
|
7
|
+
* grant is approved. The minted access token is used as `Authorization: Bearer`
|
|
8
|
+
* against the Anthropic-compatible endpoint at https://api.kimi.com/coding.
|
|
9
|
+
*/
|
|
10
|
+
import * as crypto from "node:crypto";
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import pkg from "../../../package.json";
|
|
15
|
+
import { jeoEnv } from "../../util/env";
|
|
16
|
+
import type { OAuthController, OAuthCredentials } from "../types";
|
|
17
|
+
|
|
18
|
+
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
|
|
19
|
+
const DEFAULT_OAUTH_HOST = "https://auth.kimi.com";
|
|
20
|
+
const DEVICE_ID_FILENAME = "kimi-device-id";
|
|
21
|
+
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
22
|
+
const DEFAULT_DEVICE_FLOW_TTL_MS = 15 * 60 * 1000;
|
|
23
|
+
const OAUTH_EXPIRY_SKEW_MS = 5 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
interface DeviceAuthorizationResponse {
|
|
26
|
+
user_code?: string;
|
|
27
|
+
device_code?: string;
|
|
28
|
+
verification_uri?: string;
|
|
29
|
+
verification_uri_complete?: string;
|
|
30
|
+
expires_in?: number;
|
|
31
|
+
interval?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface TokenResponse {
|
|
35
|
+
access_token?: string;
|
|
36
|
+
refresh_token?: string;
|
|
37
|
+
expires_in?: number;
|
|
38
|
+
error?: string;
|
|
39
|
+
error_description?: string;
|
|
40
|
+
interval?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** gjc parity: raw (non-JEO_) env overrides for the OAuth host. */
|
|
44
|
+
function resolveOAuthHost(): string {
|
|
45
|
+
return process.env.KIMI_CODE_OAUTH_HOST || process.env.KIMI_OAUTH_HOST || DEFAULT_OAUTH_HOST;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatDeviceModel(system: string, release: string, arch: string): string {
|
|
49
|
+
return [system, release, arch].filter(Boolean).join(" ").trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getDeviceModel(): string {
|
|
53
|
+
const platform = os.platform();
|
|
54
|
+
const release = os.release();
|
|
55
|
+
const arch = os.arch();
|
|
56
|
+
if (platform === "darwin") return formatDeviceModel("macOS", release, arch);
|
|
57
|
+
if (platform === "win32") return formatDeviceModel("Windows", release, arch);
|
|
58
|
+
const label = platform === "linux" ? "Linux" : platform;
|
|
59
|
+
return formatDeviceModel(label, release, arch);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Cached per config-dir path so a JEO_CONFIG_DIR change (tests) is honored.
|
|
63
|
+
const deviceIdCache = new Map<string, string>();
|
|
64
|
+
|
|
65
|
+
/** Stable per-machine device id, persisted like gjc's `<agent-dir>/kimi-device-id`. */
|
|
66
|
+
function getDeviceId(): string {
|
|
67
|
+
const dir = jeoEnv("CONFIG_DIR") || path.join(os.homedir(), ".jeo");
|
|
68
|
+
const deviceIdPath = path.join(dir, DEVICE_ID_FILENAME);
|
|
69
|
+
const cached = deviceIdCache.get(deviceIdPath);
|
|
70
|
+
if (cached) return cached;
|
|
71
|
+
try {
|
|
72
|
+
const trimmed = fs.readFileSync(deviceIdPath, "utf-8").trim();
|
|
73
|
+
if (trimmed) {
|
|
74
|
+
deviceIdCache.set(deviceIdPath, trimmed);
|
|
75
|
+
return trimmed;
|
|
76
|
+
}
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error;
|
|
79
|
+
}
|
|
80
|
+
const deviceId = crypto.randomUUID().replace(/-/g, "");
|
|
81
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
82
|
+
fs.writeFileSync(deviceIdPath, `${deviceId}\n`, { mode: 0o600 });
|
|
83
|
+
deviceIdCache.set(deviceIdPath, deviceId);
|
|
84
|
+
return deviceId;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** X-Msh-* device headers sent on every Kimi OAuth AND API request (gjc parity). */
|
|
88
|
+
export function getKimiCommonHeaders(): Record<string, string> {
|
|
89
|
+
return {
|
|
90
|
+
"User-Agent": `KimiCLI/${pkg.version}`,
|
|
91
|
+
"X-Msh-Platform": "kimi_cli",
|
|
92
|
+
"X-Msh-Version": pkg.version,
|
|
93
|
+
"X-Msh-Device-Name": os.hostname(),
|
|
94
|
+
"X-Msh-Device-Model": getDeviceModel(),
|
|
95
|
+
"X-Msh-Os-Version": os.version(),
|
|
96
|
+
"X-Msh-Device-Id": getDeviceId(),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Abortable sleep; injectable in pollForToken for tests. */
|
|
101
|
+
function wait(ms: number, signal?: AbortSignal): Promise<void> {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
if (signal?.aborted) return reject(new Error("Login cancelled"));
|
|
104
|
+
const onAbort = () => {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
reject(new Error("Login cancelled"));
|
|
107
|
+
};
|
|
108
|
+
const timer = setTimeout(() => {
|
|
109
|
+
signal?.removeEventListener("abort", onAbort);
|
|
110
|
+
resolve();
|
|
111
|
+
}, ms);
|
|
112
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function requestDeviceAuthorization(): Promise<{
|
|
117
|
+
userCode: string;
|
|
118
|
+
deviceCode: string;
|
|
119
|
+
verificationUri: string;
|
|
120
|
+
verificationUriComplete: string;
|
|
121
|
+
expiresInMs: number;
|
|
122
|
+
intervalMs: number;
|
|
123
|
+
}> {
|
|
124
|
+
const response = await fetch(`${resolveOAuthHost()}/api/oauth/device_authorization`, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: {
|
|
127
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
128
|
+
...getKimiCommonHeaders(),
|
|
129
|
+
},
|
|
130
|
+
body: new URLSearchParams({ client_id: CLIENT_ID }),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
const text = await response.text();
|
|
135
|
+
throw new Error(`Kimi device authorization failed: ${response.status} ${text}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const payload = (await response.json()) as DeviceAuthorizationResponse;
|
|
139
|
+
const { user_code: userCode, device_code: deviceCode, verification_uri: verificationUri } = payload;
|
|
140
|
+
if (!userCode || !deviceCode || !verificationUri) {
|
|
141
|
+
throw new Error("Kimi device authorization response missing required fields");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const expiresInMs = typeof payload.expires_in === "number" ? payload.expires_in * 1000 : DEFAULT_DEVICE_FLOW_TTL_MS;
|
|
145
|
+
const intervalMs =
|
|
146
|
+
typeof payload.interval === "number" && payload.interval > 0 ? payload.interval * 1000 : DEFAULT_POLL_INTERVAL_MS;
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
userCode,
|
|
150
|
+
deviceCode,
|
|
151
|
+
verificationUri,
|
|
152
|
+
verificationUriComplete: payload.verification_uri_complete || verificationUri,
|
|
153
|
+
expiresInMs,
|
|
154
|
+
intervalMs,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseTokenPayload(payload: TokenResponse, refreshTokenFallback?: string): OAuthCredentials {
|
|
159
|
+
if (!payload.access_token || typeof payload.expires_in !== "number") {
|
|
160
|
+
throw new Error("Kimi token response missing required fields");
|
|
161
|
+
}
|
|
162
|
+
const refresh = payload.refresh_token ?? refreshTokenFallback;
|
|
163
|
+
if (!refresh) {
|
|
164
|
+
throw new Error("Kimi token response missing refresh token");
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
access: payload.access_token,
|
|
168
|
+
refresh,
|
|
169
|
+
expires: Date.now() + payload.expires_in * 1000 - OAUTH_EXPIRY_SKEW_MS,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Poll the token endpoint until the device grant is approved (RFC 8628 semantics). */
|
|
174
|
+
export async function pollForToken(
|
|
175
|
+
deviceCode: string,
|
|
176
|
+
intervalMs: number,
|
|
177
|
+
expiresInMs: number,
|
|
178
|
+
signal?: AbortSignal,
|
|
179
|
+
sleep: (ms: number, signal?: AbortSignal) => Promise<void> = wait,
|
|
180
|
+
): Promise<OAuthCredentials> {
|
|
181
|
+
const deadline = Date.now() + expiresInMs;
|
|
182
|
+
let waitMs = Math.max(1000, intervalMs);
|
|
183
|
+
|
|
184
|
+
while (Date.now() < deadline) {
|
|
185
|
+
if (signal?.aborted) throw new Error("Login cancelled");
|
|
186
|
+
|
|
187
|
+
const response = await fetch(`${resolveOAuthHost()}/api/oauth/token`, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: {
|
|
190
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
191
|
+
...getKimiCommonHeaders(),
|
|
192
|
+
},
|
|
193
|
+
body: new URLSearchParams({
|
|
194
|
+
client_id: CLIENT_ID,
|
|
195
|
+
device_code: deviceCode,
|
|
196
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
197
|
+
}),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const payload = (await response.json()) as TokenResponse;
|
|
201
|
+
if (response.ok && payload.access_token) {
|
|
202
|
+
return parseTokenPayload(payload);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const error = payload.error;
|
|
206
|
+
if (error === "authorization_pending") {
|
|
207
|
+
await sleep(waitMs, signal);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (error === "slow_down") {
|
|
211
|
+
waitMs += 5000;
|
|
212
|
+
const retryAfter = typeof payload.interval === "number" ? payload.interval * 1000 : undefined;
|
|
213
|
+
if (retryAfter && retryAfter > waitMs) waitMs = retryAfter;
|
|
214
|
+
await sleep(waitMs, signal);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (error === "expired_token") {
|
|
218
|
+
throw new Error("Kimi device authorization expired");
|
|
219
|
+
}
|
|
220
|
+
if (error === "access_denied") {
|
|
221
|
+
throw new Error("Kimi device authorization denied");
|
|
222
|
+
}
|
|
223
|
+
const description = payload.error_description ? `: ${payload.error_description}` : "";
|
|
224
|
+
throw new Error(`Kimi device flow failed: ${error ?? response.status}${description}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
throw new Error("Kimi device flow timed out");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Login with Kimi Code OAuth (device code flow — no local callback server). */
|
|
231
|
+
export async function loginKimi(ctrl: OAuthController): Promise<OAuthCredentials> {
|
|
232
|
+
const device = await requestDeviceAuthorization();
|
|
233
|
+
ctrl.onAuth?.({
|
|
234
|
+
url: device.verificationUriComplete,
|
|
235
|
+
instructions: `Enter code: ${device.userCode}`,
|
|
236
|
+
});
|
|
237
|
+
return pollForToken(device.deviceCode, device.intervalMs, device.expiresInMs, ctrl.signal);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Refresh a Kimi OAuth access token. */
|
|
241
|
+
export async function refreshKimiToken(refreshToken: string): Promise<OAuthCredentials> {
|
|
242
|
+
const response = await fetch(`${resolveOAuthHost()}/api/oauth/token`, {
|
|
243
|
+
method: "POST",
|
|
244
|
+
headers: {
|
|
245
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
246
|
+
...getKimiCommonHeaders(),
|
|
247
|
+
},
|
|
248
|
+
body: new URLSearchParams({
|
|
249
|
+
grant_type: "refresh_token",
|
|
250
|
+
refresh_token: refreshToken,
|
|
251
|
+
client_id: CLIENT_ID,
|
|
252
|
+
}),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
if (!response.ok) {
|
|
256
|
+
const payload = (await response.json().catch(() => undefined)) as TokenResponse | undefined;
|
|
257
|
+
const description = payload?.error_description ? `: ${payload.error_description}` : "";
|
|
258
|
+
throw new Error(`Kimi token refresh failed: ${response.status}${description}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const payload = (await response.json()) as TokenResponse;
|
|
262
|
+
return parseTokenPayload(payload, refreshToken);
|
|
263
|
+
}
|
package/src/auth/flows/openai.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* which expects a platform API key. Use this flow with a Codex-compatible
|
|
8
8
|
* endpoint, or prefer an `OPENAI_API_KEY` for the bundled chat adapter.
|
|
9
9
|
*/
|
|
10
|
-
import { OAuthCallbackFlow } from "../callback-server";
|
|
10
|
+
import { CallbackPortUnavailableError, OAuthCallbackFlow } from "../callback-server";
|
|
11
11
|
import { generatePKCE } from "../pkce";
|
|
12
12
|
import type { OAuthController, OAuthCredentials } from "../types";
|
|
13
13
|
|
|
@@ -20,6 +20,15 @@ const SCOPE = "openid profile email offline_access";
|
|
|
20
20
|
const TIMEOUT_MS = 15_000;
|
|
21
21
|
const JWT_AUTH_CLAIM = "https://api.openai.com/auth";
|
|
22
22
|
const JWT_PROFILE_CLAIM = "https://api.openai.com/profile";
|
|
23
|
+
// Device-code (headless) fallback endpoints — gjc openai-codex.ts DEVICE_* parity.
|
|
24
|
+
const DEVICE_USERCODE_URL = "https://auth.openai.com/api/accounts/deviceauth/usercode";
|
|
25
|
+
const DEVICE_TOKEN_URL = "https://auth.openai.com/api/accounts/deviceauth/token";
|
|
26
|
+
const DEVICE_REDIRECT_URI = "https://auth.openai.com/deviceauth/callback";
|
|
27
|
+
const DEVICE_AUTH_URL = "https://auth.openai.com/codex/device";
|
|
28
|
+
const DEVICE_POLL_INTERVAL_MS = 5_000;
|
|
29
|
+
const DEVICE_POLL_SAFETY_MARGIN_MS = 3_000;
|
|
30
|
+
/** Upper bound on device-code polling to avoid infinite loops on server errors. */
|
|
31
|
+
const DEVICE_MAX_POLLS = 120;
|
|
23
32
|
|
|
24
33
|
function decodeJwt<T = Record<string, unknown>>(token: string): T | null {
|
|
25
34
|
try {
|
|
@@ -60,6 +69,11 @@ async function exchangeCodeForToken(code: string, verifier: string, redirectUri:
|
|
|
60
69
|
throw new Error("OpenAI token response missing required fields");
|
|
61
70
|
}
|
|
62
71
|
const { accountId, email } = profileFromToken(data.access_token);
|
|
72
|
+
if (!accountId) {
|
|
73
|
+
// gjc parity (openai-codex.ts:139-141): a Codex token without a chatgpt_account_id
|
|
74
|
+
// claim is unusable for the request path — fail the login instead of storing it.
|
|
75
|
+
throw new Error("Failed to extract accountId from token");
|
|
76
|
+
}
|
|
63
77
|
return {
|
|
64
78
|
access: data.access_token,
|
|
65
79
|
refresh: data.refresh_token,
|
|
@@ -93,7 +107,9 @@ class OpenAIOAuthFlow extends OAuthCallbackFlow {
|
|
|
93
107
|
state,
|
|
94
108
|
id_token_add_organizations: "true",
|
|
95
109
|
codex_cli_simplified_flow: "true",
|
|
96
|
-
|
|
110
|
+
// "codex_cli_rs" for self-consistency with jeo's request path (openai-responses.ts
|
|
111
|
+
// sends the same originator header) — deliberate divergence from gjc's "opencode" default.
|
|
112
|
+
originator: "codex_cli_rs",
|
|
97
113
|
});
|
|
98
114
|
return { url: `${AUTHORIZE_URL}?${params.toString()}`, instructions: "Complete login in your browser." };
|
|
99
115
|
}
|
|
@@ -104,7 +120,91 @@ class OpenAIOAuthFlow extends OAuthCallbackFlow {
|
|
|
104
120
|
}
|
|
105
121
|
|
|
106
122
|
export async function loginOpenAI(ctrl: OAuthController): Promise<OAuthCredentials> {
|
|
107
|
-
|
|
123
|
+
try {
|
|
124
|
+
return await new OpenAIOAuthFlow(ctrl).login();
|
|
125
|
+
} catch (err) {
|
|
126
|
+
// OpenAI requires the FIXED redirect http://localhost:1455/auth/callback, so a busy
|
|
127
|
+
// port cannot fall back to a random one. Fall back to the device-code flow instead
|
|
128
|
+
// (gjc loginOpenAICodexDevice parity) — no local callback server needed.
|
|
129
|
+
if (err instanceof CallbackPortUnavailableError) {
|
|
130
|
+
ctrl.onProgress?.(`Port ${CALLBACK_PORT} is busy — falling back to device-code login.`);
|
|
131
|
+
return loginOpenAIDevice(ctrl);
|
|
132
|
+
}
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Login using OpenAI's device-code (headless) flow — no local callback server.
|
|
139
|
+
* Port of gjc's loginOpenAICodexDevice (openai-codex.ts): request a user code,
|
|
140
|
+
* surface it via the controller, poll for the authorization code (403/404 =
|
|
141
|
+
* authorization pending), then run the standard token exchange against the
|
|
142
|
+
* fixed device redirect URI.
|
|
143
|
+
*/
|
|
144
|
+
export async function loginOpenAIDevice(ctrl: OAuthController): Promise<OAuthCredentials> {
|
|
145
|
+
ctrl.onProgress?.("Initiating device authorization…");
|
|
146
|
+
|
|
147
|
+
const initResponse = await fetch(DEVICE_USERCODE_URL, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: { "content-type": "application/json" },
|
|
150
|
+
body: JSON.stringify({ client_id: CLIENT_ID }),
|
|
151
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
152
|
+
});
|
|
153
|
+
if (!initResponse.ok) {
|
|
154
|
+
throw new Error(`Device authorization initiation failed: ${initResponse.status}`);
|
|
155
|
+
}
|
|
156
|
+
const initData = (await initResponse.json()) as {
|
|
157
|
+
device_auth_id?: string;
|
|
158
|
+
user_code?: string;
|
|
159
|
+
interval?: string | number;
|
|
160
|
+
};
|
|
161
|
+
if (!initData.device_auth_id || !initData.user_code) {
|
|
162
|
+
throw new Error("Device authorization response missing required fields");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const userCode = initData.user_code;
|
|
166
|
+
const pollIntervalMs =
|
|
167
|
+
(typeof initData.interval === "number"
|
|
168
|
+
? initData.interval
|
|
169
|
+
: parseInt(String(initData.interval ?? "5"), 10) || 5) *
|
|
170
|
+
1000 +
|
|
171
|
+
DEVICE_POLL_SAFETY_MARGIN_MS;
|
|
172
|
+
|
|
173
|
+
ctrl.onAuth?.({ url: DEVICE_AUTH_URL, instructions: `Enter code: ${userCode}` });
|
|
174
|
+
ctrl.onProgress?.(`Waiting for browser authorization (code: ${userCode})…`);
|
|
175
|
+
|
|
176
|
+
for (let poll = 0; poll < DEVICE_MAX_POLLS; poll++) {
|
|
177
|
+
await Bun.sleep(poll === 0 ? Math.min(pollIntervalMs, DEVICE_POLL_INTERVAL_MS) : pollIntervalMs);
|
|
178
|
+
if (ctrl.signal?.aborted) {
|
|
179
|
+
throw new Error("Device authorization cancelled");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const pollResponse = await fetch(DEVICE_TOKEN_URL, {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: { "content-type": "application/json" },
|
|
185
|
+
body: JSON.stringify({ device_auth_id: initData.device_auth_id, user_code: userCode }),
|
|
186
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// 403/404 = authorization pending, keep polling (gjc wire parity).
|
|
190
|
+
if (pollResponse.status === 403 || pollResponse.status === 404) continue;
|
|
191
|
+
if (!pollResponse.ok) {
|
|
192
|
+
throw new Error(`Device token polling failed: ${pollResponse.status}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const pollData = (await pollResponse.json()) as {
|
|
196
|
+
authorization_code?: string;
|
|
197
|
+
code_verifier?: string;
|
|
198
|
+
};
|
|
199
|
+
if (!pollData.authorization_code || !pollData.code_verifier) {
|
|
200
|
+
throw new Error("Device token response missing authorization_code or code_verifier");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
ctrl.onProgress?.("Exchanging authorization code for tokens…");
|
|
204
|
+
return exchangeCodeForToken(pollData.authorization_code, pollData.code_verifier, DEVICE_REDIRECT_URI);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
throw new Error("Device authorization timed out — user did not complete login in time");
|
|
108
208
|
}
|
|
109
209
|
|
|
110
210
|
export async function refreshOpenAIToken(refreshToken: string): Promise<OAuthCredentials> {
|
package/src/auth/oauth.ts
CHANGED
|
@@ -44,6 +44,15 @@ export const OAUTH_FLOWS: Record<OAuthProvider, OauthFlowDef> = {
|
|
|
44
44
|
"Use this for antigravity/* models; Gemini CLI OAuth can be imported separately but may not satisfy Antigravity backend permissions.",
|
|
45
45
|
],
|
|
46
46
|
},
|
|
47
|
+
kimi: {
|
|
48
|
+
label: "Kimi Code (Moonshot subscription)",
|
|
49
|
+
authorizeUrl: "https://auth.kimi.com",
|
|
50
|
+
instructions: [
|
|
51
|
+
"Device-code OAuth: no callback server. A browser tab opens at auth.kimi.com with the",
|
|
52
|
+
"verification URL; confirm the displayed user code there and jeo polls until approval.",
|
|
53
|
+
"Note: the minted token targets api.kimi.com/coding (Anthropic Messages format), not api.moonshot.ai.",
|
|
54
|
+
],
|
|
55
|
+
},
|
|
47
56
|
};
|
|
48
57
|
|
|
49
58
|
export async function openInBrowser(url: string): Promise<void> {
|
package/src/auth/refresh.ts
CHANGED
|
@@ -37,6 +37,11 @@ export interface RefreshResult {
|
|
|
37
37
|
credential: Credential;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** Refresh-ahead window: a token expiring within this many ms is treated as stale
|
|
41
|
+
* and refreshed NOW, so it never dies mid-request. gjc parity: auth-storage.ts
|
|
42
|
+
* OAUTH_REFRESH_SKEW_MS = 60_000. */
|
|
43
|
+
export const OAUTH_REFRESH_SKEW_MS = 60_000;
|
|
44
|
+
|
|
40
45
|
/**
|
|
41
46
|
* Exchange the stored refresh token for a fresh access token via the provider's
|
|
42
47
|
* real OAuth token endpoint, persist it, and return the updated credential.
|
|
@@ -56,7 +61,7 @@ export async function refreshOAuthToken(provider: AuthProvider): Promise<Refresh
|
|
|
56
61
|
return { refreshed: false, reason, credential: await resolveCredential(provider) };
|
|
57
62
|
}
|
|
58
63
|
|
|
59
|
-
if (stored.expires && stored.expires > Date.now()) {
|
|
64
|
+
if (stored.expires && stored.expires > Date.now() + OAUTH_REFRESH_SKEW_MS) {
|
|
60
65
|
return {
|
|
61
66
|
refreshed: true,
|
|
62
67
|
reason: "already_refreshed",
|
package/src/auth/storage.ts
CHANGED
|
@@ -6,16 +6,16 @@ import { jeoEnv } from "../util/env";
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
/** Providers with an interactive OAuth login + refresh flow. */
|
|
9
|
-
export type OAuthProvider = "anthropic" | "openai" | "gemini" | "antigravity";
|
|
9
|
+
export type OAuthProvider = "anthropic" | "openai" | "gemini" | "antigravity" | "kimi";
|
|
10
10
|
/** Every provider jeo resolves a credential for: OAuth-capable ∪ API-key-only. */
|
|
11
|
-
export type AuthProvider = OAuthProvider | "xai" | "
|
|
11
|
+
export type AuthProvider = OAuthProvider | "xai" | "groq" | "deepseek" | "mistral" | "openrouter" | "together" | "cerebras" | "fireworks" | "nvidia" | "alibaba-coding-plan" | "huggingface" | "nanogpt" | "qwen-portal" | "synthetic" | "venice" | "zenmux" | "qianfan" | "xiaomi" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-sgp" | "minimax-code" | "minimax-code-cn" | "zai" | "minimax" | "tencent";
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
export const OAUTH_PROVIDERS: readonly OAuthProvider[] = ["anthropic", "openai", "gemini", "antigravity"];
|
|
14
|
+
export const OAUTH_PROVIDERS: readonly OAuthProvider[] = ["anthropic", "openai", "gemini", "antigravity", "kimi"];
|
|
15
15
|
/** API-key-only providers (no OAuth flow) — resolved from config.providers / `<NAME>_API_KEY`. */
|
|
16
|
-
export const API_KEY_ONLY_PROVIDERS: readonly AuthProvider[] = ["xai", "
|
|
16
|
+
export const API_KEY_ONLY_PROVIDERS: readonly AuthProvider[] = ["xai", "groq", "deepseek", "mistral", "openrouter", "together", "cerebras", "fireworks", "nvidia", "alibaba-coding-plan", "huggingface", "nanogpt", "qwen-portal", "synthetic", "venice", "zenmux", "qianfan", "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp", "minimax-code", "minimax-code-cn", "zai", "minimax", "tencent"];
|
|
17
17
|
|
|
18
|
-
/** Narrow an AuthProvider to the OAuth-capable subset (xai
|
|
18
|
+
/** Narrow an AuthProvider to the OAuth-capable subset (xai has no OAuth flow). */
|
|
19
19
|
export function isOAuthProvider(p: AuthProvider): p is OAuthProvider {
|
|
20
20
|
return (OAUTH_PROVIDERS as readonly string[]).includes(p);
|
|
21
21
|
}
|