lazyclaw 6.3.0 → 6.4.0
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/README.ko.md +5 -1
- package/README.md +17 -13
- package/agents.mjs +54 -4
- package/channels-discord/index.mjs +5 -1
- package/channels-email/index.mjs +4 -3
- package/channels-whatsapp/index.mjs +3 -2
- package/chat_window.mjs +11 -2
- package/cli.mjs +103 -3
- package/commands/agents.mjs +6 -1
- package/commands/auth_nodes.mjs +19 -1
- package/commands/automation.mjs +20 -5
- package/commands/channels.mjs +46 -3
- package/commands/chat.mjs +95 -37
- package/commands/chat_hardening.mjs +23 -0
- package/commands/daemon.mjs +99 -1
- package/commands/gateway.mjs +123 -4
- package/commands/mcp.mjs +150 -0
- package/commands/misc.mjs +20 -0
- package/commands/sessions.mjs +68 -8
- package/commands/setup.mjs +44 -8
- package/commands/setup_channels.mjs +219 -47
- package/commands/skills.mjs +19 -1
- package/commands/workflow_named.mjs +137 -0
- package/config-validate.mjs +10 -1
- package/config_features.mjs +45 -0
- package/cron.mjs +19 -8
- package/daemon/lib/auth.mjs +25 -0
- package/daemon/lib/cost.mjs +36 -0
- package/daemon/lib/respond.mjs +20 -1
- package/daemon/lib/team_inbound.mjs +69 -0
- package/daemon/route_table.mjs +5 -1
- package/daemon/routes/_deps.mjs +2 -2
- package/daemon/routes/config.mjs +11 -6
- package/daemon/routes/conversation.mjs +103 -40
- package/daemon/routes/events.mjs +45 -0
- package/daemon/routes/meta.mjs +38 -7
- package/daemon/routes/providers.mjs +26 -6
- package/daemon/routes/workflows.mjs +30 -0
- package/daemon.mjs +26 -2
- package/dotenv_min.mjs +15 -3
- package/gateway/challenge_registry.mjs +90 -0
- package/gateway/device_auth.mjs +71 -97
- package/gateway/http_gateway.mjs +29 -3
- package/lib/args.mjs +69 -3
- package/lib/config.mjs +85 -3
- package/lib/render.mjs +43 -0
- package/lib/service_install.mjs +15 -1
- package/mas/agent_turn.mjs +61 -12
- package/mas/confidence.mjs +20 -1
- package/mas/embedder.mjs +100 -0
- package/mas/events.mjs +57 -0
- package/mas/index_db.mjs +103 -10
- package/mas/learning.mjs +96 -72
- package/mas/mention_router.mjs +48 -90
- package/mas/prompt_stack.mjs +60 -3
- package/mas/recall_blend.mjs +56 -0
- package/mas/redact.mjs +55 -0
- package/mas/router_posting.mjs +96 -0
- package/mas/router_termination.mjs +63 -0
- package/mas/scrub_env.mjs +21 -6
- package/mas/skill_synth.mjs +7 -33
- package/mas/tool_runner.mjs +3 -2
- package/mas/tools/bash.mjs +9 -2
- package/mas/tools/coding.mjs +28 -5
- package/mas/tools/delegation.mjs +34 -4
- package/mas/tools/git.mjs +19 -9
- package/mas/tools/ha.mjs +2 -0
- package/mas/tools/learning.mjs +55 -0
- package/mas/tools/media.mjs +21 -3
- package/mas/tools/os.mjs +70 -16
- package/mas/tools/recall.mjs +28 -2
- package/mcp/client.mjs +8 -1
- package/mcp/server_spawn.mjs +5 -1
- package/memory.mjs +24 -0
- package/package.json +3 -1
- package/providers/anthropic.mjs +101 -6
- package/providers/cache.mjs +9 -1
- package/providers/claude_cli.mjs +104 -22
- package/providers/claude_cli_session.mjs +166 -0
- package/providers/cli_error.mjs +38 -0
- package/providers/cli_login.mjs +179 -0
- package/providers/codex_cli.mjs +66 -11
- package/providers/gemini.mjs +101 -3
- package/providers/gemini_cli.mjs +62 -15
- package/providers/model_catalogue.mjs +33 -2
- package/providers/ollama.mjs +104 -3
- package/providers/openai.mjs +110 -8
- package/providers/openai_compat.mjs +97 -6
- package/providers/orchestrator.mjs +112 -12
- package/providers/registry.mjs +15 -9
- package/providers/retry.mjs +14 -2
- package/providers/tool_use/anthropic.mjs +17 -3
- package/providers/tool_use/claude_cli.mjs +72 -20
- package/providers/tool_use/gemini.mjs +18 -2
- package/providers/tool_use/openai.mjs +28 -5
- package/sandbox/confiners/seatbelt.mjs +37 -12
- package/sandbox/index.mjs +49 -0
- package/sandbox/local.mjs +7 -1
- package/sandbox/spawn.mjs +144 -0
- package/sandbox.mjs +5 -1
- package/scripts/loop-worker.mjs +25 -1
- package/sessions.mjs +0 -0
- package/skills/channel-style.md +20 -0
- package/skills/code-review.md +33 -0
- package/skills/commit-message.md +30 -0
- package/skills/concise.md +24 -0
- package/skills/debug-coach.md +25 -0
- package/skills/explain.md +24 -0
- package/skills/korean.md +25 -0
- package/skills/summarize.md +33 -0
- package/skills.mjs +24 -2
- package/skills_curator.mjs +6 -0
- package/skills_install.mjs +10 -2
- package/tasks.mjs +6 -1
- package/teams.mjs +78 -0
- package/tui/chat_mode_slash.mjs +59 -0
- package/tui/config_picker.mjs +1 -0
- package/tui/editor.mjs +178 -151
- package/tui/editor_keys.mjs +275 -0
- package/tui/hud.mjs +111 -0
- package/tui/login_flow.mjs +113 -0
- package/tui/modal_picker.mjs +10 -1
- package/tui/model_pick.mjs +287 -0
- package/tui/orchestrator_flow.mjs +164 -0
- package/tui/pickers.mjs +210 -95
- package/tui/repl.mjs +106 -44
- package/tui/repl_reset.mjs +37 -0
- package/tui/run_turn.mjs +228 -26
- package/tui/slash_args.mjs +159 -0
- package/tui/slash_commands.mjs +7 -5
- package/tui/slash_dispatcher.mjs +537 -344
- package/tui/slash_popup.mjs +5 -1
- package/tui/splash.mjs +15 -2
- package/tui/status_bar.mjs +26 -0
- package/tui/theme.mjs +28 -0
- package/web/avatars/01.png +0 -0
- package/web/avatars/02.png +0 -0
- package/web/avatars/03.png +0 -0
- package/web/avatars/04.png +0 -0
- package/web/avatars/05.png +0 -0
- package/web/avatars/06.png +0 -0
- package/web/avatars/07.png +0 -0
- package/web/avatars/08.png +0 -0
- package/web/avatars/09.png +0 -0
- package/web/avatars/10.png +0 -0
- package/web/avatars/11.png +0 -0
- package/web/avatars/12.png +0 -0
- package/web/avatars/13.png +0 -0
- package/web/avatars/14.png +0 -0
- package/web/avatars/15.png +0 -0
- package/web/avatars/16.png +0 -0
- package/web/avatars/17.png +0 -0
- package/web/avatars/18.png +0 -0
- package/web/avatars/19.png +0 -0
- package/web/avatars/20.png +0 -0
- package/web/dashboard.css +77 -0
- package/web/dashboard.html +29 -2
- package/web/dashboard.js +296 -33
- package/workflow/builtin_caps.mjs +94 -0
- package/workflow/declarative.mjs +101 -0
- package/workflow/named.mjs +71 -0
- package/workflow/named_cron.mjs +50 -0
- package/workflow/nodes.mjs +67 -0
- package/workflow/run_request.mjs +74 -0
- package/workflow/yaml_min.mjs +97 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// providers/cli_login.mjs — connect/login helpers for the keyless CLI
|
|
2
|
+
// providers (codex-cli / gemini-cli) so the picker can offer an inline
|
|
3
|
+
// "log in / connect" action instead of dead-ending on the CLI's own
|
|
4
|
+
// "please log in" message.
|
|
5
|
+
//
|
|
6
|
+
// Two halves:
|
|
7
|
+
// - cliLoginStatus(): pure, dependency-injectable detection of whether a
|
|
8
|
+
// provider's CLI is installed and signed in (unit-tested).
|
|
9
|
+
// - runCliLoginInteractive(): spawns the real login / install subprocess
|
|
10
|
+
// with the terminal inherited. Driven from the chat post-loop guard
|
|
11
|
+
// after the Ink UI releases stdin (same mechanism /setup uses), so the
|
|
12
|
+
// browser-OAuth flow gets a real TTY.
|
|
13
|
+
//
|
|
14
|
+
// codex has a headless login (`codex login`, `codex login status`); gemini
|
|
15
|
+
// does NOT — its Google sign-in only happens by launching `gemini`
|
|
16
|
+
// interactively, or by supplying GEMINI_API_KEY. We model both.
|
|
17
|
+
|
|
18
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
19
|
+
import { existsSync as _existsSync } from 'node:fs';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
export const CLI_LOGIN_PROVIDERS = {
|
|
24
|
+
'codex-cli': {
|
|
25
|
+
bin: 'codex',
|
|
26
|
+
pkg: '@openai/codex',
|
|
27
|
+
// `codex login` opens a browser for ChatGPT OAuth; `codex login status`
|
|
28
|
+
// exits 0 when signed in. `codex login --with-api-key` reads a key on stdin.
|
|
29
|
+
loginArgs: ['login'],
|
|
30
|
+
statusArgs: ['login', 'status'],
|
|
31
|
+
apiKeyStdinArgs: ['login', '--with-api-key'],
|
|
32
|
+
browserHint: 'codex login',
|
|
33
|
+
apiKeyHint: 'OpenAI key (sk-…) — stored by codex via `codex login --with-api-key`',
|
|
34
|
+
},
|
|
35
|
+
'gemini-cli': {
|
|
36
|
+
bin: 'gemini',
|
|
37
|
+
pkg: '@google/gemini-cli',
|
|
38
|
+
// No headless login: Google OAuth only runs when `gemini` is launched
|
|
39
|
+
// interactively. We detect sign-in by the presence of the OAuth creds
|
|
40
|
+
// file (or a GEMINI_API_KEY in the environment).
|
|
41
|
+
loginArgs: [],
|
|
42
|
+
credPath: join(homedir(), '.gemini', 'oauth_creds.json'),
|
|
43
|
+
apiKeyEnv: 'GEMINI_API_KEY',
|
|
44
|
+
browserHint: 'gemini (Google sign-in)',
|
|
45
|
+
apiKeyHint: 'Google AI Studio key — saved in lazyclaw and passed as GEMINI_API_KEY',
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Locate a binary on PATH without throwing. Returns the path or ''.
|
|
50
|
+
function _whichSync(bin) {
|
|
51
|
+
try {
|
|
52
|
+
const r = spawnSync(process.platform === 'win32' ? 'where' : 'which', [bin], { encoding: 'utf8' });
|
|
53
|
+
if (r.status === 0) return String(r.stdout || '').split('\n')[0].trim();
|
|
54
|
+
} catch (_) { /* ignore */ }
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Run `<bin> <args>` just for its exit code (codex login status). Resolves the
|
|
59
|
+
// numeric code (or 1 on spawn error) — never rejects.
|
|
60
|
+
function _runForCode(bin, args) {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
let proc;
|
|
63
|
+
try {
|
|
64
|
+
proc = spawn(bin, args, { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
65
|
+
} catch (_) { resolve(1); return; }
|
|
66
|
+
proc.once('error', () => resolve(1));
|
|
67
|
+
proc.once('close', (code) => resolve(typeof code === 'number' ? code : 1));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Detect whether a keyless CLI provider is installed and signed in.
|
|
73
|
+
* All side-effecting lookups are injectable so this is unit-testable.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} provName 'codex-cli' | 'gemini-cli'
|
|
76
|
+
* @param {{
|
|
77
|
+
* which?: (bin:string)=>string,
|
|
78
|
+
* runStatus?: (bin:string, args:string[])=>Promise<number>,
|
|
79
|
+
* existsSync?: (p:string)=>boolean,
|
|
80
|
+
* env?: Record<string,string|undefined>,
|
|
81
|
+
* hasStoredKey?: boolean, // a key already saved in lazyclaw for this provider
|
|
82
|
+
* }} [deps]
|
|
83
|
+
* @returns {Promise<{supported:boolean, binMissing?:boolean, loggedIn?:boolean, via?:string, pkg?:string}>}
|
|
84
|
+
*/
|
|
85
|
+
export async function cliLoginStatus(provName, deps = {}) {
|
|
86
|
+
const spec = CLI_LOGIN_PROVIDERS[provName];
|
|
87
|
+
if (!spec) return { supported: false };
|
|
88
|
+
const which = deps.which || _whichSync;
|
|
89
|
+
if (!which(spec.bin)) {
|
|
90
|
+
return { supported: true, binMissing: true, loggedIn: false, pkg: spec.pkg };
|
|
91
|
+
}
|
|
92
|
+
// An explicit lazyclaw-stored key means we can authenticate regardless of
|
|
93
|
+
// the CLI's own login state (codex via env, gemini via GEMINI_API_KEY).
|
|
94
|
+
if (deps.hasStoredKey) return { supported: true, binMissing: false, loggedIn: true, via: 'api-key' };
|
|
95
|
+
if (spec.statusArgs) {
|
|
96
|
+
const code = await (deps.runStatus || _runForCode)(spec.bin, spec.statusArgs);
|
|
97
|
+
return { supported: true, binMissing: false, loggedIn: code === 0, via: `${spec.bin} ${spec.statusArgs.join(' ')}` };
|
|
98
|
+
}
|
|
99
|
+
// gemini: no status command — infer from creds file / env key.
|
|
100
|
+
const existsSync = deps.existsSync || _existsSync;
|
|
101
|
+
const env = deps.env || process.env;
|
|
102
|
+
const ok = (!!spec.credPath && existsSync(spec.credPath)) || (!!spec.apiKeyEnv && !!env[spec.apiKeyEnv]);
|
|
103
|
+
return { supported: true, binMissing: false, loggedIn: ok, via: 'creds/env' };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Spawn a command with the terminal inherited; resolve on close (never reject).
|
|
107
|
+
function _spawnInherit(bin, args, opts = {}) {
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
let proc;
|
|
110
|
+
try {
|
|
111
|
+
proc = spawn(bin, args, { stdio: 'inherit', ...opts });
|
|
112
|
+
} catch (e) {
|
|
113
|
+
process.stderr.write(`\n failed to launch ${bin}: ${e?.message || e}\n`);
|
|
114
|
+
resolve(1);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
proc.once('error', (e) => { process.stderr.write(`\n ${bin} error: ${e?.message || e}\n`); resolve(1); });
|
|
118
|
+
proc.once('close', (code) => resolve(typeof code === 'number' ? code : 0));
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Pipe a secret to a command's stdin (codex login --with-api-key), inheriting
|
|
123
|
+
// stdout/stderr so the user sees the result. Resolves the exit code.
|
|
124
|
+
function _spawnKeyStdin(bin, args, key) {
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
let proc;
|
|
127
|
+
try {
|
|
128
|
+
proc = spawn(bin, args, { stdio: ['pipe', 'inherit', 'inherit'] });
|
|
129
|
+
} catch (e) { process.stderr.write(`\n failed to launch ${bin}: ${e?.message || e}\n`); resolve(1); return; }
|
|
130
|
+
proc.once('error', (e) => { process.stderr.write(`\n ${bin} error: ${e?.message || e}\n`); resolve(1); });
|
|
131
|
+
proc.once('close', (code) => resolve(typeof code === 'number' ? code : 0));
|
|
132
|
+
try { proc.stdin.write(String(key || '')); proc.stdin.end(); } catch (_) { /* closed already */ }
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Run the chosen connect action in the foreground (terminal inherited). Called
|
|
138
|
+
* from the chat post-loop guard once Ink has released stdin.
|
|
139
|
+
*
|
|
140
|
+
* @param {{ provider:string, mode:'browser'|'install'|'apikey', apiKey?:string }} req
|
|
141
|
+
*/
|
|
142
|
+
export async function runCliLoginInteractive(req = {}) {
|
|
143
|
+
const { provider, mode, apiKey } = req;
|
|
144
|
+
const spec = CLI_LOGIN_PROVIDERS[provider];
|
|
145
|
+
if (!spec) { process.stderr.write(`\n unknown login provider: ${provider}\n`); return; }
|
|
146
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
147
|
+
const ok = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
148
|
+
|
|
149
|
+
if (mode === 'install') {
|
|
150
|
+
process.stdout.write(`\n Installing ${spec.pkg} … ${dim(`npm i -g ${spec.pkg}`)}\n\n`);
|
|
151
|
+
const code = await _spawnInherit('npm', ['i', '-g', spec.pkg]);
|
|
152
|
+
process.stdout.write(code === 0
|
|
153
|
+
? `\n ${ok('✓ installed')} ${spec.pkg}. Re-open ${dim(`/provider ${provider}`)} to sign in.\n\n`
|
|
154
|
+
: `\n install exited ${code}. You can run ${dim(`npm i -g ${spec.pkg}`)} yourself.\n\n`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (mode === 'apikey') {
|
|
159
|
+
if (provider === 'codex-cli') {
|
|
160
|
+
process.stdout.write(`\n Storing your OpenAI key via ${dim('codex login --with-api-key')} …\n\n`);
|
|
161
|
+
const code = await _spawnKeyStdin(spec.bin, spec.apiKeyStdinArgs, apiKey);
|
|
162
|
+
process.stdout.write(code === 0 ? `\n ${ok('✓ codex signed in with an API key.')}\n\n` : `\n codex login exited ${code}.\n\n`);
|
|
163
|
+
}
|
|
164
|
+
// gemini's key is persisted in lazyclaw config by the caller and injected
|
|
165
|
+
// as GEMINI_API_KEY at spawn time — nothing to run here.
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Browser OAuth.
|
|
170
|
+
if (provider === 'codex-cli') {
|
|
171
|
+
process.stdout.write(`\n Opening ${dim('codex login')} — a sign-in URL will appear. Complete it in your browser, then return here.\n\n`);
|
|
172
|
+
const code = await _spawnInherit(spec.bin, spec.loginArgs);
|
|
173
|
+
process.stdout.write(code === 0 ? `\n ${ok('✓ codex signed in.')}\n\n` : `\n codex login exited ${code}.\n\n`);
|
|
174
|
+
} else if (provider === 'gemini-cli') {
|
|
175
|
+
process.stdout.write(`\n Launching ${dim('gemini')} for Google sign-in. Authenticate in the browser, then ${dim('/quit')} (or Ctrl-C) inside gemini to return.\n\n`);
|
|
176
|
+
await _spawnInherit(spec.bin, spec.loginArgs);
|
|
177
|
+
process.stdout.write(`\n Back in lazyclaw.\n\n`);
|
|
178
|
+
}
|
|
179
|
+
}
|
package/providers/codex_cli.mjs
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
// shell input.
|
|
31
31
|
|
|
32
32
|
import { spawnSandboxed } from '../sandbox.mjs';
|
|
33
|
+
import { classifyCliExit } from './cli_error.mjs';
|
|
33
34
|
|
|
34
35
|
class AbortError extends Error {
|
|
35
36
|
constructor(message = 'aborted') {
|
|
@@ -51,19 +52,23 @@ class CliExitError extends Error {
|
|
|
51
52
|
constructor(code, signal, stderr) {
|
|
52
53
|
super(`codex CLI exited ${code ?? signal}: ${String(stderr).slice(0, 400)}`);
|
|
53
54
|
this.name = 'CodexCliExitError';
|
|
54
|
-
|
|
55
|
+
// Transient upstream throttle → retriable RATE_LIMIT; genuine cap → CLI_EXIT.
|
|
56
|
+
const cls = classifyCliExit(stderr);
|
|
57
|
+
this.code = cls.code;
|
|
58
|
+
if (cls.retryAfterMs !== undefined) this.retryAfterMs = cls.retryAfterMs;
|
|
55
59
|
this.exitCode = code;
|
|
56
60
|
this.signal = signal;
|
|
57
61
|
this.stderr = stderr;
|
|
58
62
|
}
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
// No alias map. The previous aliases mapped "codex"/"gpt-codex" to
|
|
66
|
+
// "gpt-5-codex", but that model is rejected by a ChatGPT-account codex
|
|
67
|
+
// login ("not supported when using Codex with a ChatGPT account"), so the
|
|
68
|
+
// alias actively produced a broken `-m`. An empty/unknown model now means
|
|
69
|
+
// "no -m" → codex falls back to the account default in ~/.codex/config.toml,
|
|
70
|
+
// which is the only model set guaranteed to be allowed for that login.
|
|
71
|
+
const _ALIASES = {};
|
|
67
72
|
|
|
68
73
|
// Drop cross-vendor model ids (claude-*, gemini-*) silently so the
|
|
69
74
|
// CLI falls back to its own default. The orchestrator workflow forwards
|
|
@@ -107,13 +112,45 @@ function extractEventText(obj) {
|
|
|
107
112
|
return '';
|
|
108
113
|
}
|
|
109
114
|
|
|
115
|
+
// Pull a human-readable error out of a codex failure event. Codex reports
|
|
116
|
+
// API/turn failures on STDOUT (not stderr) as either:
|
|
117
|
+
// {"type":"error","message":"<json-string>"}
|
|
118
|
+
// {"type":"turn.failed","error":{"message":"<json-string>"}}
|
|
119
|
+
// where the message is usually itself a JSON document
|
|
120
|
+
// {"type":"error","status":400,"error":{"message":"<the real reason>"}}.
|
|
121
|
+
// We unwrap one level of nesting so callers surface "The 'x' model is not
|
|
122
|
+
// supported …" instead of the misleading "Reading additional input from
|
|
123
|
+
// stdin…" the CLI happens to print on stderr. Returns '' for non-error events.
|
|
124
|
+
function extractEventError(obj) {
|
|
125
|
+
if (!obj || typeof obj !== 'object') return '';
|
|
126
|
+
if (obj.type !== 'error' && obj.type !== 'turn.failed') return '';
|
|
127
|
+
let raw = obj.type === 'turn.failed' ? (obj.error?.message ?? obj.message) : obj.message;
|
|
128
|
+
if (typeof raw === 'string') {
|
|
129
|
+
try {
|
|
130
|
+
const inner = JSON.parse(raw);
|
|
131
|
+
raw = inner?.error?.message || inner?.message || raw;
|
|
132
|
+
} catch (_) { /* not nested JSON — use the string as-is */ }
|
|
133
|
+
}
|
|
134
|
+
return typeof raw === 'string' ? raw : JSON.stringify(raw ?? obj);
|
|
135
|
+
}
|
|
136
|
+
|
|
110
137
|
function extractUsage(obj) {
|
|
111
138
|
if (!obj || typeof obj !== 'object' || obj.type !== 'turn.completed') return null;
|
|
112
139
|
const u = obj.usage || {};
|
|
113
|
-
|
|
114
|
-
|
|
140
|
+
// OpenAI Responses-API convention (codex follows it): input_tokens already
|
|
141
|
+
// INCLUDES cached_input_tokens, and output_tokens already INCLUDES
|
|
142
|
+
// reasoning_output_tokens — they are subset breakdowns, not additive. Summing
|
|
143
|
+
// them double-counts and trips the cost cap early. Report the totals and
|
|
144
|
+
// surface cached input separately so it can bill at the cache-read rate.
|
|
145
|
+
const input = u.input_tokens ?? 0;
|
|
146
|
+
const output = u.output_tokens ?? 0;
|
|
115
147
|
if (!input && !output) return null;
|
|
116
|
-
return {
|
|
148
|
+
return {
|
|
149
|
+
inputTokens: input,
|
|
150
|
+
outputTokens: output,
|
|
151
|
+
cacheReadInputTokens: u.cached_input_tokens ?? 0,
|
|
152
|
+
totalCostUsd: 0,
|
|
153
|
+
};
|
|
117
154
|
}
|
|
118
155
|
|
|
119
156
|
export const codexCliProvider = {
|
|
@@ -160,6 +197,7 @@ export const codexCliProvider = {
|
|
|
160
197
|
|
|
161
198
|
proc.stdout.setEncoding('utf8');
|
|
162
199
|
let buffer = '';
|
|
200
|
+
let apiError = null;
|
|
163
201
|
let exitInfo = null;
|
|
164
202
|
const exitPromise = new Promise((resolve) => {
|
|
165
203
|
proc.on('close', (code, signal) => {
|
|
@@ -181,6 +219,8 @@ export const codexCliProvider = {
|
|
|
181
219
|
try { obj = JSON.parse(line); } catch { continue; }
|
|
182
220
|
const text = extractEventText(obj);
|
|
183
221
|
if (text) yield text;
|
|
222
|
+
const errMsg = extractEventError(obj);
|
|
223
|
+
if (errMsg) apiError = errMsg;
|
|
184
224
|
const usage = extractUsage(obj);
|
|
185
225
|
if (usage && typeof opts.onUsage === 'function') {
|
|
186
226
|
try { opts.onUsage(usage); } catch (_) { /* never break stream on usage */ }
|
|
@@ -192,6 +232,14 @@ export const codexCliProvider = {
|
|
|
192
232
|
const obj = JSON.parse(buffer.trim());
|
|
193
233
|
const text = extractEventText(obj);
|
|
194
234
|
if (text) yield text;
|
|
235
|
+
// Drain usage + error too, so a final turn.completed/turn.failed that
|
|
236
|
+
// isn't newline-terminated still reports tokens and surfaces the error.
|
|
237
|
+
const errMsg = extractEventError(obj);
|
|
238
|
+
if (errMsg) apiError = errMsg;
|
|
239
|
+
const usage = extractUsage(obj);
|
|
240
|
+
if (usage && typeof opts.onUsage === 'function') {
|
|
241
|
+
try { opts.onUsage(usage); } catch (_) { /* never break stream on usage */ }
|
|
242
|
+
}
|
|
195
243
|
} catch (_) { /* incomplete tail — drop */ }
|
|
196
244
|
}
|
|
197
245
|
// Wait for either a clean exit or an async spawn error. On ENOENT the
|
|
@@ -201,6 +249,13 @@ export const codexCliProvider = {
|
|
|
201
249
|
if (spawnError) {
|
|
202
250
|
throw spawnError.code === 'ENOENT' ? new CliMissingError() : spawnError;
|
|
203
251
|
}
|
|
252
|
+
// Prefer the real API/turn error (carried on stdout) over the CLI's
|
|
253
|
+
// unhelpful stderr ("Reading additional input from stdin…"). codex
|
|
254
|
+
// exits non-zero on turn.failed, so this runs before the generic
|
|
255
|
+
// exit-code branch and gives the actionable message.
|
|
256
|
+
if (apiError && !opts.signal?.aborted) {
|
|
257
|
+
throw new CliExitError(exitInfo?.code ?? 1, exitInfo?.signal ?? null, apiError);
|
|
258
|
+
}
|
|
204
259
|
if (exitInfo && exitInfo.code !== 0 && !opts.signal?.aborted) {
|
|
205
260
|
throw new CliExitError(exitInfo.code, exitInfo.signal, stderr);
|
|
206
261
|
}
|
|
@@ -213,4 +268,4 @@ export const codexCliProvider = {
|
|
|
213
268
|
},
|
|
214
269
|
};
|
|
215
270
|
|
|
216
|
-
export { CliMissingError, CliExitError, AbortError, resolveModel, buildPrompt, extractEventText, extractUsage };
|
|
271
|
+
export { CliMissingError, CliExitError, AbortError, resolveModel, buildPrompt, extractEventText, extractEventError, extractUsage };
|
package/providers/gemini.mjs
CHANGED
|
@@ -50,6 +50,60 @@ class ApiError extends Error {
|
|
|
50
50
|
this.body = body;
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
+
class TimeoutError extends Error {
|
|
54
|
+
constructor(idleMs) {
|
|
55
|
+
super(`gemini: idle timeout — no stream activity for ${idleMs}ms`);
|
|
56
|
+
this.name = 'TimeoutError';
|
|
57
|
+
this.code = 'TIMEOUT';
|
|
58
|
+
this.idleMs = idleMs;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 120000;
|
|
63
|
+
|
|
64
|
+
// Resolve the IDLE (inter-chunk) timeout once per request: opts override
|
|
65
|
+
// wins, then env LAZYCLAW_REQUEST_TIMEOUT_MS (positive int), else 120s.
|
|
66
|
+
function resolveIdleTimeoutMs(opts) {
|
|
67
|
+
if (Number.isFinite(opts?.idleTimeoutMs) && opts.idleTimeoutMs > 0) return opts.idleTimeoutMs;
|
|
68
|
+
const raw = parseInt(process.env.LAZYCLAW_REQUEST_TIMEOUT_MS ?? '', 10);
|
|
69
|
+
if (Number.isFinite(raw) && raw > 0) return raw;
|
|
70
|
+
return DEFAULT_IDLE_TIMEOUT_MS;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Wrap a chunk iterator with an IDLE timeout: abort only when NO chunk has
|
|
74
|
+
// arrived for idleMs (the timer resets on every received chunk, and also
|
|
75
|
+
// guards the connect/first-byte phase). This is NOT a total-duration cap,
|
|
76
|
+
// so a long but healthy generation that streams steadily is never aborted.
|
|
77
|
+
// `controller` is aborted on idle expiry so the real connection tears down.
|
|
78
|
+
async function* iterateWithIdleTimeout(body, idleMs, controller) {
|
|
79
|
+
const iterator = iterateBody(body)[Symbol.asyncIterator]();
|
|
80
|
+
try {
|
|
81
|
+
while (true) {
|
|
82
|
+
let timer;
|
|
83
|
+
const idle = new Promise((_, reject) => {
|
|
84
|
+
timer = setTimeout(() => {
|
|
85
|
+
controller.abort();
|
|
86
|
+
reject(new TimeoutError(idleMs));
|
|
87
|
+
}, idleMs);
|
|
88
|
+
});
|
|
89
|
+
let step;
|
|
90
|
+
try {
|
|
91
|
+
step = await Promise.race([iterator.next(), idle]);
|
|
92
|
+
} finally {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
}
|
|
95
|
+
if (step.done) return;
|
|
96
|
+
yield step.value;
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
// Best-effort, NON-awaited cleanup: on idle abort the underlying reader
|
|
100
|
+
// may be suspended on a read that never settles, so awaiting return()
|
|
101
|
+
// would re-hang us. The controller abort already tears down the socket.
|
|
102
|
+
if (typeof iterator.return === 'function') {
|
|
103
|
+
try { Promise.resolve(iterator.return()).catch(() => {}); } catch { /* ignore */ }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
53
107
|
|
|
54
108
|
function parseRetryAfterMs(headers) {
|
|
55
109
|
let raw = null;
|
|
@@ -117,6 +171,7 @@ function toGeminiBody(messages, opts) {
|
|
|
117
171
|
}
|
|
118
172
|
const body = { contents };
|
|
119
173
|
if (systemText) body.systemInstruction = { parts: [{ text: systemText }] };
|
|
174
|
+
if (Number.isFinite(opts.maxTokens)) body.generationConfig = { maxOutputTokens: opts.maxTokens };
|
|
120
175
|
return body;
|
|
121
176
|
}
|
|
122
177
|
|
|
@@ -131,16 +186,31 @@ export const geminiProvider = {
|
|
|
131
186
|
const fetchFn = opts.fetch || globalThis.fetch;
|
|
132
187
|
if (!fetchFn) throw new Error('gemini: no fetch implementation available');
|
|
133
188
|
const baseUrl = (opts.baseUrl || DEFAULT_BASE).replace(/\/$/, '');
|
|
134
|
-
const model = opts.model || 'gemini-
|
|
189
|
+
const model = opts.model || 'gemini-2.5-pro';
|
|
135
190
|
|
|
136
191
|
if (opts.signal?.aborted) throw new AbortError('aborted before request');
|
|
137
192
|
|
|
193
|
+
// Compose the caller's signal with an internal controller that the idle
|
|
194
|
+
// timeout aborts. The composed signal goes to fetch so a stalled
|
|
195
|
+
// connection (or a user cancel) tears down the real socket.
|
|
196
|
+
const idleMs = resolveIdleTimeoutMs(opts);
|
|
197
|
+
const idleController = new AbortController();
|
|
198
|
+
const fetchSignal = opts.signal
|
|
199
|
+
? (typeof AbortSignal.any === 'function'
|
|
200
|
+
? AbortSignal.any([opts.signal, idleController.signal])
|
|
201
|
+
: idleController.signal)
|
|
202
|
+
: idleController.signal;
|
|
203
|
+
const forwardCallerAbort = () => idleController.abort();
|
|
204
|
+
if (opts.signal && typeof AbortSignal.any !== 'function') {
|
|
205
|
+
opts.signal.addEventListener('abort', forwardCallerAbort, { once: true });
|
|
206
|
+
}
|
|
207
|
+
|
|
138
208
|
const url = `${baseUrl}/models/${encodeURIComponent(model)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(opts.apiKey)}`;
|
|
139
209
|
const res = await fetchFn(url, {
|
|
140
210
|
method: 'POST',
|
|
141
211
|
headers: { 'content-type': 'application/json' },
|
|
142
212
|
body: JSON.stringify(toGeminiBody(messages, opts)),
|
|
143
|
-
signal:
|
|
213
|
+
signal: fetchSignal,
|
|
144
214
|
});
|
|
145
215
|
|
|
146
216
|
if (!res.ok) {
|
|
@@ -152,7 +222,12 @@ export const geminiProvider = {
|
|
|
152
222
|
|
|
153
223
|
const decoder = new TextDecoder('utf-8', { fatal: false });
|
|
154
224
|
let buffer = '';
|
|
155
|
-
|
|
225
|
+
let lastUsage = null; // Gemini sends a cumulative usageMetadata; keep the last
|
|
226
|
+
let lastFinish = null;
|
|
227
|
+
try {
|
|
228
|
+
for await (const chunk of iterateWithIdleTimeout(res.body, idleMs, idleController)) {
|
|
229
|
+
// A user cancel surfaces as an idle-controller abort too; map it back
|
|
230
|
+
// to ABORT so it stays distinguishable from a TIMEOUT.
|
|
156
231
|
if (opts.signal?.aborted) throw new AbortError('aborted mid-stream');
|
|
157
232
|
buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
|
|
158
233
|
let consumed = 0;
|
|
@@ -169,6 +244,9 @@ export const geminiProvider = {
|
|
|
169
244
|
if (typeof p?.text === 'string' && p.text) yield p.text;
|
|
170
245
|
}
|
|
171
246
|
}
|
|
247
|
+
if (obj?.usageMetadata) lastUsage = obj.usageMetadata;
|
|
248
|
+
const fr = obj?.candidates?.[0]?.finishReason;
|
|
249
|
+
if (fr) lastFinish = fr;
|
|
172
250
|
// Some error responses surface mid-stream as {error: {...}}.
|
|
173
251
|
if (obj?.error) {
|
|
174
252
|
const message = obj.error.message || JSON.stringify(obj.error);
|
|
@@ -181,6 +259,26 @@ export const geminiProvider = {
|
|
|
181
259
|
}
|
|
182
260
|
if (consumed > 0) buffer = buffer.slice(consumed);
|
|
183
261
|
}
|
|
262
|
+
} finally {
|
|
263
|
+
if (opts.signal && typeof AbortSignal.any !== 'function') {
|
|
264
|
+
opts.signal.removeEventListener('abort', forwardCallerAbort);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Stream completed cleanly — surface the final usage + any truncation.
|
|
268
|
+
if (lastUsage && typeof opts.onUsage === 'function') {
|
|
269
|
+
try {
|
|
270
|
+
opts.onUsage({
|
|
271
|
+
inputTokens: lastUsage.promptTokenCount ?? null,
|
|
272
|
+
outputTokens: lastUsage.candidatesTokenCount ?? null,
|
|
273
|
+
cacheReadInputTokens: lastUsage.cachedContentTokenCount ?? 0,
|
|
274
|
+
totalCostUsd: 0,
|
|
275
|
+
});
|
|
276
|
+
} catch { /* never fail the stream on a usage callback */ }
|
|
277
|
+
}
|
|
278
|
+
if (typeof opts.onTruncated === 'function'
|
|
279
|
+
&& (lastFinish === 'MAX_TOKENS' || lastFinish === 'SAFETY' || lastFinish === 'RECITATION')) {
|
|
280
|
+
try { opts.onTruncated(lastFinish); } catch { /* ditto */ }
|
|
281
|
+
}
|
|
184
282
|
},
|
|
185
283
|
};
|
|
186
284
|
|
package/providers/gemini_cli.mjs
CHANGED
|
@@ -19,12 +19,14 @@
|
|
|
19
19
|
// REPL both treat AsyncIterable<string> as their only contract, so
|
|
20
20
|
// non-streaming is transparent to callers.
|
|
21
21
|
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
22
|
+
// The trusted-folder bypass (`--skip-trust` + GEMINI_CLI_TRUST_WORKSPACE) is
|
|
23
|
+
// enabled by default because lazyclaw orchestrator / workflow subprocesses
|
|
24
|
+
// commonly run from /tmp or scratch dirs that gemini's trusted-folder policy
|
|
25
|
+
// rejects in headless -p mode. It is a genuine trust bypass, now consolidated
|
|
26
|
+
// behind one switch (opts.trustWorkspace, default true) — see geminiArgs/geminiEnv.
|
|
26
27
|
|
|
27
28
|
import { spawnSandboxed } from '../sandbox.mjs';
|
|
29
|
+
import { classifyCliExit } from './cli_error.mjs';
|
|
28
30
|
|
|
29
31
|
class AbortError extends Error {
|
|
30
32
|
constructor(message = 'aborted') {
|
|
@@ -46,7 +48,10 @@ class CliExitError extends Error {
|
|
|
46
48
|
constructor(code, signal, stderr) {
|
|
47
49
|
super(`gemini CLI exited ${code ?? signal}: ${String(stderr).slice(0, 400)}`);
|
|
48
50
|
this.name = 'GeminiCliExitError';
|
|
49
|
-
|
|
51
|
+
// Transient upstream throttle → retriable RATE_LIMIT; genuine cap → CLI_EXIT.
|
|
52
|
+
const cls = classifyCliExit(stderr);
|
|
53
|
+
this.code = cls.code;
|
|
54
|
+
if (cls.retryAfterMs !== undefined) this.retryAfterMs = cls.retryAfterMs;
|
|
50
55
|
this.exitCode = code;
|
|
51
56
|
this.signal = signal;
|
|
52
57
|
this.stderr = stderr;
|
|
@@ -95,14 +100,45 @@ function buildPrompt(messages, system) {
|
|
|
95
100
|
function extractUsage(stats) {
|
|
96
101
|
if (!stats || typeof stats !== 'object') return null;
|
|
97
102
|
const models = stats.models || {};
|
|
98
|
-
let input = 0, output = 0;
|
|
103
|
+
let input = 0, output = 0, cached = 0;
|
|
99
104
|
for (const m of Object.values(models)) {
|
|
100
105
|
const t = m?.tokens || {};
|
|
101
106
|
input += (t.prompt ?? t.input ?? 0);
|
|
102
|
-
|
|
107
|
+
// thoughts = reasoning tokens, billed as output and reported separately from
|
|
108
|
+
// candidates (additive). cached is a subset of prompt → surface, don't sum.
|
|
109
|
+
output += (t.candidates ?? t.output ?? 0) + (t.thoughts ?? 0);
|
|
110
|
+
cached += (t.cached ?? 0);
|
|
103
111
|
}
|
|
104
112
|
if (!input && !output) return null;
|
|
105
|
-
return { inputTokens: input, outputTokens: output, totalCostUsd: 0 };
|
|
113
|
+
return { inputTokens: input, outputTokens: output, cacheReadInputTokens: cached, totalCostUsd: 0 };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The gemini trusted-folder bypass — needed because lazyclaw runs gemini from
|
|
117
|
+
// scratch dirs (/tmp, worker cwds) that gemini's trusted-folder policy would
|
|
118
|
+
// otherwise reject in headless -p mode. It is a GENUINE trust bypass, so it
|
|
119
|
+
// lives behind ONE switch: opts.trustWorkspace (default true) gates BOTH the
|
|
120
|
+
// --skip-trust flag AND the GEMINI_CLI_TRUST_WORKSPACE env that previously
|
|
121
|
+
// duplicated it (so dropping one no longer silently left the other). Pass
|
|
122
|
+
// trustWorkspace:false to enforce the policy — safest when the subprocess also
|
|
123
|
+
// runs under confinement.
|
|
124
|
+
export function geminiArgs(prompt, opts = {}) {
|
|
125
|
+
const args = [];
|
|
126
|
+
if (opts.trustWorkspace !== false) args.push('--skip-trust');
|
|
127
|
+
args.push('-p', prompt, '-o', 'json');
|
|
128
|
+
const model = resolveModel(opts.model);
|
|
129
|
+
if (model) args.push('-m', model);
|
|
130
|
+
return args;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function geminiEnv(opts = {}) {
|
|
134
|
+
return {
|
|
135
|
+
...process.env,
|
|
136
|
+
...(opts.trustWorkspace !== false ? { GEMINI_CLI_TRUST_WORKSPACE: 'true' } : {}),
|
|
137
|
+
// Forward a lazyclaw-stored key as GEMINI_API_KEY so the "paste an API key"
|
|
138
|
+
// connect path authenticates the subprocess (the gemini CLI reads this env
|
|
139
|
+
// var). Omitted when blank so a Google-OAuth login is unaffected.
|
|
140
|
+
...(opts.apiKey ? { GEMINI_API_KEY: String(opts.apiKey) } : {}),
|
|
141
|
+
};
|
|
106
142
|
}
|
|
107
143
|
|
|
108
144
|
export const geminiCliProvider = {
|
|
@@ -112,9 +148,7 @@ export const geminiCliProvider = {
|
|
|
112
148
|
const prompt = buildPrompt(messages, opts.system || messages.find(m => m.role === 'system')?.content);
|
|
113
149
|
if (!prompt) return;
|
|
114
150
|
|
|
115
|
-
const args =
|
|
116
|
-
const model = resolveModel(opts.model);
|
|
117
|
-
if (model) args.push('-m', model);
|
|
151
|
+
const args = geminiArgs(prompt, opts);
|
|
118
152
|
|
|
119
153
|
if (opts.signal?.aborted) throw new AbortError('aborted before spawn');
|
|
120
154
|
|
|
@@ -123,7 +157,7 @@ export const geminiCliProvider = {
|
|
|
123
157
|
proc = spawnSandboxed(opts.sandbox || null, bin, args, {
|
|
124
158
|
cwd: opts.cwd || process.cwd(),
|
|
125
159
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
126
|
-
env:
|
|
160
|
+
env: geminiEnv(opts),
|
|
127
161
|
});
|
|
128
162
|
} catch (err) {
|
|
129
163
|
if (err && err.code === 'ENOENT') throw new CliMissingError();
|
|
@@ -177,7 +211,16 @@ export const geminiCliProvider = {
|
|
|
177
211
|
} catch (err) {
|
|
178
212
|
throw new Error(`gemini CLI returned non-JSON stdout: ${err.message} :: ${payload.slice(0, 200)}`);
|
|
179
213
|
}
|
|
214
|
+
// The JSON payload carries an optional `error` object on failure
|
|
215
|
+
// (gemini-cli --output-format json: "response, stats, and errors").
|
|
216
|
+
// A failed turn can still exit 0 with an empty response + an error,
|
|
217
|
+
// so surface it instead of silently yielding nothing.
|
|
180
218
|
const text = typeof parsed.response === 'string' ? parsed.response : '';
|
|
219
|
+
if (!text && parsed.error) {
|
|
220
|
+
const em = parsed.error?.message
|
|
221
|
+
|| (typeof parsed.error === 'string' ? parsed.error : JSON.stringify(parsed.error));
|
|
222
|
+
throw new CliExitError(exitInfo.code, exitInfo.signal, em);
|
|
223
|
+
}
|
|
181
224
|
if (text) yield text;
|
|
182
225
|
const usage = extractUsage(parsed.stats);
|
|
183
226
|
if (usage && typeof opts.onUsage === 'function') {
|
|
@@ -185,11 +228,15 @@ export const geminiCliProvider = {
|
|
|
185
228
|
}
|
|
186
229
|
} finally {
|
|
187
230
|
if (opts.signal) opts.signal.removeEventListener('abort', onAbort);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
231
|
+
// Unlike the streaming claude-cli/codex-cli providers (which yield chunks
|
|
232
|
+
// while the child still runs and so need a mid-stream-bail kill), gemini
|
|
233
|
+
// buffers the whole turn and `await`s the close/spawn-error race BEFORE the
|
|
234
|
+
// single yield above. By the time this finally runs the child has already
|
|
235
|
+
// exited (or we threw on spawnError before reaching here), so there is no
|
|
236
|
+
// live subprocess to reap — the old `exitInfo === null` guard was dead.
|
|
191
237
|
}
|
|
192
238
|
},
|
|
193
239
|
};
|
|
194
240
|
|
|
195
241
|
export { CliMissingError, CliExitError, AbortError, resolveModel, buildPrompt, extractUsage };
|
|
242
|
+
// geminiArgs/geminiEnv are also exported at their definitions (trust-switch seam).
|
|
@@ -175,6 +175,33 @@ export function _codexStoredApiKey({ home, readFileSync } = {}) {
|
|
|
175
175
|
return null;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
// A ChatGPT-plan `codex` login has no platform API key, so /v1/models can't be
|
|
179
|
+
// listed. The one model such a login can actually use is the one configured in
|
|
180
|
+
// ~/.codex/config.toml (`model = "gpt-5.5"`). Return it so the picker shows the
|
|
181
|
+
// account's real model instead of a "fetch failed" error. Returns [] when the
|
|
182
|
+
// file is missing or has no model line.
|
|
183
|
+
export function _codexConfigModels({ home, readFileSync } = {}) {
|
|
184
|
+
const h = home || os.homedir();
|
|
185
|
+
const read = readFileSync || fs.readFileSync;
|
|
186
|
+
try {
|
|
187
|
+
const txt = read(path.join(h, '.codex/config.toml'), 'utf8');
|
|
188
|
+
const m = /^\s*model\s*=\s*"([^"]+)"/m.exec(String(txt));
|
|
189
|
+
return m && m[1] ? [m[1]] : [];
|
|
190
|
+
} catch { return []; }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Same idea for a `gemini` Google-account login: no listable platform catalogue,
|
|
194
|
+
// but ~/.gemini/settings.json may pin a model. Returns [] when absent.
|
|
195
|
+
export function _geminiConfigModels({ home, readFileSync } = {}) {
|
|
196
|
+
const h = home || os.homedir();
|
|
197
|
+
const read = readFileSync || fs.readFileSync;
|
|
198
|
+
try {
|
|
199
|
+
const obj = JSON.parse(read(path.join(h, '.gemini/settings.json'), 'utf8'));
|
|
200
|
+
const m = obj && (obj.model || obj.defaultModel || (obj.model && obj.model.name));
|
|
201
|
+
return typeof m === 'string' && m ? [m] : [];
|
|
202
|
+
} catch { return []; }
|
|
203
|
+
}
|
|
204
|
+
|
|
178
205
|
export async function fetchModelsForProvider(deps) {
|
|
179
206
|
const providerId = deps && deps.providerId;
|
|
180
207
|
const key = (id) => (typeof deps?.resolveAuthKey === 'function' ? deps.resolveAuthKey(id) : '') || '';
|
|
@@ -199,7 +226,9 @@ export async function fetchModelsForProvider(deps) {
|
|
|
199
226
|
if (providerId === 'gemini-cli') {
|
|
200
227
|
const apiKey = key('gemini-cli') || key('gemini') || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || '';
|
|
201
228
|
if (apiKey) return fetchGeminiModels({ apiKey, fetchImpl: deps?.fetchImpl });
|
|
202
|
-
|
|
229
|
+
// Google-account login can't list a platform catalogue — surface the model
|
|
230
|
+
// pinned in ~/.gemini/settings.json (if any) instead of throwing.
|
|
231
|
+
return deps?._geminiConfigModels ? deps._geminiConfigModels() : _geminiConfigModels();
|
|
203
232
|
}
|
|
204
233
|
if (providerId === 'codex-cli') {
|
|
205
234
|
const apiKey = key('codex-cli') || key('openai') || process.env.OPENAI_API_KEY
|
|
@@ -208,7 +237,9 @@ export async function fetchModelsForProvider(deps) {
|
|
|
208
237
|
const { fetchOpenAICompatModels } = await import('./openai_compat.mjs');
|
|
209
238
|
return fetchOpenAICompatModels({ baseUrl: 'https://api.openai.com/v1', apiKey, fetch: deps?.fetchImpl });
|
|
210
239
|
}
|
|
211
|
-
|
|
240
|
+
// ChatGPT-plan login has no platform API key — list the model the local
|
|
241
|
+
// codex config is set to use instead of erroring with "fetch failed".
|
|
242
|
+
return deps?._codexConfigModels ? deps._codexConfigModels() : _codexConfigModels();
|
|
212
243
|
}
|
|
213
244
|
const c = modelCatalogueFor(deps);
|
|
214
245
|
if (!c) {
|