@oh-my-pi/pi-ai 16.0.9 → 16.0.11

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/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.0.10] - 2026-06-18
6
+
7
+ ### Added
8
+
9
+ - Replaced the old legacy XML-ish `pi` owned tool-calling dialect with the new sigil-delimited format (`§` call header with inline `key=value` scalars, `«…»` verbatim body fence for the dominant string argument, `¤` reasoning, `‡‡` tool result) using single-token markers that never occur in source code. Verbatim fences escalate Markdown-style (`««…»»`) so re-rendered history never collides with payload content, and the scanner gates a bare `§` on an exact known-tool name to avoid swallowing prose. Round-trips and streams through the existing scanner contract at ~46% fewer tokens than the legacy format on typical calls; selectable via `tools.format` or `PI_DIALECT=pi`.
10
+
11
+ ### Changed
12
+
13
+ - Updated `pi` dialect formatting to use a token-frugal, sigil-delimited format (`§`, `¤`, `‡‡`)
14
+ - Updated `pi` dialect body fences to automatically escalate when content contains fence markers
15
+ - Changed `pi` dialect tool results response format to `‡‡` blocks
16
+
17
+ ### Fixed
18
+
19
+ - Fixed Bedrock application inference profile ARNs to route requests to the ARN's region instead of the default Bedrock runtime region. ([#3004](https://github.com/can1357/oh-my-pi/issues/3004))
20
+
5
21
  ## [16.0.9] - 2026-06-18
6
22
 
7
23
  ### Fixed
@@ -25,6 +25,12 @@ export interface OAuthProviderInfo {
25
25
  id: OAuthProviderId;
26
26
  name: string;
27
27
  available: boolean;
28
+ /**
29
+ * Provider id the login stores credentials under, when it differs from `id`
30
+ * (e.g. `openai-codex-device` ⇒ `openai-codex`). Lets callers map a login
31
+ * entry back to the model provider it authenticates.
32
+ */
33
+ storeCredentialsAs?: string;
28
34
  }
