@byte-42/fantastico-sdk 0.0.0-stage → 0.21.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/LICENSE +21 -0
- package/README.md +470 -3
- package/dist/activity.d.ts +72 -0
- package/dist/activity.js +22 -0
- package/dist/auth.d.ts +216 -0
- package/dist/auth.js +224 -0
- package/dist/backoff.d.ts +48 -0
- package/dist/backoff.js +31 -0
- package/dist/browser-actions.d.ts +28 -0
- package/dist/browser-actions.js +25 -0
- package/dist/client.d.ts +410 -0
- package/dist/client.js +410 -0
- package/dist/decisions.d.ts +149 -0
- package/dist/decisions.js +71 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +7 -0
- package/dist/llm.d.ts +40 -0
- package/dist/llm.js +74 -0
- package/dist/local-tools.d.ts +72 -0
- package/dist/local-tools.js +55 -0
- package/dist/max-tokens.d.ts +27 -0
- package/dist/max-tokens.js +16 -0
- package/dist/mcp.d.ts +14 -0
- package/dist/mcp.js +55 -0
- package/dist/orchestrator.d.ts +105 -0
- package/dist/orchestrator.js +296 -0
- package/dist/provider-error.d.ts +165 -0
- package/dist/provider-error.js +156 -0
- package/dist/raw.d.ts +48 -0
- package/dist/raw.js +72 -0
- package/dist/schema-digest.d.ts +25 -0
- package/dist/schema-digest.js +20 -0
- package/dist/speech.d.ts +107 -0
- package/dist/speech.js +48 -0
- package/dist/surface.d.ts +35 -0
- package/dist/surface.js +13 -0
- package/dist/tool-definition.d.ts +22 -0
- package/dist/tool-definition.js +10 -0
- package/dist/tool-origin.d.ts +32 -0
- package/dist/tool-origin.js +65 -0
- package/dist/types.d.ts +734 -0
- package/dist/types.js +27 -0
- package/package.json +48 -6
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { isClientAction } from "./local-tools.js";
|
|
2
|
+
import { toolResultOk } from "./activity.js";
|
|
3
|
+
export class OrchestrationError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(message, code) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.name = "OrchestrationError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function emit(opts, event) {
|
|
12
|
+
if (!opts.onEvent)
|
|
13
|
+
return;
|
|
14
|
+
try {
|
|
15
|
+
opts.onEvent(event);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function activityLabelFor(name, args, registry) {
|
|
21
|
+
const tool = registry.get(name);
|
|
22
|
+
if (!tool || isClientAction(tool) || !tool.activityLabel)
|
|
23
|
+
return undefined;
|
|
24
|
+
try {
|
|
25
|
+
return tool.activityLabel(args);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function tracked(tc, args, opts, run) {
|
|
32
|
+
const name = tc.function.name;
|
|
33
|
+
emit(opts, { type: "tool_start", id: tc.id, name, label: activityLabelFor(name, args, opts.localTools) });
|
|
34
|
+
const startedAt = Date.now();
|
|
35
|
+
let result;
|
|
36
|
+
try {
|
|
37
|
+
result = await run();
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
emit(opts, { type: "tool_end", id: tc.id, name, ok: false, durationMs: Date.now() - startedAt });
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
emit(opts, { type: "tool_end", id: tc.id, name, ok: toolResultOk(result), durationMs: Date.now() - startedAt });
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
function findToolForFunction(toolName, tools) {
|
|
47
|
+
return tools.find((s) => s.allowedTools.includes(toolName));
|
|
48
|
+
}
|
|
49
|
+
export const DEFAULT_BUDGET_MS = 90_000;
|
|
50
|
+
export async function orchestrate(messages, llm, opts) {
|
|
51
|
+
const state = {
|
|
52
|
+
conv: [...messages],
|
|
53
|
+
turns: 0,
|
|
54
|
+
mcpCalls: 0,
|
|
55
|
+
promptTokens: 0,
|
|
56
|
+
completionTokens: 0,
|
|
57
|
+
cacheCreationTokens: 0,
|
|
58
|
+
cacheReadTokens: 0,
|
|
59
|
+
reasoningTokens: 0,
|
|
60
|
+
costUsd: 0,
|
|
61
|
+
clientActions: [],
|
|
62
|
+
budgetMs: opts.budgetMs ?? DEFAULT_BUDGET_MS,
|
|
63
|
+
maxTurns: opts.maxTurns ?? 8,
|
|
64
|
+
model: opts.model,
|
|
65
|
+
};
|
|
66
|
+
return runLoop(state, Date.now(), llm, opts);
|
|
67
|
+
}
|
|
68
|
+
export async function resumeOrchestration(rawState, decisions, llm, opts) {
|
|
69
|
+
const state = rawState;
|
|
70
|
+
if (!state || !Array.isArray(state.conv)) {
|
|
71
|
+
throw new OrchestrationError("Stato di ripresa non valido", "INVALID_STATE");
|
|
72
|
+
}
|
|
73
|
+
const last = state.conv[state.conv.length - 1];
|
|
74
|
+
if (!last || last.role !== "assistant" || !last.tool_calls || last.tool_calls.length === 0) {
|
|
75
|
+
throw new OrchestrationError("Nessuna tool_call sospesa da riprendere", "INVALID_STATE");
|
|
76
|
+
}
|
|
77
|
+
const decisionById = new Map(decisions.map((d) => [d.id, d]));
|
|
78
|
+
for (const tc of last.tool_calls) {
|
|
79
|
+
const args = safeParseArgs(tc.function.arguments);
|
|
80
|
+
const tool = opts.localTools.get(tc.function.name);
|
|
81
|
+
const decision = decisionById.get(tc.id);
|
|
82
|
+
let result;
|
|
83
|
+
if (tool && !isClientAction(tool) && tool.requiresConfirmation) {
|
|
84
|
+
if (decision?.approved) {
|
|
85
|
+
result = await tracked(tc, args, opts, () => opts.localTools.execute(tc.function.name, args, opts.context));
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
result = JSON.stringify({ error: "Azione negata dall'operatore" });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else if (tool && isClientAction(tool) && tool.awaitsResult) {
|
|
92
|
+
if (decision && "result" in decision) {
|
|
93
|
+
result = typeof decision.result === "string" ? decision.result : JSON.stringify(decision.result ?? null);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
result = JSON.stringify({ error: "Nessun esito ricevuto dal client per questa azione" });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else if (tool && !isClientAction(tool)) {
|
|
100
|
+
result = await tracked(tc, args, opts, () => opts.localTools.execute(tc.function.name, args, opts.context));
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
result = JSON.stringify({ error: `Tool sconosciuto: ${tc.function.name}` });
|
|
104
|
+
}
|
|
105
|
+
state.conv.push({ role: "tool", tool_call_id: tc.id, content: result });
|
|
106
|
+
}
|
|
107
|
+
return runLoop(state, Date.now(), llm, opts);
|
|
108
|
+
}
|
|
109
|
+
function safeParseArgs(raw) {
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(raw);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return {};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function runLoop(state, startedAt, llm, opts) {
|
|
118
|
+
const mcpTools = toolsToDefinitions(opts.remoteTools);
|
|
119
|
+
let lastResponse = null;
|
|
120
|
+
for (let turn = state.turns; turn < state.maxTurns; turn++) {
|
|
121
|
+
if (Date.now() - startedAt > state.budgetMs) {
|
|
122
|
+
throw new OrchestrationError("Timeout orchestrazione", "TIMEOUT");
|
|
123
|
+
}
|
|
124
|
+
state.turns++;
|
|
125
|
+
const isLastTurn = turn === state.maxTurns - 1;
|
|
126
|
+
const localDefs = opts.localTools.definitionsFor(opts.context);
|
|
127
|
+
const allTools = [...mcpTools, ...localDefs];
|
|
128
|
+
const tools = allTools.length > 0 && !isLastTurn ? allTools : undefined;
|
|
129
|
+
emit(opts, { type: "thinking", turn: state.turns });
|
|
130
|
+
const response = await llm.chatCompletion({
|
|
131
|
+
model: opts.model,
|
|
132
|
+
messages: state.conv,
|
|
133
|
+
tools,
|
|
134
|
+
temperature: opts.temperature,
|
|
135
|
+
max_tokens: opts.maxTokens,
|
|
136
|
+
reasoning: opts.reasoning,
|
|
137
|
+
});
|
|
138
|
+
if (response.usage) {
|
|
139
|
+
state.promptTokens += response.usage.prompt_tokens ?? 0;
|
|
140
|
+
state.completionTokens += response.usage.completion_tokens ?? 0;
|
|
141
|
+
state.cacheCreationTokens += response.usage.prompt_tokens_details?.cache_write_tokens ?? 0;
|
|
142
|
+
state.cacheReadTokens += response.usage.prompt_tokens_details?.cached_tokens ?? 0;
|
|
143
|
+
state.reasoningTokens = (state.reasoningTokens ?? 0)
|
|
144
|
+
+ (response.usage.completion_tokens_details?.reasoning_tokens ?? 0);
|
|
145
|
+
state.costUsd += response.usage.cost ?? 0;
|
|
146
|
+
}
|
|
147
|
+
lastResponse = response;
|
|
148
|
+
const choice = response.choices[0];
|
|
149
|
+
if (!choice)
|
|
150
|
+
break;
|
|
151
|
+
const toolCalls = choice.message.tool_calls;
|
|
152
|
+
if (!toolCalls || toolCalls.length === 0 || choice.finish_reason !== "tool_calls") {
|
|
153
|
+
return buildDone(response, state);
|
|
154
|
+
}
|
|
155
|
+
state.conv.push({ role: "assistant", content: choice.message.content, tool_calls: toolCalls });
|
|
156
|
+
const blocking = blockingCallsIn(toolCalls, opts.localTools);
|
|
157
|
+
if (blocking.length > 0) {
|
|
158
|
+
return {
|
|
159
|
+
status: "pending",
|
|
160
|
+
pending: toolCalls.map((tc) => {
|
|
161
|
+
const args = safeParseArgs(tc.function.arguments);
|
|
162
|
+
return {
|
|
163
|
+
id: tc.id,
|
|
164
|
+
name: tc.function.name,
|
|
165
|
+
arguments: args,
|
|
166
|
+
reason: blockingReasonFor(tc.function.name, opts.localTools),
|
|
167
|
+
summary: confirmationSummaryFor(tc.function.name, args, opts.localTools),
|
|
168
|
+
};
|
|
169
|
+
}),
|
|
170
|
+
state,
|
|
171
|
+
message: choice.message.content || undefined,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (Date.now() - startedAt > state.budgetMs) {
|
|
175
|
+
throw new OrchestrationError("Timeout durante esecuzione tool", "TIMEOUT");
|
|
176
|
+
}
|
|
177
|
+
await executeBatch(toolCalls, state, opts);
|
|
178
|
+
}
|
|
179
|
+
if (lastResponse)
|
|
180
|
+
return buildDone(lastResponse, state);
|
|
181
|
+
throw new OrchestrationError("Orchestrazione terminata senza risposta", "UPSTREAM_ERROR");
|
|
182
|
+
}
|
|
183
|
+
function blockingReasonFor(name, registry) {
|
|
184
|
+
const tool = registry.get(name);
|
|
185
|
+
if (!tool)
|
|
186
|
+
return undefined;
|
|
187
|
+
if (isClientAction(tool))
|
|
188
|
+
return tool.awaitsResult ? "client_action" : undefined;
|
|
189
|
+
return tool.requiresConfirmation ? "confirmation" : undefined;
|
|
190
|
+
}
|
|
191
|
+
function blockingCallsIn(toolCalls, registry) {
|
|
192
|
+
return toolCalls.filter((tc) => blockingReasonFor(tc.function.name, registry) !== undefined);
|
|
193
|
+
}
|
|
194
|
+
function confirmationSummaryFor(name, args, registry) {
|
|
195
|
+
const tool = registry.get(name);
|
|
196
|
+
if (!tool || isClientAction(tool) || !tool.requiresConfirmation || !tool.confirmationSummary)
|
|
197
|
+
return undefined;
|
|
198
|
+
try {
|
|
199
|
+
return tool.confirmationSummary(args);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async function executeBatch(toolCalls, state, opts) {
|
|
206
|
+
const anySequential = toolCalls.some((tc) => {
|
|
207
|
+
const tool = opts.localTools.get(tc.function.name);
|
|
208
|
+
return tool?.sequential === true;
|
|
209
|
+
});
|
|
210
|
+
const run = async (tc) => {
|
|
211
|
+
const args = safeParseArgs(tc.function.arguments);
|
|
212
|
+
const tool = opts.localTools.get(tc.function.name);
|
|
213
|
+
let result;
|
|
214
|
+
if (tool && isClientAction(tool)) {
|
|
215
|
+
state.clientActions.push({ id: tc.id, name: tc.function.name, arguments: args });
|
|
216
|
+
result = JSON.stringify({ delivered: true });
|
|
217
|
+
}
|
|
218
|
+
else if (tool) {
|
|
219
|
+
result = await tracked(tc, args, opts, () => opts.localTools.execute(tc.function.name, args, opts.context));
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
const tool = findToolForFunction(tc.function.name, opts.remoteTools);
|
|
223
|
+
if (!tool) {
|
|
224
|
+
result = JSON.stringify({ error: `Tool sconosciuto: ${tc.function.name}` });
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
result = await tracked(tc, args, opts, async () => {
|
|
228
|
+
try {
|
|
229
|
+
const out = await opts.mcpExecutor(tool, tc.function.name, args);
|
|
230
|
+
state.mcpCalls++;
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
return JSON.stringify({ error: `Errore MCP: ${err.message}` });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
state.conv.push({ role: "tool", tool_call_id: tc.id, content: result });
|
|
240
|
+
};
|
|
241
|
+
if (anySequential) {
|
|
242
|
+
for (const tc of toolCalls)
|
|
243
|
+
await run(tc);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
await Promise.all(toolCalls.map(run));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function buildDone(response, state) {
|
|
250
|
+
const choice = response.choices[0];
|
|
251
|
+
return {
|
|
252
|
+
status: "done",
|
|
253
|
+
id: response.id,
|
|
254
|
+
object: "chat.completion",
|
|
255
|
+
model: response.model,
|
|
256
|
+
choices: [
|
|
257
|
+
{
|
|
258
|
+
index: 0,
|
|
259
|
+
message: { role: "assistant", content: choice?.message.content ?? "" },
|
|
260
|
+
finish_reason: choice?.finish_reason === "length" ? "length" : "stop",
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
usage: buildUsage(state),
|
|
264
|
+
clientActions: state.clientActions,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
function buildUsage(state) {
|
|
268
|
+
return {
|
|
269
|
+
prompt_tokens: state.promptTokens,
|
|
270
|
+
completion_tokens: state.completionTokens,
|
|
271
|
+
total_tokens: state.promptTokens + state.completionTokens,
|
|
272
|
+
internal_turns: state.turns,
|
|
273
|
+
cache_creation_tokens: state.cacheCreationTokens,
|
|
274
|
+
cache_read_tokens: state.cacheReadTokens,
|
|
275
|
+
reasoning_tokens: state.reasoningTokens ?? 0,
|
|
276
|
+
mcp_calls: state.mcpCalls,
|
|
277
|
+
cost_usd: Math.round(state.costUsd * 1e6) / 1e6,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function toolsToDefinitions(tools) {
|
|
281
|
+
const defs = [];
|
|
282
|
+
for (const svc of tools) {
|
|
283
|
+
if (svc.toolDefinitions) {
|
|
284
|
+
defs.push(...svc.toolDefinitions);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
for (const name of svc.allowedTools) {
|
|
288
|
+
defs.push({
|
|
289
|
+
type: "function",
|
|
290
|
+
function: { name, description: `Tool ${svc.name}`, parameters: { type: "object", properties: {} } },
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return defs;
|
|
296
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { EmptyCompletionError } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* L'errore del provider LLM, con dentro quel che il provider ha davvero detto.
|
|
4
|
+
*
|
|
5
|
+
* **Perché esiste.** Prima ogni chiamata fallita diventava un `Error` il cui unico contenuto era
|
|
6
|
+
* `` `Provider LLM ${status}: ${text}` ``, e chi doveva decidere se ri-coniare — `withProviderRetry`
|
|
7
|
+
* in `client.ts`, che sta sopra l'orchestratore e vede solo un `Error` qualunque — ricostruiva quella
|
|
8
|
+
* decisione con una regex sulla prosa (`/insufficient|no.?credit|payment.?required|401/i`). Lo stato
|
|
9
|
+
* HTTP non sopravviveva alla pila: esisteva solo come caratteri dentro un messaggio. Due difetti
|
|
10
|
+
* concreti, entrambi documentati da OpenRouter e non teorici:
|
|
11
|
+
*
|
|
12
|
+
* 1. **403 è "Insufficient permissions"** nella doc di OpenRouter (permessi, guardrail, moderazione).
|
|
13
|
+
* La regex ci cascava: un blocco di moderazione bruciava un conio, occupava una seconda chiave,
|
|
14
|
+
* falliva identico e arrivava al chiamante come `LLM_PROVIDER_ERROR` — nascondendo
|
|
15
|
+
* l'unica classe di errore su cui l'utente finale poteva davvero intervenire.
|
|
16
|
+
* 2. **`401` come sottostringa pesca nel prompt.** Gli errori di moderazione rimandano indietro il
|
|
17
|
+
* testo dell'utente in `metadata.flagged_input`, che finiva nel messaggio: un prompt bloccato che
|
|
18
|
+
* contenesse quelle tre cifre — un numero di fattura, una domanda sugli stati HTTP — diventava un
|
|
19
|
+
* errore di credito.
|
|
20
|
+
*
|
|
21
|
+
* Il segnale strutturato c'è sempre stato, ed era la stringificazione a buttarlo via: il corpo di
|
|
22
|
+
* ogni errore è `{ error: { code, message, metadata? } }`, e gli errori di provider portano in
|
|
23
|
+
* `metadata.error_type` un insieme chiuso di valori — fra cui `authentication`, `payment_required` e
|
|
24
|
+
* `permission_denied`, cioè esattamente la distinzione che la regex tentava di dedurre dalla prosa.
|
|
25
|
+
*
|
|
26
|
+
* **Non si conserva il corpo grezzo.** Quando il JSON si legge si prende `error.message` e nient'altro:
|
|
27
|
+
* `metadata.flagged_input` è il prompt del cliente, e un messaggio d'errore finisce nei log, dove il
|
|
28
|
+
* contenuto dei clienti non deve arrivare. Quando non si legge, il testo viene troncato.
|
|
29
|
+
*/
|
|
30
|
+
export declare class ProviderError extends Error {
|
|
31
|
+
/** Lo stato HTTP della risposta. `200` quando l'errore arrivava dentro un corpo di successo. */
|
|
32
|
+
readonly httpStatus: number;
|
|
33
|
+
/** `error.code` dal corpo: è quello che vale quando l'HTTP dice 200 (vedi `errorInBody`). */
|
|
34
|
+
readonly providerCode: number | null;
|
|
35
|
+
/** `error.metadata.error_type`, quando il provider lo manda. */
|
|
36
|
+
readonly errorType: string | null;
|
|
37
|
+
/**
|
|
38
|
+
* `Retry-After` in millisecondi, quando l'header c'è. È il provider che dice **quanto**
|
|
39
|
+
* aspettare: sostituirgli la propria curva esponenziale è indovinare dopo essere stati
|
|
40
|
+
* informati. La doc lo documenta su rate limit e indisponibilità.
|
|
41
|
+
*/
|
|
42
|
+
readonly retryAfterMs: number | null;
|
|
43
|
+
/**
|
|
44
|
+
* Il motivo del provider a monte (`upstreamReason`). **Fuori dal messaggio di proposito**: il
|
|
45
|
+
* messaggio di un errore finisce spesso davanti all'utente finale, e lì non si nomina il
|
|
46
|
+
* fornitore né si mostra la sua prosa. Questo campo è per i log, dove Node lo stampa da solo.
|
|
47
|
+
*/
|
|
48
|
+
readonly upstream: string | null;
|
|
49
|
+
constructor(message: string,
|
|
50
|
+
/** Lo stato HTTP della risposta. `200` quando l'errore arrivava dentro un corpo di successo. */
|
|
51
|
+
httpStatus: number,
|
|
52
|
+
/** `error.code` dal corpo: è quello che vale quando l'HTTP dice 200 (vedi `errorInBody`). */
|
|
53
|
+
providerCode: number | null,
|
|
54
|
+
/** `error.metadata.error_type`, quando il provider lo manda. */
|
|
55
|
+
errorType: string | null,
|
|
56
|
+
/**
|
|
57
|
+
* `Retry-After` in millisecondi, quando l'header c'è. È il provider che dice **quanto**
|
|
58
|
+
* aspettare: sostituirgli la propria curva esponenziale è indovinare dopo essere stati
|
|
59
|
+
* informati. La doc lo documenta su rate limit e indisponibilità.
|
|
60
|
+
*/
|
|
61
|
+
retryAfterMs?: number | null,
|
|
62
|
+
/**
|
|
63
|
+
* Il motivo del provider a monte (`upstreamReason`). **Fuori dal messaggio di proposito**: il
|
|
64
|
+
* messaggio di un errore finisce spesso davanti all'utente finale, e lì non si nomina il
|
|
65
|
+
* fornitore né si mostra la sua prosa. Questo campo è per i log, dove Node lo stampa da solo.
|
|
66
|
+
*/
|
|
67
|
+
upstream?: string | null);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Costruisce l'errore da una risposta non-ok. Il prefisso (`label`) resta quello che i due call site
|
|
71
|
+
* avevano già — cambiarlo renderebbe irriconoscibili i messaggi nei log di chi integra.
|
|
72
|
+
*/
|
|
73
|
+
export declare function providerErrorFromResponse(res: Response, label: string): Promise<ProviderError>;
|
|
74
|
+
/**
|
|
75
|
+
* `Retry-After` arriva in due forme, entrambe standard: un numero di secondi, o una data HTTP.
|
|
76
|
+
* Si accettano tutte e due — leggerne una sola significherebbe ignorare l'indicazione del provider
|
|
77
|
+
* esattamente nei casi in cui manda l'altra. Un valore assurdo (negativo, non numerico, una data
|
|
78
|
+
* già passata) vale "non l'ha detto": il chiamante ha comunque il proprio esponenziale.
|
|
79
|
+
*/
|
|
80
|
+
export declare function retryAfterMs(header: string | null): number | null;
|
|
81
|
+
/**
|
|
82
|
+
* L'errore consegnato dentro una risposta **riuscita**. OpenRouter documenta che una richiesta può
|
|
83
|
+
* tornare `200 OK` con un oggetto `error` al posto della completion, quando il provider comincia a
|
|
84
|
+
* elaborare e poi fallisce. Senza questo controllo quel corpo veniva castato a `LlmResponse` e
|
|
85
|
+
* arrivava all'orchestratore, che legge `response.choices[0]` su un oggetto senza `choices`: il
|
|
86
|
+
* chiamante vedeva un `INTERNAL_ERROR` con dentro un TypeError, e un `payment_required` non
|
|
87
|
+
* innescava mai il ri-conio per cui tutto il meccanismo esiste.
|
|
88
|
+
*
|
|
89
|
+
* Lo stato che conta qui è `error.code`, non il 200 del trasporto.
|
|
90
|
+
*/
|
|
91
|
+
export declare function errorInBody(payload: unknown, label: string): ProviderError | null;
|
|
92
|
+
/**
|
|
93
|
+
* Se un conio nuovo ha qualche speranza di riparare questo errore.
|
|
94
|
+
*
|
|
95
|
+
* Solo due casi, e sono quelli che la doc di OpenRouter definisce così:
|
|
96
|
+
* - **401 / `authentication`** — credenziale non valida o revocata: è una runtime key già
|
|
97
|
+
* scaduta e ritirata, o scaduta fra la lettura dalla cache e la chiamata.
|
|
98
|
+
* - **402 / `payment_required`** — "your account **or API key** has insufficient credits". L'ambiguità
|
|
99
|
+
* è loro, non nostra: la doc dice esplicitamente che quel codice non distingue il tetto della
|
|
100
|
+
* singola key dal saldo dell'account. È per questo che il verdetto non si dà, si prova: se era il
|
|
101
|
+
* tetto di questa key, una key nuova lo supera; altrimenti si ripete —
|
|
102
|
+
* e l'unico messaggio onesto resta un errore di comunicazione col provider.
|
|
103
|
+
*
|
|
104
|
+
* Tutto il resto è concluso: 403 (permessi, guardrail, moderazione), 429 (rate limit, che porta
|
|
105
|
+
* `Retry-After` e vuole un'attesa, non una credenziale nuova), 5xx (il provider), 400/404 (noi).
|
|
106
|
+
*/
|
|
107
|
+
export declare function isRemintable(err: unknown): boolean;
|
|
108
|
+
/**
|
|
109
|
+
* Se **la stessa identica richiesta**, rimandata fra un attimo, può andare a buon fine.
|
|
110
|
+
*
|
|
111
|
+
* Distinto da `isRemintable` e non sovrapponibile: lì la richiesta era giusta e la credenziale no,
|
|
112
|
+
* qui la credenziale va bene ed è il momento a essere sbagliato. Un errore non è mai entrambe le
|
|
113
|
+
* cose, e tenerle separate è ciò che impedisce a un ritentativo di incatenarsi a un ri-conio.
|
|
114
|
+
*/
|
|
115
|
+
export declare function isTransient(err: unknown): boolean;
|
|
116
|
+
/**
|
|
117
|
+
* Come un errore del provider si presenta al chiamante: `{ code, status }` per un `FantasticoError`.
|
|
118
|
+
*
|
|
119
|
+
* `null` quando non c'è niente da dire di preciso — non è un errore del provider, o il provider non
|
|
120
|
+
* l'ha classificato — e il chiamante ricade sull'`INTERNAL_ERROR` generico.
|
|
121
|
+
*
|
|
122
|
+
* **Perché il codice porta un prefisso.** `status` in questo SDK non è mai stato un numero nostro:
|
|
123
|
+
* `auth.ts` e `client.ts:api()` ci mettono già, verbatim, lo stato HTTP del Cloud. Passare anche
|
|
124
|
+
* quello del provider è quindi coerente — ma introduce una cosa nuova, cioè due mittenti diversi
|
|
125
|
+
* dietro lo stesso numero. Il control plane usa il `403` per `EMAIL_NOT_VERIFIED` e
|
|
126
|
+
* `ACCOUNT_DISABLED`; il provider lo usa per permessi, guardrail e moderazione. Chi legge
|
|
127
|
+
* `err.status === 403` deve poter sapere **chi ha parlato**, perché i due rimedi sono opposti
|
|
128
|
+
* (sistemare l'account / far riformulare il prompt all'utente finale). Il discriminante è `code`,
|
|
129
|
+
* che è una stringa e regge già ogni altra distinzione qui dentro: `PROVIDER_` lo rende esplicito
|
|
130
|
+
* e cercabile.
|
|
131
|
+
*
|
|
132
|
+
* **E non riguarda solo il 403.** Prima, ogni errore non ri-coniabile diventava `INTERNAL_ERROR`
|
|
133
|
+
* 500: `context_length_exceeded`, il modello inesistente, il payload troppo grande — cioè gli
|
|
134
|
+
* errori più azionabili che il provider produca — arrivavano tutti travestiti da guasto dell'SDK.
|
|
135
|
+
* Una regola sola li copre; non serve una tabella per codice.
|
|
136
|
+
*/
|
|
137
|
+
export declare function providerFailure(err: unknown): {
|
|
138
|
+
code: string;
|
|
139
|
+
status: number;
|
|
140
|
+
} | null;
|
|
141
|
+
/**
|
|
142
|
+
* Il 200 che non è un successo: tetto raggiunto, e in mano non è rimasto niente.
|
|
143
|
+
*
|
|
144
|
+
* `null` quando va tutto bene — e va tutto bene in molti più casi di quanti ne fermi, di proposito:
|
|
145
|
+
* **un falso positivo qui è peggio del difetto che chiude**, perché farebbe fallire una risposta
|
|
146
|
+
* che il chiamante avrebbe usato. Tre casi che sembrano questo e non lo sono, e che restano fuori:
|
|
147
|
+
*
|
|
148
|
+
* | Caso | Perché passa |
|
|
149
|
+
* |---|---|
|
|
150
|
+
* | `length` con contenuto **non vuoto** | è troncato, ma c'è: il chiamante lo vede e `finish_reason` glielo dice. Fermarlo romperebbe il "generami un testo lungo, al troncamento penso io" |
|
|
151
|
+
* | `stop` con contenuto vuoto | un modello che sceglie di non dire niente non ha sforato nessun tetto |
|
|
152
|
+
* | contenuto vuoto **con `tool_calls`** | è la forma normale di un turno che chiama un tool: qui il contenuto non serve |
|
|
153
|
+
*
|
|
154
|
+
* Quindi si ferma una sola combinazione: tetto raggiunto **e** nessun tool chiesto **e** niente da
|
|
155
|
+
* leggere. Gli spazi bianchi contano come niente: un `"\\n"` non è una risposta.
|
|
156
|
+
*/
|
|
157
|
+
export declare function emptyCompletionError(args: {
|
|
158
|
+
model: string;
|
|
159
|
+
finishReason: string | undefined;
|
|
160
|
+
content: unknown;
|
|
161
|
+
toolCalls: unknown;
|
|
162
|
+
maxTokens: number | null;
|
|
163
|
+
completionTokens: number;
|
|
164
|
+
reasoningTokens: number;
|
|
165
|
+
}): EmptyCompletionError | null;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { EmptyCompletionError } from "./types.js";
|
|
2
|
+
export class ProviderError extends Error {
|
|
3
|
+
httpStatus;
|
|
4
|
+
providerCode;
|
|
5
|
+
errorType;
|
|
6
|
+
retryAfterMs;
|
|
7
|
+
upstream;
|
|
8
|
+
constructor(message, httpStatus, providerCode, errorType, retryAfterMs = null, upstream = null) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.httpStatus = httpStatus;
|
|
11
|
+
this.providerCode = providerCode;
|
|
12
|
+
this.errorType = errorType;
|
|
13
|
+
this.retryAfterMs = retryAfterMs;
|
|
14
|
+
this.upstream = upstream;
|
|
15
|
+
this.name = "ProviderError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const CONCLUSIVE_ERROR_TYPES = new Set([
|
|
19
|
+
"context_length_exceeded", "max_tokens_exceeded", "token_limit_exceeded", "string_too_long",
|
|
20
|
+
"authentication", "permission_denied", "payment_required",
|
|
21
|
+
"rate_limit_exceeded", "provider_overloaded", "provider_unavailable",
|
|
22
|
+
"invalid_request", "invalid_prompt", "not_found", "precondition_failed",
|
|
23
|
+
"payload_too_large", "unprocessable",
|
|
24
|
+
"content_policy_violation", "refusal",
|
|
25
|
+
"invalid_image", "image_too_large", "image_too_small",
|
|
26
|
+
"unsupported_image_format", "image_not_found", "image_download_failed",
|
|
27
|
+
"server", "timeout",
|
|
28
|
+
]);
|
|
29
|
+
const REMINTABLE_ERROR_TYPES = new Set(["authentication", "payment_required"]);
|
|
30
|
+
const TRANSIENT_ERROR_TYPES = new Set(["rate_limit_exceeded", "provider_overloaded"]);
|
|
31
|
+
const TRANSIENT_STATUSES = new Set([429, 503]);
|
|
32
|
+
const MAX_RAW_MESSAGE = 300;
|
|
33
|
+
function upstreamReason(metadata) {
|
|
34
|
+
if (typeof metadata !== "object" || metadata === null)
|
|
35
|
+
return null;
|
|
36
|
+
const { provider_name: provider, raw } = metadata;
|
|
37
|
+
let reason = null;
|
|
38
|
+
if (typeof raw === "string" && raw.length > 0) {
|
|
39
|
+
try {
|
|
40
|
+
const body = JSON.parse(raw);
|
|
41
|
+
const inner = typeof body.error === "object" && body.error !== null ? body.error.message : body.error ?? body.message;
|
|
42
|
+
reason = typeof inner === "string" ? inner : null;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
reason = null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!reason)
|
|
49
|
+
return null;
|
|
50
|
+
const who = typeof provider === "string" && provider.length > 0 ? `${provider}: ` : "";
|
|
51
|
+
return truncate(`${who}${reason}`);
|
|
52
|
+
}
|
|
53
|
+
function readErrorEnvelope(payload) {
|
|
54
|
+
if (typeof payload !== "object" || payload === null)
|
|
55
|
+
return null;
|
|
56
|
+
const err = payload.error;
|
|
57
|
+
if (typeof err !== "object" || err === null)
|
|
58
|
+
return null;
|
|
59
|
+
const { code, message, metadata } = err;
|
|
60
|
+
const errorType = typeof metadata === "object" && metadata !== null
|
|
61
|
+
? metadata.error_type
|
|
62
|
+
: undefined;
|
|
63
|
+
return {
|
|
64
|
+
message: typeof message === "string" && message.length > 0 ? message : null,
|
|
65
|
+
code: typeof code === "number" ? code : null,
|
|
66
|
+
errorType: typeof errorType === "string" ? errorType : null,
|
|
67
|
+
upstream: upstreamReason(metadata),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function truncate(text) {
|
|
71
|
+
return text.length > MAX_RAW_MESSAGE ? `${text.slice(0, MAX_RAW_MESSAGE)}…` : text;
|
|
72
|
+
}
|
|
73
|
+
export async function providerErrorFromResponse(res, label) {
|
|
74
|
+
const raw = await res.text();
|
|
75
|
+
let parsed = null;
|
|
76
|
+
try {
|
|
77
|
+
parsed = readErrorEnvelope(JSON.parse(raw));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
parsed = null;
|
|
81
|
+
}
|
|
82
|
+
const detail = parsed?.message ?? truncate(raw);
|
|
83
|
+
return new ProviderError(`${label} ${res.status}: ${detail}`, res.status, parsed?.code ?? null, parsed?.errorType ?? null, retryAfterMs(res.headers.get("Retry-After")), parsed?.upstream ?? null);
|
|
84
|
+
}
|
|
85
|
+
export function retryAfterMs(header) {
|
|
86
|
+
if (header === null)
|
|
87
|
+
return null;
|
|
88
|
+
const seconds = Number(header.trim());
|
|
89
|
+
if (Number.isFinite(seconds))
|
|
90
|
+
return seconds > 0 ? Math.round(seconds * 1000) : null;
|
|
91
|
+
const at = Date.parse(header);
|
|
92
|
+
if (Number.isNaN(at))
|
|
93
|
+
return null;
|
|
94
|
+
const delta = at - Date.now();
|
|
95
|
+
return delta > 0 ? delta : null;
|
|
96
|
+
}
|
|
97
|
+
export function errorInBody(payload, label) {
|
|
98
|
+
const parsed = readErrorEnvelope(payload);
|
|
99
|
+
if (!parsed)
|
|
100
|
+
return null;
|
|
101
|
+
const code = parsed.code ?? 200;
|
|
102
|
+
return new ProviderError(`${label} ${code}: ${parsed.message ?? "errore nel corpo della risposta"}`, 200, parsed.code, parsed.errorType, null, parsed.upstream);
|
|
103
|
+
}
|
|
104
|
+
export function isRemintable(err) {
|
|
105
|
+
if (!(err instanceof ProviderError))
|
|
106
|
+
return false;
|
|
107
|
+
if (err.errorType !== null && CONCLUSIVE_ERROR_TYPES.has(err.errorType)) {
|
|
108
|
+
return REMINTABLE_ERROR_TYPES.has(err.errorType);
|
|
109
|
+
}
|
|
110
|
+
const code = err.providerCode ?? err.httpStatus;
|
|
111
|
+
return code === 401 || code === 402;
|
|
112
|
+
}
|
|
113
|
+
export function isTransient(err) {
|
|
114
|
+
if (!(err instanceof ProviderError))
|
|
115
|
+
return false;
|
|
116
|
+
if (err.errorType !== null && CONCLUSIVE_ERROR_TYPES.has(err.errorType)) {
|
|
117
|
+
return TRANSIENT_ERROR_TYPES.has(err.errorType);
|
|
118
|
+
}
|
|
119
|
+
return TRANSIENT_STATUSES.has(err.providerCode ?? err.httpStatus);
|
|
120
|
+
}
|
|
121
|
+
export function providerFailure(err) {
|
|
122
|
+
if (!(err instanceof ProviderError))
|
|
123
|
+
return null;
|
|
124
|
+
if (err.errorType === null || !CONCLUSIVE_ERROR_TYPES.has(err.errorType))
|
|
125
|
+
return null;
|
|
126
|
+
const status = err.providerCode ?? (err.httpStatus === 200 ? 502 : err.httpStatus);
|
|
127
|
+
return { code: `PROVIDER_${err.errorType.toUpperCase()}`, status };
|
|
128
|
+
}
|
|
129
|
+
function textOf(content) {
|
|
130
|
+
if (typeof content === "string")
|
|
131
|
+
return content;
|
|
132
|
+
if (Array.isArray(content)) {
|
|
133
|
+
return content
|
|
134
|
+
.map((part) => (typeof part === "object" && part !== null && "text" in part
|
|
135
|
+
? String(part.text ?? "")
|
|
136
|
+
: ""))
|
|
137
|
+
.join("");
|
|
138
|
+
}
|
|
139
|
+
return "";
|
|
140
|
+
}
|
|
141
|
+
export function emptyCompletionError(args) {
|
|
142
|
+
if (args.finishReason !== "length")
|
|
143
|
+
return null;
|
|
144
|
+
if (Array.isArray(args.toolCalls) && args.toolCalls.length > 0)
|
|
145
|
+
return null;
|
|
146
|
+
if (textOf(args.content).trim() !== "")
|
|
147
|
+
return null;
|
|
148
|
+
const budget = args.maxTokens === null ? "il budget di output" : `${args.maxTokens} token di output`;
|
|
149
|
+
const cause = args.reasoningTokens > 0
|
|
150
|
+
? `ne ha spesi ${args.reasoningTokens} a ragionare e non ha restituito contenuto`
|
|
151
|
+
: "ha esaurito il budget senza restituire contenuto";
|
|
152
|
+
const remedy = args.reasoningTokens > 0
|
|
153
|
+
? "spegni il ragionamento (`reasoning: { enabled: false }`) o alza `max_tokens`"
|
|
154
|
+
: "alza `max_tokens`";
|
|
155
|
+
return new EmptyCompletionError(`${args.model}: raggiunto ${budget} (${args.completionTokens} usati), ${cause} — ${remedy}`, args.model, args.maxTokens, args.completionTokens, args.reasoningTokens);
|
|
156
|
+
}
|
package/dist/raw.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { RawCompletionCreateParams, RawChatMessage, CompletionUsage } from "./types.js";
|
|
2
|
+
import { type RetryConfig } from "./backoff.js";
|
|
3
|
+
/** Risposta grezza di OpenRouter per una chat/completions — non normalizzata. */
|
|
4
|
+
export interface RawLlmResponse {
|
|
5
|
+
id: string;
|
|
6
|
+
model: string;
|
|
7
|
+
provider?: string;
|
|
8
|
+
choices: Array<{
|
|
9
|
+
message: RawChatMessage;
|
|
10
|
+
finish_reason: string;
|
|
11
|
+
}>;
|
|
12
|
+
usage?: {
|
|
13
|
+
prompt_tokens?: number;
|
|
14
|
+
completion_tokens?: number;
|
|
15
|
+
total_tokens?: number;
|
|
16
|
+
cost?: number;
|
|
17
|
+
completion_tokens_details?: {
|
|
18
|
+
reasoning_tokens?: number;
|
|
19
|
+
};
|
|
20
|
+
prompt_tokens_details?: {
|
|
21
|
+
cached_tokens?: number;
|
|
22
|
+
cache_write_tokens?: number;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Una singola chiamata a OpenRouter, pass-through quasi totale (nessuna orchestrazione,
|
|
28
|
+
* nessuna esecuzione di tool: il chiamante gestisce il proprio loop — vedi
|
|
29
|
+
* `RawCompletionCreateParams` in types.ts).
|
|
30
|
+
*
|
|
31
|
+
* Pin del provider su Anthropic diretto quando c'è `cache_control` nei messaggi: verificato
|
|
32
|
+
* empiricamente (2026-07) che senza pin il routing di default di OpenRouter può finire su
|
|
33
|
+
* un provider (Amazon Bedrock) che ignora `cache_control` senza errore — si paga il prezzo
|
|
34
|
+
* pieno in silenzio.
|
|
35
|
+
*
|
|
36
|
+
* **Preferito, non esclusivo** (2026-10-05). Il pin era `allow_fallbacks: false`, e il 5 ottobre
|
|
37
|
+
* l'organizzazione Anthropic usata da OpenRouter è stata messa in attesa (`organization_on_hold`):
|
|
38
|
+
* ogni chat con `cache_control` falliva con un 400, mentre la stessa chiamata con il fallback
|
|
39
|
+
* permesso rispondeva. La cache persa per qualche ora costa il prezzo pieno; una chat ferma costa
|
|
40
|
+
* il prodotto. Quindi Anthropic resta il primo della lista, e se rifiuta si passa al successivo.
|
|
41
|
+
*/
|
|
42
|
+
export declare function callOpenRouterRaw(runtimeKey: string, params: RawCompletionCreateParams & {
|
|
43
|
+
model: string;
|
|
44
|
+
}, fetchImpl: typeof fetch, retry?: RetryConfig | false): Promise<RawLlmResponse>;
|
|
45
|
+
/** Mappa l'usage grezzo di OpenRouter (nomi di campo verificati empiricamente) in CompletionUsage. */
|
|
46
|
+
export declare function normalizeRawUsage(usage: RawLlmResponse["usage"]): CompletionUsage;
|
|
47
|
+
/** Normalizza finish_reason di OpenRouter nell'union tipizzato di RawChatCompletion. */
|
|
48
|
+
export declare function normalizeFinishReason(reason: string | undefined): "stop" | "length" | "tool_calls";
|