@alma-harness/providers 0.1.0 → 0.2.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/dist/index.js CHANGED
@@ -1,10 +1,57 @@
1
1
  // src/anthropic/client.ts
2
2
  import Anthropic from "@anthropic-ai/sdk";
3
3
 
4
+ // src/errors.ts
5
+ import { ProviderError } from "@alma-harness/core";
6
+ var CONTEXT_WINDOW = /context_length_exceeded|prompt is too long|context window|maximum context length|too many tokens|exceeds the context|input length/i;
7
+ var OVERLOADED = /overloaded|too many requests|capacity|server_busy/i;
8
+ function hintsOf(e) {
9
+ const nested = typeof e.error === "object" && e.error !== null ? e.error : {};
10
+ const inner = typeof nested.error === "object" && nested.error !== null ? nested.error : {};
11
+ return [e.code, e.type, nested.code, nested.type, inner.code, inner.type, inner.message].filter((h) => typeof h === "string").join(" ").toLowerCase();
12
+ }
13
+ function toProviderError(provider, err) {
14
+ if (err instanceof ProviderError) return err;
15
+ const e = typeof err === "object" && err !== null ? err : {};
16
+ const own = typeof e.name === "string" && e.name !== "Error" ? e.name : "";
17
+ const name = own !== "" ? own : err?.constructor?.name ?? "";
18
+ if (name === "APIUserAbortError" || name === "AbortError") return err;
19
+ const message = err instanceof Error ? err.message : String(err);
20
+ const status = typeof e.status === "number" ? e.status : void 0;
21
+ const kind = classifyFailure(name, status, `${hintsOf(e)} ${message.toLowerCase()}`);
22
+ return new ProviderError(provider, kind, message, { ...status !== void 0 ? { status } : {}, cause: err });
23
+ }
24
+ function classifyFailure(name, status, hints) {
25
+ if (status === 429 || /rate_limit/.test(hints)) return hints.includes("insufficient_quota") ? "rejected" : "rate_limited";
26
+ if (status === 529 || status === 503 || (status === void 0 || status >= 500) && OVERLOADED.test(hints)) return "overloaded";
27
+ if (status !== void 0 && status >= 500) return "unavailable";
28
+ if ((status === void 0 || status === 400) && CONTEXT_WINDOW.test(hints)) return "context_window";
29
+ if (status !== void 0 && status >= 400) return "rejected";
30
+ if (/connection|timeout|timed out|fetch|network|socket|econn|enotfound|server_error|internal|unavailable/i.test(`${name} ${hints}`)) {
31
+ return "unavailable";
32
+ }
33
+ return "provider_drift";
34
+ }
35
+
4
36
  // src/anthropic/translate.ts
5
- var AnthropicTranslationError = class extends Error {
6
- constructor(message) {
7
- super(message);
37
+ import {
38
+ ProviderError as ProviderError2
39
+ } from "@alma-harness/core";
40
+
41
+ // src/arguments.ts
42
+ function parseToolArguments(json) {
43
+ if (json === "") return { input: {} };
44
+ try {
45
+ return { input: JSON.parse(json) };
46
+ } catch {
47
+ return { input: {}, malformed: json };
48
+ }
49
+ }
50
+
51
+ // src/anthropic/translate.ts
52
+ var AnthropicTranslationError = class extends ProviderError2 {
53
+ constructor(message, kind = "rejected") {
54
+ super("anthropic", kind, message);
8
55
  this.name = "AnthropicTranslationError";
9
56
  }
10
57
  };
@@ -14,17 +61,50 @@ function toAnthropicParams(req) {
14
61
  `AnthropicModelClient received a request for provider ${JSON.stringify(req.model.provider)}`
15
62
  );
16
63
  }
64
+ const reasoning = req.reasoning;
65
+ const replay = reasoning !== void 0 && reasoning.effort !== "none";
17
66
  const params = {
18
67
  model: req.model.id,
19
68
  max_tokens: req.maxTokens,
20
69
  stream: true,
21
70
  system: toSystem(req.system),
22
- messages: req.messages.map(toMessageParam)
71
+ messages: req.messages.flatMap((m) => toMessageParams(m, replay))
23
72
  };
24
- if (req.tools.length > 0) params.tools = req.tools.map(toTool);
73
+ if (reasoning !== void 0) {
74
+ if (reasoning.effort === "none") {
75
+ params.thinking = { type: "disabled" };
76
+ } else {
77
+ params.thinking = { type: "adaptive" };
78
+ params.output_config = { effort: toAnthropicEffort(reasoning.effort) };
79
+ }
80
+ }
81
+ switch (req.serviceTier) {
82
+ case void 0:
83
+ case "standard":
84
+ break;
85
+ case "priority":
86
+ params.service_tier = "auto";
87
+ break;
88
+ default:
89
+ throw new AnthropicTranslationError(
90
+ `the Anthropic Messages API cannot serve the ${req.serviceTier} tier on a streaming request`
91
+ );
92
+ }
93
+ const tools = [...req.tools.map(toTool), ...(req.providerTools ?? []).map(toServerTool)];
94
+ if (tools.length > 0) params.tools = tools;
25
95
  markConversationTail(params.messages);
26
96
  return params;
27
97
  }
98
+ function toServerTool(spec) {
99
+ const tool = { type: "web_search_20250305", name: "web_search" };
100
+ if (spec.maxUses !== void 0) tool.max_uses = spec.maxUses;
101
+ if (spec.allowedDomains !== void 0) tool.allowed_domains = [...spec.allowedDomains];
102
+ if (spec.blockedDomains !== void 0) tool.blocked_domains = [...spec.blockedDomains];
103
+ return tool;
104
+ }
105
+ function toAnthropicEffort(effort) {
106
+ return effort === "minimal" ? "low" : effort;
107
+ }
28
108
  function markConversationTail(messages) {
29
109
  const last = messages.at(-1);
30
110
  if (!last || typeof last.content === "string") return;
@@ -42,14 +122,16 @@ function toSystem(blocks) {
42
122
  return param;
43
123
  });
44
124
  }