29
35
  export interface OAuthController {
30
36
  onAuth?(info: OAuthAuthInfo): void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "16.0.9",
4
+ "version": "16.0.11",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,8 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "@oh-my-pi/pi-catalog": "16.0.9",
42
- "@oh-my-pi/pi-utils": "16.0.9",
41
+ "@oh-my-pi/pi-catalog": "16.0.11",
42
+ "@oh-my-pi/pi-utils": "16.0.11",
43
+ "@oh-my-pi/pi-wire": "16.0.11",
43
44
  "arktype": "^2.2.0",
44
45
  "partial-json": "^0.1.7",
45
46
  "zod": "^4"
@@ -19,7 +19,7 @@ const RESPONSE_OPEN_TOKENS: Record<Dialect, readonly string[]> = {
19
19
  minimax: ["<function_results>", "<tool_response>"],
20
20
  deepseek: ["<|tool▁outputs▁begin|>", "<|tool▁output▁begin|>"],
21
21
  harmony: ["<|start|>functions."],
22
- pi: ["<tool_response>"],
22
+ pi: ["‡‡"],
23
23
  qwen3: ["<tool_response>"],
24
24
  gemini: ["```tool_outputs"],
25
25
  gemma: ["<|tool_response>"],
package/src/dialect/pi.md CHANGED
@@ -1,57 +1,55 @@
1
1
  ## Format guide
2
2
 
3
- A tool call is a `<call:NAME>…</call:NAME>` block (or self-closing `<call:NAME …/>`) written as plain assistant text; arguments are given as tag attributes, child elements, or a verbatim inline body.
3
+ A tool call begins with `§` immediately followed by the function NAME (start each call on its own line). Scalar arguments follow on the same line as `key=value` pairs; a single large or multi-line string argument goes in a verbatim body fenced by `«…»` right after the header.
4
4
 
5
- ```text
6
- <call:read path="src/a.ts" offset=50/>
7
- ```
8
-
9
- Objects and arrays use child elements, repeating an element for each array item:
5
+ Scalar-only call (the line ends the call):
10
6
 
11
7
  ```text
12
- <call:configure>
13
- <object>
14
- <y>4</y>
15
- <list>alpha</list>
16
- <list>beta</list>
17
- </object>
18
- </call:configure>
8
+ §read path=src/a.ts offset=50 limit=200
19
9
  ```
20
10
 
21
- A single string argument can fill the body directly:
11
+ Call with a verbatim body everything between `«` and `»` is taken literally, no quoting or escaping:
22
12
 
23
13
  ```text
24
- <call:edit>
14
+ §edit path=src/server/auth.ts«
25
15
  *** Begin Patch
26
- ...
16
+ *** Update File: src/server/auth.ts
17
+ @@ class AuthService
18
+ - login(user) {
19
+ + async login(user, opts) {
27
20
  *** End Patch
28
- </call:edit>
21
+ »
29
22
  ```
30
23
 
31
- Tool results arrive as response blocks, read in call order:
24
+ Argument values:
25
+
26
+ - Strings are written bare and verbatim (`path=src/a.ts`). Quote with `"…"` only when the value contains spaces or starts with `"`, `[`, or `{` (`_i="run the tests"`).
27
+ - Numbers, booleans, and `null` are JSON literals (`offset=50`, `force=true`).
28
+ - Arrays and objects are inline JSON (`paths=["src","test"]`).
29
+ - The body fence holds the call's first long/multi-line string parameter; its key is implied, never written.
30
+
31
+ Private reasoning goes in a `¤…¤` block before your calls:
32
32
 
33
33
  ```text
34
- <tool_response>
35
- verbatim tool result
36
- </tool_response>
34
+ ¤
35
+ brief reasoning
36
+ ¤
37
37
  ```
38
38
 
39
- Private reasoning goes in a `<thinking>…</thinking>` block before your calls:
39
+ Tool results arrive in `‡‡…‡‡` blocks, read in call order:
40
40
 
41
41
  ```text
42
- <thinking>
43
- brief reasoning
44
- </thinking>
42
+ ‡‡
43
+ verbatim tool result
44
+ ‡‡
45
45
  ```
46
46
 
47
47
  ## Rules
48
48
 
49
- - `NAME` must match a listed function; never wrap calls in JSON or fences.
50
- - Use attributes only for top-level scalars; put objects, arrays, and long strings in child elements.
51
- - Strings are verbatim (no quotes, no entity escaping); numbers, booleans, and null are JSON literals.
52
- - An object opens a child block whose scalar subfields may also be attributes; an array repeats its element once per item.
53
- - The inline body fills the first unset string-typed parameter and may contain any raw text except `</call:NAME>`.
54
- - Emit parallel calls as consecutive blocks. NEVER invent call ids; results are positional.
55
- - Private reasoning goes in a `<thinking>…</thinking>` block before your calls; NEVER put calls inside it.
56
- - Read each `<tool_response>` in call order. NEVER emit `<tool_response>` yourself.
49
+ - `NAME` MUST match a listed function; never wrap calls in JSON or fences.
50
+ - Put each scalar argument once as `key=value`; reserve the `«…»` body for the one dominant string argument (file contents, patches, commands, queries).
51
+ - Body text is verbatim — include no surrounding quotes. If the body itself contains `»`, widen BOTH guillemet fences equally (`««…»»`, `«««…»»»`).
52
+ - Emit parallel calls as consecutive `§…` blocks. NEVER invent call ids; results are positional.
53
+ - Private reasoning goes in a `¤…¤` block before your calls; NEVER put calls inside it, and keep a literal `¤` out of the reasoning text.
54
+ - Read each `‡‡…‡‡` result in call order. NEVER emit a `‡‡` block yourself.
57
55
  - After emitting your tool calls, YOU MUST EMIT THE STOP SEQUENCE AND HALT.
package/src/dialect/pi.ts CHANGED
@@ -1,19 +1,8 @@
1
1
  import type { Message, ToolCall } from "../types";
2
2
  import type { ToolArgShape } from "./coercion";
3
- import {
4
- buildArgShapes,
5
- coerceValue,
6
- collectSchemaTypes,
7
- getArrayItemSchema,
8
- getObjectProperties,
9
- isArraySchema,
10
- isObjectSchema,
11
- isStringOnlySchema,
12
- mintToolCallId,
13
- partialSuffixOverlapAny,
14
- } from "./coercion";
3
+ import { buildArgShapes, coerceValue, isStringOnlySchema, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
15
4
  import dialectPrompt from "./pi.md" with { type: "text" };
16
- import { renderChatMlTranscript, renderToolResponseResults, stringifyJson } from "./rendering";
5
+ import { renderChatMlTranscript, stringifyJson } from "./rendering";
17
6
  import type {
18
7
  DialectDefinition,
19
8
  DialectRenderOptions,
@@ -23,57 +12,66 @@ import type {
23
12
  InbandScannerOptions,
24
13
  } from "./types";
25
14
 
26
- const CALL_PREFIX = "<call:";
27
- const THINK_OPEN = "<thinking>";
28
- const THINK_CLOSE = "</thinking>";
29
- const CALL_TAGS = [CALL_PREFIX] as const;
30
- const OUTSIDE_TAGS = [CALL_PREFIX, THINK_OPEN] as const;
15
+ // Pi a sigil-delimited, token-frugal owned dialect.
16
+ //
17
+ // §read path=src/a.ts offset=50 ← scalar-only call (newline-terminated)
18
+ // §edit path=src/a.ts« ← header + verbatim body fence
19
+ // *** Begin Patch
20
+ // ...
21
+ // *** End Patch»
22
+ //
23
+ // Design goals vs the XML-ish `pi` dialect:
24
+ // - one-token structural sigils (`§` call, `«»` body fence, `¤` thinking, `‡‡`
25
+ // tool result — each a single o200k token that never occurs in source code)
26
+ // instead of `<call:NAME>` / `</call:NAME>` (3 tokens + the repeated name);
27
+ // - scalar arguments inline as `key=value` (the key appears once) rather than
28
+ // `<key>value</key>` (key twice + four bracket tokens);
29
+ // - the dominant string argument fills a verbatim body fence, dropping its key
30
+ // entirely and needing no escaping for code/patches.
31
+ //
32
+ // Verbatim fences (body `«»`, result `‡‡`) escalate Markdown/raw-string style:
33
+ // when the content contains the closer, the renderer widens the fence (`««…»»`,
34
+ // `‡‡‡…‡‡‡`) so re-rendered history can never collide with payload content.
35
+
36
+ const CALL_SIGIL = "§";
37
+ const FENCE_OPEN = "«";
38
+ const FENCE_CLOSE = "»";
39
+ const THINK_SIGIL = "¤";
40
+ const RESULT_FENCE = "‡";
41
+ const OUTSIDE_TAGS = [CALL_SIGIL, THINK_SIGIL] as const;
42
+ const CALL_TAGS = [CALL_SIGIL] as const;
31
43
  const NAME_START = /[A-Za-z_]/;
32
44
  const NAME_CHAR = /[A-Za-z0-9_-]/;
33
45
  const EMPTY_STRING_ARGS: ReadonlySet<string> = new Set<string>();
34
46
 
35
47
  type ScannerState = "outside" | "body" | "thinking";
36
- type BodyMode = "undecided" | "inline" | "members";
37
48
 
38
- type RawAttribute = { name: string; value: string | true };
39
-
40
- type OpenTag = {
41
- name: string;
42
- rawAttrs: RawAttribute[];
43
- selfClosing: boolean;
44
- end: number;
45
- };
46
-
47
- type MembersResult = {
48
- ok: boolean;
49
- value: Record<string, unknown>;
50
- next: number;
51
- };
52
-
53
- type ValueResult = {
54
- ok: boolean;
55
- value: unknown;
56
- next: number;
57
- };
49
+ type HeaderEnd =
50
+ | { kind: "fence"; index: number }
51
+ | { kind: "newline"; index: number }
52
+ | { kind: "eof"; index: number }
53
+ | { kind: "incomplete" };
58
54
 
59
55
  export class PiNativeInbandScanner implements InbandScanner {
60
56
  #buffer = "";
61
57
  #state: ScannerState = "outside";
62
- #bodyMode: BodyMode = "undecided";
63
58
  #id = "";
64
59
  #name = "";
65
60
  #args: Record<string, unknown> = {};
66
- #inlineKey = "";
67
- #inlineValue = "";
68
- #inlineLeading = false;
61
+ #bodyKey = "";
62
+ #bodyValue = "";
63
+ #bodyLeading = false;
64
+ #closeMarker = "";
69
65
  #rawBlock = "";
66
+ #thinking = "";
70
67
  readonly #argShapes: Map<string, ToolArgShape>;
71
68
  readonly #stringArgs: (toolName: string) => ReadonlySet<string>;
72
- #thinking = "";
69
+ readonly #knowsTools: boolean;
73
70
  readonly #parseThinking: boolean;
74
71
 
75
72
  constructor(options: InbandScannerOptions = {}) {
76
73
  this.#argShapes = buildArgShapes(options.tools);
74
+ this.#knowsTools = this.#argShapes.size > 0;
77
75
  this.#stringArgs =
78
76
  options.stringArgs ?? (toolName => this.#argShapes.get(toolName)?.stringArgs ?? EMPTY_STRING_ARGS);
79
77
  this.#parseThinking = options.parseThinking !== false;
@@ -96,37 +94,19 @@ export class PiNativeInbandScanner implements InbandScanner {
96
94
  if (!this.#consumeOutside(events, final)) break;
97
95
  continue;
98
96
  }
99
-
100
97
  if (this.#state === "thinking") {
101
98
  if (!this.#consumeThinking(events, final)) break;
102
99
  continue;
103
100
  }
104
-
105
- if (this.#bodyMode === "undecided") {
106
- const mode = this.#classifyBody(final);
107
- if (!mode) break;
108
- this.#bodyMode = mode;
109
- if (mode === "inline") {
110
- this.#inlineKey = this.#inlineTargetKey() ?? "input";
111
- this.#inlineValue = "";
112
- this.#inlineLeading = true;
113
- }
114
- }
115
-
116
- if (this.#bodyMode === "inline") {
117
- if (!this.#consumeInline(events, final)) break;
118
- continue;
119
- }
120
-
121
- if (!this.#consumeMembers(events, final)) break;
101
+ if (!this.#consumeBody(events, final)) break;
122
102
  }
123
103
  if (final && this.#state === "thinking") this.#endThinking(events);
124
104
  return events;
125
105
  }
126
106
 
127
107
  #consumeOutside(events: InbandScanEvent[], final: boolean): boolean {
128
- const call = this.#buffer.indexOf(CALL_PREFIX);
129
- const think = this.#parseThinking ? this.#buffer.indexOf(THINK_OPEN) : -1;
108
+ const call = this.#buffer.indexOf(CALL_SIGIL);
109
+ const think = this.#parseThinking ? this.#buffer.indexOf(THINK_SIGIL) : -1;
130
110
  let start = call;
131
111
  let isThink = false;
132
112
  if (think !== -1 && (start === -1 || think < start)) {
@@ -148,124 +128,109 @@ export class PiNativeInbandScanner implements InbandScanner {
148
128
  }
149
129
 
150
130
  if (isThink) {
151
- this.#buffer = this.#buffer.slice(THINK_OPEN.length);
131
+ this.#buffer = this.#buffer.slice(THINK_SIGIL.length);
152
132
  this.#thinking = "";
153
133
  events.push({ type: "thinkingStart" });
154
134
  this.#state = "thinking";
155
135
  return true;
156
136
  }
157
137
 
158
- const tagEnd = findTagEnd(this.#buffer, 0);
159
- if (tagEnd === -1) {
160
- if (final) this.#buffer = "";
161
- return false;
138
+ return this.#beginCall(events, final);
139
+ }
140
+
141
+ // Buffer starts with `§`. Resolve the tool name, then the header terminator.
142
+ // Returns false to wait for more input (call still streaming in).
143
+ #beginCall(events: InbandScanEvent[], final: boolean): boolean {
144
+ const nameStart = CALL_SIGIL.length;
145
+ if (nameStart >= this.#buffer.length && !final) return false; // just `§` so far
146
+ let nameEnd = nameStart;
147
+ if (!isNameStart(this.#buffer[nameEnd])) return this.#rejectSigil(events);
148
+ nameEnd++;
149
+ while (nameEnd < this.#buffer.length && isNameChar(this.#buffer[nameEnd])) nameEnd++;
150
+ if (nameEnd >= this.#buffer.length && !final) return false; // name may continue
151
+
152
+ const name = this.#buffer.slice(CALL_SIGIL.length, nameEnd);
153
+ // Guard against `§` in prose: only claim a known tool when schemas exist.
154
+ if (this.#knowsTools && !this.#argShapes.has(name)) return this.#rejectSigil(events);
155
+
156
+ const header = findHeaderEnd(this.#buffer, nameEnd);
157
+ if (header.kind === "fence") {
158
+ let runEnd = header.index;
159
+ while (runEnd < this.#buffer.length && this.#buffer[runEnd] === FENCE_OPEN) runEnd++;
160
+ if (runEnd >= this.#buffer.length && !final) return false; // fence run may grow
161
+ return this.#startCall(events, name, nameEnd, header.index, "fence", runEnd - header.index);
162
162
  }
163
-
164
- const tag = parseCallOpenTag(this.#buffer);
165
- if (!tag) {
166
- events.push({ type: "text", text: this.#buffer[0] ?? "" });
167
- this.#buffer = this.#buffer.slice(1);
168
- return true;
163
+ if (header.kind === "newline") {
164
+ return this.#startCall(events, name, nameEnd, header.index, "newline", 0);
169
165
  }
170
-
171
- this.#beginCall(tag, events);
172
- this.#buffer = this.#buffer.slice(tag.end);
173
- if (tag.selfClosing) {
174
- events.push({
175
- type: "toolEnd",
176
- id: this.#id,
177
- name: this.#name,
178
- arguments: this.#args,
179
- rawBlock: this.#rawBlock,
180
- });
181
- this.#reset();
182
- return true;
183
- }
184
-
185
- this.#state = "body";
186
- this.#bodyMode = "undecided";
187
- return true;
166
+ // "eof"/"incomplete": the header may still be streaming in — only a scalar
167
+ // call with no trailing newline at true end-of-stream finalizes here.
168
+ if (!final) return false;
169
+ return this.#startCall(events, name, nameEnd, this.#buffer.length, "eof", 0);
188
170
  }
189
171
 
190
- #consumeThinking(events: InbandScanEvent[], final: boolean): boolean {
191
- const close = this.#buffer.indexOf(THINK_CLOSE);
192
- if (close === -1) {
193
- const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, [THINK_CLOSE]);
194
- this.#emitThinking(this.#buffer.slice(0, this.#buffer.length - hold), events);
195
- this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
196
- if (final) {
197
- this.#endThinking(events);
198
- this.#state = "outside";
199
- }
200
- return false;
201
- }
202
- this.#emitThinking(this.#buffer.slice(0, close), events);
203
- this.#buffer = this.#buffer.slice(close + THINK_CLOSE.length);
204
- this.#endThinking(events);
205
- this.#state = "outside";
172
+ // `§` not followed by a known tool name is prose — surface it as literal text.
173
+ #rejectSigil(events: InbandScanEvent[]): boolean {
174
+ events.push({ type: "text", text: CALL_SIGIL });
175
+ this.#buffer = this.#buffer.slice(CALL_SIGIL.length);
206
176
  return true;
207
177
  }
208
178
 
209
- #emitThinking(delta: string, events: InbandScanEvent[]): void {
210
- if (delta.length === 0) return;
211
- this.#thinking += delta;
212
- events.push({ type: "thinkingDelta", delta });
213
- }
214
-
215
- #endThinking(events: InbandScanEvent[]): void {
216
- events.push({ type: "thinkingEnd", thinking: this.#thinking });
217
- this.#thinking = "";
218
- this.#state = "outside";
219
- }
220
-
221
- #beginCall(tag: OpenTag, events: InbandScanEvent[]): void {
179
+ #startCall(
180
+ events: InbandScanEvent[],
181
+ name: string,
182
+ argsStart: number,
183
+ headerEnd: number,
184
+ kind: "fence" | "newline" | "eof",
185
+ fenceLen: number,
186
+ ): boolean {
187
+ const shape = this.#argShapes.get(name);
222
188
  this.#id = mintToolCallId();
223
- this.#name = tag.name;
224
- this.#args = coerceAttributes(tag.rawAttrs, this.#shape()?.properties ?? {});
225
- this.#inlineKey = "";
226
- this.#inlineValue = "";
227
- this.#inlineLeading = false;
228
- this.#rawBlock = this.#buffer.slice(0, tag.end);
189
+ this.#name = name;
190
+ this.#args = parseHeaderArgs(this.#buffer.slice(argsStart, headerEnd), shape?.properties ?? {});
229
191
  events.push({ type: "toolStart", id: this.#id, name: this.#name });
230
- }
231
192
 
232
- #classifyBody(final: boolean): BodyMode | undefined {
233
- const closeTag = this.#closeTag();
234
- const first = skipWhitespace(this.#buffer, 0);
235
- const close = this.#buffer.indexOf(closeTag);
236
- const inlineKey = this.#inlineTargetKey();
237
-
238
- if (close !== -1 && first >= close) return inlineKey ? "inline" : "members";
239
- if (first >= this.#buffer.length) return undefined;
240
-
241
- const fromFirst = this.#buffer.slice(first);
242
- if (!final && closeTag.startsWith(fromFirst)) return undefined;
243
-
244
- if (this.#buffer[first] !== "<") return inlineKey ? "inline" : "members";
245
- if (this.#buffer.startsWith(closeTag, first)) return inlineKey ? "inline" : "members";
246
- if (this.#buffer.startsWith("</", first)) return "members";
247
-
248
- const elementName = readElementNamePrefix(this.#buffer, first);
249
- if (elementName === undefined) return final ? (inlineKey ? "inline" : "members") : undefined;
250
- if (elementName.length === 0) return inlineKey ? "inline" : "members";
193
+ if (kind === "fence") {
194
+ const fenceEnd = headerEnd + fenceLen;
195
+ this.#rawBlock = this.#buffer.slice(0, fenceEnd);
196
+ this.#closeMarker = FENCE_CLOSE.repeat(fenceLen);
197
+ this.#bodyKey = this.#inlineTargetKey() ?? "input";
198
+ this.#bodyValue = "";
199
+ this.#bodyLeading = true;
200
+ this.#buffer = this.#buffer.slice(fenceEnd);
201
+ this.#state = "body";
202
+ return true;
203
+ }
251
204
 
252
- const shape = this.#shape();
253
- if (!shape) return "members";
254
- return Object.hasOwn(shape.properties, elementName) ? "members" : inlineKey ? "inline" : "members";
205
+ this.#rawBlock = this.#buffer.slice(0, headerEnd);
206
+ events.push({
207
+ type: "toolEnd",
208
+ id: this.#id,
209
+ name: this.#name,
210
+ arguments: this.#args,
211
+ rawBlock: this.#rawBlock,
212
+ });
213
+ let next = headerEnd;
214
+ if (kind === "newline") {
215
+ if (this.#buffer[next] === "\r") next++;
216
+ if (this.#buffer[next] === "\n") next++;
217
+ }
218
+ this.#buffer = this.#buffer.slice(next);
219
+ this.#reset();
220
+ return true;
255
221
  }
256
222
 
257
- #consumeInline(events: InbandScanEvent[], final: boolean): boolean {
258
- this.#stripInlineLeadingDelimiter(final);
259
- const closeTag = this.#closeTag();
260
- const close = this.#buffer.indexOf(closeTag);
223
+ #consumeBody(events: InbandScanEvent[], final: boolean): boolean {
224
+ this.#stripBodyLeading(final);
225
+ const close = this.#buffer.indexOf(this.#closeMarker);
261
226
  if (close === -1) {
262
227
  if (final) {
263
228
  this.#reset();
264
229
  this.#buffer = "";
265
230
  return false;
266
231
  }
267
- const overlap = partialSuffixOverlapAny(this.#buffer, [closeTag]);
268
- let hold = Math.max(1, overlap);
232
+ const overlap = partialSuffixOverlapAny(this.#buffer, [this.#closeMarker]);
233
+ let hold = Math.max(this.#closeMarker.length, overlap);
269
234
  if (overlap > 0) {
270
235
  const beforeOverlap = this.#buffer.length - overlap - 1;
271
236
  if (this.#buffer[beforeOverlap] === "\n") {
@@ -277,67 +242,33 @@ export class PiNativeInbandScanner implements InbandScanner {
277
242
  if (emitLength > 0) {
278
243
  const delta = this.#buffer.slice(0, emitLength);
279
244
  this.#rawBlock += delta;
280
- this.#emitInlineDelta(delta, events);
245
+ this.#emitBodyDelta(delta, events);
281
246
  this.#buffer = this.#buffer.slice(emitLength);
282
247
  }
283
248
  return false;
284
249
  }
285
250
 
286
251
  const rawDelta = this.#buffer.slice(0, close);
287
- this.#rawBlock += rawDelta + closeTag;
252
+ this.#rawBlock += rawDelta + this.#closeMarker;
288
253
  let delta = rawDelta;
289
254
  if (delta.endsWith("\r\n")) delta = delta.slice(0, -2);
290
255
  else if (delta.endsWith("\n")) delta = delta.slice(0, -1);
291
- this.#emitInlineDelta(delta, events);
292
- this.#args[this.#inlineKey] = this.#inlineValue;
293
- events.push({ type: "toolEnd", id: this.#id, name: this.#name, arguments: this.#args, rawBlock: this.#rawBlock });
294
- this.#buffer = this.#buffer.slice(close + closeTag.length);
256
+ this.#emitBodyDelta(delta, events);
257
+ this.#args[this.#bodyKey] = this.#bodyValue;
258
+ events.push({
259
+ type: "toolEnd",
260
+ id: this.#id,
261
+ name: this.#name,
262
+ arguments: this.#args,
263
+ rawBlock: this.#rawBlock,
264
+ });
265
+ this.#buffer = this.#buffer.slice(close + this.#closeMarker.length);
295
266
  this.#reset();
296
267
  return true;
297
268
  }
298
269
 
299
- #consumeMembers(events: InbandScanEvent[], final: boolean): boolean {
300
- const closeTag = this.#closeTag();
301
- let searchFrom = 0;
302
- while (true) {
303
- const close = this.#buffer.indexOf(closeTag, searchFrom);
304
- if (close === -1) {
305
- if (final) {
306
- this.#reset();
307
- this.#buffer = "";
308
- }
309
- return false;
310
- }
311
-
312
- const body = this.#buffer.slice(0, close);
313
- const parsed = parseMembers(body, 0, undefined, this.#shape()?.properties ?? {});
314
- if (!parsed.ok || skipWhitespace(body, parsed.next) !== body.length) {
315
- searchFrom = close + closeTag.length;
316
- continue;
317
- }
318
-
319
- const bodyArgs = parsed.value;
320
- const args = { ...this.#args, ...bodyArgs };
321
- this.#rawBlock += body + closeTag;
322
- this.#emitCompletedStringDeltas(bodyArgs, events);
323
- events.push({ type: "toolEnd", id: this.#id, name: this.#name, arguments: args, rawBlock: this.#rawBlock });
324
- this.#buffer = this.#buffer.slice(close + closeTag.length);
325
- this.#reset();
326
- return true;
327
- }
328
- }
329
-
330
- #emitCompletedStringDeltas(args: Record<string, unknown>, events: InbandScanEvent[]): void {
331
- for (const key in args) {
332
- const value = args[key];
333
- if (typeof value === "string" && value.length > 0) {
334
- events.push({ type: "toolArgDelta", id: this.#id, name: this.#name, key, delta: value });
335
- }
336
- }
337
- }
338
-
339
- #stripInlineLeadingDelimiter(final: boolean): void {
340
- if (!this.#inlineLeading) return;
270
+ #stripBodyLeading(final: boolean): void {
271
+ if (!this.#bodyLeading) return;
341
272
  if (this.#buffer.length === 0) return;
342
273
  if (this.#buffer[0] === "\r") {
343
274
  if (this.#buffer.length === 1 && !final) return;
@@ -345,24 +276,55 @@ export class PiNativeInbandScanner implements InbandScanner {
345
276
  this.#rawBlock += this.#buffer.slice(0, 2);
346
277
  this.#buffer = this.#buffer.slice(2);
347
278
  }
348
- this.#inlineLeading = false;
279
+ this.#bodyLeading = false;
349
280
  return;
350
281
  }
351
282
  if (this.#buffer[0] === "\n") {
352
283
  this.#rawBlock += this.#buffer[0];
353
284
  this.#buffer = this.#buffer.slice(1);
354
285
  }
355
- this.#inlineLeading = false;
286
+ this.#bodyLeading = false;
287
+ }
288
+
289
+ #emitBodyDelta(delta: string, events: InbandScanEvent[]): void {
290
+ if (delta.length === 0) return;
291
+ this.#bodyValue += delta;
292
+ events.push({ type: "toolArgDelta", id: this.#id, name: this.#name, key: this.#bodyKey, delta });
293
+ }
294
+
295
+ #consumeThinking(events: InbandScanEvent[], final: boolean): boolean {
296
+ const close = this.#buffer.indexOf(THINK_SIGIL);
297
+ if (close === -1) {
298
+ const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, [THINK_SIGIL]);
299
+ this.#emitThinking(this.#buffer.slice(0, this.#buffer.length - hold), events);
300
+ this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
301
+ if (final) {
302
+ this.#endThinking(events);
303
+ this.#state = "outside";
304
+ }
305
+ return false;
306
+ }
307
+ this.#emitThinking(this.#buffer.slice(0, close), events);
308
+ this.#buffer = this.#buffer.slice(close + THINK_SIGIL.length);
309
+ this.#endThinking(events);
310
+ this.#state = "outside";
311
+ return true;
356
312
  }
357
313
 
358
- #emitInlineDelta(delta: string, events: InbandScanEvent[]): void {
314
+ #emitThinking(delta: string, events: InbandScanEvent[]): void {
359
315
  if (delta.length === 0) return;
360
- this.#inlineValue += delta;
361
- events.push({ type: "toolArgDelta", id: this.#id, name: this.#name, key: this.#inlineKey, delta });
316
+ this.#thinking += delta;
317
+ events.push({ type: "thinkingDelta", delta });
318
+ }
319
+
320
+ #endThinking(events: InbandScanEvent[]): void {
321
+ events.push({ type: "thinkingEnd", thinking: this.#thinking });
322
+ this.#thinking = "";
323
+ this.#state = "outside";
362
324
  }
363
325
 
364
326
  #inlineTargetKey(): string | undefined {
365
- const shape = this.#shape();
327
+ const shape = this.#argShapes.get(this.#name);
366
328
  if (shape) {
367
329
  for (const key of shape.parameterOrder) {
368
330
  if (Object.hasOwn(this.#args, key)) continue;
@@ -370,258 +332,149 @@ export class PiNativeInbandScanner implements InbandScanner {
370
332
  }
371
333
  return undefined;
372
334
  }
373
-
374
335
  for (const key of this.#stringArgs(this.#name)) {
375
336
  if (!Object.hasOwn(this.#args, key)) return key;
376
337
  }
377
338
  return "input";
378
339
  }
379
340
 
380
- #shape(): ToolArgShape | undefined {
381
- return this.#argShapes.get(this.#name);
382
- }
383
-
384
- #closeTag(): string {
385
- return `</call:${this.#name}>`;
386
- }
387
-
388
341
  #reset(): void {
389
342
  this.#state = "outside";
390
- this.#bodyMode = "undecided";
391
343
  this.#id = "";
392
344
  this.#name = "";
393
345
  this.#args = {};
394
- this.#inlineKey = "";
395
- this.#inlineValue = "";
396
- this.#inlineLeading = false;
346
+ this.#bodyKey = "";
347
+ this.#bodyValue = "";
348
+ this.#bodyLeading = false;
349
+ this.#closeMarker = "";
397
350
  this.#rawBlock = "";
398
351
  }
399
352
  }
400
353
 
401
- function parseMembers(
402
- text: string,
403
- position: number,
404
- endTag: string | undefined,
405
- properties: Record<string, unknown>,
406
- ): MembersResult {
407
- const value: Record<string, unknown> = {};
408
- let index = position;
354
+ function parseHeaderArgs(text: string, properties: Record<string, unknown>): Record<string, unknown> {
355
+ const args: Record<string, unknown> = {};
356
+ let index = skipWhitespace(text, 0);
409
357
  while (index < text.length) {
410
- index = skipWhitespace(text, index);
411
- if (endTag && text.startsWith(endTag, index)) return { ok: true, value, next: index + endTag.length };
412
- if (index >= text.length) break;
413
- if (text[index] !== "<" || text.startsWith("</", index) || text.startsWith(CALL_PREFIX, index)) {
414
- return { ok: false, value, next: index };
415
- }
416
-
417
- const tag = parseElementOpenTag(text, index);
418
- if (!tag) return { ok: false, value, next: index };
419
- const propertySchema = properties[tag.name];
420
- const schemaArray = isArraySchema(propertySchema);
421
- const itemSchema = schemaArray ? getArrayItemSchema(propertySchema) : propertySchema;
422
- const parsed = parseElementValue(text, tag, itemSchema);
423
- if (!parsed.ok) return { ok: false, value, next: index };
424
- addMember(value, tag.name, parsed.value, schemaArray);
425
- index = parsed.next;
426
- }
427
-
428
- return endTag ? { ok: false, value, next: index } : { ok: true, value, next: index };
429
- }
430
-
431
- function parseElementValue(text: string, tag: OpenTag, schema: unknown): ValueResult {
432
- const attrProperties = getObjectProperties(schema);
433
- const attrs = coerceAttributes(tag.rawAttrs, attrProperties);
434
- if (tag.selfClosing) {
435
- if (isObjectSchema(schema) || tag.rawAttrs.length > 0) return { ok: true, value: attrs, next: tag.end };
436
- return { ok: true, value: coerceValue("", schema), next: tag.end };
437
- }
438
-
439
- const bodyStart = tag.end;
440
- const closeTag = `</${tag.name}>`;
441
- if (shouldParseObjectBody(text, bodyStart, closeTag, schema, tag.rawAttrs.length > 0)) {
442
- const parsed = parseMembers(text, bodyStart, closeTag, attrProperties);
443
- if (!parsed.ok) return { ok: false, value: undefined, next: bodyStart };
444
- return { ok: true, value: { ...attrs, ...parsed.value }, next: parsed.next };
445
- }
446
-
447
- const close = text.indexOf(closeTag, bodyStart);
448
- if (close === -1) return { ok: false, value: undefined, next: bodyStart };
449
- const raw = stripBlockDelimiters(text.slice(bodyStart, close));
450
- return { ok: true, value: coerceValue(raw, schema), next: close + closeTag.length };
451
- }
452
-
453
- function shouldParseObjectBody(
454
- text: string,
455
- bodyStart: number,
456
- closeTag: string,
457
- schema: unknown,
458
- hasAttrs: boolean,
459
- ): boolean {
460
- if (isObjectSchema(schema)) return true;
461
- if (isTypedScalarSchema(schema)) return false;
462
- if (hasAttrs) return true;
463
- const first = skipWhitespace(text, bodyStart);
464
- if (text.startsWith(closeTag, first)) return false;
465
- return text[first] === "<" && !text.startsWith("</", first) && !text.startsWith(CALL_PREFIX, first);
466
- }
467
-
468
- function isTypedScalarSchema(schema: unknown): boolean {
469
- const types = collectSchemaTypes(schema);
470
- if (types.size === 0) return false;
471
- return !types.has("object") && !types.has("array");
472
- }
473
-
474
- function addMember(target: Record<string, unknown>, key: string, value: unknown, schemaArray: boolean): void {
475
- if (schemaArray) {
476
- const existing = target[key];
477
- if (Array.isArray(existing)) existing.push(value);
478
- else target[key] = [value];
479
- return;
480
- }
481
-
482
- if (!Object.hasOwn(target, key)) {
483
- target[key] = value;
484
- return;
485
- }
486
-
487
- const existing = target[key];
488
- if (Array.isArray(existing)) existing.push(value);
489
- else target[key] = [existing, value];
490
- }
491
-
492
- function coerceAttributes(
493
- rawAttrs: readonly RawAttribute[],
494
- properties: Record<string, unknown>,
495
- ): Record<string, unknown> {
496
- const attrs: Record<string, unknown> = {};
497
- for (const attr of rawAttrs) {
498
- attrs[attr.name] = attr.value === true ? true : coerceValue(attr.value, properties[attr.name]);
499
- }
500
- return attrs;
501
- }
502
-
503
- function parseCallOpenTag(text: string): OpenTag | undefined {
504
- if (!text.startsWith(CALL_PREFIX)) return undefined;
505
- const tagEnd = findTagEnd(text, 0);
506
- if (tagEnd === -1) return undefined;
507
- return parseOpenTagContent(text, CALL_PREFIX.length, tagEnd);
508
- }
509
-
510
- function parseElementOpenTag(text: string, start: number): OpenTag | undefined {
511
- if (text[start] !== "<" || text.startsWith("</", start) || text.startsWith(CALL_PREFIX, start)) return undefined;
512
- const tagEnd = findTagEnd(text, start);
513
- if (tagEnd === -1) return undefined;
514
- return parseOpenTagContent(text, start + 1, tagEnd);
515
- }
516
-
517
- function parseOpenTagContent(text: string, contentStart: number, tagEnd: number): OpenTag | undefined {
518
- let contentEnd = tagEnd;
519
- let cursor = skipWhitespace(text, contentStart);
520
- const nameStart = cursor;
521
- if (!isNameStart(text[cursor])) return undefined;
522
- cursor++;
523
- while (cursor < contentEnd && isNameChar(text[cursor])) cursor++;
524
- const name = text.slice(nameStart, cursor);
525
-
526
- let selfClosing = false;
527
- let last = contentEnd - 1;
528
- while (last >= cursor && isWhitespace(text[last])) last--;
529
- if (text[last] === "/") {
530
- selfClosing = true;
531
- contentEnd = last;
532
- }
533
-
534
- return {
535
- name,
536
- rawAttrs: parseRawAttributes(text.slice(cursor, contentEnd)),
537
- selfClosing,
538
- end: tagEnd + 1,
539
- };
540
- }
541
-
542
- function parseRawAttributes(text: string): RawAttribute[] {
543
- const attrs: RawAttribute[] = [];
544
- let index = 0;
545
- while (index < text.length) {
546
- index = skipWhitespace(text, index);
547
- if (index >= text.length) break;
548
358
  if (!isNameStart(text[index])) {
549
359
  index++;
550
360
  continue;
551
361
  }
552
-
553
362
  const nameStart = index;
554
363
  index++;
555
364
  while (index < text.length && isNameChar(text[index])) index++;
556
- const name = text.slice(nameStart, index);
365
+ const key = text.slice(nameStart, index);
557
366
  index = skipWhitespace(text, index);
558
367
  if (text[index] !== "=") {
559
- attrs.push({ name, value: true });
368
+ args[key] = true;
560
369
  continue;
561
370
  }
371
+ index = skipWhitespace(text, index + 1);
372
+ const parsed = readInlineValue(text, index, properties[key]);
373
+ args[key] = parsed.value;
374
+ index = skipWhitespace(text, parsed.next);
375
+ }
376
+ return args;
377
+ }
562
378
 
563
- index++;
564
- index = skipWhitespace(text, index);
565
- if (index >= text.length) {
566
- attrs.push({ name, value: "" });
567
- break;
568
- }
379
+ type InlineValue = { value: unknown; next: number };
569
380
 
570
- const quote = text[index];
571
- if (quote === '"' || quote === "'") {
572
- const valueStart = ++index;
573
- while (index < text.length && text[index] !== quote) index++;
574
- attrs.push({ name, value: text.slice(valueStart, index) });
575
- if (index < text.length) index++;
576
- continue;
381
+ function readInlineValue(text: string, start: number, schema: unknown): InlineValue {
382
+ const ch = text[start];
383
+ if (ch === '"') {
384
+ let index = start + 1;
385
+ while (index < text.length) {
386
+ const c = text[index];
387
+ if (c === "\\") {
388
+ index += 2;
389
+ continue;
390
+ }
391
+ if (c === '"') {
392
+ index++;
393
+ break;
394
+ }
395
+ index++;
396
+ }
397
+ const raw = text.slice(start, index);
398
+ try {
399
+ return { value: JSON.parse(raw) as unknown, next: index };
400
+ } catch {
401
+ return { value: raw.slice(1, raw.endsWith('"') ? -1 : undefined), next: index };
577
402
  }
578
-
579
- const valueStart = index;
580
- while (index < text.length && !isWhitespace(text[index])) index++;
581
- attrs.push({ name, value: text.slice(valueStart, index) });
582
403
  }
583
- return attrs;
584
- }
585
-
586
- function readElementNamePrefix(text: string, ltIndex: number): string | undefined {
587
- let index = ltIndex + 1;
588
- if (index >= text.length) return undefined;
589
- if (!isNameStart(text[index])) return "";
590
- const start = index;
591
- index++;
592
- while (index < text.length && isNameChar(text[index])) index++;
593
- if (index >= text.length) return undefined;
594
- const next = text[index];
595
- return isWhitespace(next) || next === "/" || next === ">" ? text.slice(start, index) : "";
404
+ if (ch === "[" || ch === "{") {
405
+ const end = matchBracket(text, start);
406
+ const raw = text.slice(start, end);
407
+ try {
408
+ return { value: JSON.parse(raw) as unknown, next: end };
409
+ } catch {
410
+ return { value: raw, next: end };
411
+ }
412
+ }
413
+ let index = start;
414
+ while (index < text.length && !isWhitespace(text[index])) index++;
415
+ return { value: coerceValue(text.slice(start, index), schema), next: index };
596
416
  }
597
417
 
598
- function findTagEnd(text: string, start: number): number {
599
- let quote = "";
418
+ function matchBracket(text: string, start: number): number {
419
+ let depth = 0;
420
+ let inString = false;
600
421
  for (let index = start; index < text.length; index++) {
601
422
  const ch = text[index];
602
- if (quote) {
603
- if (ch === quote) quote = "";
423
+ if (inString) {
424
+ if (ch === "\\") {
425
+ index++;
426
+ continue;
427
+ }
428
+ if (ch === '"') inString = false;
604
429
  continue;
605
430
  }
606
- if (ch === '"' || ch === "'") {
607
- quote = ch;
431
+ if (ch === '"') {
432
+ inString = true;
608
433
  continue;
609
434
  }
610
- if (ch === ">") return index;
435
+ if (ch === "[" || ch === "{") depth++;
436
+ else if (ch === "]" || ch === "}") {
437
+ depth--;
438
+ if (depth === 0) return index + 1;
439
+ }
611
440
  }
612
- return -1;
441
+ return text.length;
613
442
  }
614
443
 
615
- function stripBlockDelimiters(raw: string): string {
616
- let start = 0;
617
- let end = raw.length;
618
- if (raw.startsWith("\r\n")) start = 2;
619
- else if (raw.startsWith("\n")) start = 1;
620
- if (end > start) {
621
- if (raw.endsWith("\r\n")) end -= 2;
622
- else if (raw.endsWith("\n")) end -= 1;
444
+ // Locate where a call header ends: the first top-level body fence, the first
445
+ // literal newline (scalar-only call), end-of-input, or "incomplete" when a
446
+ // quoted/bracketed value is still mid-stream.
447
+ function findHeaderEnd(text: string, start: number): HeaderEnd {
448
+ let inString = false;
449
+ let depth = 0;
450
+ for (let index = start; index < text.length; index++) {
451
+ const ch = text[index];
452
+ if (inString) {
453
+ if (ch === "\\") {
454
+ index++;
455
+ continue;
456
+ }
457
+ if (ch === '"') inString = false;
458
+ continue;
459
+ }
460
+ if (ch === '"') {
461
+ inString = true;
462
+ continue;
463
+ }
464
+ if (ch === "[" || ch === "{") {
465
+ depth++;
466
+ continue;
467
+ }
468
+ if (ch === "]" || ch === "}") {
469
+ if (depth > 0) depth--;
470
+ continue;
471
+ }
472
+ if (depth > 0) continue;
473
+ if (ch === FENCE_OPEN) return { kind: "fence", index };
474
+ if (ch === "\n" || ch === "\r") return { kind: "newline", index };
623
475
  }
624
- return raw.slice(start, end);
476
+ if (inString || depth > 0) return { kind: "incomplete" };
477
+ return { kind: "eof", index: text.length };
625
478
  }
626
479
 
627
480
  function skipWhitespace(text: string, index: number): number {
@@ -645,26 +498,83 @@ function renderToolCall(call: ToolCall, options: DialectRenderOptions = {}): str
645
498
  return renderInvocation(call, buildArgShapes(options.tools).get(call.name));
646
499
  }
647
500
 
501
+ function renderAssistantToolCalls(calls: readonly ToolCall[], options: DialectRenderOptions = {}): string {
502
+ const shapes = buildArgShapes(options.tools);
503
+ return calls.map(call => renderInvocation(call, shapes.get(call.name))).join("\n");
504
+ }
505
+
648
506
  function renderInvocation(call: ToolCall, shape: ToolArgShape | undefined): string {
649
- let body = `<call:${call.name}>`;
507
+ const properties = shape?.properties ?? {};
508
+ const bodyKey = selectBodyKey(call.arguments, shape);
509
+ let header = `${CALL_SIGIL}${call.name}`;
650
510
  for (const key in call.arguments) {
651
- body += `\n${renderElement(key, call.arguments[key], shape?.properties[key])}`;
511
+ if (key === bodyKey) continue;
512
+ header += ` ${key}=${renderInlineValue(call.arguments[key], properties[key])}`;
652
513
  }
653
- return `${body}\n</call:${call.name}>`;
514
+ if (bodyKey === undefined) return header;
515
+ const body = String(call.arguments[bodyKey]);
516
+ const fence = 1 + maxRun(body, FENCE_CLOSE);
517
+ return `${header}${FENCE_OPEN.repeat(fence)}\n${body}\n${FENCE_CLOSE.repeat(fence)}`;
654
518
  }
655
519
 
656
- function renderAssistantToolCalls(calls: readonly ToolCall[], options: DialectRenderOptions = {}): string {
657
- const shapes = buildArgShapes(options.tools);
658
- return calls.map(call => renderInvocation(call, shapes.get(call.name))).join("\n");
520
+ // The body holds a single dominant string argument: the first string-only
521
+ // parameter whose value contains a newline. Single-line strings stay inline
522
+ // (quoted when needed) so the verbatim fence is reserved for genuine blocks.
523
+ function selectBodyKey(args: Record<string, unknown>, shape: ToolArgShape | undefined): string | undefined {
524
+ // Round-trip requires renderer and scanner to agree on the omitted body key.
525
+ // The scanner assigns the body to the first string-only parameter missing from
526
+ // the header, so a body is only safe when no earlier parameter is also absent
527
+ // (no schema → keep everything inline).
528
+ if (!shape) return undefined;
529
+ for (const key of shape.parameterOrder) {
530
+ if (!Object.hasOwn(args, key)) return undefined;
531
+ const value = args[key];
532
+ if (typeof value === "string" && value.includes("\n") && isStringOnlySchema(shape.properties[key])) return key;
533
+ }
534
+ return undefined;
535
+ }
536
+
537
+ function renderInlineValue(value: unknown, schema: unknown): string {
538
+ if (typeof value === "string") {
539
+ return needsQuote(value) ? JSON.stringify(value) : value;
540
+ }
541
+ if (isStringOnlySchema(schema) && value === null) return '""';
542
+ return stringifyJson(value);
543
+ }
544
+
545
+ function needsQuote(value: string): boolean {
546
+ if (value.length === 0) return true;
547
+ const first = value[0];
548
+ if (first === '"' || first === "[" || first === "{") return true;
549
+ return /[\s«»]/.test(value);
550
+ }
551
+
552
+ function maxRun(text: string, ch: string): number {
553
+ let best = 0;
554
+ let run = 0;
555
+ for (let index = 0; index < text.length; index++) {
556
+ if (text[index] === ch) {
557
+ run++;
558
+ if (run > best) best = run;
559
+ } else {
560
+ run = 0;
561
+ }
562
+ }
563
+ return best;
659
564
  }
660
565
 
661
566
  function renderToolResults(results: readonly DialectToolResult[], _options?: DialectRenderOptions): string {
662
- return renderToolResponseResults(results);
567
+ return results
568
+ .map(result => {
569
+ const fence = RESULT_FENCE.repeat(Math.max(2, 1 + maxRun(result.text, RESULT_FENCE)));
570
+ return `${fence}\n${result.text}\n${fence}`;
571
+ })
572
+ .join("\n");
663
573
  }
664
574
 
665
575
  function renderThinking(text: string): string {
666
576
  if (!text) return "";
667
- return `<thinking>\n${text}\n</thinking>`;
577
+ return `${THINK_SIGIL}\n${text}\n${THINK_SIGIL}`;
668
578
  }
669
579
 
670
580
  function renderTranscript(messages: readonly Message[], options: DialectRenderOptions = {}): string {
@@ -676,29 +586,6 @@ function renderTranscript(messages: readonly Message[], options: DialectRenderOp
676
586
  });
677
587
  }
678
588
 
679
- function renderElement(key: string, value: unknown, schema: unknown): string {
680
- if (Array.isArray(value)) {
681
- const itemSchema = getArrayItemSchema(schema);
682
- return value.map(item => renderElement(key, item, itemSchema)).join("\n");
683
- }
684
- if (value && typeof value === "object") {
685
- const record = value as Record<string, unknown>;
686
- const properties = getObjectProperties(schema);
687
- let body = `<${key}>`;
688
- for (const childKey in record) {
689
- body += `\n${renderElement(childKey, record[childKey], properties[childKey])}`;
690
- }
691
- return `${body}\n</${key}>`;
692
- }
693
- return `<${key}>${renderScalar(value, schema)}</${key}>`;
694
- }
695
-
696
- function renderScalar(value: unknown, schema: unknown): string {
697
- if (typeof value === "string") return value;
698
- if (isStringOnlySchema(schema) && value === null) return "";
699
- return stringifyJson(value);
700
- }
701
-
702
589
  const definition: DialectDefinition = {
703
590
  dialect: "pi",
704
591
  prompt: dialectPrompt,
@@ -80,6 +80,13 @@ function resolveBearerToken(options: BedrockOptions): string | undefined {
80
80
  return options.bearerToken || apiKey || $env.AWS_BEARER_TOKEN_BEDROCK;
81
81
  }
82
82
 
83
+ function inferRegionFromBedrockArn(modelId: string): string | undefined {
84
+ const parts = modelId.split(":", 6);
85
+ if (parts[0] !== "arn" || parts[2] !== "bedrock") return undefined;
86
+ const region = parts[3];
87
+ return region || undefined;
88
+ }
89
+
83
90
  type Block = (TextContent | ThinkingContent | ToolCall) & {
84
91
  index?: number;
85
92
  partialJson?: string;
@@ -206,7 +213,12 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
206
213
 
207
214
  const blocks = output.content as Block[];
208
215
  let rawRequestDump: RawHttpRequestDump | undefined;
209
- const region = options.region || $env.AWS_REGION || $env.AWS_DEFAULT_REGION || "us-east-1";
216
+ const region =
217
+ options.region ||
218
+ inferRegionFromBedrockArn(model.id) ||
219
+ $env.AWS_REGION ||
220
+ $env.AWS_DEFAULT_REGION ||
221
+ "us-east-1";
210
222
 
211
223
  try {
212
224
  const cacheRetention = resolveCacheRetention(options.cacheRetention);
@@ -19,6 +19,7 @@ const builtInOAuthProviders: OAuthProviderInfo[] = PROVIDER_REGISTRY.filter(
19
19
  id: provider.id,
20
20
  name: provider.name,
21
21
  available: provider.available ?? true,
22
+ storeCredentialsAs: provider.storeCredentialsAs,
22
23
  }));
23
24
 
24
25
  const customOAuthProviders = new Map<string, OAuthProviderInterface>();
@@ -163,6 +164,7 @@ export function getOAuthProviders(): OAuthProviderInfo[] {
163
164
  id: provider.id,
164
165
  name: provider.name,
165
166
  available: true,
167
+ storeCredentialsAs: provider.storeCredentialsAs,
166
168
  }));
167
169
  return [...builtInOAuthProviders, ...customProviders];
168
170
  }
@@ -31,6 +31,12 @@ export interface OAuthProviderInfo {
31
31
  id: OAuthProviderId;
32
32
  name: string;
33
33
  available: boolean;
34
+ /**
35
+ * Provider id the login stores credentials under, when it differs from `id`
36
+ * (e.g. `openai-codex-device` ⇒ `openai-codex`). Lets callers map a login
37
+ * entry back to the model provider it authenticates.
38
+ */
39
+ storeCredentialsAs?: string;
34
40
  }
35
41
 
36
42
  export interface OAuthController {