@anteros/core 0.0.40 → 0.0.42
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/database/audit.ts +143 -0
- package/database/collection.ts +6 -4
- package/database/deleteLog.ts +98 -0
- package/database/file.ts +7 -6
- package/database/mongodbadapter.ts +420 -185
- package/database/replication.ts +858 -0
- package/database/schema.ts +140 -125
- package/database/tenant.ts +4 -0
- package/database/ttl.ts +68 -0
- package/database/vars.ts +405 -0
- package/database/workflow.ts +466 -240
- package/index.ts +16 -0
- package/lib/agent.ts +1327 -0
- package/lib/agentMemory.ts +586 -0
- package/lib/agents.ts +157 -0
- package/lib/ai.ts +0 -0
- package/lib/api.ts +246 -0
- package/lib/attachment.ts +351 -0
- package/lib/audit.ts +271 -0
- package/lib/auditResult.ts +133 -0
- package/lib/define.ts +62 -1
- package/lib/endpoints.ts +80 -0
- package/lib/files.ts +66 -12
- package/lib/jsonSchema.ts +183 -0
- package/lib/lifecycle.ts +147 -0
- package/lib/load.ts +17 -0
- package/lib/mcp.ts +3 -2
- package/lib/messages.ts +299 -0
- package/lib/providers.ts +867 -0
- package/lib/redact.ts +117 -0
- package/lib/routes.ts +14 -1
- package/lib/scriptWorker.ts +4 -1
- package/lib/scripts.ts +6 -1
- package/lib/sockets.ts +3 -1
- package/lib/workflow.ts +209 -37
- package/lib/zip.ts +109 -0
- package/package.json +1 -1
- package/server/access.ts +96 -0
- package/server/agents.ts +451 -0
- package/server/api.ts +121 -95
- package/server/boot.ts +196 -53
- package/server/flags.ts +136 -0
- package/server/hono.ts +9 -4
- package/server/mcp.ts +17 -63
- package/server/routes.ts +19 -24
- package/tests/agent-memory.test.ts +617 -0
- package/tests/agent-working-memory.test.ts +402 -0
- package/tests/agent.test.ts +1239 -0
- package/tests/agents-api.test.ts +593 -0
- package/tests/api.test.ts +51 -0
- package/tests/attachment.test.ts +241 -0
- package/tests/audit-file.test.ts +296 -0
- package/tests/audit-operations.test.ts +184 -0
- package/tests/audit-redact.test.ts +259 -0
- package/tests/audit-results.test.ts +188 -0
- package/tests/audit-retention-config.test.ts +76 -0
- package/tests/audit-run-action.test.ts +120 -0
- package/tests/audit.test.ts +320 -0
- package/tests/db-cache.test.ts +7 -7
- package/tests/endpoints.test.ts +80 -0
- package/tests/file-collection-replication.test.ts +98 -0
- package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
- package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
- package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
- package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
- package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
- package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
- package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
- package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
- package/tests/fixtures/attachment/data.csv +2 -0
- package/tests/fixtures/fake-provider.ts +128 -0
- package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
- package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
- package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
- package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
- package/tests/fixtures/repl-only/app.ts +26 -0
- package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
- package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
- package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
- package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
- package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
- package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
- package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
- package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +9 -0
- package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
- package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
- package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
- package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
- package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
- package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
- package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
- package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
- package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
- package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
- package/tests/hono.test.ts +6 -3
- package/tests/lifecycle.test.ts +96 -0
- package/tests/locks.test.ts +120 -0
- package/tests/mcp.test.ts +11 -11
- package/tests/messages.test.ts +258 -0
- package/tests/replication-control.test.ts +128 -0
- package/tests/replication-initial-sync.test.ts +86 -0
- package/tests/replication-meta.test.ts +243 -0
- package/tests/replication-only.test.ts +358 -0
- package/tests/replication.test.ts +188 -0
- package/tests/upload.test.ts +141 -8
- package/tests/vars-api.test.ts +123 -0
- package/tests/vars-replication.test.ts +96 -0
- package/tests/vars.test.ts +216 -0
- package/tests/workflow-engine.test.ts +340 -0
- package/tests/workflow-retention.test.ts +103 -0
- package/tests/workflow.test.ts +186 -0
- package/types/agent.d.ts +837 -0
- package/types/collection.d.ts +3 -0
- package/types/config.d.ts +80 -0
- package/types/file.d.ts +11 -8
- package/types/hook.d.ts +6 -0
- package/types/lifecycle.d.ts +77 -0
- package/types/mcp.d.ts +11 -1
- package/types/replication.d.ts +228 -0
- package/types/route.d.ts +7 -1
- package/types/scripts.d.ts +6 -0
- package/types/service.d.ts +7 -1
- package/types/tenant.d.ts +36 -0
- package/types/vars.d.ts +147 -0
- package/types/websocket.d.ts +6 -0
- package/types/workflow.d.ts +50 -3
- package/utils/func.ts +67 -52
package/lib/providers.ts
ADDED
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
import { AppError } from "./error";
|
|
2
|
+
import type {
|
|
3
|
+
AgentCompatible,
|
|
4
|
+
AgentMediaPart,
|
|
5
|
+
AgentMessage,
|
|
6
|
+
AgentProvider,
|
|
7
|
+
AgentThinking,
|
|
8
|
+
AgentThinkingBlock,
|
|
9
|
+
AgentToolChoice,
|
|
10
|
+
AgentUsage,
|
|
11
|
+
} from "../types/agent";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Model provider adapters.
|
|
15
|
+
*
|
|
16
|
+
* Two wire protocols cover the whole field:
|
|
17
|
+
*
|
|
18
|
+
* - `openai` — `POST {baseUrl}/chat/completions`, `Authorization: Bearer …`.
|
|
19
|
+
* Every OpenAI-compatible gateway (Groq, Mistral, OpenRouter,
|
|
20
|
+
* Ollama, LM Studio, vLLM…) speaks it too, only `baseUrl` changes.
|
|
21
|
+
* - `anthropic` — `POST {baseUrl}/v1/messages`, `x-api-key: …`.
|
|
22
|
+
*
|
|
23
|
+
* Both are called with `fetch` — no SDK, no transitive dependency, and the
|
|
24
|
+
* streaming path is plain SSE in both cases. The agent runtime only ever sees
|
|
25
|
+
* the normalized shapes below.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** A tool advertised to the model (already a JSON Schema). */
|
|
29
|
+
export type AgentToolSpec = {
|
|
30
|
+
name: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
parameters: any;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type ModelChatRequest = {
|
|
36
|
+
/** System prompt (kept separate from `messages` — Anthropic wants it out of band). */
|
|
37
|
+
system?: string;
|
|
38
|
+
messages: AgentMessage[];
|
|
39
|
+
tools?: AgentToolSpec[];
|
|
40
|
+
/** How the model may use the tools (default: it chooses). */
|
|
41
|
+
toolChoice?: AgentToolChoice;
|
|
42
|
+
/** Extended thinking — mapped to a budget (Anthropic) or an effort (OpenAI). */
|
|
43
|
+
thinking?: AgentThinking;
|
|
44
|
+
/** Per-request override of the adapter's `options.temperature`. */
|
|
45
|
+
temperature?: number;
|
|
46
|
+
/** Per-request override of the adapter's `options.maxTokens`. */
|
|
47
|
+
maxTokens?: number;
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
/** Ask for a JSON object (OpenAI-compatible providers: `response_format`). */
|
|
50
|
+
json?: boolean;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type ModelChatResponse = {
|
|
54
|
+
text: string;
|
|
55
|
+
toolCalls: Array<{ id: string; name: string; argsText: string }>;
|
|
56
|
+
finishReason: string;
|
|
57
|
+
usage: AgentUsage;
|
|
58
|
+
/** The model's reasoning, when extended thinking is on. */
|
|
59
|
+
reasoning?: string;
|
|
60
|
+
/** The raw reasoning blocks — Anthropic needs them echoed back with a tool round. */
|
|
61
|
+
thinking?: AgentThinkingBlock[];
|
|
62
|
+
raw?: any;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type ModelStreamEvent =
|
|
66
|
+
| { type: "text"; text: string }
|
|
67
|
+
/** Thinking output — the consumer decides whether to show it. */
|
|
68
|
+
| { type: "reasoning"; text: string }
|
|
69
|
+
/**
|
|
70
|
+
* The finished reasoning blocks, signatures included — emitted at the end of a
|
|
71
|
+
* turn. Anthropic requires them to be echoed back verbatim on a tool round.
|
|
72
|
+
*/
|
|
73
|
+
| { type: "thinking_blocks"; blocks: AgentThinkingBlock[] }
|
|
74
|
+
| { type: "tool_call"; index: number; id?: string; name?: string; argsText?: string }
|
|
75
|
+
| { type: "usage"; usage: Partial<AgentUsage> }
|
|
76
|
+
| { type: "finish"; finishReason: string };
|
|
77
|
+
|
|
78
|
+
export type ModelAdapter = {
|
|
79
|
+
compatible: AgentCompatible;
|
|
80
|
+
model: string;
|
|
81
|
+
chat(req: ModelChatRequest): Promise<ModelChatResponse>;
|
|
82
|
+
chatStream(req: ModelChatRequest): AsyncGenerator<ModelStreamEvent, void, unknown>;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const DEFAULT_BASE_URL: Record<AgentCompatible, string> = {
|
|
86
|
+
openai: "https://api.openai.com/v1",
|
|
87
|
+
anthropic: "https://api.anthropic.com",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const ENV_API_KEY: Record<AgentCompatible, string[]> = {
|
|
91
|
+
openai: ["ANTEROS_AI_API_KEY", "OPENAI_API_KEY"],
|
|
92
|
+
anthropic: ["ANTEROS_AI_API_KEY", "ANTHROPIC_API_KEY"],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const DEFAULT_MAX_TOKENS = 4096;
|
|
96
|
+
const DEFAULT_TIMEOUT = 120_000;
|
|
97
|
+
const DEFAULT_RETRIES = 2;
|
|
98
|
+
|
|
99
|
+
const EMPTY_USAGE: AgentUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, requests: 0 };
|
|
100
|
+
|
|
101
|
+
// ─── Configuration resolution ────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
function resolveCompatible(provider: AgentProvider): AgentCompatible {
|
|
104
|
+
const compatible = provider.compatible ?? "openai";
|
|
105
|
+
if (compatible !== "openai" && compatible !== "anthropic") {
|
|
106
|
+
throw new AppError(`Unknown provider 'compatible': '${compatible}' — expected 'openai' or 'anthropic'`, {
|
|
107
|
+
status: 500,
|
|
108
|
+
code: "AGENT_PROVIDER_INVALID",
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return compatible;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function resolveApiKey(provider: AgentProvider, compatible: AgentCompatible): string {
|
|
115
|
+
if (provider.apiKey) return provider.apiKey;
|
|
116
|
+
for (const name of ENV_API_KEY[compatible]) {
|
|
117
|
+
const value = Bun.env?.[name] ?? process.env?.[name];
|
|
118
|
+
if (value) return value;
|
|
119
|
+
}
|
|
120
|
+
throw new AppError(
|
|
121
|
+
`No API key for provider '${provider.model}' (compatible: ${compatible}) — pass \`provider.apiKey\` or set ${ENV_API_KEY[compatible].join(" / ")}`,
|
|
122
|
+
{ status: 500, code: "AGENT_PROVIDER_NO_API_KEY" },
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* API root. A bare host gets the conventional prefix appended
|
|
128
|
+
* (`http://localhost:11434` → `http://localhost:11434/v1` for OpenAI-compatible
|
|
129
|
+
* servers), an explicit path is used verbatim.
|
|
130
|
+
*/
|
|
131
|
+
function resolveBaseUrl(provider: AgentProvider, compatible: AgentCompatible): string {
|
|
132
|
+
const raw = provider.baseUrl ?? DEFAULT_BASE_URL[compatible];
|
|
133
|
+
const trimmed = raw.replace(/\/+$/, "");
|
|
134
|
+
if (compatible !== "openai") return trimmed;
|
|
135
|
+
try {
|
|
136
|
+
const url = new URL(trimmed);
|
|
137
|
+
if (url.pathname === "" || url.pathname === "/") url.pathname = "/v1";
|
|
138
|
+
return url.toString().replace(/\/+$/, "");
|
|
139
|
+
} catch {
|
|
140
|
+
return trimmed;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Message conversion ──────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/** A part the adapters must never see — `normalizeMessages` hoists tool parts away. */
|
|
147
|
+
function unsupportedPart(part: unknown): AppError {
|
|
148
|
+
return new AppError(
|
|
149
|
+
`Unsupported content part '${(part as any)?.type}' — a \`tool-call\` / \`tool-result\` part belongs to the message, not its content`,
|
|
150
|
+
{ status: 500, code: "AGENT_CONTENT_INVALID" },
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function openaiContent(content: string | AgentMediaPart[]): any {
|
|
155
|
+
if (typeof content === "string") return content;
|
|
156
|
+
return content.map((part) => {
|
|
157
|
+
switch (part.type) {
|
|
158
|
+
case "text":
|
|
159
|
+
return { type: "text", text: part.text };
|
|
160
|
+
case "image":
|
|
161
|
+
// A URL is handed to the endpoint (it fetches the image itself);
|
|
162
|
+
// base64 becomes the `data:` URL OpenAI expects
|
|
163
|
+
return {
|
|
164
|
+
type: "image_url",
|
|
165
|
+
image_url: {
|
|
166
|
+
url: part.url ?? `data:${part.mimeType ?? "image/png"};base64,${part.data}`,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
case "file":
|
|
170
|
+
// Document input (PDF) — supported by the recent models; a model that
|
|
171
|
+
// does not accept it answers with a clear provider error. There is no
|
|
172
|
+
// URL form: it has been inlined by `normalizeMessages`.
|
|
173
|
+
return {
|
|
174
|
+
type: "file",
|
|
175
|
+
file: {
|
|
176
|
+
filename: part.name ?? "document.pdf",
|
|
177
|
+
file_data: part.url ?? `data:${part.mimeType ?? "application/pdf"};base64,${part.data}`,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
default:
|
|
181
|
+
// `tool-call` / `tool-result` parts are hoisted by `normalizeMessages`
|
|
182
|
+
// before this point — reaching one here is a bug, not a payload
|
|
183
|
+
throw unsupportedPart(part);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** `AgentMessage[]` → OpenAI `messages[]`. */
|
|
189
|
+
function openaiMessages(system: string | undefined, messages: AgentMessage[]): any[] {
|
|
190
|
+
const out: any[] = [];
|
|
191
|
+
if (system) out.push({ role: "system", content: system });
|
|
192
|
+
for (const message of messages) {
|
|
193
|
+
const name = message.name ? { name: message.name } : {};
|
|
194
|
+
switch (message.role) {
|
|
195
|
+
case "system":
|
|
196
|
+
case "developer":
|
|
197
|
+
// `developer` is OpenAI's own role — passed through as such
|
|
198
|
+
out.push({ role: message.role, content: message.content, ...name });
|
|
199
|
+
break;
|
|
200
|
+
case "user":
|
|
201
|
+
out.push({ role: "user", content: openaiContent(message.content), ...name });
|
|
202
|
+
break;
|
|
203
|
+
case "assistant": {
|
|
204
|
+
const msg: any = { role: "assistant", content: message.content ?? null, ...name };
|
|
205
|
+
if (message.toolCalls?.length) {
|
|
206
|
+
msg.tool_calls = message.toolCalls.map((call) => ({
|
|
207
|
+
id: call.id,
|
|
208
|
+
type: "function",
|
|
209
|
+
function: { name: call.name, arguments: call.argsText ?? JSON.stringify(call.args ?? {}) },
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
out.push(msg);
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
case "tool":
|
|
216
|
+
out.push({ role: "tool", tool_call_id: message.toolCallId, content: message.content });
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function anthropicContent(content: string | AgentMediaPart[]): any {
|
|
224
|
+
if (typeof content === "string") return [{ type: "text", text: content }];
|
|
225
|
+
return content.map((part) => {
|
|
226
|
+
switch (part.type) {
|
|
227
|
+
case "text":
|
|
228
|
+
return { type: "text", text: part.text };
|
|
229
|
+
case "image":
|
|
230
|
+
return {
|
|
231
|
+
type: "image",
|
|
232
|
+
source: part.url
|
|
233
|
+
? { type: "url", url: part.url }
|
|
234
|
+
: { type: "base64", media_type: part.mimeType ?? "image/png", data: part.data },
|
|
235
|
+
};
|
|
236
|
+
case "file":
|
|
237
|
+
return {
|
|
238
|
+
type: "document",
|
|
239
|
+
source: part.url
|
|
240
|
+
? { type: "url", url: part.url }
|
|
241
|
+
: { type: "base64", media_type: part.mimeType ?? "application/pdf", data: part.data },
|
|
242
|
+
};
|
|
243
|
+
default:
|
|
244
|
+
throw unsupportedPart(part);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* `AgentMessage[]` → Anthropic `messages[]`.
|
|
251
|
+
*
|
|
252
|
+
* Two structural differences with OpenAI: `system` is out of band (returned
|
|
253
|
+
* separately) and a tool result is a **user** message holding `tool_result`
|
|
254
|
+
* blocks — consecutive results are merged into a single turn. A `developer`
|
|
255
|
+
* message joins the system prompt, and a message `name` is dropped (the API has
|
|
256
|
+
* no such field).
|
|
257
|
+
*/
|
|
258
|
+
function anthropicMessages(messages: AgentMessage[]): { system: string | undefined; messages: any[] } {
|
|
259
|
+
const out: any[] = [];
|
|
260
|
+
const systems: string[] = [];
|
|
261
|
+
|
|
262
|
+
for (const message of messages) {
|
|
263
|
+
switch (message.role) {
|
|
264
|
+
case "system":
|
|
265
|
+
case "developer":
|
|
266
|
+
systems.push(message.content);
|
|
267
|
+
break;
|
|
268
|
+
case "user":
|
|
269
|
+
out.push({ role: "user", content: anthropicContent(message.content) });
|
|
270
|
+
break;
|
|
271
|
+
case "assistant": {
|
|
272
|
+
// The thinking blocks come first and **as returned**: Anthropic rejects a
|
|
273
|
+
// tool round whose assistant turn lost its signed reasoning blocks.
|
|
274
|
+
const blocks: any[] = [...(message.thinking ?? [])];
|
|
275
|
+
if (message.content) blocks.push({ type: "text", text: message.content });
|
|
276
|
+
for (const call of message.toolCalls ?? []) {
|
|
277
|
+
blocks.push({ type: "tool_use", id: call.id, name: call.name, input: call.args ?? {} });
|
|
278
|
+
}
|
|
279
|
+
out.push({ role: "assistant", content: blocks.length ? blocks : [{ type: "text", text: "" }] });
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
case "tool": {
|
|
283
|
+
const block: any = { type: "tool_result", tool_use_id: message.toolCallId, content: message.content };
|
|
284
|
+
if (message.isError) block.is_error = true;
|
|
285
|
+
const last = out[out.length - 1];
|
|
286
|
+
if (last?.role === "user" && Array.isArray(last.content)
|
|
287
|
+
&& last.content.every((b: any) => b.type === "tool_result")) {
|
|
288
|
+
last.content.push(block);
|
|
289
|
+
} else {
|
|
290
|
+
out.push({ role: "user", content: [block] });
|
|
291
|
+
}
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return { system: systems.length ? systems.join("\n\n") : undefined, messages: out };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function openaiToolSpecs(tools: AgentToolSpec[]): any[] {
|
|
301
|
+
return tools.map((tool) => ({
|
|
302
|
+
type: "function",
|
|
303
|
+
function: {
|
|
304
|
+
name: tool.name,
|
|
305
|
+
description: tool.description,
|
|
306
|
+
parameters: tool.parameters,
|
|
307
|
+
},
|
|
308
|
+
}));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function anthropicToolSpecs(tools: AgentToolSpec[]): any[] {
|
|
312
|
+
return tools.map((tool) => ({
|
|
313
|
+
name: tool.name,
|
|
314
|
+
description: tool.description,
|
|
315
|
+
input_schema: tool.parameters,
|
|
316
|
+
}));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Extended thinking, normalized: an effort level and/or an Anthropic token budget. */
|
|
320
|
+
function thinkingOf(thinking?: AgentThinking): { effort: "low" | "medium" | "high"; budgetTokens: number } | null {
|
|
321
|
+
if (!thinking) return null;
|
|
322
|
+
if (thinking === true) return { effort: "medium", budgetTokens: 4_096 };
|
|
323
|
+
if (thinking === "low") return { effort: "low", budgetTokens: 2_048 };
|
|
324
|
+
if (thinking === "medium") return { effort: "medium", budgetTokens: 4_096 };
|
|
325
|
+
if (thinking === "high") return { effort: "high", budgetTokens: 8_192 };
|
|
326
|
+
|
|
327
|
+
const budgetTokens = Number((thinking as { budgetTokens?: number })?.budgetTokens);
|
|
328
|
+
if (!Number.isFinite(budgetTokens) || budgetTokens < 1_024) {
|
|
329
|
+
throw new AppError("`thinking.budgetTokens` must be at least 1024", {
|
|
330
|
+
status: 400, code: "AGENT_THINKING_INVALID",
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
// OpenAI-compatible endpoints only know levels — pick the closest one
|
|
334
|
+
const effort = budgetTokens <= 2_048 ? "low" : budgetTokens <= 8_192 ? "medium" : "high";
|
|
335
|
+
return { effort, budgetTokens };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** `tool_choice` — the one place the two vocabularies differ. */
|
|
339
|
+
function openaiToolChoice(choice?: AgentToolChoice): any | undefined {
|
|
340
|
+
if (!choice || choice === "auto") return undefined;
|
|
341
|
+
if (choice === "none" || choice === "required") return choice;
|
|
342
|
+
return { type: "function", function: { name: choice.name } };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function anthropicToolChoice(choice?: AgentToolChoice): any | undefined {
|
|
346
|
+
if (!choice || choice === "auto") return undefined;
|
|
347
|
+
if (choice === "none") return { type: "none" };
|
|
348
|
+
if (choice === "required") return { type: "any" };
|
|
349
|
+
return { type: "tool", name: choice.name };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Provider-specific finish reasons → the normalized vocabulary. */
|
|
353
|
+
function normalizeFinishReason(reason: string | null | undefined): string {
|
|
354
|
+
switch (reason) {
|
|
355
|
+
case "tool_calls":
|
|
356
|
+
case "tool_use":
|
|
357
|
+
return "tool_use";
|
|
358
|
+
case "end_turn":
|
|
359
|
+
case "stop_sequence":
|
|
360
|
+
case "stop":
|
|
361
|
+
return "stop";
|
|
362
|
+
case "max_tokens":
|
|
363
|
+
case "length":
|
|
364
|
+
return "length";
|
|
365
|
+
case "content_filter":
|
|
366
|
+
return "content_filter";
|
|
367
|
+
case null:
|
|
368
|
+
case undefined:
|
|
369
|
+
return "stop";
|
|
370
|
+
default:
|
|
371
|
+
return reason;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function mapUsage(raw: any, compatible: AgentCompatible): AgentUsage {
|
|
376
|
+
if (!raw) return { ...EMPTY_USAGE };
|
|
377
|
+
const input = compatible === "anthropic" ? raw.input_tokens : raw.prompt_tokens;
|
|
378
|
+
const output = compatible === "anthropic" ? raw.output_tokens : raw.completion_tokens;
|
|
379
|
+
const inputTokens = Number(input ?? 0);
|
|
380
|
+
const outputTokens = Number(output ?? 0);
|
|
381
|
+
return {
|
|
382
|
+
inputTokens,
|
|
383
|
+
outputTokens,
|
|
384
|
+
totalTokens: Number(raw.total_tokens ?? inputTokens + outputTokens),
|
|
385
|
+
requests: 1,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ─── HTTP + SSE ──────────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
function providerErrorMessage(body: string, status: number): string {
|
|
392
|
+
try {
|
|
393
|
+
const parsed = JSON.parse(body);
|
|
394
|
+
const message = parsed?.error?.message ?? parsed?.message ?? parsed?.error;
|
|
395
|
+
if (typeof message === "string" && message) return message;
|
|
396
|
+
} catch {
|
|
397
|
+
// not JSON — fall through to the raw body
|
|
398
|
+
}
|
|
399
|
+
return body.slice(0, 500) || `HTTP ${status}`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Fetch with retries on network failures, 429 and 5xx.
|
|
404
|
+
* HTTP-specific (it inspects the status code), unlike the replication retry.
|
|
405
|
+
*/
|
|
406
|
+
async function fetchWithRetry(
|
|
407
|
+
url: string,
|
|
408
|
+
init: RequestInit,
|
|
409
|
+
provider: AgentProvider,
|
|
410
|
+
compatible: AgentCompatible,
|
|
411
|
+
): Promise<Response> {
|
|
412
|
+
const options = provider.options ?? {};
|
|
413
|
+
const retries = options.retries ?? DEFAULT_RETRIES;
|
|
414
|
+
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
415
|
+
const callerSignal = init.signal ?? undefined;
|
|
416
|
+
let lastError: any;
|
|
417
|
+
|
|
418
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
419
|
+
if (callerSignal?.aborted) throw abortedError();
|
|
420
|
+
const signal = AbortSignal.any([AbortSignal.timeout(timeout), ...(callerSignal ? [callerSignal] : [])]);
|
|
421
|
+
try {
|
|
422
|
+
const response = await fetch(url, { ...init, signal });
|
|
423
|
+
if (response.status === 429 || response.status >= 500) {
|
|
424
|
+
const body = await response.text().catch(() => "");
|
|
425
|
+
lastError = new AppError(`Provider error (${response.status}): ${providerErrorMessage(body, response.status)}`, {
|
|
426
|
+
status: 502,
|
|
427
|
+
code: "AGENT_PROVIDER_ERROR",
|
|
428
|
+
reason: { provider: compatible, status: response.status },
|
|
429
|
+
});
|
|
430
|
+
} else {
|
|
431
|
+
return response;
|
|
432
|
+
}
|
|
433
|
+
} catch (err: any) {
|
|
434
|
+
if (callerSignal?.aborted) throw abortedError();
|
|
435
|
+
if (err instanceof AppError) lastError = err;
|
|
436
|
+
if (err?.name === "TimeoutError" || err?.name === "AbortError") {
|
|
437
|
+
throw new AppError(`Provider '${provider.model}' timed out after ${timeout}ms`, {
|
|
438
|
+
status: 504,
|
|
439
|
+
code: "AGENT_PROVIDER_TIMEOUT",
|
|
440
|
+
});
|
|
441
|
+
} else {
|
|
442
|
+
lastError = new AppError(`Provider request failed: ${err?.message ?? err}`, {
|
|
443
|
+
status: 502,
|
|
444
|
+
code: "AGENT_PROVIDER_ERROR",
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (attempt < retries) await Bun.sleep(250 * Math.pow(2, attempt));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
throw lastError;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function abortedError(): AppError {
|
|
456
|
+
return new AppError("Agent run aborted", { status: 499, code: "AGENT_ABORTED" });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Read a `text/event-stream` body and yield the `data:` payloads. */
|
|
460
|
+
async function* sseData(body: ReadableStream<Uint8Array>): AsyncGenerator<string> {
|
|
461
|
+
const decoder = new TextDecoder();
|
|
462
|
+
const separator = /\r?\n\r?\n/;
|
|
463
|
+
let buffer = "";
|
|
464
|
+
|
|
465
|
+
for await (const chunk of body as any) {
|
|
466
|
+
buffer += decoder.decode(chunk as Uint8Array, { stream: true });
|
|
467
|
+
let match: RegExpMatchArray | null;
|
|
468
|
+
while ((match = separator.exec(buffer)) !== null) {
|
|
469
|
+
const start = match.index ?? 0;
|
|
470
|
+
const block = buffer.slice(0, start);
|
|
471
|
+
buffer = buffer.slice(start + match[0].length);
|
|
472
|
+
const data = block
|
|
473
|
+
.split(/\r?\n/)
|
|
474
|
+
.filter((line) => line.startsWith("data:"))
|
|
475
|
+
.map((line) => line.slice(5).trim())
|
|
476
|
+
.join("\n");
|
|
477
|
+
if (data) yield data;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const tail = buffer
|
|
482
|
+
.split(/\r?\n/)
|
|
483
|
+
.filter((line) => line.startsWith("data:"))
|
|
484
|
+
.map((line) => line.slice(5).trim())
|
|
485
|
+
.join("\n");
|
|
486
|
+
if (tail) yield tail;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** An error event sent by the provider in the middle of a stream. */
|
|
490
|
+
function streamError(payload: any, compatible: AgentCompatible): AppError | null {
|
|
491
|
+
const message = payload?.error?.message ?? (typeof payload?.error === "string" ? payload.error : null);
|
|
492
|
+
if (!message) return null;
|
|
493
|
+
return new AppError(`Provider stream error: ${message}`, {
|
|
494
|
+
status: 502,
|
|
495
|
+
code: "AGENT_PROVIDER_ERROR",
|
|
496
|
+
reason: { provider: compatible },
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ─── OpenAI-compatible adapter ───────────────────────────────────────────
|
|
501
|
+
|
|
502
|
+
function openaiAdapter(provider: AgentProvider, compatible: AgentCompatible): ModelAdapter {
|
|
503
|
+
const baseUrl = resolveBaseUrl(provider, compatible);
|
|
504
|
+
const url = `${baseUrl}/chat/completions`;
|
|
505
|
+
const options = provider.options ?? {};
|
|
506
|
+
|
|
507
|
+
const headers = (): Record<string, string> => ({
|
|
508
|
+
"Content-Type": "application/json",
|
|
509
|
+
Authorization: `Bearer ${resolveApiKey(provider, compatible)}`,
|
|
510
|
+
...(provider.headers ?? {}),
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
const body = (req: ModelChatRequest, stream: boolean): any => {
|
|
514
|
+
const payload: any = {
|
|
515
|
+
model: provider.model,
|
|
516
|
+
messages: openaiMessages(req.system, req.messages),
|
|
517
|
+
max_tokens: req.maxTokens ?? options.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
518
|
+
};
|
|
519
|
+
if (req.tools?.length) {
|
|
520
|
+
payload.tools = openaiToolSpecs(req.tools);
|
|
521
|
+
payload.tool_choice = openaiToolChoice(req.toolChoice) ?? "auto";
|
|
522
|
+
}
|
|
523
|
+
const thinking = thinkingOf(req.thinking);
|
|
524
|
+
if (thinking) payload.reasoning_effort = thinking.effort;
|
|
525
|
+
const temperature = req.temperature ?? options.temperature;
|
|
526
|
+
if (temperature !== undefined) payload.temperature = temperature;
|
|
527
|
+
if (options.topP !== undefined) payload.top_p = options.topP;
|
|
528
|
+
if (req.json) payload.response_format = { type: "json_object" };
|
|
529
|
+
if (stream) {
|
|
530
|
+
payload.stream = true;
|
|
531
|
+
payload.stream_options = { include_usage: true };
|
|
532
|
+
}
|
|
533
|
+
return payload;
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
compatible,
|
|
538
|
+
model: provider.model,
|
|
539
|
+
|
|
540
|
+
async chat(req) {
|
|
541
|
+
const response = await fetchWithRetry(url, {
|
|
542
|
+
method: "POST",
|
|
543
|
+
headers: headers(),
|
|
544
|
+
body: JSON.stringify(body(req, false)),
|
|
545
|
+
signal: req.signal,
|
|
546
|
+
}, provider, compatible);
|
|
547
|
+
|
|
548
|
+
if (!response.ok) {
|
|
549
|
+
const text = await response.text().catch(() => "");
|
|
550
|
+
throw new AppError(`Provider error (${response.status}): ${providerErrorMessage(text, response.status)}`, {
|
|
551
|
+
status: 502,
|
|
552
|
+
code: "AGENT_PROVIDER_ERROR",
|
|
553
|
+
reason: { provider: compatible, status: response.status },
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const raw: any = await response.json();
|
|
558
|
+
const choice = raw?.choices?.[0];
|
|
559
|
+
const toolCalls = (choice?.message?.tool_calls ?? []).map((call: any, index: number) => ({
|
|
560
|
+
id: call?.id ?? `call_${index}`,
|
|
561
|
+
name: call?.function?.name ?? "unknown",
|
|
562
|
+
argsText: call?.function?.arguments ?? "{}",
|
|
563
|
+
}));
|
|
564
|
+
|
|
565
|
+
return {
|
|
566
|
+
text: typeof choice?.message?.content === "string" ? choice.message.content : "",
|
|
567
|
+
// Reasoning models on OpenAI-compatible gateways (DeepSeek, Groq…) use
|
|
568
|
+
// `reasoning_content` — never mixed with the answer
|
|
569
|
+
...(typeof choice?.message?.reasoning_content === "string" && choice.message.reasoning_content
|
|
570
|
+
? { reasoning: choice.message.reasoning_content }
|
|
571
|
+
: {}),
|
|
572
|
+
toolCalls,
|
|
573
|
+
finishReason: normalizeFinishReason(choice?.finish_reason),
|
|
574
|
+
usage: mapUsage(raw?.usage, compatible),
|
|
575
|
+
raw,
|
|
576
|
+
};
|
|
577
|
+
},
|
|
578
|
+
|
|
579
|
+
async *chatStream(req) {
|
|
580
|
+
const response = await fetchWithRetry(url, {
|
|
581
|
+
method: "POST",
|
|
582
|
+
headers: headers(),
|
|
583
|
+
body: JSON.stringify(body(req, true)),
|
|
584
|
+
signal: req.signal,
|
|
585
|
+
}, provider, compatible);
|
|
586
|
+
|
|
587
|
+
if (!response.ok || !response.body) {
|
|
588
|
+
const text = await response.text().catch(() => "");
|
|
589
|
+
throw new AppError(`Provider error (${response.status}): ${providerErrorMessage(text, response.status)}`, {
|
|
590
|
+
status: 502,
|
|
591
|
+
code: "AGENT_PROVIDER_ERROR",
|
|
592
|
+
reason: { provider: compatible, status: response.status },
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
for await (const data of sseData(response.body)) {
|
|
597
|
+
if (data === "[DONE]") return;
|
|
598
|
+
let payload: any;
|
|
599
|
+
try {
|
|
600
|
+
payload = JSON.parse(data);
|
|
601
|
+
} catch {
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
const error = streamError(payload, compatible);
|
|
605
|
+
if (error) throw error;
|
|
606
|
+
|
|
607
|
+
const choice = payload?.choices?.[0];
|
|
608
|
+
const delta = choice?.delta;
|
|
609
|
+
if (typeof delta?.content === "string" && delta.content) {
|
|
610
|
+
yield { type: "text", text: delta.content };
|
|
611
|
+
}
|
|
612
|
+
if (typeof delta?.reasoning_content === "string" && delta.reasoning_content) {
|
|
613
|
+
yield { type: "reasoning", text: delta.reasoning_content };
|
|
614
|
+
}
|
|
615
|
+
for (const call of delta?.tool_calls ?? []) {
|
|
616
|
+
const index = call?.index ?? 0;
|
|
617
|
+
const argsText = call?.function?.arguments;
|
|
618
|
+
if (call?.id || call?.function?.name || argsText) {
|
|
619
|
+
yield {
|
|
620
|
+
type: "tool_call",
|
|
621
|
+
index,
|
|
622
|
+
...(call?.id ? { id: call.id } : {}),
|
|
623
|
+
...(call?.function?.name ? { name: call.function.name } : {}),
|
|
624
|
+
...(argsText ? { argsText } : {}),
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (payload?.usage) yield { type: "usage", usage: mapUsage(payload.usage, compatible) };
|
|
629
|
+
if (choice?.finish_reason) yield { type: "finish", finishReason: normalizeFinishReason(choice.finish_reason) };
|
|
630
|
+
}
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ─── Anthropic adapter ───────────────────────────────────────────────────
|
|
636
|
+
|
|
637
|
+
function anthropicAdapter(provider: AgentProvider, compatible: AgentCompatible): ModelAdapter {
|
|
638
|
+
const baseUrl = resolveBaseUrl(provider, compatible);
|
|
639
|
+
const url = `${baseUrl}/v1/messages`;
|
|
640
|
+
const options = provider.options ?? {};
|
|
641
|
+
|
|
642
|
+
const headers = (): Record<string, string> => ({
|
|
643
|
+
"Content-Type": "application/json",
|
|
644
|
+
"x-api-key": resolveApiKey(provider, compatible),
|
|
645
|
+
"anthropic-version": "2023-06-01",
|
|
646
|
+
...(provider.headers ?? {}),
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
const body = (req: ModelChatRequest, stream: boolean): any => {
|
|
650
|
+
const converted = anthropicMessages(req.messages);
|
|
651
|
+
const system = [req.system, converted.system].filter(Boolean).join("\n\n");
|
|
652
|
+
const thinking = thinkingOf(req.thinking);
|
|
653
|
+
const maxTokens = req.maxTokens ?? options.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
654
|
+
|
|
655
|
+
const payload: any = {
|
|
656
|
+
model: provider.model,
|
|
657
|
+
messages: converted.messages,
|
|
658
|
+
// `max_tokens` is REQUIRED by the Messages API — and must stay **above**
|
|
659
|
+
// the thinking budget, so it is raised rather than refused
|
|
660
|
+
max_tokens: thinking && maxTokens <= thinking.budgetTokens
|
|
661
|
+
? thinking.budgetTokens + 1_024
|
|
662
|
+
: maxTokens,
|
|
663
|
+
};
|
|
664
|
+
if (system) payload.system = system;
|
|
665
|
+
if (req.tools?.length) {
|
|
666
|
+
payload.tools = anthropicToolSpecs(req.tools);
|
|
667
|
+
const toolChoice = anthropicToolChoice(req.toolChoice);
|
|
668
|
+
if (toolChoice) payload.tool_choice = toolChoice;
|
|
669
|
+
}
|
|
670
|
+
if (thinking) payload.thinking = { type: "enabled", budget_tokens: thinking.budgetTokens };
|
|
671
|
+
const temperature = req.temperature ?? options.temperature;
|
|
672
|
+
// Extended thinking requires the default temperature (1)
|
|
673
|
+
if (temperature !== undefined && !thinking) payload.temperature = temperature;
|
|
674
|
+
if (options.topP !== undefined) payload.top_p = options.topP;
|
|
675
|
+
if (stream) payload.stream = true;
|
|
676
|
+
return payload;
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
return {
|
|
680
|
+
compatible,
|
|
681
|
+
model: provider.model,
|
|
682
|
+
|
|
683
|
+
async chat(req) {
|
|
684
|
+
const response = await fetchWithRetry(url, {
|
|
685
|
+
method: "POST",
|
|
686
|
+
headers: headers(),
|
|
687
|
+
body: JSON.stringify(body(req, false)),
|
|
688
|
+
signal: req.signal,
|
|
689
|
+
}, provider, compatible);
|
|
690
|
+
|
|
691
|
+
if (!response.ok) {
|
|
692
|
+
const text = await response.text().catch(() => "");
|
|
693
|
+
throw new AppError(`Provider error (${response.status}): ${providerErrorMessage(text, response.status)}`, {
|
|
694
|
+
status: 502,
|
|
695
|
+
code: "AGENT_PROVIDER_ERROR",
|
|
696
|
+
reason: { provider: compatible, status: response.status },
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const raw: any = await response.json();
|
|
701
|
+
const blocks: any[] = Array.isArray(raw?.content) ? raw.content : [];
|
|
702
|
+
const toolCalls = blocks
|
|
703
|
+
.filter((block) => block?.type === "tool_use")
|
|
704
|
+
.map((block: any, index: number) => ({
|
|
705
|
+
id: block?.id ?? `toolu_${index}`,
|
|
706
|
+
name: block?.name ?? "unknown",
|
|
707
|
+
argsText: JSON.stringify(block?.input ?? {}),
|
|
708
|
+
}));
|
|
709
|
+
|
|
710
|
+
return {
|
|
711
|
+
text: blocks.filter((block) => block?.type === "text").map((block) => block.text).join(""),
|
|
712
|
+
toolCalls,
|
|
713
|
+
// The raw blocks are kept: a tool round must echo the **signed** ones back
|
|
714
|
+
...(blocks.some((block) => block?.type === "thinking" || block?.type === "redacted_thinking")
|
|
715
|
+
? {
|
|
716
|
+
thinking: blocks.filter((block) =>
|
|
717
|
+
block?.type === "thinking" || block?.type === "redacted_thinking"),
|
|
718
|
+
reasoning: blocks
|
|
719
|
+
.filter((block) => block?.type === "thinking")
|
|
720
|
+
.map((block) => block.thinking)
|
|
721
|
+
.join(""),
|
|
722
|
+
}
|
|
723
|
+
: {}),
|
|
724
|
+
finishReason: normalizeFinishReason(raw?.stop_reason),
|
|
725
|
+
usage: mapUsage(raw?.usage, compatible),
|
|
726
|
+
raw,
|
|
727
|
+
};
|
|
728
|
+
},
|
|
729
|
+
|
|
730
|
+
async *chatStream(req) {
|
|
731
|
+
// Thinking blocks, accumulated by index — signatures included, so the run
|
|
732
|
+
// can echo them back if the model then asks for a tool
|
|
733
|
+
const thinking = new Map<number, any>();
|
|
734
|
+
|
|
735
|
+
const response = await fetchWithRetry(url, {
|
|
736
|
+
method: "POST",
|
|
737
|
+
headers: headers(),
|
|
738
|
+
body: JSON.stringify(body(req, true)),
|
|
739
|
+
signal: req.signal,
|
|
740
|
+
}, provider, compatible);
|
|
741
|
+
|
|
742
|
+
if (!response.ok || !response.body) {
|
|
743
|
+
const text = await response.text().catch(() => "");
|
|
744
|
+
throw new AppError(`Provider error (${response.status}): ${providerErrorMessage(text, response.status)}`, {
|
|
745
|
+
status: 502,
|
|
746
|
+
code: "AGENT_PROVIDER_ERROR",
|
|
747
|
+
reason: { provider: compatible, status: response.status },
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
for await (const data of sseData(response.body)) {
|
|
752
|
+
let payload: any;
|
|
753
|
+
try {
|
|
754
|
+
payload = JSON.parse(data);
|
|
755
|
+
} catch {
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
const error = streamError(payload, compatible);
|
|
759
|
+
if (error) throw error;
|
|
760
|
+
|
|
761
|
+
switch (payload?.type) {
|
|
762
|
+
case "message_start": {
|
|
763
|
+
const usage = payload?.message?.usage;
|
|
764
|
+
// Only the keys actually reported: the agent merges the partials
|
|
765
|
+
// (input at the start, output at the end) instead of overwriting.
|
|
766
|
+
if (usage) yield { type: "usage", usage: { inputTokens: Number(usage.input_tokens ?? 0) } };
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
case "content_block_start": {
|
|
770
|
+
const block = payload?.content_block;
|
|
771
|
+
const index = payload?.index ?? 0;
|
|
772
|
+
if (block?.type === "tool_use") {
|
|
773
|
+
yield {
|
|
774
|
+
type: "tool_call",
|
|
775
|
+
index,
|
|
776
|
+
...(block.id ? { id: block.id } : {}),
|
|
777
|
+
...(block.name ? { name: block.name } : {}),
|
|
778
|
+
};
|
|
779
|
+
} else if (block?.type === "thinking") {
|
|
780
|
+
// A reasoning block: text and signature arrive as deltas
|
|
781
|
+
thinking.set(index, { type: "thinking", thinking: "" });
|
|
782
|
+
} else if (block?.type === "redacted_thinking" && block.data) {
|
|
783
|
+
thinking.set(index, { type: "redacted_thinking", data: block.data });
|
|
784
|
+
}
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
case "content_block_delta": {
|
|
788
|
+
const delta = payload?.delta;
|
|
789
|
+
const index = payload?.index ?? 0;
|
|
790
|
+
if (delta?.type === "text_delta" && delta.text) {
|
|
791
|
+
yield { type: "text", text: delta.text };
|
|
792
|
+
} else if (delta?.type === "thinking_delta" && delta.thinking) {
|
|
793
|
+
const block = thinking.get(index) ?? { type: "thinking", thinking: "" };
|
|
794
|
+
block.thinking = `${block.thinking ?? ""}${delta.thinking}`;
|
|
795
|
+
thinking.set(index, block);
|
|
796
|
+
yield { type: "reasoning", text: delta.thinking };
|
|
797
|
+
} else if (delta?.type === "signature_delta" && delta.signature) {
|
|
798
|
+
const block = thinking.get(index) ?? { type: "thinking", thinking: "" };
|
|
799
|
+
block.signature = delta.signature;
|
|
800
|
+
thinking.set(index, block);
|
|
801
|
+
} else if (delta?.type === "input_json_delta" && delta.partial_json) {
|
|
802
|
+
yield { type: "tool_call", index, argsText: delta.partial_json };
|
|
803
|
+
}
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
case "message_delta": {
|
|
807
|
+
if (payload?.usage) {
|
|
808
|
+
yield { type: "usage", usage: { outputTokens: Number(payload.usage.output_tokens ?? 0) } };
|
|
809
|
+
}
|
|
810
|
+
if (payload?.delta?.stop_reason) {
|
|
811
|
+
yield { type: "finish", finishReason: normalizeFinishReason(payload.delta.stop_reason) };
|
|
812
|
+
}
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
case "error":
|
|
816
|
+
throw new AppError(`Provider stream error: ${payload?.error?.message ?? "unknown"}`, {
|
|
817
|
+
status: 502,
|
|
818
|
+
code: "AGENT_PROVIDER_ERROR",
|
|
819
|
+
reason: { provider: compatible },
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// The reasoning blocks, whole and signed — what a tool round must echo
|
|
825
|
+
if (thinking.size) {
|
|
826
|
+
yield {
|
|
827
|
+
type: "thinking_blocks",
|
|
828
|
+
blocks: [...thinking.entries()].sort((a, b) => a[0] - b[0]).map(([, block]) => block),
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
},
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ─── Entry point ─────────────────────────────────────────────────────────
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Build the adapter for a provider — resolves the key, the base URL and the
|
|
839
|
+
* defaults once, so a run never re-reads the environment.
|
|
840
|
+
*/
|
|
841
|
+
function resolveProvider(provider: AgentProvider): ModelAdapter {
|
|
842
|
+
if (!provider?.model) {
|
|
843
|
+
throw new AppError("`provider.model` is required", { status: 500, code: "AGENT_PROVIDER_INVALID" });
|
|
844
|
+
}
|
|
845
|
+
const compatible = resolveCompatible(provider);
|
|
846
|
+
return compatible === "anthropic"
|
|
847
|
+
? anthropicAdapter(provider, compatible)
|
|
848
|
+
: openaiAdapter(provider, compatible);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export {
|
|
852
|
+
EMPTY_USAGE,
|
|
853
|
+
DEFAULT_MAX_TOKENS,
|
|
854
|
+
resolveCompatible,
|
|
855
|
+
resolveApiKey,
|
|
856
|
+
resolveBaseUrl,
|
|
857
|
+
resolveProvider,
|
|
858
|
+
openaiContent,
|
|
859
|
+
openaiMessages,
|
|
860
|
+
anthropicContent,
|
|
861
|
+
anthropicMessages,
|
|
862
|
+
openaiToolSpecs,
|
|
863
|
+
anthropicToolSpecs,
|
|
864
|
+
normalizeFinishReason,
|
|
865
|
+
mapUsage,
|
|
866
|
+
sseData,
|
|
867
|
+
};
|