45
- function toMessageParam(msg) {
125
+ function toMessageParams(msg, replayReasoning) {
46
126
  switch (msg.role) {
47
127
  case "user":
48
- return { role: "user", content: msg.blocks.map(toUserBlock) };
49
- case "assistant":
50
- return { role: "assistant", content: msg.blocks.map(toAssistantBlock) };
128
+ return [{ role: "user", content: msg.blocks.map(toUserBlock) }];
129
+ case "assistant": {
130
+ const content = msg.blocks.flatMap((b) => toAssistantBlocks(b, replayReasoning));
131
+ return content.length === 0 ? [] : [{ role: "assistant", content }];
132
+ }
51
133
  case "tool":
52
- return { role: "user", content: msg.blocks.map(toToolResultBlock) };
134
+ return [{ role: "user", content: msg.blocks.map(toToolResultBlock) }];
53
135
  }
54
136
  }
55
137
  function toUserBlock(block) {
@@ -69,18 +151,41 @@ function toUserBlock(block) {
69
151
  throw new AnthropicTranslationError(`block type ${JSON.stringify(block.type)} is not valid in a user message`);
70
152
  }
71
153
  }
72
- function toAssistantBlock(block) {
154
+ function toAssistantBlocks(block, replayReasoning) {
73
155
  switch (block.type) {
74
156
  case "text":
75
- return { type: "text", text: block.text };
157
+ return [{ type: "text", text: block.text }];
76
158
  case "tool_call":
77
- return { type: "tool_use", id: block.id, name: block.name, input: block.input };
159
+ return [{ type: "tool_use", id: block.id, name: block.name, input: block.input }];
160
+ case "reasoning":
161
+ return block.provider === "anthropic" && replayReasoning ? [toThinkingParam(block)] : [];
162
+ case "provider_tool_call":
163
+ return block.provider === "anthropic" ? [{ type: "server_tool_use", id: block.id, name: block.name, input: block.input }] : [];
164
+ case "provider_tool_result":
165
+ return block.provider === "anthropic" ? [toWebSearchResultParam(block)] : [];
78
166
  default:
79
167
  throw new AnthropicTranslationError(
80
168
  `block type ${JSON.stringify(block.type)} is not valid in an assistant message`
81
169
  );
82
170
  }
83
171
  }
172
+ function toWebSearchResultParam(block) {
173
+ const opaque = block.opaque;
174
+ if (opaque?.content === void 0) {
175
+ throw new AnthropicTranslationError("an Anthropic web search result carries no replayable content");
176
+ }
177
+ return { type: "web_search_tool_result", tool_use_id: block.callId, content: opaque.content };
178
+ }
179
+ function toThinkingParam(block) {
180
+ const opaque = block.opaque;
181
+ if (typeof opaque?.redacted === "string") {
182
+ return { type: "redacted_thinking", data: opaque.redacted };
183
+ }
184
+ if (typeof opaque?.signature === "string") {
185
+ return { type: "thinking", thinking: block.text ?? "", signature: opaque.signature };
186
+ }
187
+ throw new AnthropicTranslationError("an Anthropic reasoning block carries neither a signature nor redacted data");
188
+ }
84
189
  function toToolResultBlock(block) {
85
190
  if (block.type !== "tool_result") {
86
191
  throw new AnthropicTranslationError(
@@ -109,6 +214,9 @@ async function* translateStream(events) {
109
214
  const usage = { inputTokens: 0, outputTokens: 0 };
110
215
  let stopReason = null;
111
216
  const pendingTools = /* @__PURE__ */ new Map();
217
+ const pendingThinking = /* @__PURE__ */ new Map();
218
+ const pendingRedacted = /* @__PURE__ */ new Map();
219
+ const pendingServer = /* @__PURE__ */ new Map();
112
220
  for await (const event of events) {
113
221
  switch (event.type) {
114
222
  case "message_start": {
@@ -118,6 +226,10 @@ async function* translateStream(events) {
118
226
  if (u.cache_creation_input_tokens != null) {
119
227
  usage.cacheWriteInputTokens = u.cache_creation_input_tokens;
120
228
  }
229
+ if (u.server_tool_use?.web_search_requests) usage.webSearchRequests = u.server_tool_use.web_search_requests;
230
+ if (u.service_tier === "standard" || u.service_tier === "priority" || u.service_tier === "batch") {
231
+ usage.serviceTier = u.service_tier;
232
+ }
121
233
  break;
122
234
  }
123
235
  case "content_block_start":
@@ -127,25 +239,58 @@ async function* translateStream(events) {
127
239
  name: event.content_block.name,
128
240
  json: ""
129
241
  });
242
+ } else if (event.content_block.type === "thinking") {
243
+ pendingThinking.set(event.index, { text: event.content_block.thinking, signature: event.content_block.signature });
244
+ } else if (event.content_block.type === "redacted_thinking") {
245
+ pendingRedacted.set(event.index, event.content_block.data);
246
+ } else if (event.content_block.type === "server_tool_use") {
247
+ if (event.content_block.name !== "web_search") {
248
+ throw new AnthropicTranslationError(`Unmapped Anthropic server tool ${JSON.stringify(event.content_block.name)} \u2014 provider drift?`, "provider_drift");
249
+ }
250
+ pendingServer.set(event.index, { id: event.content_block.id, name: "web_search", json: "" });
251
+ } else if (event.content_block.type === "web_search_tool_result") {
252
+ yield { type: "provider_tool_result", block: toProviderToolResult(event.content_block) };
130
253
  }
131
254
  break;
132
255
  case "content_block_delta":
133
256
  if (event.delta.type === "text_delta") {
134
257
  yield { type: "text_delta", text: event.delta.text };
135
258
  } else if (event.delta.type === "input_json_delta") {
136
- const pending = pendingTools.get(event.index);
259
+ const pending = pendingTools.get(event.index) ?? pendingServer.get(event.index);
137
260
  if (pending) pending.json += event.delta.partial_json;
261
+ } else if (event.delta.type === "thinking_delta") {
262
+ const pending = pendingThinking.get(event.index);
263
+ if (pending) pending.text += event.delta.thinking;
264
+ } else if (event.delta.type === "signature_delta") {
265
+ const pending = pendingThinking.get(event.index);
266
+ if (pending) pending.signature = event.delta.signature;
138
267
  }
139
268
  break;
140
269
  case "content_block_stop": {
141
270
  const pending = pendingTools.get(event.index);
142
271
  if (pending) {
143
272
  pendingTools.delete(event.index);
273
+ yield { type: "tool_call", id: pending.id, name: pending.name, ...parseToolArguments(pending.json) };
274
+ }
275
+ const thinking = pendingThinking.get(event.index);
276
+ if (thinking) {
277
+ pendingThinking.delete(event.index);
278
+ yield {
279
+ type: "reasoning",
280
+ block: { type: "reasoning", provider: "anthropic", text: thinking.text, opaque: { signature: thinking.signature } }
281
+ };
282
+ }
283
+ const redacted = pendingRedacted.get(event.index);
284
+ if (redacted !== void 0) {
285
+ pendingRedacted.delete(event.index);
286
+ yield { type: "reasoning", block: { type: "reasoning", provider: "anthropic", opaque: { redacted } } };
287
+ }
288
+ const server = pendingServer.get(event.index);
289
+ if (server) {
290
+ pendingServer.delete(event.index);
144
291
  yield {
145
- type: "tool_call",
146
- id: pending.id,
147
- name: pending.name,
148
- input: pending.json === "" ? {} : JSON.parse(pending.json)
292
+ type: "provider_tool_call",
293
+ block: { type: "provider_tool_call", id: server.id, name: server.name, provider: "anthropic", input: server.json === "" ? {} : JSON.parse(server.json) }
149
294
  };
150
295
  }
151
296
  break;
@@ -153,6 +298,7 @@ async function* translateStream(events) {
153
298
  case "message_delta":
154
299
  if (event.delta.stop_reason != null) stopReason = event.delta.stop_reason;
155
300
  usage.outputTokens = event.usage.output_tokens;
301
+ if (event.usage.server_tool_use?.web_search_requests) usage.webSearchRequests = event.usage.server_tool_use.web_search_requests;
156
302
  break;
157
303
  case "message_stop":
158
304
  yield { type: "usage", usage: { ...usage } };
@@ -161,6 +307,26 @@ async function* translateStream(events) {
161
307
  }
162
308
  }
163
309
  }
310
+ function toProviderToolResult(block) {
311
+ const result = {
312
+ type: "provider_tool_result",
313
+ callId: block.tool_use_id,
314
+ name: "web_search",
315
+ provider: "anthropic",
316
+ results: [],
317
+ opaque: { content: block.content }
318
+ };
319
+ if (Array.isArray(block.content)) {
320
+ result.results = block.content.map((r) => ({
321
+ url: r.url,
322
+ ...r.title !== void 0 ? { title: r.title } : {},
323
+ ...r.page_age != null ? { pageAge: r.page_age } : {}
324
+ }));
325
+ } else {
326
+ result.error = block.content.error_code;
327
+ }
328
+ return result;
329
+ }
164
330
  function mapStopReason(reason) {
165
331
  switch (reason) {
166
332
  case "end_turn":
@@ -174,9 +340,12 @@ function mapStopReason(reason) {
174
340
  return "refusal";
175
341
  case "model_context_window_exceeded":
176
342
  return "context_window_exceeded";
343
+ case "pause_turn":
344
+ return "pause";
177
345
  default:
178
346
  throw new AnthropicTranslationError(
179
- `Unmapped Anthropic stop_reason ${JSON.stringify(reason)} \u2014 provider drift?`
347
+ `Unmapped Anthropic stop_reason ${JSON.stringify(reason)} \u2014 provider drift?`,
348
+ "provider_drift"
180
349
  );
181
350
  }
182
351
  }
@@ -195,11 +364,101 @@ var AnthropicModelClient = class {
195
364
  return translateStream(this.#rawEvents(params, opts?.signal));
196
365
  }
197
366
  async *#rawEvents(params, signal) {
198
- const stream = await this.#client.messages.create(
199
- params,
200
- signal !== void 0 ? { signal } : void 0
201
- );
202
- for await (const event of stream) yield event;
367
+ try {
368
+ const stream = await this.#client.messages.create(
369
+ params,
370
+ signal !== void 0 ? { signal } : void 0
371
+ );
372
+ for await (const event of stream) yield event;
373
+ } catch (err) {
374
+ throw toProviderError("anthropic", err);
375
+ }
376
+ }
377
+ };
378
+
379
+ // src/anthropic/jobs.ts
380
+ import Anthropic2 from "@anthropic-ai/sdk";
381
+ function translateMessage(message) {
382
+ const blocks = [];
383
+ for (const block of message.content) {
384
+ switch (block.type) {
385
+ case "text":
386
+ blocks.push({ type: "text", text: block.text });
387
+ break;
388
+ case "tool_use":
389
+ blocks.push({ type: "tool_call", id: block.id, name: block.name, input: block.input });
390
+ break;
391
+ case "thinking":
392
+ blocks.push({ type: "reasoning", provider: "anthropic", text: block.thinking, opaque: { signature: block.signature } });
393
+ break;
394
+ case "redacted_thinking":
395
+ blocks.push({ type: "reasoning", provider: "anthropic", opaque: { redacted: block.data } });
396
+ break;
397
+ default:
398
+ throw new AnthropicTranslationError(`Unmapped Anthropic content block ${JSON.stringify(block.type)} \u2014 provider drift?`, "provider_drift");
399
+ }
400
+ }
401
+ const u = message.usage;
402
+ const usage = { inputTokens: u.input_tokens, outputTokens: u.output_tokens };
403
+ if (u.cache_read_input_tokens != null) usage.cacheReadInputTokens = u.cache_read_input_tokens;
404
+ if (u.cache_creation_input_tokens != null) usage.cacheWriteInputTokens = u.cache_creation_input_tokens;
405
+ if (u.service_tier === "standard" || u.service_tier === "priority" || u.service_tier === "batch") {
406
+ usage.serviceTier = u.service_tier;
407
+ }
408
+ return { blocks, usage, stop: mapStopReason(message.stop_reason) };
409
+ }
410
+ var AnthropicJobClient = class {
411
+ #client;
412
+ constructor(opts = {}) {
413
+ const init = {};
414
+ if (opts.apiKey !== void 0) init.apiKey = opts.apiKey;
415
+ if (opts.baseURL !== void 0) init.baseURL = opts.baseURL;
416
+ this.#client = new Anthropic2(init);
417
+ }
418
+ async submit(items) {
419
+ const first = items[0];
420
+ if (!first) throw new AnthropicTranslationError("a batch needs at least one item");
421
+ const requests = items.map((item) => {
422
+ const { serviceTier: _tier, ...req } = item.request;
423
+ void _tier;
424
+ const { stream: _stream, ...params } = toAnthropicParams(req);
425
+ void _stream;
426
+ return { custom_id: item.id, params };
427
+ });
428
+ const batch = await this.#client.messages.batches.create({ requests });
429
+ return { provider: "anthropic", id: batch.id, model: first.request.model };
430
+ }
431
+ async status(handle) {
432
+ const batch = await this.#client.messages.batches.retrieve(handle.id);
433
+ const c = batch.request_counts;
434
+ const total = c.processing + c.succeeded + c.errored + c.canceled + c.expired;
435
+ const status = batch.processing_status === "canceling" ? "cancelled" : batch.processing_status === "in_progress" ? "running" : c.canceled === total && total > 0 ? "cancelled" : c.expired === total && total > 0 ? "expired" : "done";
436
+ return { status, counts: { total, done: c.succeeded, failed: c.errored + c.canceled + c.expired } };
437
+ }
438
+ async *results(handle) {
439
+ const decoder = await this.#client.messages.batches.results(handle.id);
440
+ for await (const entry of decoder) {
441
+ const { custom_id: id, result } = entry;
442
+ switch (result.type) {
443
+ case "succeeded":
444
+ yield { id, outcome: "succeeded", output: translateMessage(result.message) };
445
+ break;
446
+ case "errored": {
447
+ const error = result.error?.error?.message;
448
+ yield { id, outcome: "errored", ...error !== void 0 ? { error } : {} };
449
+ break;
450
+ }
451
+ case "canceled":
452
+ yield { id, outcome: "cancelled" };
453
+ break;
454
+ case "expired":
455
+ yield { id, outcome: "expired" };
456
+ break;
457
+ }
458
+ }
459
+ }
460
+ async cancel(handle) {
461
+ await this.#client.messages.batches.cancel(handle.id);
203
462
  }
204
463
  };
205
464
 
@@ -207,9 +466,12 @@ var AnthropicModelClient = class {
207
466
  import OpenAI from "openai";
208
467
 
209
468
  // src/openai/translate.ts
210
- var OpenAITranslationError = class extends Error {
211
- constructor(message) {
212
- super(message);
469
+ import {
470
+ ProviderError as ProviderError3
471
+ } from "@alma-harness/core";
472
+ var OpenAITranslationError = class extends ProviderError3 {
473
+ constructor(message, kind = "rejected") {
474
+ super("openai", kind, message);
213
475
  this.name = "OpenAITranslationError";
214
476
  }
215
477
  };
@@ -219,6 +481,8 @@ function toOpenAIParams(req) {
219
481
  `OpenAIModelClient received a request for provider ${JSON.stringify(req.model.provider)}`
220
482
  );
221
483
  }
484
+ const reasoning = req.reasoning;
485
+ const replay = reasoning !== void 0 && reasoning.effort !== "none";
222
486
  const params = {
223
487
  model: req.model.id,
224
488
  max_output_tokens: req.maxTokens,
@@ -226,22 +490,49 @@ function toOpenAIParams(req) {
226
490
  // Privacy-first (§3, §10): the Responses API stores responses server-side
227
491
  // by default; the harness never leaves conversation state at the provider.
228
492
  store: false,
229
- input: req.messages.flatMap(toInputItems)
493
+ input: req.messages.flatMap((m) => toInputItems(m, replay))
230
494
  };
495
+ if (reasoning !== void 0) {
496
+ params.reasoning = replay ? { effort: reasoning.effort, summary: "auto" } : { effort: "none" };
497
+ if (replay) params.include = ["reasoning.encrypted_content"];
498
+ }
499
+ switch (req.serviceTier) {
500
+ case void 0:
501
+ case "standard":
502
+ break;
503
+ case "flex":
504
+ case "priority":
505
+ params.service_tier = req.serviceTier;
506
+ break;
507
+ default:
508
+ throw new OpenAITranslationError(
509
+ `the OpenAI Responses API cannot serve the ${req.serviceTier} tier on a streaming request`
510
+ );
511
+ }
231
512
  const instructions = toInstructions(req.system);
232
513
  if (instructions !== "") params.instructions = instructions;
233
- if (req.tools.length > 0) params.tools = req.tools.map(toTool2);
514
+ const tools = [...req.tools.map(toTool2), ...(req.providerTools ?? []).map(toWebSearchTool)];
515
+ if (tools.length > 0) params.tools = tools;
516
+ if ((req.providerTools ?? []).length > 0) params.include = [...params.include ?? [], "web_search_call.action.sources"];
234
517
  return params;
235
518
  }
519
+ function toWebSearchTool(spec) {
520
+ if (spec.blockedDomains !== void 0) {
521
+ throw new OpenAITranslationError("the OpenAI web search has no blocked-domains form \u2014 refusing rather than searching them");
522
+ }
523
+ const tool = { type: "web_search" };
524
+ if (spec.allowedDomains !== void 0) tool.filters = { allowed_domains: [...spec.allowedDomains] };
525
+ return tool;
526
+ }
236
527
  function toInstructions(blocks) {
237
528
  return blocks.map((b) => b.text).join("\n\n");
238
529
  }
239
- function toInputItems(msg) {
530
+ function toInputItems(msg, replayReasoning) {
240
531
  switch (msg.role) {
241
532
  case "user":
242
533
  return [{ role: "user", content: msg.blocks.map(toUserContentPart) }];
243
534
  case "assistant":
244
- return msg.blocks.map(toAssistantItem);
535
+ return msg.blocks.flatMap((b) => toAssistantItems(b, replayReasoning));
245
536
  case "tool":
246
537
  return msg.blocks.map(toFunctionCallOutput);
247
538
  }
@@ -263,23 +554,50 @@ function toUserContentPart(block) {
263
554
  );
264
555
  }
265
556
  }
266
- function toAssistantItem(block) {
557
+ function toAssistantItems(block, replayReasoning) {
267
558
  switch (block.type) {
268
559
  case "text":
269
- return { role: "assistant", content: block.text };
560
+ return [{ role: "assistant", content: block.text }];
270
561
  case "tool_call":
271
- return {
272
- type: "function_call",
273
- call_id: block.id,
274
- name: block.name,
275
- arguments: JSON.stringify(block.input)
276
- };
562
+ return [
563
+ {
564
+ type: "function_call",
565
+ call_id: block.id,
566
+ name: block.name,
567
+ arguments: JSON.stringify(block.input)
568
+ }
569
+ ];
570
+ case "reasoning":
571
+ return block.provider === "openai" && replayReasoning ? [toReasoningItem(block)] : [];
572
+ case "provider_tool_call":
573
+ return [];
574
+ case "provider_tool_result":
575
+ return block.provider === "openai" ? [toWebSearchItem(block)] : [];
277
576
  default:
278
577
  throw new OpenAITranslationError(
279
578
  `block type ${JSON.stringify(block.type)} is not valid in an assistant message`
280
579
  );
281
580
  }
282
581
  }
582
+ function toWebSearchItem(block) {
583
+ const opaque = block.opaque;
584
+ if (opaque?.type !== "web_search_call") {
585
+ throw new OpenAITranslationError("an OpenAI web search result carries no replayable item");
586
+ }
587
+ return opaque;
588
+ }
589
+ function toReasoningItem(block) {
590
+ const opaque = block.opaque;
591
+ if (typeof opaque?.id !== "string" || typeof opaque.encrypted_content !== "string") {
592
+ throw new OpenAITranslationError("an OpenAI reasoning block carries no id or encrypted content");
593
+ }
594
+ return {
595
+ type: "reasoning",
596
+ id: opaque.id,
597
+ summary: Array.isArray(opaque.summary) ? opaque.summary : [],
598
+ encrypted_content: opaque.encrypted_content
599
+ };
600
+ }
283
601
  function toFunctionCallOutput(block) {
284
602
  if (block.type !== "tool_result") {
285
603
  throw new OpenAITranslationError(
@@ -309,6 +627,8 @@ function toTool2(spec) {
309
627
  async function* translateOpenAIStream(events) {
310
628
  let sawToolCall = false;
311
629
  let sawRefusal = false;
630
+ let searches = 0;
631
+ const withSearches = (usage) => searches > 0 ? { ...usage, webSearchRequests: searches } : usage;
312
632
  for await (const event of events) {
313
633
  switch (event.type) {
314
634
  case "response.output_text.delta":
@@ -321,37 +641,66 @@ async function* translateOpenAIStream(events) {
321
641
  case "response.output_item.done":
322
642
  if (event.item.type === "function_call") {
323
643
  sawToolCall = true;
644
+ yield { type: "tool_call", id: event.item.call_id, name: event.item.name, ...parseToolArguments(event.item.arguments) };
645
+ } else if (event.item.type === "web_search_call") {
646
+ searches += 1;
647
+ const item = event.item;
648
+ const action = item.action;
649
+ const sources = action.type === "search" ? (action.sources ?? []).map((s) => ({ url: s.url })) : [];
650
+ yield { type: "provider_tool_call", block: { type: "provider_tool_call", id: item.id, name: "web_search", provider: "openai", input: action } };
324
651
  yield {
325
- type: "tool_call",
326
- id: event.item.call_id,
327
- name: event.item.name,
328
- input: event.item.arguments === "" ? {} : JSON.parse(event.item.arguments)
652
+ type: "provider_tool_result",
653
+ block: {
654
+ type: "provider_tool_result",
655
+ callId: item.id,
656
+ name: "web_search",
657
+ provider: "openai",
658
+ results: sources,
659
+ ...item.status === "failed" ? { error: "failed" } : {},
660
+ opaque: item
661
+ }
662
+ };
663
+ } else if (event.item.type === "reasoning" && typeof event.item.encrypted_content === "string") {
664
+ const item = event.item;
665
+ const text = [
666
+ ...(item.content ?? []).map((c) => c.text),
667
+ ...item.summary.map((s) => s.text)
668
+ ].filter((t) => t !== "").join("\n");
669
+ yield {
670
+ type: "reasoning",
671
+ block: {
672
+ type: "reasoning",
673
+ provider: "openai",
674
+ ...text !== "" ? { text } : {},
675
+ opaque: { id: item.id, summary: item.summary, encrypted_content: item.encrypted_content }
676
+ }
329
677
  };
330
678
  }
331
679
  break;
332
680
  case "response.completed":
333
- yield usageEvent(event.response);
681
+ yield { type: "usage", usage: withSearches(usageOf(event.response)) };
334
682
  yield { type: "stop", reason: sawRefusal ? "refusal" : sawToolCall ? "tool_use" : "end_turn" };
335
683
  break;
336
684
  case "response.incomplete": {
337
- yield usageEvent(event.response);
685
+ yield { type: "usage", usage: withSearches(usageOf(event.response)) };
338
686
  yield { type: "stop", reason: mapIncompleteReason(event.response) };
339
687
  break;
340
688
  }
341
689
  case "response.failed": {
342
690
  const err = event.response.error;
343
- throw new OpenAITranslationError(
344
- `OpenAI response failed: ${err ? `${err.code}: ${err.message}` : "unknown error"}`
345
- );
691
+ const message = `OpenAI response failed: ${err ? `${err.code}: ${err.message}` : "unknown error"}`;
692
+ throw new OpenAITranslationError(message, classifyFailure("ResponseFailed", void 0, message.toLowerCase()));
693
+ }
694
+ case "error": {
695
+ const message = `OpenAI stream error: ${event.message}`;
696
+ throw new OpenAITranslationError(message, classifyFailure("StreamError", void 0, `${event.code ?? ""} ${message}`.toLowerCase()));
346
697
  }
347
- case "error":
348
- throw new OpenAITranslationError(`OpenAI stream error: ${event.message}`);
349
698
  default:
350
699
  break;
351
700
  }
352
701
  }
353
702
  }
354
- function usageEvent(response) {
703
+ function usageOf(response) {
355
704
  const u = response.usage;
356
705
  const cachedRead = u?.input_tokens_details?.cached_tokens ?? 0;
357
706
  const usage = {
@@ -365,7 +714,12 @@ function usageEvent(response) {
365
714
  if (cachedRead > 0) usage.cacheReadInputTokens = cachedRead;
366
715
  const cacheWrite = u?.input_tokens_details?.cache_write_tokens;
367
716
  if (cacheWrite !== void 0 && cacheWrite > 0) usage.cacheWriteInputTokens = cacheWrite;
368
- return { type: "usage", usage };
717
+ const reasoningTokens = u?.output_tokens_details?.reasoning_tokens;
718
+ if (reasoningTokens !== void 0 && reasoningTokens > 0) usage.reasoningTokens = reasoningTokens;
719
+ const served = response.service_tier;
720
+ if (served === "default") usage.serviceTier = "standard";
721
+ else if (served === "flex" || served === "priority") usage.serviceTier = served;
722
+ return usage;
369
723
  }
370
724
  function mapIncompleteReason(response) {
371
725
  const reason = response.incomplete_details?.reason;
@@ -376,7 +730,8 @@ function mapIncompleteReason(response) {
376
730
  return "refusal";
377
731
  default:
378
732
  throw new OpenAITranslationError(
379
- `Unmapped OpenAI incomplete reason ${JSON.stringify(reason)} \u2014 provider drift?`
733
+ `Unmapped OpenAI incomplete reason ${JSON.stringify(reason)} \u2014 provider drift?`,
734
+ "provider_drift"
380
735
  );
381
736
  }
382
737
  }
@@ -395,21 +750,136 @@ var OpenAIModelClient = class {
395
750
  return translateOpenAIStream(this.#rawEvents(params, opts?.signal));
396
751
  }
397
752
  async *#rawEvents(params, signal) {
398
- const stream = await this.#client.responses.create(
399
- params,
400
- signal !== void 0 ? { signal } : void 0
401
- );
402
- for await (const event of stream) yield event;
753
+ try {
754
+ const stream = await this.#client.responses.create(
755
+ params,
756
+ signal !== void 0 ? { signal } : void 0
757
+ );
758
+ for await (const event of stream) yield event;
759
+ } catch (err) {
760
+ throw toProviderError("openai", err);
761
+ }
762
+ }
763
+ };
764
+
765
+ // src/openai/jobs.ts
766
+ import OpenAI2, { toFile } from "openai";
767
+ function toBatchLines(items) {
768
+ return items.map((item) => {
769
+ const { serviceTier: _tier, ...req } = item.request;
770
+ void _tier;
771
+ const { stream: _stream, ...body } = toOpenAIParams(req);
772
+ void _stream;
773
+ return { custom_id: item.id, method: "POST", url: "/v1/responses", body };
774
+ });
775
+ }
776
+ function translateResponse(response) {
777
+ const blocks = [];
778
+ let sawToolCall = false;
779
+ let sawRefusal = false;
780
+ for (const item of response.output) {
781
+ switch (item.type) {
782
+ case "message":
783
+ for (const part of item.content) {
784
+ if (part.type === "output_text") blocks.push({ type: "text", text: part.text });
785
+ else if (part.type === "refusal") {
786
+ sawRefusal = true;
787
+ blocks.push({ type: "text", text: part.refusal });
788
+ }
789
+ }
790
+ break;
791
+ case "function_call":
792
+ sawToolCall = true;
793
+ blocks.push({
794
+ type: "tool_call",
795
+ id: item.call_id,
796
+ name: item.name,
797
+ input: item.arguments === "" ? {} : JSON.parse(item.arguments)
798
+ });
799
+ break;
800
+ case "reasoning": {
801
+ if (typeof item.encrypted_content !== "string") break;
802
+ const text = [...(item.content ?? []).map((c) => c.text), ...item.summary.map((s) => s.text)].filter((t) => t !== "").join("\n");
803
+ blocks.push({
804
+ type: "reasoning",
805
+ provider: "openai",
806
+ ...text !== "" ? { text } : {},
807
+ opaque: { id: item.id, summary: item.summary, encrypted_content: item.encrypted_content }
808
+ });
809
+ break;
810
+ }
811
+ default:
812
+ throw new OpenAITranslationError(`Unmapped OpenAI output item ${JSON.stringify(item.type)} \u2014 provider drift?`, "provider_drift");
813
+ }
814
+ }
815
+ const stop = response.incomplete_details?.reason !== void 0 && response.incomplete_details?.reason !== null ? mapIncompleteReason(response) : sawRefusal ? "refusal" : sawToolCall ? "tool_use" : "end_turn";
816
+ return { blocks, usage: usageOf(response), stop };
817
+ }
818
+ var OpenAIJobClient = class {
819
+ #client;
820
+ constructor(opts = {}) {
821
+ const init = {};
822
+ if (opts.apiKey !== void 0) init.apiKey = opts.apiKey;
823
+ if (opts.baseURL !== void 0) init.baseURL = opts.baseURL;
824
+ this.#client = new OpenAI2(init);
825
+ }
826
+ async submit(items) {
827
+ const first = items[0];
828
+ if (!first) throw new OpenAITranslationError("a batch needs at least one item");
829
+ const jsonl = toBatchLines(items).map((line) => JSON.stringify(line)).join("\n") + "\n";
830
+ const file = await this.#client.files.create({
831
+ file: await toFile(Buffer.from(jsonl, "utf8"), "alma-batch.jsonl", { type: "application/jsonl" }),
832
+ purpose: "batch"
833
+ });
834
+ const batch = await this.#client.batches.create({
835
+ input_file_id: file.id,
836
+ endpoint: "/v1/responses",
837
+ completion_window: "24h"
838
+ });
839
+ return { provider: "openai", id: batch.id, model: first.request.model };
840
+ }
841
+ async status(handle) {
842
+ const batch = await this.#client.batches.retrieve(handle.id);
843
+ const status = batch.status === "validating" ? "queued" : batch.status === "in_progress" || batch.status === "finalizing" ? "running" : batch.status === "completed" ? "done" : batch.status === "failed" ? "failed" : batch.status === "expired" ? "expired" : "cancelled";
844
+ const c = batch.request_counts;
845
+ return {
846
+ status,
847
+ ...c ? { counts: { total: c.total, done: c.completed, failed: c.failed } } : {}
848
+ };
849
+ }
850
+ async *results(handle) {
851
+ const batch = await this.#client.batches.retrieve(handle.id);
852
+ for (const fileId of [batch.output_file_id, batch.error_file_id]) {
853
+ if (!fileId) continue;
854
+ const text = await (await this.#client.files.content(fileId)).text();
855
+ for (const raw of text.split("\n")) {
856
+ if (raw.trim() === "") continue;
857
+ const line = JSON.parse(raw);
858
+ const body = line.response?.body;
859
+ if (line.response && line.response.status_code >= 200 && line.response.status_code < 300 && body && "output" in body) {
860
+ yield { id: line.custom_id, outcome: "succeeded", output: translateResponse(body) };
861
+ } else {
862
+ const error = line.error?.message ?? (body && "error" in body ? body.error?.message : void 0) ?? `status ${line.response?.status_code ?? "unknown"}`;
863
+ yield { id: line.custom_id, outcome: "errored", error };
864
+ }
865
+ }
866
+ }
867
+ }
868
+ async cancel(handle) {
869
+ await this.#client.batches.cancel(handle.id);
403
870
  }
404
871
  };
405
872
 
406
873
  // src/openrouter/client.ts
407
- import OpenAI2 from "openai";
874
+ import OpenAI3 from "openai";
408
875
 
409
876
  // src/openrouter/translate.ts
410
- var OpenRouterTranslationError = class extends Error {
411
- constructor(message) {
412
- super(message);
877
+ import {
878
+ ProviderError as ProviderError4
879
+ } from "@alma-harness/core";
880
+ var OpenRouterTranslationError = class extends ProviderError4 {
881
+ constructor(message, kind = "rejected") {
882
+ super("openrouter", kind, message);
413
883
  this.name = "OpenRouterTranslationError";
414
884
  }
415
885
  };
@@ -428,6 +898,8 @@ function toOpenRouterParams(req, routing) {
428
898
  if (req.tools.length > 0) {
429
899
  provider.require_parameters = true;
430
900
  }
901
+ const reasoning = req.reasoning;
902
+ const replay = reasoning !== void 0 && reasoning.effort !== "none";
431
903
  const params = {
432
904
  model: req.model.id,
433
905
  // The SDK deprecates this in favor of the OpenAI-specific
@@ -438,9 +910,20 @@ function toOpenRouterParams(req, routing) {
438
910
  // Review amendment (spec 014): streamed chat completions only carry usage
439
911
  // when asked — without this the BudgetGuard never sees a usage event.
440
912
  stream_options: { include_usage: true },
441
- messages: [...systemMessages(req.system), ...req.messages.flatMap(toWireMessages)],
913
+ messages: [...systemMessages(req.system), ...req.messages.flatMap((m) => toWireMessages(m, replay))],
442
914
  provider
443
915
  };
916
+ if (reasoning !== void 0) {
917
+ params.reasoning = replay ? { effort: reasoning.effort === "max" ? "xhigh" : reasoning.effort } : { enabled: false };
918
+ }
919
+ if (req.serviceTier !== void 0 && req.serviceTier !== "standard") {
920
+ throw new OpenRouterTranslationError(
921
+ `the OpenRouter adapter cannot serve the ${req.serviceTier} tier \u2014 the gateway prices by upstream`
922
+ );
923
+ }
924
+ if ((req.providerTools ?? []).length > 0) {
925
+ throw new OpenRouterTranslationError("the OpenRouter adapter cannot declare provider-executed tools \u2014 the gateway has no neutral web search");
926
+ }
444
927
  if (req.tools.length > 0) params.tools = req.tools.map(toTool3);
445
928
  return params;
446
929
  }
@@ -448,12 +931,12 @@ function systemMessages(blocks) {
448
931
  if (blocks.length === 0) return [];
449
932
  return [{ role: "system", content: blocks.map((b) => b.text).join("\n\n") }];
450
933
  }
451
- function toWireMessages(msg) {
934
+ function toWireMessages(msg, replayReasoning) {
452
935
  switch (msg.role) {
453
936
  case "user":
454
937
  return [{ role: "user", content: msg.blocks.map(toUserContentPart2) }];
455
938
  case "assistant":
456
- return [toAssistantMessage(msg.blocks)];
939
+ return [toAssistantMessage(msg.blocks, replayReasoning)];
457
940
  case "tool":
458
941
  return msg.blocks.map(toToolMessage);
459
942
  }
@@ -475,9 +958,10 @@ function toUserContentPart2(block) {
475
958
  );
476
959
  }
477
960
  }
478
- function toAssistantMessage(blocks) {
961
+ function toAssistantMessage(blocks, replayReasoning) {
479
962
  let text = "";
480
963
  const toolCalls = [];
964
+ const details = [];
481
965
  for (const block of blocks) {
482
966
  switch (block.type) {
483
967
  case "text":
@@ -490,6 +974,16 @@ function toAssistantMessage(blocks) {
490
974
  function: { name: block.name, arguments: JSON.stringify(block.input) }
491
975
  });
492
976
  break;
977
+ case "reasoning": {
978
+ const opaque = block.opaque;
979
+ if (block.provider === "openrouter" && replayReasoning && Array.isArray(opaque?.reasoning_details)) {
980
+ details.push(...opaque.reasoning_details);
981
+ }
982
+ break;
983
+ }
984
+ case "provider_tool_call":
985
+ case "provider_tool_result":
986
+ break;
493
987
  default:
494
988
  throw new OpenRouterTranslationError(
495
989
  `block type ${JSON.stringify(block.type)} is not valid in an assistant message`
@@ -501,6 +995,7 @@ function toAssistantMessage(blocks) {
501
995
  content: text === "" ? null : text
502
996
  };
503
997
  if (toolCalls.length > 0) message.tool_calls = toolCalls;
998
+ if (details.length > 0) message.reasoning_details = details;
504
999
  return message;
505
1000
  }
506
1001
  function toToolMessage(block) {
@@ -533,9 +1028,15 @@ async function* translateOpenRouterStream(chunks) {
533
1028
  let finish = null;
534
1029
  let usage = null;
535
1030
  let toolsEmitted = false;
1031
+ let reasoningText = "";
1032
+ const reasoningDetails = [];
1033
+ let reasoningEmitted = false;
536
1034
  for await (const chunk of chunks) {
537
1035
  const choice = chunk.choices[0];
538
1036
  if (choice) {
1037
+ const extra = choice.delta;
1038
+ if (typeof extra.reasoning === "string") reasoningText += extra.reasoning;
1039
+ if (Array.isArray(extra.reasoning_details)) reasoningDetails.push(...extra.reasoning_details);
539
1040
  if (choice.delta.content != null && choice.delta.content !== "") {
540
1041
  yield { type: "text_delta", text: choice.delta.content };
541
1042
  }
@@ -548,15 +1049,22 @@ async function* translateOpenRouterStream(chunks) {
548
1049
  }
549
1050
  if (choice.finish_reason != null) {
550
1051
  finish = choice.finish_reason;
1052
+ if (!reasoningEmitted && (reasoningText !== "" || reasoningDetails.length > 0)) {
1053
+ reasoningEmitted = true;
1054
+ yield {
1055
+ type: "reasoning",
1056
+ block: {
1057
+ type: "reasoning",
1058
+ provider: "openrouter",
1059
+ ...reasoningText !== "" ? { text: reasoningText } : {},
1060
+ ...reasoningDetails.length > 0 ? { opaque: { reasoning_details: reasoningDetails } } : {}
1061
+ }
1062
+ };
1063
+ }
551
1064
  if (!toolsEmitted) {
552
1065
  toolsEmitted = true;
553
1066
  for (const [, call] of [...pendingTools.entries()].sort(([a], [b]) => a - b)) {
554
- yield {
555
- type: "tool_call",
556
- id: call.id,
557
- name: call.name,
558
- input: call.json === "" ? {} : JSON.parse(call.json)
559
- };
1067
+ yield { type: "tool_call", id: call.id, name: call.name, ...parseToolArguments(call.json) };
560
1068
  }
561
1069
  pendingTools.clear();
562
1070
  }
@@ -571,11 +1079,14 @@ async function* translateOpenRouterStream(chunks) {
571
1079
  outputTokens: chunk.usage.completion_tokens
572
1080
  };
573
1081
  if (cachedRead > 0) usage.cacheReadInputTokens = cachedRead;
1082
+ const reasoningTokens = chunk.usage.completion_tokens_details?.reasoning_tokens;
1083
+ if (reasoningTokens !== void 0 && reasoningTokens > 0) usage.reasoningTokens = reasoningTokens;
574
1084
  }
575
1085
  }
576
1086
  if (finish === null) {
577
1087
  throw new OpenRouterTranslationError(
578
- "OpenRouter stream ended without a finish_reason \u2014 provider drift?"
1088
+ "OpenRouter stream ended without a finish_reason \u2014 provider drift?",
1089
+ "provider_drift"
579
1090
  );
580
1091
  }
581
1092
  if (usage !== null) yield { type: "usage", usage };
@@ -593,7 +1104,8 @@ function mapFinishReason(reason) {
593
1104
  return "refusal";
594
1105
  default:
595
1106
  throw new OpenRouterTranslationError(
596
- `Unmapped OpenRouter finish_reason ${JSON.stringify(reason)} \u2014 provider drift?`
1107
+ `Unmapped OpenRouter finish_reason ${JSON.stringify(reason)} \u2014 provider drift?`,
1108
+ "provider_drift"
597
1109
  );
598
1110
  }
599
1111
  }
@@ -620,36 +1132,47 @@ var OpenRouterModelClient = class {
620
1132
  "OpenRouterModelClient needs an API key: pass `apiKey` or set OPENROUTER_API_KEY"
621
1133
  );
622
1134
  }
623
- this.#client = new OpenAI2({ apiKey, baseURL: opts.baseURL ?? OPENROUTER_BASE_URL });
1135
+ this.#client = new OpenAI3({ apiKey, baseURL: opts.baseURL ?? OPENROUTER_BASE_URL });
624
1136
  }
625
1137
  stream(req, opts) {
626
1138
  const params = toOpenRouterParams(req, this.#routing);
627
1139
  return translateOpenRouterStream(this.#rawChunks(params, opts?.signal));
628
1140
  }
629
1141
  async *#rawChunks(params, signal) {
630
- const stream = await this.#client.chat.completions.create(
631
- params,
632
- signal !== void 0 ? { signal } : void 0
633
- );
634
- for await (const chunk of stream) yield chunk;
1142
+ try {
1143
+ const stream = await this.#client.chat.completions.create(
1144
+ params,
1145
+ signal !== void 0 ? { signal } : void 0
1146
+ );
1147
+ for await (const chunk of stream) yield chunk;
1148
+ } catch (err) {
1149
+ throw toProviderError("openrouter", err);
1150
+ }
635
1151
  }
636
1152
  };
637
1153
 
638
1154
  // src/index.ts
639
1155
  var SUPPORTED_PROVIDERS = ["anthropic", "openai", "openrouter"];
640
1156
  export {
1157
+ AnthropicJobClient,
641
1158
  AnthropicModelClient,
642
1159
  AnthropicTranslationError,
1160
+ OpenAIJobClient,
643
1161
  OpenAIModelClient,
644
1162
  OpenAITranslationError,
645
1163
  OpenRouterModelClient,
646
1164
  OpenRouterTranslationError,
647
1165
  SUPPORTED_PROVIDERS,
1166
+ classifyFailure,
648
1167
  toAnthropicParams,
1168
+ toBatchLines,
649
1169
  toOpenAIParams,
650
1170
  toOpenRouterParams,
1171
+ toProviderError,
1172
+ translateMessage,
651
1173
  translateOpenAIStream,
652
1174
  translateOpenRouterStream,
1175
+ translateResponse,
653
1176
  translateStream
654
1177
  };
655
1178
  //# sourceMappingURL=index.js.map