@oh-my-pi/pi-ai 18.4.2 → 18.4.4

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +30 -41
  2. package/dist/types/auth-broker/protocol.d.ts +12 -0
  3. package/dist/types/dialect/rendering.d.ts +4 -0
  4. package/dist/types/images/openai-hosted.d.ts +1 -1
  5. package/dist/types/images/shared.d.ts +5 -2
  6. package/dist/types/providers/anthropic-wire.d.ts +9 -1
  7. package/dist/types/providers/anthropic.d.ts +17 -0
  8. package/dist/types/providers/aws-sigv4.d.ts +5 -0
  9. package/dist/types/providers/bedrock-anthropic.d.ts +9 -0
  10. package/dist/types/providers/bedrock-request-metadata.d.ts +2 -0
  11. package/dist/types/providers/cursor/interaction-query.d.ts +10 -0
  12. package/dist/types/providers/openai-chat-wire.d.ts +2 -2
  13. package/dist/types/providers/openai-codex/request-transformer.d.ts +1 -1
  14. package/dist/types/providers/openai-responses-wire.d.ts +2 -2
  15. package/dist/types/providers/xai-base-url.d.ts +17 -0
  16. package/dist/types/types.d.ts +14 -3
  17. package/dist/types/usage/commandcode.d.ts +4 -0
  18. package/dist/types/usage/shared.d.ts +13 -1
  19. package/dist/types/utils/event-stream.d.ts +7 -0
  20. package/dist/types/utils/openai-http.d.ts +7 -1
  21. package/package.json +6 -6
  22. package/src/auth-broker/client.ts +1 -12
  23. package/src/auth-broker/protocol.ts +32 -0
  24. package/src/auth-broker/remote-store.ts +4 -40
  25. package/src/auth-broker/server.ts +1 -20
  26. package/src/auth-broker/snapshot-cache.ts +1 -9
  27. package/src/dialect/anthropic.ts +3 -25
  28. package/src/dialect/minimax.ts +3 -24
  29. package/src/dialect/rendering.ts +18 -0
  30. package/src/dialect/thinking.ts +24 -6
  31. package/src/dialect/xml.ts +3 -19
  32. package/src/images/openai-hosted.ts +5 -2
  33. package/src/images/openai-images.ts +10 -4
  34. package/src/images/shared.ts +9 -4
  35. package/src/providers/amazon-bedrock.ts +61 -22
  36. package/src/providers/anthropic-compaction.ts +10 -1
  37. package/src/providers/anthropic-wire.ts +12 -1
  38. package/src/providers/anthropic.ts +58 -15
  39. package/src/providers/aws-eventstream.ts +4 -3
  40. package/src/providers/aws-sigv4.ts +1 -1
  41. package/src/providers/azure-openai-responses.ts +3 -4
  42. package/src/providers/bedrock-anthropic.ts +30 -0
  43. package/src/providers/bedrock-request-metadata.ts +6 -0
  44. package/src/providers/connect-error-detail.ts +1 -5
  45. package/src/providers/cursor/interaction-query.ts +4 -2
  46. package/src/providers/cursor.ts +30 -26
  47. package/src/providers/google-gemini-cli.ts +9 -2
  48. package/src/providers/google-shared.ts +16 -6
  49. package/src/providers/openai-chat-wire.ts +2 -2
  50. package/src/providers/openai-codex/request-transformer.ts +1 -1
  51. package/src/providers/openai-codex-responses.ts +27 -16
  52. package/src/providers/openai-completions.ts +51 -36
  53. package/src/providers/openai-responses-wire.ts +2 -2
  54. package/src/providers/openai-responses.ts +3 -4
  55. package/src/providers/openai-shared.ts +130 -18
  56. package/src/providers/xai-base-url.ts +32 -0
  57. package/src/registry/engine/api-key.ts +8 -3
  58. package/src/types.ts +35 -4
  59. package/src/usage/claude.ts +4 -11
  60. package/src/usage/cline-pass.ts +2 -14
  61. package/src/usage/commandcode.ts +209 -0
  62. package/src/usage/cursor.ts +9 -1
  63. package/src/usage/openai-codex.ts +3 -5
  64. package/src/usage/registry.ts +3 -0
  65. package/src/usage/shared.ts +28 -1
  66. package/src/usage/synthetic.ts +4 -40
  67. package/src/usage/umans.ts +8 -36
  68. package/src/usage/zai.ts +10 -38
  69. package/src/utils/event-stream.ts +38 -2
  70. package/src/utils/http-inspector.ts +4 -8
  71. package/src/utils/openai-http.ts +10 -3
  72. package/src/utils/schema/json-schema-validator.ts +23 -26
  73. package/src/utils/schema/meta-validator.ts +4 -7
  74. package/src/utils/schema/wire.ts +17 -21
