@alma-harness/providers 0.1.0 → 0.3.0

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