@ruvnet/open-claude-code 2.0.1 → 2.1.1-nightly.20261011.202
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/LICENSE +21 -0
- package/README.md +383 -0
- package/package.json +29 -4
- package/src/auth/oauth.mjs +46 -6
- package/src/config/cli-args.mjs +20 -0
- package/src/config/env.mjs +2 -2
- package/src/config/settings.mjs +30 -130
- package/src/core/agent-loop.mjs +92 -28
- package/src/core/providers.mjs +19 -1
- package/src/core/session.mjs +34 -149
- package/src/core/system-prompt.mjs +43 -47
- package/src/hooks/engine.mjs +10 -2
- package/src/index.mjs +60 -32
- package/src/optimize/cascade.mjs +128 -0
- package/src/optimize/commands.mjs +139 -0
- package/src/optimize/router.mjs +212 -0
- package/src/optimize/store.mjs +170 -0
- package/src/permissions/checker.mjs +8 -79
- package/src/permissions/injection-check.mjs +3 -2
- package/src/permissions/path-check.mjs +8 -0
- package/src/permissions/sandbox.mjs +8 -2
- package/src/permissions/ssrf-check.mjs +102 -0
- package/src/plugins/loader.mjs +14 -6
- package/src/skills/loader.mjs +16 -4
- package/src/tools/bash.mjs +3 -40
- package/src/tools/enter-worktree.mjs +5 -3
- package/src/tools/exit-worktree.mjs +4 -3
- package/src/tools/grep.mjs +26 -11
- package/src/tools/lsp.mjs +23 -9
- package/src/tools/read.mjs +20 -9
- package/src/tools/send-message.mjs +5 -1
- package/src/tools/web-fetch.mjs +15 -1
- package/src/tools/web-search.mjs +13 -1
- package/src/ui/app.mjs +286 -0
- package/src/ui/commands.mjs +121 -71
- package/src/ui/components.mjs +219 -0
- package/src/ui/ink-app.mjs +13 -52
- package/src/ui/markdown.mjs +278 -0
- package/src/ui/repl.mjs +10 -25
- package/test/test.mjs +0 -2657
package/src/config/settings.mjs
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Supports all 76 properties from Claude Code's settings schema.
|
|
5
5
|
* Five-layer hierarchy: user > project > local > managed > feature flags.
|
|
6
|
-
* Deep merge with source tracking and write-back.
|
|
7
6
|
*/
|
|
8
7
|
import fs from 'fs';
|
|
9
8
|
import path from 'path';
|
|
@@ -59,103 +58,34 @@ export const SETTINGS_SCHEMA = {
|
|
|
59
58
|
agentId: null,
|
|
60
59
|
cronEnabled: true,
|
|
61
60
|
featureFlags: {},
|
|
61
|
+
// Metaharness self-optimization (opt-in; default OFF — behavior unchanged).
|
|
62
|
+
selfOptimize: false,
|
|
63
|
+
selfOptimizeQualityBar: 0.7,
|
|
64
|
+
selfOptimizeLadder: null,
|
|
62
65
|
};
|
|
63
66
|
|
|
64
|
-
|
|
65
|
-
const SCOPE_NAMES = ['managed', 'user', 'project', 'local'];
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Get file paths for each settings scope.
|
|
69
|
-
* @param {string} [cwd]
|
|
70
|
-
* @returns {Record<string, string>}
|
|
71
|
-
*/
|
|
72
|
-
export function getSettingsPaths(cwd = process.cwd()) {
|
|
73
|
-
return {
|
|
74
|
-
user: path.join(os.homedir(), '.claude', 'settings.json'),
|
|
75
|
-
project: path.join(cwd, '.claude', 'settings.json'),
|
|
76
|
-
local: path.join(cwd, '.claude', 'settings.local.json'),
|
|
77
|
-
managed: path.join(os.homedir(), '.claude', 'settings.managed.json'),
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Load settings with 4-source deep merge and source tracking.
|
|
83
|
-
* Order: defaults -> managed -> user -> project -> local -> env overrides
|
|
84
|
-
* @param {string} [cwd]
|
|
85
|
-
* @returns {Promise<object>} merged settings with _sources metadata
|
|
86
|
-
*/
|
|
87
|
-
export async function loadSettings(cwd) {
|
|
88
|
-
const paths = getSettingsPaths(cwd);
|
|
67
|
+
export async function loadSettings() {
|
|
89
68
|
const chain = [
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
{ scope: 'local', file: paths.local },
|
|
69
|
+
path.join(os.homedir(), '.claude', 'settings.json'),
|
|
70
|
+
path.join(process.cwd(), '.claude', 'settings.json'),
|
|
71
|
+
path.join(process.cwd(), '.claude', 'settings.local.json'),
|
|
94
72
|
];
|
|
95
73
|
|
|
96
74
|
let merged = deepClone(SETTINGS_SCHEMA);
|
|
97
|
-
const sources = {};
|
|
98
75
|
|
|
99
|
-
|
|
100
|
-
trackSources(sources, SETTINGS_SCHEMA, 'default', '');
|
|
101
|
-
|
|
102
|
-
for (const { scope, file } of chain) {
|
|
76
|
+
for (const file of chain) {
|
|
103
77
|
try {
|
|
104
78
|
const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
105
79
|
merged = deepMerge(merged, data);
|
|
106
|
-
trackSources(sources, data, scope, '');
|
|
107
80
|
} catch {
|
|
108
|
-
// File not found or invalid
|
|
81
|
+
// File not found or invalid
|
|
109
82
|
}
|
|
110
83
|
}
|
|
111
84
|
|
|
112
|
-
applyEnvOverrides(merged
|
|
113
|
-
|
|
114
|
-
// Attach metadata (non-enumerable so it does not interfere with tests)
|
|
115
|
-
Object.defineProperty(merged, '_sources', { value: sources, enumerable: false, writable: true });
|
|
116
|
-
Object.defineProperty(merged, '_paths', { value: paths, enumerable: false, writable: true });
|
|
117
|
-
|
|
85
|
+
applyEnvOverrides(merged);
|
|
118
86
|
return merged;
|
|
119
87
|
}
|
|
120
88
|
|
|
121
|
-
/**
|
|
122
|
-
* Save a single setting back to the correct scope file.
|
|
123
|
-
* @param {'user'|'project'|'local'} scope
|
|
124
|
-
* @param {string} key — dot-separated path (e.g. "permissions.defaultMode")
|
|
125
|
-
* @param {*} value
|
|
126
|
-
* @param {string} [cwd]
|
|
127
|
-
*/
|
|
128
|
-
export function saveSettings(scope, key, value, cwd) {
|
|
129
|
-
const paths = getSettingsPaths(cwd);
|
|
130
|
-
const file = paths[scope];
|
|
131
|
-
if (!file) throw new Error(`Unknown scope: ${scope}`);
|
|
132
|
-
|
|
133
|
-
let existing = {};
|
|
134
|
-
try {
|
|
135
|
-
existing = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
136
|
-
} catch {
|
|
137
|
-
// New file
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
setNestedValue(existing, key, value);
|
|
141
|
-
|
|
142
|
-
const dir = path.dirname(file);
|
|
143
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
144
|
-
fs.writeFileSync(file, JSON.stringify(existing, null, 2) + '\n');
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Get the source scope for a specific setting key.
|
|
149
|
-
* @param {object} settings — settings object returned by loadSettings
|
|
150
|
-
* @param {string} key — dot-separated path
|
|
151
|
-
* @returns {string} scope name ('default', 'user', 'project', 'local', 'managed', 'env')
|
|
152
|
-
*/
|
|
153
|
-
export function getSettingSource(settings, key) {
|
|
154
|
-
return settings._sources?.[key] || 'default';
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// ---------- Helpers ----------
|
|
158
|
-
|
|
159
89
|
function deepClone(obj) {
|
|
160
90
|
return JSON.parse(JSON.stringify(obj));
|
|
161
91
|
}
|
|
@@ -163,8 +93,7 @@ function deepClone(obj) {
|
|
|
163
93
|
function deepMerge(target, source) {
|
|
164
94
|
const result = { ...target };
|
|
165
95
|
for (const [key, value] of Object.entries(source)) {
|
|
166
|
-
if (value && typeof value === 'object' && !Array.isArray(value)
|
|
167
|
-
&& typeof result[key] === 'object' && !Array.isArray(result[key])) {
|
|
96
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && typeof result[key] === 'object' && !Array.isArray(result[key])) {
|
|
168
97
|
result[key] = deepMerge(result[key] || {}, value);
|
|
169
98
|
} else {
|
|
170
99
|
result[key] = value;
|
|
@@ -173,53 +102,24 @@ function deepMerge(target, source) {
|
|
|
173
102
|
return result;
|
|
174
103
|
}
|
|
175
104
|
|
|
176
|
-
function
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function setNestedValue(obj, key, value) {
|
|
187
|
-
const parts = key.split('.');
|
|
188
|
-
let cur = obj;
|
|
189
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
190
|
-
if (!cur[parts[i]] || typeof cur[parts[i]] !== 'object') {
|
|
191
|
-
cur[parts[i]] = {};
|
|
192
|
-
}
|
|
193
|
-
cur = cur[parts[i]];
|
|
105
|
+
function applyEnvOverrides(settings) {
|
|
106
|
+
if (process.env.ANTHROPIC_MODEL) settings.model = process.env.ANTHROPIC_MODEL;
|
|
107
|
+
if (process.env.CLAUDE_CODE_SUBAGENT_MODEL) settings.subagentModel = process.env.CLAUDE_CODE_SUBAGENT_MODEL;
|
|
108
|
+
if (process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) {
|
|
109
|
+
const n = parseInt(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS, 10);
|
|
110
|
+
if (!isNaN(n)) { settings.maxOutputTokens = n; settings.maxTokens = n; }
|
|
194
111
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
function applyEnvOverrides(settings, sources) {
|
|
199
|
-
const envMap = [
|
|
200
|
-
['ANTHROPIC_MODEL', (v) => { settings.model = v; }, 'model'],
|
|
201
|
-
['CLAUDE_CODE_SUBAGENT_MODEL', (v) => { settings.subagentModel = v; }, 'subagentModel'],
|
|
202
|
-
['CLAUDE_CODE_MAX_OUTPUT_TOKENS', (v) => {
|
|
203
|
-
const n = parseInt(v, 10);
|
|
204
|
-
if (!isNaN(n)) { settings.maxOutputTokens = n; settings.maxTokens = n; }
|
|
205
|
-
}, 'maxOutputTokens'],
|
|
206
|
-
['CLAUDE_CODE_MAX_CONTEXT_TOKENS', (v) => {
|
|
207
|
-
const n = parseInt(v, 10);
|
|
208
|
-
if (!isNaN(n)) settings.maxContextTokens = n;
|
|
209
|
-
}, 'maxContextTokens'],
|
|
210
|
-
['CLAUDE_CODE_BRIEF', (v) => { if (v === '1') settings.briefMode = true; }, 'briefMode'],
|
|
211
|
-
['CLAUDE_CODE_DEBUG', (v) => { if (v === '1') settings.debugMode = true; }, 'debugMode'],
|
|
212
|
-
['CLAUDE_CODE_PERMISSION_MODE', (v) => { settings.permissions.defaultMode = v; }, 'permissions.defaultMode'],
|
|
213
|
-
['CLAUDE_CODE_STREAMING', (v) => { if (v === '0') settings.stream = false; }, 'stream'],
|
|
214
|
-
['CLAUDE_CODE_THINKING', (v) => { if (v === '1') settings.alwaysThinkingEnabled = true; }, 'alwaysThinkingEnabled'],
|
|
215
|
-
['CLAUDE_CODE_DISABLE_CRON', (v) => { if (v === '1') settings.cronEnabled = false; }, 'cronEnabled'],
|
|
216
|
-
['CLAUDE_CODE_ENABLE_TASKS', (v) => { if (v === '1') settings.enableTeams = true; }, 'enableTeams'],
|
|
217
|
-
];
|
|
218
|
-
|
|
219
|
-
for (const [envKey, apply, settingsKey] of envMap) {
|
|
220
|
-
if (process.env[envKey]) {
|
|
221
|
-
apply(process.env[envKey]);
|
|
222
|
-
if (sources) sources[settingsKey] = 'env';
|
|
223
|
-
}
|
|
112
|
+
if (process.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS) {
|
|
113
|
+
const n = parseInt(process.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, 10);
|
|
114
|
+
if (!isNaN(n)) settings.maxContextTokens = n;
|
|
224
115
|
}
|
|
116
|
+
if (process.env.CLAUDE_CODE_BRIEF === '1') settings.briefMode = true;
|
|
117
|
+
if (process.env.CLAUDE_CODE_DEBUG === '1') settings.debugMode = true;
|
|
118
|
+
if (process.env.CLAUDE_CODE_PERMISSION_MODE) settings.permissions.defaultMode = process.env.CLAUDE_CODE_PERMISSION_MODE;
|
|
119
|
+
if (process.env.CLAUDE_CODE_STREAMING === '0') settings.stream = false;
|
|
120
|
+
if (process.env.CLAUDE_CODE_THINKING === '1') settings.alwaysThinkingEnabled = true;
|
|
121
|
+
if (process.env.CLAUDE_CODE_DISABLE_CRON === '1') settings.cronEnabled = false;
|
|
122
|
+
if (process.env.CLAUDE_CODE_ENABLE_TASKS === '1') settings.enableTeams = true;
|
|
123
|
+
if (process.env.CLAUDE_CODE_SELF_OPTIMIZE === '1') settings.selfOptimize = true;
|
|
124
|
+
if (process.env.CLAUDE_CODE_SELF_OPTIMIZE === '0') settings.selfOptimize = false;
|
|
225
125
|
}
|
package/src/core/agent-loop.mjs
CHANGED
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
import { streamResponse, accumulateStream } from './streaming.mjs';
|
|
6
6
|
import { ContextManager } from './context-manager.mjs';
|
|
7
7
|
import { buildSystemPrompt } from './system-prompt.mjs';
|
|
8
|
-
import
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
import { readApiKey } from './providers.mjs';
|
|
9
|
+
|
|
10
|
+
/** Maximum number of consecutive tool-use continuation turns before aborting. */
|
|
11
|
+
const MAX_TOOL_RECURSION_DEPTH = 50;
|
|
12
|
+
|
|
13
|
+
export function createAgentLoop({ model, tools, permissions, settings, hooks, cascade = null }) {
|
|
11
14
|
const contextManager = new ContextManager(settings.maxContextTokens || 180000);
|
|
12
15
|
|
|
13
16
|
// Build system prompt using the new builder
|
|
@@ -26,9 +29,41 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
|
|
|
26
29
|
model,
|
|
27
30
|
tools,
|
|
28
31
|
_contextManager: contextManager,
|
|
32
|
+
_cascade: cascade,
|
|
29
33
|
};
|
|
30
34
|
|
|
35
|
+
// Self-optimization bookkeeping for the current top-level task (opt-in).
|
|
36
|
+
let _optTask = null; // { model, complexity, startedAt, tokensAtStart }
|
|
37
|
+
|
|
38
|
+
/** Record the outcome of the current task, if self-optimization is on. */
|
|
39
|
+
function recordOptOutcome(success) {
|
|
40
|
+
if (!cascade || !_optTask) return;
|
|
41
|
+
const inputDelta = state.tokenUsage.input - _optTask.tokensAtStart.input;
|
|
42
|
+
const outputDelta = state.tokenUsage.output - _optTask.tokensAtStart.output;
|
|
43
|
+
try {
|
|
44
|
+
cascade.recordOutcome({
|
|
45
|
+
model: _optTask.model,
|
|
46
|
+
success,
|
|
47
|
+
latencyMs: Date.now() - _optTask.startedAt,
|
|
48
|
+
inputTokens: inputDelta >= 0 ? inputDelta : 0,
|
|
49
|
+
outputTokens: outputDelta >= 0 ? outputDelta : 0,
|
|
50
|
+
complexity: _optTask.complexity,
|
|
51
|
+
task: _optTask.task,
|
|
52
|
+
});
|
|
53
|
+
} catch { /* best-effort */ }
|
|
54
|
+
_optTask = null;
|
|
55
|
+
}
|
|
56
|
+
|
|
31
57
|
async function* run(userMessage, options = {}) {
|
|
58
|
+
const depth = (options._depth || 0);
|
|
59
|
+
|
|
60
|
+
// Guard against runaway tool-call recursion
|
|
61
|
+
if (depth >= MAX_TOOL_RECURSION_DEPTH) {
|
|
62
|
+
yield { type: 'error', message: `Max tool recursion depth (${MAX_TOOL_RECURSION_DEPTH}) reached. Stopping to prevent infinite loop.` };
|
|
63
|
+
yield { type: 'stop', reason: 'max_recursion' };
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
32
67
|
// Add user message (skip for continuation turns)
|
|
33
68
|
if (userMessage && !options.continuation) {
|
|
34
69
|
state.messages = contextManager.addMessage(state.messages, {
|
|
@@ -36,6 +71,22 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
|
|
|
36
71
|
content: userMessage,
|
|
37
72
|
});
|
|
38
73
|
state.turnCount++;
|
|
74
|
+
|
|
75
|
+
// Opt-in cost-cascade routing: pick the cheapest good-enough model
|
|
76
|
+
// for this task and record outcome bookkeeping. Default path (no
|
|
77
|
+
// cascade) leaves state.model exactly as constructed.
|
|
78
|
+
if (cascade) {
|
|
79
|
+
const route = cascade.decide(userMessage);
|
|
80
|
+
state.model = route.model;
|
|
81
|
+
state._lastRoute = route;
|
|
82
|
+
_optTask = {
|
|
83
|
+
model: route.model,
|
|
84
|
+
complexity: route.complexity,
|
|
85
|
+
startedAt: Date.now(),
|
|
86
|
+
tokensAtStart: { input: state.tokenUsage.input, output: state.tokenUsage.output },
|
|
87
|
+
task: String(userMessage).slice(0, 120),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
39
90
|
}
|
|
40
91
|
|
|
41
92
|
// Check max turns
|
|
@@ -53,14 +104,16 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
|
|
|
53
104
|
|
|
54
105
|
yield { type: 'stream_request_start', turn: state.turnCount };
|
|
55
106
|
|
|
56
|
-
// Detect provider and call API
|
|
57
|
-
|
|
107
|
+
// Detect provider and call API. Use state.model so an opt-in cascade
|
|
108
|
+
// pick takes effect; defaults to the constructed model otherwise.
|
|
109
|
+
const activeModel = state.model || model;
|
|
110
|
+
const provider = detectProvider(activeModel);
|
|
58
111
|
let response;
|
|
59
112
|
|
|
60
113
|
try {
|
|
61
|
-
if (settings.stream !== false) {
|
|
114
|
+
if (settings.stream !== false && provider === 'anthropic') {
|
|
62
115
|
// Streaming mode
|
|
63
|
-
response = await callApiStreaming(provider,
|
|
116
|
+
response = await callApiStreaming(provider, activeModel, state, tools.list(), settings);
|
|
64
117
|
const collectedContent = [];
|
|
65
118
|
let currentText = '';
|
|
66
119
|
let currentThinking = '';
|
|
@@ -87,9 +140,10 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
|
|
|
87
140
|
response = response.accumulated;
|
|
88
141
|
} else {
|
|
89
142
|
// Non-streaming mode
|
|
90
|
-
response = await callApi(provider,
|
|
143
|
+
response = await callApi(provider, activeModel, state, tools.list(), settings);
|
|
91
144
|
}
|
|
92
145
|
} catch (err) {
|
|
146
|
+
recordOptOutcome(false);
|
|
93
147
|
yield { type: 'error', message: err.message };
|
|
94
148
|
return;
|
|
95
149
|
}
|
|
@@ -180,7 +234,7 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
|
|
|
180
234
|
state.messages.push({ role: 'user', content: toolResults });
|
|
181
235
|
|
|
182
236
|
// Recursive: continue the loop after tool execution
|
|
183
|
-
yield* run(null, { continuation: true });
|
|
237
|
+
yield* run(null, { continuation: true, _depth: depth + 1 });
|
|
184
238
|
return;
|
|
185
239
|
}
|
|
186
240
|
|
|
@@ -193,11 +247,14 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
|
|
|
193
247
|
role: 'user',
|
|
194
248
|
content: '[System: A hook prevented stopping. Please continue with the task.]',
|
|
195
249
|
});
|
|
196
|
-
yield* run(null, { continuation: true });
|
|
250
|
+
yield* run(null, { continuation: true, _depth: depth + 1 });
|
|
197
251
|
return;
|
|
198
252
|
}
|
|
199
253
|
}
|
|
200
254
|
|
|
255
|
+
// Task completed normally — record a successful outcome (opt-in).
|
|
256
|
+
recordOptOutcome(true);
|
|
257
|
+
|
|
201
258
|
yield { type: 'stop', reason: response.stop_reason || 'end_turn' };
|
|
202
259
|
}
|
|
203
260
|
|
|
@@ -223,8 +280,8 @@ async function callApiStreaming(provider, model, state, toolDefs, settings) {
|
|
|
223
280
|
}
|
|
224
281
|
|
|
225
282
|
async function callAnthropic(model, state, toolDefs, settings, stream) {
|
|
226
|
-
const apiKey =
|
|
227
|
-
if (!apiKey) throw new Error('ANTHROPIC_API_KEY not set');
|
|
283
|
+
const apiKey = readApiKey('ANTHROPIC_API_KEY');
|
|
284
|
+
if (!apiKey) throw new Error('ANTHROPIC_API_KEY is not set. Export it in your environment before running.');
|
|
228
285
|
|
|
229
286
|
const body = {
|
|
230
287
|
model,
|
|
@@ -240,7 +297,8 @@ async function callAnthropic(model, state, toolDefs, settings, stream) {
|
|
|
240
297
|
body.thinking = { type: 'enabled', budget_tokens: settings.thinkingBudget || 10000 };
|
|
241
298
|
}
|
|
242
299
|
|
|
243
|
-
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
300
|
+
const res = await fetch((process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/$/, '') + '/v1/messages', {
|
|
301
|
+
redirect: 'error',
|
|
244
302
|
method: 'POST',
|
|
245
303
|
headers: {
|
|
246
304
|
'Content-Type': 'application/json',
|
|
@@ -252,7 +310,7 @@ async function callAnthropic(model, state, toolDefs, settings, stream) {
|
|
|
252
310
|
|
|
253
311
|
if (!res.ok) {
|
|
254
312
|
const err = await res.text();
|
|
255
|
-
throw new Error(`Anthropic API error ${res.status}
|
|
313
|
+
throw new Error(`Anthropic API error ${res.status}`);
|
|
256
314
|
}
|
|
257
315
|
|
|
258
316
|
if (stream) {
|
|
@@ -275,24 +333,29 @@ async function callAnthropic(model, state, toolDefs, settings, stream) {
|
|
|
275
333
|
}
|
|
276
334
|
|
|
277
335
|
async function callOpenAI(model, state, toolDefs, settings, stream) {
|
|
278
|
-
const apiKey =
|
|
279
|
-
if (!apiKey) throw new Error('OPENAI_API_KEY not set');
|
|
336
|
+
const apiKey = readApiKey('OPENAI_API_KEY');
|
|
337
|
+
if (!apiKey) throw new Error('OPENAI_API_KEY is not set. Export it in your environment before running.');
|
|
280
338
|
|
|
281
339
|
const messages = [];
|
|
282
|
-
if (state.systemPrompt) {
|
|
283
|
-
messages.push({ role: 'system', content: state.systemPrompt });
|
|
284
|
-
}
|
|
340
|
+
if (state.systemPrompt) messages.push({ role: 'system', content: state.systemPrompt });
|
|
285
341
|
for (const msg of state.messages) {
|
|
286
342
|
if (typeof msg.content === 'string') {
|
|
287
343
|
messages.push({ role: msg.role, content: msg.content });
|
|
288
344
|
} else if (Array.isArray(msg.content)) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
345
|
+
if (msg.role === 'assistant') {
|
|
346
|
+
const calls = msg.content.filter(b => b.type === 'tool_use').map(b => ({
|
|
347
|
+
id: b.id, type: 'function',
|
|
348
|
+
function: { name: b.name, arguments: JSON.stringify(b.input || {}) }
|
|
349
|
+
}));
|
|
350
|
+
messages.push({ role: 'assistant',
|
|
351
|
+
content: msg.content.filter(b => b.type === 'text').map(b => b.text).join('') || null,
|
|
352
|
+
...(calls.length && { tool_calls: calls }) });
|
|
353
|
+
} else {
|
|
354
|
+
for (const block of msg.content) {
|
|
355
|
+
if (block.type === 'tool_result') messages.push({ role: 'tool',
|
|
293
356
|
tool_call_id: block.tool_use_id,
|
|
294
|
-
content: block.content
|
|
295
|
-
});
|
|
357
|
+
content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content) });
|
|
358
|
+
else if (block.type === 'text') messages.push({ role: msg.role, content: block.text });
|
|
296
359
|
}
|
|
297
360
|
}
|
|
298
361
|
}
|
|
@@ -311,6 +374,7 @@ async function callOpenAI(model, state, toolDefs, settings, stream) {
|
|
|
311
374
|
|
|
312
375
|
const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1';
|
|
313
376
|
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
377
|
+
redirect: 'error',
|
|
314
378
|
method: 'POST',
|
|
315
379
|
headers: {
|
|
316
380
|
'Content-Type': 'application/json',
|
|
@@ -321,7 +385,7 @@ async function callOpenAI(model, state, toolDefs, settings, stream) {
|
|
|
321
385
|
|
|
322
386
|
if (!res.ok) {
|
|
323
387
|
const err = await res.text();
|
|
324
|
-
throw new Error(`OpenAI API error ${res.status}
|
|
388
|
+
throw new Error(`OpenAI API error ${res.status}`);
|
|
325
389
|
}
|
|
326
390
|
|
|
327
391
|
const data = await res.json();
|
|
@@ -329,8 +393,8 @@ async function callOpenAI(model, state, toolDefs, settings, stream) {
|
|
|
329
393
|
}
|
|
330
394
|
|
|
331
395
|
async function callGoogle(model, state, toolDefs, settings, stream) {
|
|
332
|
-
const apiKey =
|
|
333
|
-
if (!apiKey) throw new Error('GOOGLE_API_KEY or GEMINI_API_KEY not set');
|
|
396
|
+
const apiKey = readApiKey('GOOGLE_API_KEY', 'GEMINI_API_KEY');
|
|
397
|
+
if (!apiKey) throw new Error('GOOGLE_API_KEY or GEMINI_API_KEY is not set. Export one in your environment before running.');
|
|
334
398
|
|
|
335
399
|
const contents = [];
|
|
336
400
|
for (const msg of state.messages) {
|
package/src/core/providers.mjs
CHANGED
|
@@ -204,6 +204,24 @@ export function listProviders() {
|
|
|
204
204
|
}));
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Read an API key from the environment, in order of preference.
|
|
209
|
+
*
|
|
210
|
+
* Surrounding whitespace is trimmed — keys routinely arrive from .env files or
|
|
211
|
+
* command substitution with a trailing newline — and a whitespace-only value is
|
|
212
|
+
* treated as unset, so the next name is tried. No format validation: the
|
|
213
|
+
* provider is what authenticates the key.
|
|
214
|
+
* @param {...string} envKeys - env var names to try, most preferred first
|
|
215
|
+
* @returns {string} the trimmed key, or '' if none is configured
|
|
216
|
+
*/
|
|
217
|
+
export function readApiKey(...envKeys) {
|
|
218
|
+
for (const envKey of envKeys) {
|
|
219
|
+
const value = (process.env[envKey] || '').trim();
|
|
220
|
+
if (value) return value;
|
|
221
|
+
}
|
|
222
|
+
return '';
|
|
223
|
+
}
|
|
224
|
+
|
|
207
225
|
/**
|
|
208
226
|
* Check which providers have API keys configured.
|
|
209
227
|
* @returns {Array<{ id: string, name: string, configured: boolean }>}
|
|
@@ -212,7 +230,7 @@ export function checkProviderKeys() {
|
|
|
212
230
|
return Object.entries(PROVIDERS).map(([key, p]) => ({
|
|
213
231
|
id: key,
|
|
214
232
|
name: p.name,
|
|
215
|
-
configured: !!(
|
|
233
|
+
configured: !!readApiKey(p.envKey, ...(p.altEnvKey ? [p.altEnvKey] : [])),
|
|
216
234
|
}));
|
|
217
235
|
}
|
|
218
236
|
|