@@ -9,6 +9,7 @@
9
9
  import * as fs from "node:fs/promises";
10
10
  import * as path from "node:path";
11
11
  import { isEnoent, logger } from "@oh-my-pi/pi-utils";
12
+ import { asStrict } from "../providers/aws-sigv4";
12
13
  import type { SnapshotResponse } from "./types";
13
14
 
14
15
  const MAGIC = new Uint8Array([0x4f, 0x4d, 0x50, 0x53]); // "OMPS"
@@ -210,15 +211,6 @@ async function deriveAesKey(token: string, usages: Array<"encrypt" | "decrypt">)
210
211
  return globalThis.crypto.subtle.importKey("raw", digest, AES_ALGORITHM, false, usages);
211
212
  }
212
213
 
213
- function asStrict(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
214
- if (bytes.buffer instanceof ArrayBuffer && bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
215
- return bytes as Uint8Array<ArrayBuffer>;
216
- }
217
- const copy = new Uint8Array(bytes.byteLength);
218
- copy.set(bytes);
219
- return copy;
220
- }
221
-
222
214
  function randomHex(byteLength: number): string {
223
215
  const bytes = new Uint8Array(byteLength);
224
216
  globalThis.crypto.getRandomValues(bytes);
@@ -1,14 +1,8 @@
1
- import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
1
+ import { escapeXmlText, parseJsonWithRepair } from "@oh-my-pi/pi-utils";
2
2
  import type { Message, ToolCall } from "../types";
3
3
  import dialectPrompt from "./anthropic.md" with { type: "text" };
4
- import { buildArgShapes, buildStringArgsResolver, mintToolCallId, type ToolArgShape } from "./coercion";
5
- import {
6
- escapeXmlAttr,
7
- escapeXmlText,
8
- renderDelimitedThinking,
9
- renderLegacyTextTranscript,
10
- stringifyJson,
11
- } from "./rendering";
4
+ import { buildArgShapes, buildStringArgsResolver, mintToolCallId } from "./coercion";
5
+ import { renderDelimitedThinking, renderInvoke, renderInvokes, renderLegacyTextTranscript } from "./rendering";
12
6
  import type {
13
7
  DialectDefinition,
14
8
  DialectRenderOptions,
@@ -578,22 +572,6 @@ function renderTranscript(messages: readonly Message[], options: DialectRenderOp
578
572
  });
579
573
  }
580
574
 
581
- function renderInvoke(call: ToolCall, shape: ToolArgShape | undefined): string {
582
- let body = `<invoke name="${escapeXmlAttr(call.name)}">`;
583
- for (const key in call.arguments) {
584
- const value = call.arguments[key];
585
- const isString = shape?.stringArgs.has(key) === true;
586
- const rendered = isString && typeof value === "string" ? value : stringifyJson(value);
587
- body += `<parameter name="${escapeXmlAttr(key)}">${rendered}</parameter>`;
588
- }
589
- return `${body}</invoke>`;
590
- }
591
-
592
- function renderInvokes(calls: readonly ToolCall[], tools: NonNullable<DialectRenderOptions["tools"]>): string {
593
- const shapes = buildArgShapes(tools);
594
- return calls.map(call => renderInvoke(call, shapes.get(call.name))).join("\n");
595
- }
596
-
597
575
  const definition: DialectDefinition = {
598
576
  dialect: "anthropic",
599
577
  prompt: dialectPrompt,
@@ -1,18 +1,13 @@
1
+ import { escapeXmlText } from "@oh-my-pi/pi-utils";
1
2
  import type { Message, ToolCall } from "../types";
2
3
  import {
3
4
  ANTHROPIC_THINKING_TAG_PREFIXES,
4
5
  AnthropicInbandScanner,
5
6
  type AnthropicInbandScannerConfig,
6
7
  } from "./anthropic";
7
- import { buildArgShapes, type ToolArgShape } from "./coercion";
8
+ import { buildArgShapes } from "./coercion";
8
9
  import dialectPrompt from "./minimax.md" with { type: "text" };
9
- import {
10
- escapeXmlAttr,
11
- escapeXmlText,
12
- renderDelimitedThinking,
13
- renderLegacyTextTranscript,
14
- stringifyJson,
15
- } from "./rendering";
10
+ import { renderDelimitedThinking, renderInvoke, renderInvokes, renderLegacyTextTranscript } from "./rendering";
16
11
  import type { DialectDefinition, DialectRenderOptions, DialectToolResult } from "./types";
17
12
 
18
13
  const MINIMAX_WRAPPER_TAGS: Readonly<Record<string, true>> = { tool_call: true };
@@ -65,22 +60,6 @@ function renderTranscript(messages: readonly Message[], options: DialectRenderOp
65
60
  });
