jeo-code 0.7.27 → 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 +16 -0
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/package.json +1 -1
- package/src/agent/config-schema.ts +1 -0
- package/src/agent/engine.ts +9 -2
- package/src/agent/loop.ts +3 -0
- package/src/agent/state.ts +84 -5
- package/src/agent/task-tool.ts +3 -0
- package/src/ai/model-catalog.ts +23 -0
- package/src/ai/model-discovery.ts +6 -2
- package/src/ai/model-manager.ts +33 -12
- package/src/ai/providers/anthropic.ts +56 -17
- 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.ts +6 -2
- package/src/commands/team.ts +3 -0
- package/src/tui/components/welcome.ts +98 -39
- package/src/util/retry.ts +68 -31
|
@@ -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
|
}
|
package/src/commands/launch.ts
CHANGED
|
@@ -1027,6 +1027,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1027
1027
|
maxTokens: resolveMaxOutputTokens(activeModel, sessionThinking),
|
|
1028
1028
|
reasoningEffort: sessionThinking ? thinkingToReasoningEffort(sessionThinking) : undefined,
|
|
1029
1029
|
signal: ac.signal,
|
|
1030
|
+
sessionKey: sessionId,
|
|
1030
1031
|
steer: drainSteer,
|
|
1031
1032
|
events: wrapEvents(withStepPersistence({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, persistTurnTail), opik),
|
|
1032
1033
|
});
|
|
@@ -1046,6 +1047,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1046
1047
|
maxTokens: resolveMaxOutputTokens(activeModel, sessionThinking),
|
|
1047
1048
|
reasoningEffort: sessionThinking ? thinkingToReasoningEffort(sessionThinking) : undefined,
|
|
1048
1049
|
signal: ac.signal,
|
|
1050
|
+
sessionKey: sessionId,
|
|
1049
1051
|
steer: drainSteer,
|
|
1050
1052
|
events: wrapEvents(withToolDetailCapture(tui ? tui.events() : streamEvents), opik),
|
|
1051
1053
|
});
|
|
@@ -2693,7 +2695,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2693
2695
|
// TTY only — the caller prints scriptable guidance otherwise.
|
|
2694
2696
|
const pickApiKeyProvider = async (statuses: Awaited<ReturnType<typeof describeAllProviders>>): Promise<AuthProvider | undefined> => {
|
|
2695
2697
|
const subs = new Set<string>(SUBSCRIPTION_PROVIDER_NAMES); // surfaced under OAuth/subscription login instead
|
|
2696
|
-
|
|
2698
|
+
// kimi is dual-credential (moonshot API key OR Kimi Code OAuth) — keep it key-settable.
|
|
2699
|
+
const keyed = new Set<string>([...API_KEY_ONLY_PROVIDERS, "kimi"]);
|
|
2697
2700
|
const list = apiKeyPicker(statuses.filter(s => keyed.has(s.name) && !subs.has(s.name)), true);
|
|
2698
2701
|
let chosen: ProviderName | undefined;
|
|
2699
2702
|
await runSelectPicker(
|
|
@@ -3693,7 +3696,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3693
3696
|
// `/provider key [name] [key]` → store an API key for an API-key-only provider
|
|
3694
3697
|
// (groq/deepseek/mistral/…). Interactive: pick the provider, then paste the key.
|
|
3695
3698
|
if (name === "key") {
|
|
3696
|
-
|
|
3699
|
+
// kimi is dual-credential (moonshot API key OR Kimi Code OAuth) — keep it key-settable.
|
|
3700
|
+
const keyed = new Set<string>([...API_KEY_ONLY_PROVIDERS, "kimi"]);
|
|
3697
3701
|
let target = tokens.slice(1).map(t => t.toLowerCase()).find(t => keyed.has(t)) as AuthProvider | undefined;
|
|
3698
3702
|
// A trailing token after the provider name is treated as the key itself.
|
|
3699
3703
|
const inlineKey = target ? tokens.slice(1).filter(t => t.toLowerCase() !== target).pop() : undefined;
|
package/src/commands/team.ts
CHANGED
|
@@ -437,6 +437,9 @@ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: str
|
|
|
437
437
|
cwd: ctx.cwd,
|
|
438
438
|
model,
|
|
439
439
|
maxSteps,
|
|
440
|
+
// Per-run prompt-cache key: each worker replays its own growing history every
|
|
441
|
+
// step, so a stable per-run key gets provider cache hits (gjc sub-session parity).
|
|
442
|
+
sessionKey: crypto.randomUUID(),
|
|
440
443
|
// Bounded delegation: ralph/team subagents keep an exact step contract; the
|
|
441
444
|
// orchestrator owns retries, so the gjc step-extension flow is disabled here.
|
|
442
445
|
budget: { maxExtensions: 0 },
|