aegis-desktop 0.3.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/bin/aegis.js +30 -0
- package/build/icon.png +0 -0
- package/lib/local/agents.js +102 -0
- package/lib/local/context.js +81 -0
- package/lib/local/engine.js +460 -0
- package/lib/local/ollama.js +77 -0
- package/lib/local/prompt.js +91 -0
- package/lib/local/providers.js +536 -0
- package/lib/local/shell.js +208 -0
- package/lib/local/tools.js +638 -0
- package/lib/settings.js +225 -0
- package/lib/sync/memory-queue.js +57 -0
- package/lib/sync/sessions.js +199 -0
- package/main.js +715 -0
- package/package.json +46 -0
- package/preload.js +168 -0
- package/renderer/app.js +1990 -0
- package/renderer/index.html +289 -0
- package/renderer/max-tokens.js +18 -0
- package/renderer/style.css +1454 -0
- package/vendor/aegis.js +694 -0
- package/vendor/foreign-memory.js +666 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ollama.js — local Ollama transport (plan P1 §5.1). Wire calls only: probe
|
|
5
|
+
* the daemon, list tags, and chat over its OpenAI-compatible
|
|
6
|
+
* /v1/chat/completions (keyless). No orchestration/routing logic.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { openaiCompatible } = require('./providers.js');
|
|
10
|
+
|
|
11
|
+
const DEFAULT_BASE = 'http://localhost:11434';
|
|
12
|
+
|
|
13
|
+
function baseOf(baseURL) {
|
|
14
|
+
return String(baseURL || DEFAULT_BASE).replace(/\/+$/, '');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Probe the local daemon (with a short timeout). Never throws. */
|
|
18
|
+
async function probe(baseURL = DEFAULT_BASE) {
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(`${baseOf(baseURL)}/api/tags`, {
|
|
21
|
+
signal: AbortSignal.timeout(1500),
|
|
22
|
+
});
|
|
23
|
+
return { running: res.ok, baseURL: baseOf(baseURL) };
|
|
24
|
+
} catch {
|
|
25
|
+
return { running: false, baseURL: baseOf(baseURL) };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** List installed model tags (`GET /api/tags` → [{ id, ...details }]). */
|
|
30
|
+
async function listTags(baseURL = DEFAULT_BASE) {
|
|
31
|
+
const res = await fetch(`${baseOf(baseURL)}/api/tags`);
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
const err = new Error(`ollama /api/tags ${res.status}`);
|
|
34
|
+
err.status = res.status;
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
const data = await res.json();
|
|
38
|
+
const models = (data && data.models) || [];
|
|
39
|
+
return models.map((m) => ({
|
|
40
|
+
id: m.name,
|
|
41
|
+
...(m.details ? { details: m.details } : {}),
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Streaming chat against a local model (OpenAI-compatible, keyless). */
|
|
46
|
+
async function chat({
|
|
47
|
+
baseURL = DEFAULT_BASE,
|
|
48
|
+
model,
|
|
49
|
+
messages,
|
|
50
|
+
system,
|
|
51
|
+
prompt,
|
|
52
|
+
maxTokens = 4096,
|
|
53
|
+
temperature,
|
|
54
|
+
tools,
|
|
55
|
+
toolChoice,
|
|
56
|
+
signal,
|
|
57
|
+
onDelta,
|
|
58
|
+
} = {}) {
|
|
59
|
+
return openaiCompatible({
|
|
60
|
+
baseURL: baseOf(baseURL),
|
|
61
|
+
apiKey: null,
|
|
62
|
+
model,
|
|
63
|
+
messages,
|
|
64
|
+
system,
|
|
65
|
+
prompt,
|
|
66
|
+
maxTokens,
|
|
67
|
+
temperature,
|
|
68
|
+
// Ollama's OpenAI-compatible shim accepts `tools` on current builds; the
|
|
69
|
+
// engine retries without them once if an older daemon 400s.
|
|
70
|
+
tools,
|
|
71
|
+
toolChoice,
|
|
72
|
+
signal,
|
|
73
|
+
onDelta,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { DEFAULT_BASE, baseOf, probe, listTags, chat };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* prompt.js — the desktop client's system prompt (client half of
|
|
5
|
+
* aegiscodex-dev's tool calling).
|
|
6
|
+
*
|
|
7
|
+
* aegiscodex-dev sends a real persona on every provider turn
|
|
8
|
+
* (src/backend.js MAIN_CHAT_PROMPT plus a docs/operating-context.md block).
|
|
9
|
+
* The desktop renderer used to send nothing at all, so every model answered a
|
|
10
|
+
* bare user string with no identity, no work rules and no idea which machine
|
|
11
|
+
* it was on — hence the "which OS are you using?" round trips.
|
|
12
|
+
*
|
|
13
|
+
* MAIN_CHAT_PROMPT below is the same identity + truthfulness rule set, ported
|
|
14
|
+
* verbatim where it still applies, including the CLI's "delegate with the
|
|
15
|
+
* task tool" rule now that the desktop has a subagent runner (engine.js
|
|
16
|
+
* runSubagent). One naming deviation from the CLI text: the tool names are
|
|
17
|
+
* readFile/writeFile/editFile/listDir/glob/grep/exec/task (lowerCamelCase,
|
|
18
|
+
* matching this repo's existing tool vocabulary — see
|
|
19
|
+
* desktop/lib/local/tools.js) rather than the CLI's Read/Write/Edit/Grep/
|
|
20
|
+
* Bash/Task.
|
|
21
|
+
*
|
|
22
|
+
* The environment preamble names the platform, the home directory and the
|
|
23
|
+
* repo roots the host already knows (main.js), so the model stops asking.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Identity + work rules. Ported from aegiscodex-dev src/backend.js. */
|
|
27
|
+
const MAIN_CHAT_PROMPT =
|
|
28
|
+
`You are Aegiscodex, a terminal coding assistant that works in the user's repository. ` +
|
|
29
|
+
`Help with software engineering tasks: read and reason about code, write and edit files, ` +
|
|
30
|
+
`run shell commands, and investigate bugs. Work rules:\n` +
|
|
31
|
+
`- Use tools silently. A one-line reason is enough; do not narrate your plan as a story. ` +
|
|
32
|
+
`- Never claim what a tool found or what a command returned before the tool actually runs. ` +
|
|
33
|
+
` Report only the results you really received. ` +
|
|
34
|
+
`- Act, don't just inspect. After at most 2 rounds of reading or exploration, start making ` +
|
|
35
|
+
` changes with writeFile or editFile. Reconnaissance is not progress — implement, then verify.\n` +
|
|
36
|
+
`- When you have what you need, stop using tools and give a concise, direct answer to the ` +
|
|
37
|
+
` user's question. Never end your turn with an intention like "Let me check…" or "I'll now…" ` +
|
|
38
|
+
` — that is not an answer. ` +
|
|
39
|
+
`- When a focused multi-step sub-task can be delegated, use the task tool to spawn a ` +
|
|
40
|
+
` specialist subagent rather than doing everything inline. ` +
|
|
41
|
+
`- If the user references a workflow you don't recognize, inspect the repo/scripts for that ` +
|
|
42
|
+
` mechanism before acting — don't assume it means inline work.`;
|
|
43
|
+
|
|
44
|
+
/** The tools the desktop actually advertises (kept in step with tools.js). */
|
|
45
|
+
const TOOL_LINE =
|
|
46
|
+
'Tools: readFile, writeFile, editFile, listDir, glob, grep, exec, task. Paths are absolute; ' +
|
|
47
|
+
'exec runs in a persistent shell session on this machine — cd and exported env vars carry ' +
|
|
48
|
+
'across calls within the turn, like a real terminal. task spawns a specialist subagent ' +
|
|
49
|
+
'(its own tool loop, same model) for a focused, self-contained piece of work.';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Render the environment preamble. Everything is optional: a missing field is
|
|
53
|
+
* simply omitted, so a headless caller can build a persona with nothing but
|
|
54
|
+
* the identity block.
|
|
55
|
+
*/
|
|
56
|
+
function environmentPreamble(env = {}) {
|
|
57
|
+
const { platform, arch, homedir, cwd, roots, appVersion, model } = env || {};
|
|
58
|
+
const bits = [];
|
|
59
|
+
if (platform) bits.push(`platform: ${platform}${arch ? ` (${arch})` : ''}`);
|
|
60
|
+
if (homedir) bits.push(`home directory: ${homedir}`);
|
|
61
|
+
if (cwd) bits.push(`working directory: ${cwd}`);
|
|
62
|
+
if (model) bits.push(`model: ${model}`);
|
|
63
|
+
if (appVersion) bits.push(`AEGIS Desktop ${appVersion}`);
|
|
64
|
+
|
|
65
|
+
const rootList = Array.isArray(roots) ? roots.filter(Boolean) : [];
|
|
66
|
+
const lines = [];
|
|
67
|
+
if (bits.length) lines.push(bits.join('\n'));
|
|
68
|
+
if (rootList.length) {
|
|
69
|
+
lines.push(`repo roots:\n${rootList.map((r) => ` - ${r}`).join('\n')}`);
|
|
70
|
+
}
|
|
71
|
+
if (!lines.length) return '';
|
|
72
|
+
return `# Environment\n${lines.join('\n')}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The full system prompt for a desktop chat turn: identity + work rules +
|
|
77
|
+
* the tool line + the environment block. Never empty.
|
|
78
|
+
*/
|
|
79
|
+
function buildSystemPrompt(env = {}) {
|
|
80
|
+
const parts = [MAIN_CHAT_PROMPT, TOOL_LINE];
|
|
81
|
+
const preamble = environmentPreamble(env);
|
|
82
|
+
if (preamble) parts.push(preamble);
|
|
83
|
+
return parts.join('\n\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = {
|
|
87
|
+
MAIN_CHAT_PROMPT,
|
|
88
|
+
TOOL_LINE,
|
|
89
|
+
environmentPreamble,
|
|
90
|
+
buildSystemPrompt,
|
|
91
|
+
};
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* providers.js — direct streaming transport for two wire formats (plan P1
|
|
5
|
+
* §5.1). Pure Node (Electron main), no engine/routing logic: it only speaks
|
|
6
|
+
* the OpenAI-compatible and Anthropic Messages wire formats and normalises
|
|
7
|
+
* both to the shared `{ delta }` chunk shape plus a final result.
|
|
8
|
+
*
|
|
9
|
+
* - OpenAI-compatible: POST {baseURL}/v1/chat/completions (Bearer or keyless)
|
|
10
|
+
* - Anthropic Messages: POST {baseURL}/v1/messages (x-api-key when a key is
|
|
11
|
+
* set + version header; the credential header is omitted, never blanked)
|
|
12
|
+
*
|
|
13
|
+
* Every function returns the same normalised result the renderer already
|
|
14
|
+
* paints: { model, choices: [{ message: { content } }], usage? }.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Version/endpoint segments a user may already have typed into "base URL".
|
|
19
|
+
* Both OpenAI's and Anthropic's docs hand out base URLs that end in `/v1`, and
|
|
20
|
+
* the settings field invites pasting exactly that — so blindly appending
|
|
21
|
+
* `/v1/<endpoint>` produced `.../v1/v1/chat/completions`, a 404/400 on every
|
|
22
|
+
* call (defect A). A base URL that already names the endpoint
|
|
23
|
+
* (`…/v1/messages`) or carries a longer prefix (`…/openai/v1`) must normalise
|
|
24
|
+
* to the same single `/v1` too.
|
|
25
|
+
*/
|
|
26
|
+
const TRAILING_ENDPOINT = /\/(?:chat\/completions|completions|messages|models)$/;
|
|
27
|
+
const TRAILING_VERSION = /\/v\d+(?:\.\d+)?$/;
|
|
28
|
+
|
|
29
|
+
/** Drop a trailing version and/or endpoint segment, keeping any prefix. */
|
|
30
|
+
function stripEndpointSuffix(path) {
|
|
31
|
+
let out = path;
|
|
32
|
+
for (;;) {
|
|
33
|
+
const trimmed = out.replace(/\/+$/, '');
|
|
34
|
+
if (TRAILING_ENDPOINT.test(trimmed)) {
|
|
35
|
+
out = trimmed.replace(TRAILING_ENDPOINT, '');
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (TRAILING_VERSION.test(trimmed)) {
|
|
39
|
+
out = trimmed.replace(TRAILING_VERSION, '');
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
return trimmed;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the absolute request URL for a wire-format endpoint. `baseURL` may be
|
|
48
|
+
* bare (`https://api.openai.com`), versioned (`…/v1`), versioned with a
|
|
49
|
+
* trailing slash, or already name the endpoint — all of them resolve to
|
|
50
|
+
* exactly one version segment. Any other path prefix survives, as does a
|
|
51
|
+
* query string or fragment. The endpoint path is supplied by the caller so
|
|
52
|
+
* this stays the single shared path builder for both transports.
|
|
53
|
+
*/
|
|
54
|
+
function endpointURL(baseURL, endpointPath) {
|
|
55
|
+
const raw = String(baseURL == null ? '' : baseURL).trim();
|
|
56
|
+
const cut = raw.search(/[?#]/);
|
|
57
|
+
const base = cut === -1 ? raw : raw.slice(0, cut);
|
|
58
|
+
const tail = cut === -1 ? '' : raw.slice(cut);
|
|
59
|
+
return `${stripEndpointSuffix(base)}${endpointPath}${tail}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A model id is mandatory over both wire formats; a URL is never one. */
|
|
63
|
+
function requireModel(model) {
|
|
64
|
+
if (typeof model === 'string' && model.trim()) return model.trim();
|
|
65
|
+
const err = new Error(
|
|
66
|
+
'a model id is required — type the provider\'s model name (a base URL is not a model)'
|
|
67
|
+
);
|
|
68
|
+
err.status = 400;
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Normalise arbitrary message entries to a wire-safe { role, content } row.
|
|
74
|
+
*
|
|
75
|
+
* The agent loop threads tool traffic through this same list: an assistant row
|
|
76
|
+
* carrying `tool_calls` and a `tool` row carrying `tool_call_id` must survive
|
|
77
|
+
* verbatim, and an Anthropic row may carry structured `content` blocks
|
|
78
|
+
* (tool_use / tool_result) instead of a string. Both are passed through
|
|
79
|
+
* untouched; a plain string row normalises exactly as before.
|
|
80
|
+
*/
|
|
81
|
+
function normalizeMessages(messages) {
|
|
82
|
+
const out = [];
|
|
83
|
+
if (!Array.isArray(messages)) return out;
|
|
84
|
+
for (const m of messages) {
|
|
85
|
+
if (!m) continue;
|
|
86
|
+
const role = m.role || 'user';
|
|
87
|
+
const structured = m.content != null && typeof m.content !== 'string';
|
|
88
|
+
const content = structured
|
|
89
|
+
? m.content
|
|
90
|
+
: typeof m.content === 'string'
|
|
91
|
+
? m.content
|
|
92
|
+
: m.text != null
|
|
93
|
+
? String(m.text)
|
|
94
|
+
: '';
|
|
95
|
+
const row = { role, content };
|
|
96
|
+
if (Array.isArray(m.tool_calls) && m.tool_calls.length) row.tool_calls = m.tool_calls;
|
|
97
|
+
if (m.tool_call_id) row.tool_call_id = m.tool_call_id;
|
|
98
|
+
if (m.name) row.name = m.name;
|
|
99
|
+
out.push(row);
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Append the single-shot `prompt` shorthand as a final user turn.
|
|
106
|
+
*
|
|
107
|
+
* The old builder *replaced* the whole list whenever `prompt` was non-empty:
|
|
108
|
+
* a turn carrying both an agent-loop history and a fresh prompt silently lost
|
|
109
|
+
* every prior message — including any system message, which is why the ported
|
|
110
|
+
* persona would have vanished on the renderer's own call path. History is now
|
|
111
|
+
* preserved and the prompt is appended once (skipped when the history already
|
|
112
|
+
* ends with that exact user turn, so a caller that sends both is not doubled).
|
|
113
|
+
*/
|
|
114
|
+
function appendPrompt(out, prompt) {
|
|
115
|
+
if (prompt == null || prompt === '') return out;
|
|
116
|
+
const last = out[out.length - 1];
|
|
117
|
+
if (last && last.role === 'user' && last.content === prompt) return out;
|
|
118
|
+
out.push({ role: 'user', content: prompt });
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** OpenAI-format message list with the system message prepended. */
|
|
123
|
+
function openAIMessages(messages, system, prompt) {
|
|
124
|
+
const out = [];
|
|
125
|
+
if (system) out.push({ role: 'system', content: system });
|
|
126
|
+
out.push(...normalizeMessages(messages));
|
|
127
|
+
return appendPrompt(out, prompt);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Fold OpenAI-shaped tool traffic into Anthropic content-block turns.
|
|
132
|
+
*
|
|
133
|
+
* Anthropic's Messages API has no `tool` role and no `tool_calls` field: an
|
|
134
|
+
* assistant turn's tool calls must become `tool_use` content blocks, and each
|
|
135
|
+
* result must become a `tool_result` block inside the *following* `user`
|
|
136
|
+
* turn (parallel results share one turn, not one each — Anthropic 400s on
|
|
137
|
+
* separate turns). Passing the OpenAI shapes through verbatim, which
|
|
138
|
+
* `normalizeMessages` alone did, is a hard 400 on any real tool round-trip.
|
|
139
|
+
*/
|
|
140
|
+
function toAnthropicToolTurns(messages) {
|
|
141
|
+
const out = [];
|
|
142
|
+
for (const m of messages) {
|
|
143
|
+
const role = m.role;
|
|
144
|
+
if (role === 'tool') {
|
|
145
|
+
const block = {
|
|
146
|
+
type: 'tool_result',
|
|
147
|
+
tool_use_id: m.tool_call_id || '',
|
|
148
|
+
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? ''),
|
|
149
|
+
};
|
|
150
|
+
const last = out[out.length - 1];
|
|
151
|
+
if (last && last.role === 'user' && Array.isArray(last.content)) {
|
|
152
|
+
last.content.push(block);
|
|
153
|
+
} else {
|
|
154
|
+
out.push({ role: 'user', content: [block] });
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
|
|
159
|
+
const blocks = [];
|
|
160
|
+
if (m.content) blocks.push({ type: 'text', text: String(m.content) });
|
|
161
|
+
for (const tc of m.tool_calls) {
|
|
162
|
+
const fn = tc.function || {};
|
|
163
|
+
let input = {};
|
|
164
|
+
if (typeof fn.arguments === 'string') {
|
|
165
|
+
try {
|
|
166
|
+
input = fn.arguments.trim() ? JSON.parse(fn.arguments) : {};
|
|
167
|
+
} catch {
|
|
168
|
+
input = {};
|
|
169
|
+
}
|
|
170
|
+
} else if (fn.arguments && typeof fn.arguments === 'object') {
|
|
171
|
+
input = fn.arguments;
|
|
172
|
+
}
|
|
173
|
+
blocks.push({ type: 'tool_use', id: tc.id || '', name: fn.name || '', input });
|
|
174
|
+
}
|
|
175
|
+
out.push({ role: 'assistant', content: blocks });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
out.push(m);
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Anthropic-format message list (system is a top-level field, not a turn). */
|
|
184
|
+
function buildAnthropicMessages(messages, prompt) {
|
|
185
|
+
return toAnthropicToolTurns(appendPrompt(normalizeMessages(messages), prompt));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Tool-call accumulation ──────────────────────────────────────────────────
|
|
189
|
+
//
|
|
190
|
+
// Both wire formats stream a call in fragments: OpenAI sends
|
|
191
|
+
// `delta.tool_calls[{index, id?, function:{name?, arguments?}}]` and Anthropic
|
|
192
|
+
// sends a `content_block_start` (tool_use) followed by `input_json_delta`
|
|
193
|
+
// fragments keyed by content-block index. The parsers below used to keep only
|
|
194
|
+
// the text delta; the agent loop needs the calls themselves, so each transport
|
|
195
|
+
// accumulates them and reports a normalised `toolCalls: [{id, name, args}]`
|
|
196
|
+
// on its result (plus the provider's own shape, so a caller that speaks one
|
|
197
|
+
// wire format natively can read it unchanged).
|
|
198
|
+
|
|
199
|
+
/** Parse accumulated argument JSON; a malformed fragment degrades to {}. */
|
|
200
|
+
function parseToolArgs(raw) {
|
|
201
|
+
try {
|
|
202
|
+
const v = JSON.parse(raw);
|
|
203
|
+
return v && typeof v === 'object' ? v : {};
|
|
204
|
+
} catch {
|
|
205
|
+
return {};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Stamp one OpenAI `tool_calls` fragment list into an index-keyed map. */
|
|
210
|
+
function addOpenAIToolCall(map, tc) {
|
|
211
|
+
if (!tc) return;
|
|
212
|
+
const idx = tc.index == null ? 0 : tc.index;
|
|
213
|
+
const cur = map.get(idx) || { id: '', name: '', args: '' };
|
|
214
|
+
if (tc.id) cur.id = tc.id;
|
|
215
|
+
const fn = tc.function || {};
|
|
216
|
+
if (fn.name) cur.name = fn.name;
|
|
217
|
+
if (typeof fn.arguments === 'string') cur.args += fn.arguments;
|
|
218
|
+
map.set(idx, cur);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Map → normalised calls, preserving the provider's index order. */
|
|
222
|
+
function finalizeToolCalls(map) {
|
|
223
|
+
return [...map.entries()]
|
|
224
|
+
.sort((a, b) => a[0] - b[0])
|
|
225
|
+
.map(([, c]) => ({ id: c.id, name: c.name, args: parseToolArgs(c.args) }));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** One OpenAI-format tool definition → Anthropic's {name, input_schema}. */
|
|
229
|
+
function openaiToAnthropicTool(tool) {
|
|
230
|
+
const fn = (tool && tool.function) || tool || {};
|
|
231
|
+
return {
|
|
232
|
+
name: fn.name,
|
|
233
|
+
description: fn.description || '',
|
|
234
|
+
input_schema: fn.parameters || { type: 'object', properties: {} },
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Read an SSE body, invoking onEvent(json) for each parsed `data:` payload. */
|
|
239
|
+
async function readSSE(res, onEvent) {
|
|
240
|
+
const reader = res.body.getReader();
|
|
241
|
+
const decoder = new TextDecoder();
|
|
242
|
+
let buffer = '';
|
|
243
|
+
for (;;) {
|
|
244
|
+
const { done, value } = await reader.read();
|
|
245
|
+
if (done) break;
|
|
246
|
+
buffer += decoder.decode(value, { stream: true });
|
|
247
|
+
const lines = buffer.split('\n');
|
|
248
|
+
buffer = lines.pop(); // keep the trailing partial line
|
|
249
|
+
for (const raw of lines) {
|
|
250
|
+
const line = raw.trim();
|
|
251
|
+
if (!line.startsWith('data:')) continue;
|
|
252
|
+
const payload = line.slice(5).trim();
|
|
253
|
+
if (!payload || payload === '[DONE]') continue;
|
|
254
|
+
let json;
|
|
255
|
+
try {
|
|
256
|
+
json = JSON.parse(payload);
|
|
257
|
+
} catch {
|
|
258
|
+
continue; // keepalive / partial line
|
|
259
|
+
}
|
|
260
|
+
onEvent(json);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** POST with stream:true; streams SSE events or falls back to plain JSON. */
|
|
266
|
+
async function requestStream({ url, headers, body, signal, onEvent }) {
|
|
267
|
+
const res = await fetch(url, {
|
|
268
|
+
method: 'POST',
|
|
269
|
+
headers,
|
|
270
|
+
body: JSON.stringify(body),
|
|
271
|
+
signal,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
if (!res.ok) {
|
|
275
|
+
const text = await res.text().catch(() => '');
|
|
276
|
+
const err = new Error(
|
|
277
|
+
`upstream ${res.status}: ${(text || res.statusText).slice(0, 300)}`
|
|
278
|
+
);
|
|
279
|
+
err.status = res.status;
|
|
280
|
+
throw err;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const contentType = res.headers.get('content-type') || '';
|
|
284
|
+
if (contentType.includes('text/event-stream')) {
|
|
285
|
+
await readSSE(res, onEvent);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Server ignored stream:true and answered with plain JSON — one-shot fallback.
|
|
290
|
+
const data = await res.json().catch(() => null);
|
|
291
|
+
if (data) onEvent(data);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** OpenAI-compatible streaming chat (Bearer auth or keyless for Ollama). */
|
|
295
|
+
async function openaiCompatible({
|
|
296
|
+
baseURL,
|
|
297
|
+
apiKey,
|
|
298
|
+
model,
|
|
299
|
+
messages,
|
|
300
|
+
system,
|
|
301
|
+
prompt,
|
|
302
|
+
maxTokens = 4096,
|
|
303
|
+
temperature,
|
|
304
|
+
tools,
|
|
305
|
+
toolChoice,
|
|
306
|
+
signal,
|
|
307
|
+
onDelta,
|
|
308
|
+
} = {}) {
|
|
309
|
+
const url = endpointURL(baseURL, '/v1/chat/completions');
|
|
310
|
+
const modelId = requireModel(model);
|
|
311
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
312
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
313
|
+
|
|
314
|
+
const body = {
|
|
315
|
+
model: modelId,
|
|
316
|
+
messages: openAIMessages(messages, system, prompt),
|
|
317
|
+
max_tokens: maxTokens || 4096,
|
|
318
|
+
stream: true,
|
|
319
|
+
};
|
|
320
|
+
if (temperature != null) body.temperature = temperature;
|
|
321
|
+
// Only advertise tools when the caller passes a non-empty list — an empty
|
|
322
|
+
// `tools: []` is a 400 on some gateways.
|
|
323
|
+
if (Array.isArray(tools) && tools.length) {
|
|
324
|
+
body.tools = tools;
|
|
325
|
+
if (toolChoice) body.tool_choice = toolChoice;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
let fullText = '';
|
|
329
|
+
let resultModel = modelId;
|
|
330
|
+
let usage = null;
|
|
331
|
+
let finishReason = null;
|
|
332
|
+
const toolCallMap = new Map(); // index → {id, name, args}
|
|
333
|
+
|
|
334
|
+
await requestStream({
|
|
335
|
+
url,
|
|
336
|
+
headers,
|
|
337
|
+
body,
|
|
338
|
+
signal,
|
|
339
|
+
onEvent: (json) => {
|
|
340
|
+
if (json.error) return;
|
|
341
|
+
if (json.model) resultModel = json.model;
|
|
342
|
+
if (json.usage) usage = json.usage;
|
|
343
|
+
const choice = json.choices && json.choices[0];
|
|
344
|
+
if (choice && choice.finish_reason) finishReason = choice.finish_reason;
|
|
345
|
+
const delta =
|
|
346
|
+
(choice &&
|
|
347
|
+
((choice.delta && choice.delta.content) ||
|
|
348
|
+
(choice.message && choice.message.content))) ||
|
|
349
|
+
'';
|
|
350
|
+
if (delta) {
|
|
351
|
+
fullText += delta;
|
|
352
|
+
if (onDelta) onDelta({ delta });
|
|
353
|
+
}
|
|
354
|
+
// Streamed fragments (delta.tool_calls) and the whole-answer shape a
|
|
355
|
+
// non-streaming fallback returns (message.tool_calls) both land here.
|
|
356
|
+
const fragments =
|
|
357
|
+
(choice && choice.delta && choice.delta.tool_calls) ||
|
|
358
|
+
(choice && choice.message && choice.message.tool_calls);
|
|
359
|
+
if (Array.isArray(fragments)) {
|
|
360
|
+
for (const tc of fragments) addOpenAIToolCall(toolCallMap, tc);
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const toolCalls = finalizeToolCalls(toolCallMap);
|
|
366
|
+
const message = { content: fullText };
|
|
367
|
+
if (toolCalls.length) {
|
|
368
|
+
message.tool_calls = toolCalls.map((c) => ({
|
|
369
|
+
id: c.id,
|
|
370
|
+
type: 'function',
|
|
371
|
+
function: { name: c.name, arguments: JSON.stringify(c.args) },
|
|
372
|
+
}));
|
|
373
|
+
}
|
|
374
|
+
const result = {
|
|
375
|
+
model: resultModel,
|
|
376
|
+
choices: [{ message, ...(finishReason ? { finish_reason: finishReason } : {}) }],
|
|
377
|
+
};
|
|
378
|
+
if (usage) result.usage = usage;
|
|
379
|
+
if (toolCalls.length) result.toolCalls = toolCalls;
|
|
380
|
+
return result;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Anthropic Messages streaming chat (x-api-key + anthropic-version). */
|
|
384
|
+
async function anthropicMessages({
|
|
385
|
+
baseURL,
|
|
386
|
+
apiKey,
|
|
387
|
+
model,
|
|
388
|
+
messages,
|
|
389
|
+
system,
|
|
390
|
+
prompt,
|
|
391
|
+
maxTokens = 4096,
|
|
392
|
+
temperature,
|
|
393
|
+
tools,
|
|
394
|
+
toolChoice,
|
|
395
|
+
signal,
|
|
396
|
+
onDelta,
|
|
397
|
+
} = {}) {
|
|
398
|
+
const url = endpointURL(baseURL, '/v1/messages');
|
|
399
|
+
const modelId = requireModel(model);
|
|
400
|
+
const headers = {
|
|
401
|
+
'Content-Type': 'application/json',
|
|
402
|
+
'anthropic-version': '2023-06-01',
|
|
403
|
+
};
|
|
404
|
+
// Omit the credential header entirely when no key is configured: an empty
|
|
405
|
+
// `x-api-key: ''` is still a credential attempt and turns a proxied/keyless
|
|
406
|
+
// endpoint into a 401 (defect #3).
|
|
407
|
+
if (apiKey) headers['x-api-key'] = apiKey;
|
|
408
|
+
|
|
409
|
+
const body = {
|
|
410
|
+
model: modelId,
|
|
411
|
+
max_tokens: maxTokens || 4096,
|
|
412
|
+
stream: true,
|
|
413
|
+
messages: buildAnthropicMessages(messages, prompt),
|
|
414
|
+
};
|
|
415
|
+
if (system) body.system = system;
|
|
416
|
+
if (temperature != null) body.temperature = temperature;
|
|
417
|
+
// Anthropic wants {name, description, input_schema} — an OpenAI-shaped list
|
|
418
|
+
// is converted rather than sent raw (this is the documented footgun).
|
|
419
|
+
if (Array.isArray(tools) && tools.length) {
|
|
420
|
+
body.tools = tools.map((t) => (t && t.function ? openaiToAnthropicTool(t) : t));
|
|
421
|
+
if (toolChoice) body.tool_choice = toolChoice;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
let fullText = '';
|
|
425
|
+
let resultModel = modelId;
|
|
426
|
+
let usage = null;
|
|
427
|
+
let stopReason = null;
|
|
428
|
+
const toolBlocks = new Map(); // content-block index → {id, name, args}
|
|
429
|
+
|
|
430
|
+
await requestStream({
|
|
431
|
+
url,
|
|
432
|
+
headers,
|
|
433
|
+
body,
|
|
434
|
+
signal,
|
|
435
|
+
onEvent: (json) => {
|
|
436
|
+
if (!json || json.type === 'error' || json.error) {
|
|
437
|
+
if (json && (json.error || json.type === 'error')) {
|
|
438
|
+
const e = json.error || {};
|
|
439
|
+
const msg =
|
|
440
|
+
(typeof e === 'string' ? e : e.message) || 'anthropic stream error';
|
|
441
|
+
const err = new Error(msg);
|
|
442
|
+
err.status = err.status || 400;
|
|
443
|
+
throw err;
|
|
444
|
+
}
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (json.type === 'message_start' && json.message) {
|
|
448
|
+
if (json.message.model) resultModel = json.message.model;
|
|
449
|
+
if (json.message.usage) usage = json.message.usage;
|
|
450
|
+
}
|
|
451
|
+
// A tool_use block opens here; its arguments arrive as
|
|
452
|
+
// input_json_delta fragments below (the text deltas we already parsed
|
|
453
|
+
// are `text_delta`, which carry `.text`).
|
|
454
|
+
if (json.type === 'content_block_start' && json.content_block) {
|
|
455
|
+
const cb = json.content_block;
|
|
456
|
+
if (cb.type === 'tool_use') {
|
|
457
|
+
toolBlocks.set(json.index == null ? 0 : json.index, {
|
|
458
|
+
id: cb.id || '',
|
|
459
|
+
name: cb.name || '',
|
|
460
|
+
args: '',
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (json.type === 'content_block_delta' && json.delta) {
|
|
465
|
+
if (json.delta.type === 'input_json_delta') {
|
|
466
|
+
const block = toolBlocks.get(json.index == null ? 0 : json.index);
|
|
467
|
+
if (block) block.args += json.delta.partial_json || '';
|
|
468
|
+
} else {
|
|
469
|
+
const delta = typeof json.delta.text === 'string' ? json.delta.text : '';
|
|
470
|
+
if (delta) {
|
|
471
|
+
fullText += delta;
|
|
472
|
+
if (onDelta) onDelta({ delta });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (json.type === 'message_delta') {
|
|
477
|
+
if (json.usage) usage = json.usage;
|
|
478
|
+
if (json.delta && json.delta.stop_reason) stopReason = json.delta.stop_reason;
|
|
479
|
+
}
|
|
480
|
+
// Non-streaming fallback: a full Anthropic Message with content blocks.
|
|
481
|
+
if (Array.isArray(json.content)) {
|
|
482
|
+
if (json.model) resultModel = json.model;
|
|
483
|
+
if (json.usage) usage = json.usage;
|
|
484
|
+
if (json.stop_reason) stopReason = json.stop_reason;
|
|
485
|
+
json.content.forEach((block, i) => {
|
|
486
|
+
if (block && block.type === 'text' && block.text && !fullText) {
|
|
487
|
+
fullText += block.text;
|
|
488
|
+
if (onDelta) onDelta({ delta: block.text });
|
|
489
|
+
}
|
|
490
|
+
if (block && block.type === 'tool_use') {
|
|
491
|
+
toolBlocks.set(i, {
|
|
492
|
+
id: block.id || '',
|
|
493
|
+
name: block.name || '',
|
|
494
|
+
args: JSON.stringify(block.input || {}),
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const toolCalls = finalizeToolCalls(toolBlocks);
|
|
503
|
+
const result = { model: resultModel, choices: [{ message: { content: fullText } }] };
|
|
504
|
+
if (usage) result.usage = usage;
|
|
505
|
+
if (stopReason) result.stop_reason = stopReason;
|
|
506
|
+
if (toolCalls.length) {
|
|
507
|
+
result.toolCalls = toolCalls;
|
|
508
|
+
// Provider-native shape, for callers that read Anthropic blocks directly.
|
|
509
|
+
result.choices[0].message.tool_calls = toolCalls.map((c) => ({
|
|
510
|
+
id: c.id,
|
|
511
|
+
type: 'function',
|
|
512
|
+
function: { name: c.name, arguments: JSON.stringify(c.args) },
|
|
513
|
+
}));
|
|
514
|
+
}
|
|
515
|
+
return result;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
module.exports = {
|
|
519
|
+
normalizeMessages,
|
|
520
|
+
openAIMessages,
|
|
521
|
+
buildAnthropicMessages,
|
|
522
|
+
appendPrompt,
|
|
523
|
+
readSSE,
|
|
524
|
+
requestStream,
|
|
525
|
+
openaiCompatible,
|
|
526
|
+
anthropicMessages,
|
|
527
|
+
// shared path builder + model-id guard (unit-tested directly)
|
|
528
|
+
endpointURL,
|
|
529
|
+
stripEndpointSuffix,
|
|
530
|
+
requireModel,
|
|
531
|
+
// tool-call plumbing (unit-tested directly)
|
|
532
|
+
addOpenAIToolCall,
|
|
533
|
+
finalizeToolCalls,
|
|
534
|
+
parseToolArgs,
|
|
535
|
+
openaiToAnthropicTool,
|
|
536
|
+
};
|