@oh-my-pi/pi-ai 15.1.3 → 15.1.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.1.4] - 2026-05-19
6
+ ### Changed
7
+
8
+ - Updated auth-gateway format and pi-native request handling to invalidate the failed API key and retry the provider request with a replacement key when authentication fails
9
+
10
+ ### Fixed
11
+
12
+ - Fixed OpenAI Responses and Codex tool schema normalization to emit `properties: {}` for no-argument object schemas without rewriting literal payloads. ([#1147](https://github.com/can1357/oh-my-pi/issues/1147))
13
+ - Fixed Anthropic 400 (`unexpected tool_use_id found in tool_result blocks ... Each tool_result block must have a corresponding tool_use block in the previous message`) when handoff/compaction folds an assistant `tool_use` into the handoff summary string but leaves the matching user-side `tool_result` message in the history. `transformMessages` now indexes every `tool_use` id surviving the first pass and drops orphan `tool_result` messages whose originator was compacted away, preserving the text payload as a user-level `<stale-tool-result>` note so the model still sees what the tool returned. The note is emitted with `role: "user"` rather than `role: "developer"` so providers that elevate developer-role messages (Ollama: `developer` → `system`; OpenAI chat-completions reasoning models: `developer` → `developer`) cannot lift stale tool output to an instruction-priority tier above the surrounding user/developer messages.
14
+ - Fixed streaming authentication retry to trigger when a provider emits a 401 `error` event after a `start` event but before any replay-unsafe content is emitted
15
+ - Added `credential_process` support to the Bedrock provider's AWS credential resolver so profiles delegating to external brokers (`aws-vault`, `granted`, in-house tools) resolve instead of falling through to `Unable to resolve AWS credentials`. Parses the AWS SDK `Version: 1` JSON envelope, honors `Expiration` in the per-profile cache, propagates `AbortSignal` to the spawned helper, routes Windows `.cmd`/`.bat` helpers through `cmd.exe /c`, and ships a POSIX-shell-style tokenizer that preserves backslashes inside double quotes so Windows paths survive ([#1142](https://github.com/can1357/oh-my-pi/issues/1142))
16
+
5
17
  ## [15.1.3] - 2026-05-17
6
18
  ### Breaking Changes
7
19
 
@@ -9,6 +9,9 @@
9
9
  * - SSO profile referencing a cached token in `~/.aws/sso/cache/*.json`,
10
10
  * which we exchange for short-lived role credentials via
11
11
  * `https://portal.sso.{region}.amazonaws.com/federation/credentials`.
12
+ * - `credential_process` — an external command emitting the AWS SDK
13
+ * `Version: 1` JSON envelope on stdout. Used by `aws-vault`, `granted`,
14
+ * in-house brokers, etc.
12
15
  * 3. EC2 IMDSv2 (only when `AWS_EC2_METADATA_DISABLED` is unset / falsey and
13
16
  * `169.254.169.254` is reachable within a 1 s timeout).
14
17
  *
@@ -28,5 +31,13 @@ export interface CredentialResolveOptions {
28
31
  signal?: AbortSignal;
29
32
  }
30
33
  export declare function resolveAwsCredentials(opts?: CredentialResolveOptions): Promise<ResolvedCredentials>;
34
+ /** POSIX-shell-style tokenizer used by the AWS CLI for `credential_process`.
35
+ *
36
+ * Outside quotes a backslash escapes the next character. Inside single quotes
37
+ * everything is literal (no escapes, cannot contain `'`). Inside double quotes
38
+ * a backslash only escapes `$`, `` ` ``, `"`, and `\` — every other backslash
39
+ * is preserved verbatim, which is what makes Windows paths like
40
+ * `"C:\Program Files\tool\auth.exe"` survive tokenization. */
41
+ export declare function tokenizeCredentialProcessCommand(cmd: string): string[];
31
42
  /** Test/diagnostic helper — drops cached credentials. */
32
43
  export declare function clearAwsCredentialCache(): void;
@@ -126,8 +126,9 @@ export interface StreamOptions {
126
126
  signal?: AbortSignal;
127
127
  apiKey?: string;
128
128
  /**
129
- * Called when a provider returns 401 before any assistant event has been
130
- * emitted. Returning a different key retries the provider request once.
129
+ * Called when a provider returns 401 before any replay-unsafe assistant
130
+ * event has been emitted. Returning a different key retries the provider
131
+ * request once.
131
132
  */
132
133
  onAuthError?: (provider: string, apiKey: string, error: unknown) => Promise<string | undefined>;
133
134
  cacheRetention?: CacheRetention;
@@ -368,6 +369,8 @@ export interface AssistantMessage {
368
369
  usage: Usage;
369
370
  stopReason: StopReason;
370
371
  errorMessage?: string;
372
+ /** HTTP status surfaced by the provider when the request failed. Populated by every provider's catch block alongside `errorMessage` so consumers (auth retry, telemetry, UI) can branch without regex-scraping the message. */
373
+ errorStatus?: number;
371
374
  /** Provider-specific opaque payload used to reconstruct transport-native history. */
372
375
  providerPayload?: ProviderPayload;
373
376
  timestamp: number;
@@ -10,3 +10,4 @@ export * from "./normalize";
10
10
  export * from "./spill";
11
11
  export * from "./types";
12
12
  export * from "./wire";
13
+ export * from "./zod-decontaminate";
@@ -1,5 +1,5 @@
1
1
  import { type DescriptionSpillFormat } from "./spill";
2
- import type { JsonObject } from "./types";
2
+ import { type JsonObject } from "./types";
3
3
  export type ResidualSchemaIncompatibility = "type-array" | "type-null" | "nullable" | "combiners";
4
4
  export interface NormalizeSchemaOptions {
5
5
  unsupportedFields: (key: string) => boolean;
@@ -37,8 +37,9 @@ export declare function normalizeSchemaForCCA(value: unknown): unknown;
37
37
  export declare function normalizeSchemaForMCP(value: unknown): unknown;
38
38
  /**
39
39
  * OpenAI Responses rejects `oneOf` in tool schemas even when strict mode is
40
- * disabled. Non-strict schemas can still use `anyOf`, so preserve the union
41
- * shape by recursively rewriting `oneOf` branches to `anyOf`.
40
+ * disabled, and rejects every schema node with `type: "object"` unless it has
41
+ * a `properties` member. Normalize only schema-valued positions so literal
42
+ * payloads under `enum`, `const`, `default`, and `examples` remain unchanged.
42
43
  *
43
44
  * Identity-preserving: returns the input reference unchanged when no rewrite
44
45
  * occurred so callers can dedupe via reference equality (and the strict-mode
@@ -12,7 +12,19 @@
12
12
  */
13
13
  import { type ZodType } from "zod/v4";
14
14
  import type { Tool } from "../../types";
15
- /** True when `value` is a Zod schema instance. */
15
+ /**
16
+ * True when `value` is a live Zod schema instance.
17
+ *
18
+ * The check is stricter than "has a `_zod` property" because a JSON
19
+ * round-trip preserves the `_zod` key as a plain object and would otherwise
20
+ * fool the predicate — see issue #1101, where MCP servers ship
21
+ * `JSON.stringify(zodSchemaInstance)` as a tool's `inputSchema` and the
22
+ * resulting plain object then explodes `z.toJSONSchema` because the prototype
23
+ * (and every Zod parsing method) is gone.
24
+ *
25
+ * Live Zod instances always carry a `.parse` function on the prototype;
26
+ * impostors do not.
27
+ */
16
28
  export declare function isZodSchema(value: unknown): value is ZodType;
17
29
  /** Convert a Zod schema into the JSON Schema shape providers consume. */
18
30
  export declare function zodToWireSchema(schema: ZodType): Record<string, unknown>;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Defensive rewrite for nodes that look like `JSON.stringify(zodSchemaInstance)`
3
+ * output rather than JSON Schema. MCP servers using Zod 4 sometimes ship a
4
+ * serialised schema instance directly as a tool's `inputSchema`, because the
5
+ * fields Zod surfaces on its instances (`type`, `enum`, `options`, `def`) shadow
6
+ * (and clash with) JSON Schema keywords. The resulting payload is neither valid
7
+ * Zod nor valid JSON Schema 2020-12 and Anthropic's strict validator rejects
8
+ * the whole tool list.
9
+ *
10
+ * Symptoms we've observed (gitnexus_impact.direction):
11
+ * {
12
+ * def: { type: "enum", entries: { upstream: "upstream", ... } },
13
+ * type: "enum", // <- invalid `type` value
14
+ * enum: { upstream: "upstream", ... }, // <- `enum` MUST be an array
15
+ * options: ["upstream", "downstream"],
16
+ * }
17
+ *
18
+ * This module recognises the shape (`def.type === node.type` and `def.type` is
19
+ * a known Zod kind) and rewrites it to clean JSON Schema where deterministic.
20
+ * For Zod kinds we don't fully model, we strip the toxic siblings (`def`,
21
+ * `options`, object-shaped `enum`) and drop an invalid `type` so the remainder
22
+ * passes meta-schema validation as a permissive node.
23
+ *
24
+ * Pure / identity-preserving: returns the input reference when nothing changes.
25
+ */
26
+ /**
27
+ * Walks a JSON value and rewrites every Zod-instance-shaped node into clean
28
+ * JSON Schema 2020-12. Identity-preserving when no rewrite fires. Tolerates
29
+ * self-referential graphs — a revisited node returns as-is.
30
+ */
31
+ export declare function decontaminateZodInstance(value: unknown): unknown;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "15.1.3",
4
+ "version": "15.1.4",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -43,7 +43,7 @@
43
43
  "dependencies": {
44
44
  "@anthropic-ai/sdk": "^0.94.0",
45
45
  "@bufbuild/protobuf": "^2.12.0",
46
- "@oh-my-pi/pi-utils": "15.1.3",
46
+ "@oh-my-pi/pi-utils": "15.1.4",
47
47
  "openai": "^6.36.0",
48
48
  "partial-json": "^0.1.7",
49
49
  "zod": "4.4.3"
@@ -231,6 +231,26 @@ function classifyGatewayError(err: unknown): { status: number; type: string; mes
231
231
  return { status: 502, type: "upstream_error", message };
232
232
  }
233
233
 
234
+ async function refreshGatewayApiKeyAfterAuthError(
235
+ storage: AuthStorage,
236
+ model: Model<Api>,
237
+ provider: string,
238
+ oldKey: string,
239
+ error: unknown,
240
+ signal: AbortSignal,
241
+ format: string,
242
+ peer: string,
243
+ ): Promise<string | undefined> {
244
+ await storage.invalidateCredentialMatching(provider, oldKey, signal);
245
+ logger.debug("auth-gateway retrying provider request after credential invalidation", {
246
+ format,
247
+ provider,
248
+ peer,
249
+ error: error instanceof Error ? error.message : String(error),
250
+ });
251
+ return storage.getApiKey(provider, undefined, { modelId: model.id, signal });
252
+ }
253
+
234
254
  function clientClosedResponse(route: { module: FormatModule }): Response {
235
255
  return route.module.formatError(499, "request_aborted", "client closed request");
236
256
  }
@@ -332,6 +352,17 @@ async function handleFormatEndpoint(
332
352
 
333
353
  const streamOpts = buildStreamOptions(parsed, model.api, controller.signal);
334
354
  streamOpts.apiKey = apiKey;
355
+ streamOpts.onAuthError = (provider, oldKey, error) =>
356
+ refreshGatewayApiKeyAfterAuthError(
357
+ bootOpts.storage,
358
+ model,
359
+ provider,
360
+ oldKey,
361
+ error,
362
+ controller.signal,
363
+ route.label,
364
+ peer,
365
+ );
335
366
 
336
367
  logger.info("auth-gateway request", {
337
368
  format: route.label,
@@ -469,6 +500,17 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
469
500
  // only inject server-controlled fields. The codex temperature/topP strip
470
501
  // matches `buildStreamOptions` — Codex rejects them with a 400.
471
502
  const streamOpts: SimpleStreamOptions = { ...parsed.options, apiKey, signal: controller.signal };
503
+ streamOpts.onAuthError = (provider, oldKey, error) =>
504
+ refreshGatewayApiKeyAfterAuthError(
505
+ bootOpts.storage,
506
+ model,
507
+ provider,
508
+ oldKey,
509
+ error,
510
+ controller.signal,
511
+ "pi-native",
512
+ peer,
513
+ );
472
514
  if (model.api === "openai-codex-responses") {
473
515
  delete streamOpts.temperature;
474
516
  delete streamOpts.topP;
@@ -7,7 +7,7 @@
7
7
  * Bun's native `HTTPS_PROXY` support.
8
8
  */
9
9
 
10
- import { $env, $flag, fetchWithRetry } from "@oh-my-pi/pi-utils";
10
+ import { $env, $flag, extractHttpStatusFromError, fetchWithRetry } from "@oh-my-pi/pi-utils";
11
11
  import type { Effort } from "../model-thinking";
12
12
  import { mapEffortToAnthropicAdaptiveEffort, requireSupportedEffort } from "../model-thinking";
13
13
  import { calculateCost } from "../models";
@@ -333,6 +333,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
333
333
  delete (block as Block).partialJson;
334
334
  }
335
335
  output.stopReason = options.signal?.aborted ? "aborted" : "error";
336
+ output.errorStatus = extractHttpStatusFromError(error);
336
337
  const baseMessage = error instanceof Error ? error.message : JSON.stringify(error);
337
338
  // Enrich error with thinking block diagnostics for signature-related failures
338
339
  let diagnostics = "";
@@ -1324,6 +1324,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1324
1324
  }
1325
1325
  const firstEventTimeoutError = activeAbortTracker.getLocalAbortReason();
1326
1326
  output.stopReason = activeAbortTracker.wasCallerAbort() ? "aborted" : "error";
1327
+ output.errorStatus = extractHttpStatusFromError(error);
1327
1328
  output.errorMessage = firstEventTimeoutError?.message ?? (await finalizeErrorMessage(error, rawRequestDump));
1328
1329
  output.errorMessage = rewriteCopilotError(output.errorMessage, error, model.provider);
1329
1330
  output.duration = Date.now() - startTime;
@@ -9,6 +9,9 @@
9
9
  * - SSO profile referencing a cached token in `~/.aws/sso/cache/*.json`,
10
10
  * which we exchange for short-lived role credentials via
11
11
  * `https://portal.sso.{region}.amazonaws.com/federation/credentials`.
12
+ * - `credential_process` — an external command emitting the AWS SDK
13
+ * `Version: 1` JSON envelope on stdout. Used by `aws-vault`, `granted`,
14
+ * in-house brokers, etc.
12
15
  * 3. EC2 IMDSv2 (only when `AWS_EC2_METADATA_DISABLED` is unset / falsey and
13
16
  * `169.254.169.254` is reachable within a 1 s timeout).
14
17
  *
@@ -162,6 +165,10 @@ async function readProfileCredentials(
162
165
  return readSsoCredentials(merged, configIni, region, signal);
163
166
  }
164
167
 
168
+ if (merged.credential_process) {
169
+ return readCredentialProcess(profile, merged.credential_process, signal);
170
+ }
171
+
165
172
  return undefined;
166
173
  }
167
174
 
@@ -276,6 +283,166 @@ async function sha1Hex(input: string): Promise<string> {
276
283
  return out;
277
284
  }
278
285
 
286
+ // ---------- credential_process ----------
287
+
288
+ /** JSON envelope emitted by an external credential process. Matches the
289
+ * AWS CLI / SDK contract documented at
290
+ * https://docs.aws.amazon.com/sdkref/latest/guide/feature-process-credentials.html */
291
+ interface CredentialProcessEnvelope {
292
+ Version?: number;
293
+ AccessKeyId?: string;
294
+ SecretAccessKey?: string;
295
+ SessionToken?: string;
296
+ Expiration?: string;
297
+ }
298
+
299
+ async function readCredentialProcess(
300
+ profile: string,
301
+ command: string,
302
+ signal: AbortSignal | undefined,
303
+ ): Promise<ResolvedCredentials> {
304
+ const argv = buildCredentialProcessArgv(profile, command);
305
+
306
+ const child = Bun.spawn(argv, {
307
+ stdin: "ignore",
308
+ stdout: "pipe",
309
+ stderr: "pipe",
310
+ windowsHide: true,
311
+ signal,
312
+ });
313
+ const [stdout, stderr, exitCode] = await Promise.all([
314
+ new Response(child.stdout).text(),
315
+ new Response(child.stderr).text(),
316
+ child.exited,
317
+ ]);
318
+ if (exitCode !== 0) {
319
+ const tail = stderr.trim().slice(-512) || stdout.trim().slice(-512) || "(no output)";
320
+ throw new Error(`AWS credential_process for profile '${profile}' exited ${exitCode}: ${tail}`);
321
+ }
322
+
323
+ let parsed: CredentialProcessEnvelope;
324
+ try {
325
+ parsed = JSON.parse(stdout) as CredentialProcessEnvelope;
326
+ } catch (err) {
327
+ throw new Error(`AWS credential_process for profile '${profile}' did not emit valid JSON: ${String(err)}`);
328
+ }
329
+ if (parsed.Version !== 1) {
330
+ throw new Error(
331
+ `AWS credential_process for profile '${profile}' returned unsupported Version ${parsed.Version ?? "<missing>"}; expected 1.`,
332
+ );
333
+ }
334
+ if (!parsed.AccessKeyId || !parsed.SecretAccessKey) {
335
+ throw new Error(
336
+ `AWS credential_process for profile '${profile}' returned envelope without AccessKeyId/SecretAccessKey.`,
337
+ );
338
+ }
339
+
340
+ const out: ResolvedCredentials = {
341
+ accessKeyId: parsed.AccessKeyId,
342
+ secretAccessKey: parsed.SecretAccessKey,
343
+ };
344
+ if (parsed.SessionToken) out.sessionToken = parsed.SessionToken;
345
+ if (parsed.Expiration) {
346
+ const exp = Date.parse(parsed.Expiration);
347
+ if (!Number.isNaN(exp)) out.expiresAt = exp;
348
+ }
349
+ return out;
350
+ }
351
+
352
+ /** Resolve the argv for `Bun.spawn`. On Windows we route `.cmd`/`.bat` helpers
353
+ * through `cmd.exe /c` because direct execution refuses batch files (mirrors
354
+ * Node's `execFile` policy and avoids surprise no-ops). */
355
+ function buildCredentialProcessArgv(profile: string, command: string): string[] {
356
+ const tokens = tokenizeCredentialProcessCommand(command);
357
+ if (tokens.length === 0) {
358
+ throw new Error(`AWS credential_process for profile '${profile}' is empty.`);
359
+ }
360
+ if (process.platform === "win32" && isBatchScript(tokens[0])) {
361
+ return ["cmd.exe", "/d", "/s", "/c", command];
362
+ }
363
+ return tokens;
364
+ }
365
+
366
+ function isBatchScript(executable: string): boolean {
367
+ const lower = executable.toLowerCase();
368
+ return lower.endsWith(".cmd") || lower.endsWith(".bat");
369
+ }
370
+
371
+ /** POSIX-shell-style tokenizer used by the AWS CLI for `credential_process`.
372
+ *
373
+ * Outside quotes a backslash escapes the next character. Inside single quotes
374
+ * everything is literal (no escapes, cannot contain `'`). Inside double quotes
375
+ * a backslash only escapes `$`, `` ` ``, `"`, and `\` — every other backslash
376
+ * is preserved verbatim, which is what makes Windows paths like
377
+ * `"C:\Program Files\tool\auth.exe"` survive tokenization. */
378
+ export function tokenizeCredentialProcessCommand(cmd: string): string[] {
379
+ const tokens: string[] = [];
380
+ let current = "";
381
+ let hasToken = false;
382
+ let mode: "normal" | "single" | "double" = "normal";
383
+ for (let i = 0; i < cmd.length; i++) {
384
+ const ch = cmd[i];
385
+ if (mode === "normal") {
386
+ if (ch === "'") {
387
+ mode = "single";
388
+ hasToken = true;
389
+ continue;
390
+ }
391
+ if (ch === '"') {
392
+ mode = "double";
393
+ hasToken = true;
394
+ continue;
395
+ }
396
+ if (ch === "\\" && i + 1 < cmd.length) {
397
+ current += cmd[++i];
398
+ hasToken = true;
399
+ continue;
400
+ }
401
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
402
+ if (hasToken) {
403
+ tokens.push(current);
404
+ current = "";
405
+ hasToken = false;
406
+ }
407
+ continue;
408
+ }
409
+ current += ch;
410
+ hasToken = true;
411
+ continue;
412
+ }
413
+ if (mode === "single") {
414
+ if (ch === "'") {
415
+ mode = "normal";
416
+ continue;
417
+ }
418
+ current += ch;
419
+ continue;
420
+ }
421
+ // double-quote
422
+ if (ch === '"') {
423
+ mode = "normal";
424
+ continue;
425
+ }
426
+ if (ch === "\\" && i + 1 < cmd.length) {
427
+ const next = cmd[i + 1];
428
+ if (next === "$" || next === "`" || next === '"' || next === "\\") {
429
+ current += next;
430
+ i++;
431
+ continue;
432
+ }
433
+ // Preserve literal backslash for Windows paths.
434
+ current += ch;
435
+ continue;
436
+ }
437
+ current += ch;
438
+ }
439
+ if (mode !== "normal") {
440
+ throw new Error("AWS credential_process command has an unterminated quote.");
441
+ }
442
+ if (hasToken) tokens.push(current);
443
+ return tokens;
444
+ }
445
+
279
446
  // ---------- IMDSv2 ----------
280
447
 
281
448
  const IMDS_HOST = "169.254.169.254";
@@ -1,4 +1,4 @@
1
- import { $env } from "@oh-my-pi/pi-utils";
1
+ import { $env, extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
2
2
  import { AzureOpenAI } from "openai";
3
3
  import type {
4
4
  Tool as OpenAITool,
@@ -173,6 +173,7 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
173
173
  for (const block of output.content) delete (block as { index?: number }).index;
174
174
  const firstEventTimeoutError = abortTracker.getLocalAbortReason();
175
175
  output.stopReason = abortTracker.wasCallerAbort() ? "aborted" : "error";
176
+ output.errorStatus = extractHttpStatusFromError(error);
176
177
  output.errorMessage = firstEventTimeoutError?.message ?? (await finalizeErrorMessage(error, rawRequestDump));
177
178
  output.duration = Date.now() - startTime;
178
179
  if (firstTokenTime) output.ttft = firstTokenTime - startTime;
@@ -3,7 +3,7 @@ import * as fs from "node:fs/promises";
3
3
  import http2 from "node:http2";
4
4
  import { create, fromBinary, fromJson, type JsonValue, toBinary, toJson } from "@bufbuild/protobuf";
5
5
  import { ValueSchema } from "@bufbuild/protobuf/wkt";
6
- import { $env, sanitizeText } from "@oh-my-pi/pi-utils";
6
+ import { $env, extractHttpStatusFromError, sanitizeText } from "@oh-my-pi/pi-utils";
7
7
  import { calculateCost } from "../models";
8
8
  import type {
9
9
  Api,
@@ -547,6 +547,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
547
547
  stream.end();
548
548
  } catch (error) {
549
549
  output.stopReason = options?.signal?.aborted ? "aborted" : "error";
550
+ output.errorStatus = extractHttpStatusFromError(error);
550
551
  output.errorMessage = formatErrorMessageWithRetryAfter(error);
551
552
  output.duration = Date.now() - startTime;
552
553
  if (firstTokenTime) output.ttft = firstTokenTime - startTime;
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { createHash, randomBytes, randomUUID } from "node:crypto";
7
7
  import { scheduler } from "node:timers/promises";
8
- import { fetchWithRetry, readSseJson } from "@oh-my-pi/pi-utils";
8
+ import { extractHttpStatusFromError, fetchWithRetry, readSseJson } from "@oh-my-pi/pi-utils";
9
9
  import { calculateCost } from "../models";
10
10
  import type {
11
11
  Api,
@@ -596,6 +596,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
596
596
  }
597
597
  }
598
598
  output.stopReason = options?.signal?.aborted ? "aborted" : "error";
599
+ output.errorStatus = extractHttpStatusFromError(error);
599
600
  output.errorMessage = await appendRawHttpRequestDumpFor400(
600
601
  error instanceof Error ? error.message : JSON.stringify(error),
601
602
  error,
@@ -2,7 +2,7 @@
2
2
  * Shared utilities for Google Generative AI and Google Cloud Code Assist providers.
3
3
  */
4
4
 
5
- import { readSseJson } from "@oh-my-pi/pi-utils";
5
+ import { extractHttpStatusFromError, readSseJson } from "@oh-my-pi/pi-utils";
6
6
  import { calculateCost } from "../models";
7
7
  import type {
8
8
  Api,
@@ -836,6 +836,7 @@ export function streamGoogleGenAI<T extends "google-generative-ai" | "google-ver
836
836
  }
837
837
  }
838
838
  output.stopReason = options?.signal?.aborted ? "aborted" : "error";
839
+ output.errorStatus = extractHttpStatusFromError(error);
839
840
  output.errorMessage = await finalizeErrorMessage(error, rawRequestDump);
840
841
  output.duration = Date.now() - startTime;
841
842
  if (firstTokenTime) output.ttft = firstTokenTime - startTime;
@@ -1,4 +1,4 @@
1
- import { fetchWithRetry } from "@oh-my-pi/pi-utils";
1
+ import { extractHttpStatusFromError, fetchWithRetry } from "@oh-my-pi/pi-utils";
2
2
  import { getEnvApiKey } from "../stream";
3
3
  import type {
4
4
  Api,
@@ -505,6 +505,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
505
505
  }
506
506
  }
507
507
  output.stopReason = options.signal?.aborted ? "aborted" : "error";
508
+ output.errorStatus = extractHttpStatusFromError(error);
508
509
  output.errorMessage = await finalizeErrorMessage(error, rawRequestDump);
509
510
  output.duration = Date.now() - startTime;
510
511
  if (firstTokenTime) {
@@ -1,6 +1,15 @@
1
1
  import * as os from "node:os";
2
2
  import { scheduler } from "node:timers/promises";
3
- import { $env, $flag, asRecord, fetchWithRetry, logger, readSseJson, structuredCloneJSON } from "@oh-my-pi/pi-utils";
3
+ import {
4
+ $env,
5
+ $flag,
6
+ asRecord,
7
+ extractHttpStatusFromError,
8
+ fetchWithRetry,
9
+ logger,
10
+ readSseJson,
11
+ structuredCloneJSON,
12
+ } from "@oh-my-pi/pi-utils";
4
13
  import type OpenAI from "openai";
5
14
  import type {
6
15
  ResponseCustomToolCall,
@@ -40,7 +49,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
40
49
  import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
41
50
  import { getOpenAIStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
42
51
  import { parseStreamingJson } from "../utils/json-parse";
43
- import { adaptSchemaForStrict, NO_STRICT, toolWireSchema } from "../utils/schema";
52
+ import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
44
53
  import { compactGrammarDefinition } from "./grammar";
45
54
  import { CODEX_BASE_URL, getCodexAccountId, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "./openai-codex/constants";
46
55
  import {
@@ -1505,6 +1514,7 @@ async function handleCodexStreamFailure(
1505
1514
  resetCodexSessionMetadata(context.requestContext.websocketState);
1506
1515
  }
1507
1516
  output.stopReason = context.options?.signal?.aborted ? "aborted" : "error";
1517
+ output.errorStatus = extractHttpStatusFromError(error);
1508
1518
  output.errorMessage = await finalizeErrorMessage(error, context.requestContext.rawRequestDump);
1509
1519
  output.duration = Date.now() - context.startTime;
1510
1520
  if (context.firstTokenTime) {
@@ -2485,7 +2495,7 @@ export function convertOpenAICodexResponsesTools(
2485
2495
  };
2486
2496
  }
2487
2497
  const strict = !!(!NO_STRICT && tool.strict);
2488
- const baseParameters = toolWireSchema(tool);
2498
+ const baseParameters = sanitizeSchemaForOpenAIResponses(toolWireSchema(tool));
2489
2499
  const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(baseParameters, strict);
2490
2500
  return {
2491
2501
  type: "function",
@@ -860,6 +860,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
860
860
  for (const block of output.content) delete (block as any).index;
861
861
  const firstEventTimeoutError = abortTracker.getLocalAbortReason();
862
862
  output.stopReason = abortTracker.wasCallerAbort() ? "aborted" : "error";
863
+ output.errorStatus = extractHttpStatusFromError(error) ?? getCapturedErrorResponse?.()?.status;
863
864
  output.errorMessage =
864
865
  firstEventTimeoutError?.message ??
865
866
  (await finalizeErrorMessage(error, rawRequestDump, getCapturedErrorResponse?.()));
@@ -1,4 +1,4 @@
1
- import { $env, structuredCloneJSON } from "@oh-my-pi/pi-utils";
1
+ import { $env, extractHttpStatusFromError, structuredCloneJSON } from "@oh-my-pi/pi-utils";
2
2
  import OpenAI from "openai";
3
3
  import type {
4
4
  Tool as OpenAITool,
@@ -290,6 +290,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
290
290
  for (const block of output.content) delete (block as { index?: number }).index;
291
291
  const firstEventTimeoutError = abortTracker.getLocalAbortReason();
292
292
  output.stopReason = abortTracker.wasCallerAbort() ? "aborted" : "error";
293
+ output.errorStatus = extractHttpStatusFromError(error);
293
294
  output.errorMessage = firstEventTimeoutError?.message ?? (await finalizeErrorMessage(error, rawRequestDump));
294
295
  output.errorMessage = rewriteCopilotError(output.errorMessage, error, model.provider);
295
296
  output.duration = Date.now() - startTime;