66
61
  }
67
62
 
68
- function renderInvoke(call: ToolCall, shape: ToolArgShape | undefined): string {
69
- let body = `<invoke name="${escapeXmlAttr(call.name)}">`;
70
- for (const key in call.arguments) {
71
- const value = call.arguments[key];
72
- const isString = shape?.stringArgs.has(key) === true;
73
- const rendered = isString && typeof value === "string" ? value : stringifyJson(value);
74
- body += `<parameter name="${escapeXmlAttr(key)}">${rendered}</parameter>`;
75
- }
76
- return `${body}</invoke>`;
77
- }
78
-
79
- function renderInvokes(calls: readonly ToolCall[], tools: NonNullable<DialectRenderOptions["tools"]>): string {
80
- const shapes = buildArgShapes(tools);
81
- return calls.map(call => renderInvoke(call, shapes.get(call.name))).join("\n");
82
- }
83
-
84
63
  const definition: DialectDefinition = {
85
64
  dialect: "minimax",
86
65
  prompt: dialectPrompt,
@@ -1,5 +1,6 @@
1
1
  import { stringifyJson as stringifyJsonValue } from "@oh-my-pi/pi-utils";
2
2
  import type { AssistantMessage, Message, ToolCall, ToolResultMessage } from "../types";
3
+ import { buildArgShapes, type ToolArgShape } from "./coercion";
3
4
  import type { DialectRenderOptions, DialectToolResult } from "./types";
4
5
 
5
6
  export function renderToolResponseResults(results: readonly DialectToolResult[]): string {
@@ -81,6 +82,23 @@ export function escapeXmlText(value: string): string {
81
82
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
82
83
  }
83
84
 
85
+ /** Render one Anthropic-style `<invoke>`; declared string args stay raw, everything else is JSON. */
86
+ export function renderInvoke(call: ToolCall, shape: ToolArgShape | undefined): string {
87
+ let body = `<invoke name="${escapeXmlAttr(call.name)}">`;
88
+ for (const key in call.arguments) {
89
+ const value = call.arguments[key];
90
+ const isString = shape?.stringArgs.has(key) === true;
91
+ const rendered = isString && typeof value === "string" ? value : stringifyJson(value);
92
+ body += `<parameter name="${escapeXmlAttr(key)}">${rendered}</parameter>`;
93
+ }
94
+ return `${body}</invoke>`;
95
+ }
96
+
97
+ export function renderInvokes(calls: readonly ToolCall[], tools: NonNullable<DialectRenderOptions["tools"]>): string {
98
+ const shapes = buildArgShapes(tools);
99
+ return calls.map(call => renderInvoke(call, shapes.get(call.name))).join("\n");
100
+ }
101
+
84
102
  export type AssistantTranscriptParts = {
85
103
  readonly text: string;
86
104
  readonly thinking: string;
@@ -36,6 +36,18 @@ const TAGS: readonly Tag[] = [
36
36
  const OPENS = TAGS.map(tag => tag.open);
37
37
  const IMPLIED_OPEN_TAGS = TAGS.filter(tag => tag.impliedOpen);
38
38
  const IMPLIED_OPEN_DELIMITERS = [...OPENS, ...IMPLIED_OPEN_TAGS.map(tag => tag.close)];
39
+ /** A hold needs the buffer tail to be a proper prefix of some delimiter, so it must be shorter than this. */
40
+ const MAX_DELIMITER_LENGTH = Math.max(...IMPLIED_OPEN_DELIMITERS.map(delimiter => delimiter.length));
41
+ const BACKTICK = 0x60;
42
+ const BOUNDARY_LEAD_CODES = [...IMPLIED_OPEN_DELIMITERS.map(delimiter => delimiter.charCodeAt(0)), BACKTICK];
43
+ /**
44
+ * `1` at the char code of every character a {@link scanVisible} boundary can
45
+ * start on: the first character of any delimiter (a tag open, an implied close,
46
+ * or a partial of either) and the backtick. Every other character — including
47
+ * any code past the table — is skipped without comparing.
48
+ */
49
+ const BOUNDARY_LEAD = new Uint8Array(Math.max(...BOUNDARY_LEAD_CODES) + 1);
50
+ for (const code of BOUNDARY_LEAD_CODES) BOUNDARY_LEAD[code] = 1;
39
51
 
40
52
  export interface ThinkingInbandScannerOptions {
41
53
  /**
@@ -240,20 +252,26 @@ type VisibleHit =
240
252
  */
241
253
  function scanVisible(buffer: string, final: boolean, impliedOpen: boolean): VisibleHit {
242
254
  const delimiters = impliedOpen ? IMPLIED_OPEN_DELIMITERS : OPENS;
255
+ // Only the tail can be a proper prefix of a delimiter.
256
+ const holdFrom = final ? buffer.length : buffer.length - MAX_DELIMITER_LENGTH + 1;
243
257
  for (let i = 0; i < buffer.length; i++) {
244
- const tag = TAGS.find(candidate => buffer.startsWith(candidate.open, i));
245
- if (tag) return { kind: "tag", index: i, tag };
258
+ const code = buffer.charCodeAt(i);
259
+ if (code >= BOUNDARY_LEAD.length || BOUNDARY_LEAD[code] === 0) continue;
260
+ for (const tag of TAGS) {
261
+ if (buffer.startsWith(tag.open, i)) return { kind: "tag", index: i, tag };
262
+ }
246
263
  if (impliedOpen) {
247
- const closed = IMPLIED_OPEN_TAGS.find(candidate => buffer.startsWith(candidate.close, i));
248
- if (closed) return { kind: "impliedClose", index: i, tag: closed };
264
+ for (const tag of IMPLIED_OPEN_TAGS) {
265
+ if (buffer.startsWith(tag.close, i)) return { kind: "impliedClose", index: i, tag };
266
+ }
249
267
  }
250
- if (!final) {
268
+ if (i >= holdFrom) {
251
269
  const rest = buffer.slice(i);
252
270
  if (delimiters.some(delimiter => delimiter.length > rest.length && delimiter.startsWith(rest))) {
253
271
  return { kind: "hold", index: i };
254
272
  }
255
273
  }
256
- if (buffer[i] === "`") {
274
+ if (code === BACKTICK) {
257
275
  const ticks = backtickRun(buffer, i);
258
276
  if (!final && i + ticks === buffer.length) return { kind: "hold", index: i };
259
277
  return { kind: "code", index: i, ticks };
@@ -1,13 +1,13 @@
1
1
  import type { Message, ToolCall } from "../types";
2
2
  import { AnthropicInbandScanner } from "./anthropic";
3
- import { buildArgShapes, type ToolArgShape } from "./coercion";
3
+ import { buildArgShapes } from "./coercion";
4
4
  import { DeepSeekInbandScanner } from "./deepseek";
5
5
  import {
6
- escapeXmlAttr,
7
6
  renderDelimitedThinking,
7
+ renderInvoke,
8
+ renderInvokes,
8
9
  renderLegacyTextTranscript,
9
10
  renderToolResponseResults,
10
- stringifyJson,
11
11
  } from "./rendering";
12
12
  import type {
13
13
  DialectDefinition,
@@ -60,22 +60,6 @@ function renderTranscript(messages: readonly Message[], options: DialectRenderOp
60
60
  });
61
61
  }
62
62
 
63
- function renderInvoke(call: ToolCall, shape: ToolArgShape | undefined): string {
64
- let body = `<invoke name="${escapeXmlAttr(call.name)}">`;
65
- for (const key in call.arguments) {
66
- const value = call.arguments[key];
67
- const isString = shape?.stringArgs.has(key) === true;
68
- const rendered = isString && typeof value === "string" ? value : stringifyJson(value);
69
- body += `<parameter name="${escapeXmlAttr(key)}">${rendered}</parameter>`;
70
- }
71
- return `${body}</invoke>`;
72
- }
73
-
74
- function renderInvokes(calls: readonly ToolCall[], tools: NonNullable<DialectRenderOptions["tools"]>): string {
75
- const shapes = buildArgShapes(tools);
76
- return calls.map(call => renderInvoke(call, shapes.get(call.name))).join("\n");
77
- }
78
-
79
63
  const definition: DialectDefinition = {
80
64
  dialect: "xml",
81
65
  prompt: dialectPrompt,
@@ -1,4 +1,4 @@
1
- import type { Api, Model } from "@oh-my-pi/pi-catalog/types";
1
+ import { type Api, type Model, modelKind } from "@oh-my-pi/pi-catalog/types";
2
2
  import {
3
3
  applyCodexResidencyHeader,
4
4
  CODEX_BASE_URL,
@@ -155,7 +155,10 @@ export async function generateHostedImage(
155
155
  action: content.length > 1 ? "edit" : "generate",
156
156
  output_format: "webp",
157
157
  ...(size ? { size } : {}),
158
- ...(model.api === "openai-responses" ? { model: model.requestModelId ?? model.id } : {}),
158
+ // A chat model generating on its own lets the host pick the image model.
159
+ ...(model.api === "openai-responses" && modelKind(model) === "image"
160
+ ? { model: model.requestModelId ?? model.id }
161
+ : {}),
159
162
  };
160
163
  const body = {
161
164
  model: carrier.requestModelId ?? carrier.id,
@@ -1,5 +1,6 @@
1
1
  import type { Model } from "@oh-my-pi/pi-catalog/types";
2
2
  import * as AIError from "../error";
3
+ import { resolveXaiBaseUrl } from "../providers/xai-base-url";
3
4
  import {
4
5
  decodeImageResponse,
5
6
  imageBaseUrl,
@@ -54,11 +55,16 @@ export async function generateOpenAIImage(
54
55
  : { ...generationBody, images: references }
55
56
  : { ...generationBody, input_references: references };
56
57
  const baseUrl = imageBaseUrl(model);
58
+ // xAI resolves the endpoint per bearer: XAI_BASE_URL never receives an xai-oauth OAuth access token.
59
+ const endpoint = (path: string) =>
60
+ isXAI
61
+ ? (bearer: string) => `${resolveXaiBaseUrl(model.provider, baseUrl, bearer) ?? baseUrl}${path}`
62
+ : `${baseUrl}${path}`;
57
63
  let response: unknown;
58
64
  if (references.length === 0) {
59
65
  response = await postJson({
60
66
  model,
61
- url: `${baseUrl}/images/generations`,
67
+ url: endpoint("/images/generations"),
62
68
  body: generationBody,
63
69
  apiKey: options.apiKey,
64
70
  fetch: fetchImpl,
@@ -78,7 +84,7 @@ export async function generateOpenAIImage(
78
84
  }
79
85
  response = await postMultipart({
80
86
  model,
81
- url: `${baseUrl}/images/edits`,
87
+ url: endpoint("/images/edits"),
82
88
  body: form,
83
89
  apiKey: options.apiKey,
84
90
  fetch: fetchImpl,
@@ -87,7 +93,7 @@ export async function generateOpenAIImage(
87
93
  } else {
88
94
  response = await postJson({
89
95
  model,
90
- url: `${baseUrl}/images/edits`,
96
+ url: endpoint("/images/edits"),
91
97
  body,
92
98
  apiKey: options.apiKey,
93
99
  fetch: fetchImpl,
@@ -98,7 +104,7 @@ export async function generateOpenAIImage(
98
104
  if (!(error instanceof AIError.ProviderHttpError) || error.status !== 404) throw error;
99
105
  response = await postJson({
100
106
  model,
101
- url: `${baseUrl}/images/generations`,
107
+ url: endpoint("/images/generations"),
102
108
  body,
103
109
  apiKey: options.apiKey,
104
110
  fetch: fetchImpl,
@@ -69,9 +69,12 @@ async function parseImageApiResponse(model: Model, response: Response): Promise<
69
69
  }
70
70
  }
71
71
 
72
+ /** Request URL, or a builder for routes that depend on the bearer (xAI's `XAI_BASE_URL` rule). */
73
+ type ImageRequestUrl = string | ((bearer: string) => string);
74
+
72
75
  export async function postJson(options: {
73
76
  model: Model;
74
- url: string;
77
+ url: ImageRequestUrl;
75
78
  body: unknown;
76
79
  apiKey: ApiKey;
77
80
  fetch: FetchImpl;
@@ -80,7 +83,8 @@ export async function postJson(options: {
80
83
  return withAuth(
81
84
  options.apiKey,
82
85
  async key => {
83
- const response = await options.fetch(options.url, {
86
+ const url = typeof options.url === "string" ? options.url : options.url(key);
87
+ const response = await options.fetch(url, {
84
88
  method: "POST",
85
89
  headers: {
86
90
  ...(await modelHeaders(options.model, options.signal)),
@@ -99,7 +103,7 @@ export async function postJson(options: {
99
103
 
100
104
  export async function postMultipart(options: {
101
105
  model: Model;
102
- url: string;
106
+ url: ImageRequestUrl;
103
107
  body: FormData;
104
108
  apiKey: ApiKey;
105
109
  fetch: FetchImpl;
@@ -108,7 +112,8 @@ export async function postMultipart(options: {
108
112
  return withAuth(
109
113
  options.apiKey,
110
114
  async key => {
111
- const response = await options.fetch(options.url, {
115
+ const url = typeof options.url === "string" ? options.url : options.url(key);
116
+ const response = await options.fetch(url, {
112
117
  method: "POST",
113
118
  headers: {
114
119
  ...(await modelHeaders(options.model, options.signal)),
@@ -56,6 +56,7 @@ import { invalidateAwsCredentialCache, resolveAwsCredentials } from "./aws-crede
56
56
  import { decodeEventStream } from "./aws-eventstream";
57
57
  import { signRequest } from "./aws-sigv4";
58
58
  import { parseAnthropicInputTransformations, THINKING_BINDING_CONTROLS_BETA } from "./anthropic-wire";
59
+ import { isBedrockRequestMetadataValue } from "./bedrock-request-metadata";
59
60
  import { transformMessages } from "./transform-messages";
60
61
 
61
62
  /**
@@ -381,9 +382,7 @@ interface MetadataEvent {
381
382
  };
382
383
  }
383
384
 
384
- const REQUEST_METADATA_PATTERN = /^[a-zA-Z0-9\s:_@$#=/+,\-.]*$/;
385
385
  const REQUEST_METADATA_MAX_ENTRIES = 16;
386
- const REQUEST_METADATA_MAX_LENGTH = 256;
387
386
 
388
387
  /**
389
388
  * Bedrock rejects the whole invocation on a malformed `requestMetadata` entry.
@@ -400,10 +399,8 @@ function sanitizeRequestMetadata(raw: unknown): Record<string, string> | undefin
400
399
  if (
401
400
  typeof value !== "string" ||
402
401
  key.length < 1 ||
403
- key.length > REQUEST_METADATA_MAX_LENGTH ||
404
- !REQUEST_METADATA_PATTERN.test(key) ||
405
- value.length > REQUEST_METADATA_MAX_LENGTH ||
406
- !REQUEST_METADATA_PATTERN.test(value) ||
402
+ !isBedrockRequestMetadataValue(key) ||
403
+ !isBedrockRequestMetadataValue(value) ||
407
404
  kept >= REQUEST_METADATA_MAX_ENTRIES
408
405
  ) {
409
406
  dropped.push(key);
@@ -446,6 +443,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
446
443
  };
447
444
 
448
445
  const blocks = output.content as Block[];
446
+ const contentIndexByBlockIndex = new Map<number, number>();
449
447
  let rawRequestDump: RawHttpRequestDump | undefined;
450
448
  const region = resolveBedrockRegion(model.id, options);
451
449
 
@@ -640,7 +638,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
640
638
  if (messageType === "exception") {
641
639
  const exceptionType = message.headers[":exception-type"] || "Exception";
642
640
  const payload = safeParsePayload(message.payload) as { message?: string } | undefined;
643
- const errorMessage = payload?.message || new TextDecoder().decode(message.payload);
641
+ const errorMessage = payload?.message || PAYLOAD_DECODER.decode(message.payload);
644
642
  const text = `${exceptionType}: ${errorMessage}`;
645
643
  throw new AIError.BedrockApiError(text, bedrockStreamExceptionStatus(exceptionType), {
646
644
  code: exceptionType,
@@ -648,7 +646,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
648
646
  }
649
647
  if (messageType === "error") {
650
648
  const code = message.headers[":error-code"] || "UnknownError";
651
- const errorMessage = message.headers[":error-message"] || new TextDecoder().decode(message.payload);
649
+ const errorMessage = message.headers[":error-message"] || PAYLOAD_DECODER.decode(message.payload);
652
650
  throw new AIError.BedrockApiError(`${code}: ${errorMessage}`, bedrockStreamExceptionStatus(code), {
653
651
  code,
654
652
  });
@@ -673,16 +671,35 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
673
671
  }
674
672
  case "contentBlockStart": {
675
673
  if (!firstTokenTime) firstTokenTime = performance.now();
676
- handleContentBlockStart(payload as ContentBlockStartEvent, blocks, output, stream, sentinelInjected);
674
+ handleContentBlockStart(
675
+ payload as ContentBlockStartEvent,
676
+ blocks,
677
+ contentIndexByBlockIndex,
678
+ output,
679
+ stream,
680
+ sentinelInjected,
681
+ );
677
682
  break;
678
683
  }
679
684
  case "contentBlockDelta": {
680
685
  if (!firstTokenTime) firstTokenTime = performance.now();
681
- handleContentBlockDelta(payload as ContentBlockDeltaEvent, blocks, output, stream);
686
+ handleContentBlockDelta(
687
+ payload as ContentBlockDeltaEvent,
688
+ blocks,
689
+ contentIndexByBlockIndex,
690
+ output,
691
+ stream,
692
+ );
682
693
  break;
683
694
  }
684
695
  case "contentBlockStop": {
685
- handleContentBlockStop(payload as ContentBlockStopEvent, blocks, output, stream);
696
+ handleContentBlockStop(
697
+ payload as ContentBlockStopEvent,
698
+ blocks,
699
+ contentIndexByBlockIndex,
700
+ output,
701
+ stream,
702
+ );
686
703
  break;
687
704
  }
688
705
  case "messageStop": {
@@ -782,18 +799,40 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
782
799
  return stream;
783
800
  };
784
801
 
802
+ /** Shared across events: every payload decode is a complete, non-streaming call. */
803
+ const PAYLOAD_DECODER = new TextDecoder();
804
+
785
805
  function safeParsePayload(payload: Uint8Array): unknown {
786
806
  if (payload.length === 0) return {};
787
807
  try {
788
- return JSON.parse(new TextDecoder().decode(payload));
808
+ return JSON.parse(PAYLOAD_DECODER.decode(payload));
789
809
  } catch {
790
810
  return undefined;
791
811
  }
792
812
  }
793
813
 
814
+ /**
815
+ * Append a streamed block and index it by Bedrock's `contentBlockIndex`, so
816
+ * per-delta routing is an O(1) lookup instead of a scan over every block
817
+ * (quadratic over a long turn). The first block registered for an index wins,
818
+ * as the first-match scan did. Returns the block's content index.
819
+ */
820
+ function pushStreamBlock(
821
+ blocks: Block[],
822
+ contentIndexByBlockIndex: Map<number, number>,
823
+ block: Block,
824
+ contentBlockIndex: number,
825
+ ): number {
826
+ const contentIndex = blocks.length;
827
+ blocks.push(block);
828
+ if (!contentIndexByBlockIndex.has(contentBlockIndex)) contentIndexByBlockIndex.set(contentBlockIndex, contentIndex);
829
+ return contentIndex;
830
+ }
831
+
794
832
  function handleContentBlockStart(
795
833
  event: ContentBlockStartEvent,
796
834
  blocks: Block[],
835
+ contentIndexByBlockIndex: Map<number, number>,
797
836
  output: AssistantMessage,
798
837
  stream: AssistantMessageEventStream,
799
838
  sentinelInjected: boolean,
@@ -815,29 +854,29 @@ function handleContentBlockStart(
815
854
  [kStreamingPartialJson]: "",
816
855
  [kStreamingBlockIndex]: index,
817
856
  };
818
- output.content.push(block);
819
- stream.push({ type: "toolcall_start", contentIndex: blocks.length - 1, partial: output });
857
+ const contentIndex = pushStreamBlock(blocks, contentIndexByBlockIndex, block, index);
858
+ stream.push({ type: "toolcall_start", contentIndex, partial: output });
820
859
  }
821
860
  }
822
861
 
823
862
  function handleContentBlockDelta(
824
863
  event: ContentBlockDeltaEvent,
825
864
  blocks: Block[],
865
+ contentIndexByBlockIndex: Map<number, number>,
826
866
  output: AssistantMessage,
827
867
  stream: AssistantMessageEventStream,
828
868
  ): void {
829
869
  const contentBlockIndex = event.contentBlockIndex;
830
870
  const delta = event.delta;
831
- let index = blocks.findIndex(b => b[kStreamingBlockIndex] === contentBlockIndex);
871
+ let index = contentIndexByBlockIndex.get(contentBlockIndex) ?? -1;
832
872
  let block = blocks[index];
833
873
 
834
874
  if (delta?.text !== undefined) {
835
875
  // If no text block exists yet, create one — `handleContentBlockStart` is not sent for text blocks
836
876
  if (!block) {
837
877
  const newBlock: Block = { type: "text", text: "", [kStreamingBlockIndex]: contentBlockIndex };
838
- output.content.push(newBlock);
839
- index = blocks.length - 1;
840
- block = blocks[index];
878
+ index = pushStreamBlock(blocks, contentIndexByBlockIndex, newBlock, contentBlockIndex);
879
+ block = newBlock;
841
880
  stream.push({ type: "text_start", contentIndex: index, partial: output });
842
881
  }
843
882
  if (block.type === "text") {
@@ -863,9 +902,8 @@ function handleContentBlockDelta(
863
902
  thinkingSignature: "",
864
903
  [kStreamingBlockIndex]: contentBlockIndex,
865
904
  };
866
- output.content.push(newBlock);
867
- thinkingIndex = blocks.length - 1;
868
- thinkingBlock = blocks[thinkingIndex];
905
+ thinkingIndex = pushStreamBlock(blocks, contentIndexByBlockIndex, newBlock, contentBlockIndex);
906
+ thinkingBlock = newBlock;
869
907
  stream.push({ type: "thinking_start", contentIndex: thinkingIndex, partial: output });
870
908
  }
871
909
 
@@ -901,10 +939,11 @@ function handleMetadata(event: MetadataEvent, model: Model<"bedrock-converse-str
901
939
  function handleContentBlockStop(
902
940
  event: ContentBlockStopEvent,
903
941
  blocks: Block[],
942
+ contentIndexByBlockIndex: Map<number, number>,
904
943
  output: AssistantMessage,
905
944
  stream: AssistantMessageEventStream,
906
945
  ): void {
907
- const index = blocks.findIndex(b => b[kStreamingBlockIndex] === event.contentBlockIndex);
946
+ const index = contentIndexByBlockIndex.get(event.contentBlockIndex) ?? -1;
908
947
  const block = blocks[index];
909
948
  if (!block) return;
910
949
 
@@ -1,4 +1,4 @@
1
- import { isOfficialAnthropicApiUrl } from "@oh-my-pi/pi-catalog/compat/anthropic";
1
+ import { isBedrockAnthropicRoute, isOfficialAnthropicApiUrl } from "@oh-my-pi/pi-catalog/compat/anthropic";
2
2
  import type { Model } from "../types";
3
3
  import type { AnthropicMessagesClientLike } from "./anthropic-client";
4
4
  import { normalizeAnthropicBaseUrl, resolveDirectAnthropicBaseUrl } from "./anthropic-state";
@@ -46,6 +46,15 @@ export function supportsAnthropicCompaction(model: Model<"anthropic-messages">,
46
46
  (model.provider === "anthropic"
47
47
  ? resolveDirectAnthropicBaseUrl(model)
48
48
  : normalizeAnthropicBaseUrl(model.baseUrl));
49
+ // Bedrock's Anthropic Messages API implements on-demand compaction. The flag is detected
50
+ // from a Bedrock `/anthropic` baseUrl, or set in models.yml for a proxy or a reroute; it
51
+ // applies to the model's own endpoint or a Bedrock `/anthropic` route it reaches.
52
+ if (
53
+ model.compat.bedrockMessagesApi === true &&
54
+ (isBedrockAnthropicRoute(route) || route === normalizeAnthropicBaseUrl(model.baseUrl))
55
+ ) {
56
+ return true;
57
+ }
49
58
  return (
50
59
  isSupportedCompactionEndpoint(route) &&
51
60
  (model.compat.firstPartyProvider === true ||
@@ -272,7 +272,18 @@ export type ThinkingConfigAdaptive = {
272
272
  block_binding?: ThinkingBlockBinding;
273
273
  };
274
274
 
275
- export type ThinkingConfigParam = ThinkingConfigEnabled | ThinkingConfigDisabled | ThinkingConfigAdaptive;
275
+ /**
276
+ * Sonnet 5.5's replacement for `disabled`: no up-front thinking, progress
277
+ * updates between tool calls only. Takes no other field, and effort above
278
+ * `high` is rejected alongside it.
279
+ */
280
+ export type ThinkingConfigBetweenTools = { type: "between_tools" };
281
+
282
+ export type ThinkingConfigParam =
283
+ | ThinkingConfigEnabled
284
+ | ThinkingConfigDisabled
285
+ | ThinkingConfigAdaptive
286
+ | ThinkingConfigBetweenTools;
276
287
 
277
288
  export type OutputConfig = {
278
289
  /** Adaptive-thinking effort level (effort beta). */