@oh-my-pi/pi-ai 16.1.0 → 16.1.2

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,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.2] - 2026-06-19
6
+
7
+ ### Added
8
+
9
+ - Added improved JSON repair capabilities for Anthropic tool arguments
10
+ - Added authentication broker discovery to sync credentials between local SQLite and remote state
11
+
12
+ ### Fixed
13
+
14
+ - Improved error feedback and transparency for malformed Anthropic tool call arguments
15
+ - Added automatic fallback for unsupported OpenAI reasoning effort levels
16
+ - Improved reliability when handling invalid reasoning parameter errors across OpenAI-compatible APIs
17
+ - Fixed OpenAI-compatible Chat Completions, Responses, and Azure Responses requests to retry once with the nearest provider-supported reasoning effort when an endpoint rejects `xhigh`/`minimal`-style effort values.
18
+
5
19
  ## [16.1.0] - 2026-06-19
6
20
 
7
21
  ### Added
@@ -3920,4 +3934,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
3920
3934
 
3921
3935
  ## [0.9.4] - 2025-11-26
3922
3936
 
3923
- Initial release with multi-provider LLM support.
3937
+ Initial release with multi-provider LLM support.
@@ -0,0 +1,35 @@
1
+ import { AuthStorage } from "../auth-storage";
2
+ export interface AuthBrokerClientConfig {
3
+ url: string;
4
+ token: string;
5
+ }
6
+ export interface ResolveAuthBrokerConfigOptions {
7
+ agentDir?: string;
8
+ configValueResolver?: (config: string) => Promise<string | undefined>;
9
+ }
10
+ export interface DiscoverAuthStorageOptions {
11
+ agentDir?: string;
12
+ configValueResolver?: (config: string) => Promise<string | undefined>;
13
+ cachePath?: string;
14
+ sourceLabel?: string;
15
+ }
16
+ /** Path to the local bearer token file. Created by `omp auth-broker token`. */
17
+ export declare function getAuthBrokerTokenFilePath(): string;
18
+ /**
19
+ * Resolve broker connection configuration using the same precedence as the TUI:
20
+ *
21
+ * 1. `OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN` env vars.
22
+ * 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml`.
23
+ * 3. `<config-root>/auth-broker.token` file (paired with a URL from env/config).
24
+ *
25
+ * Returns `null` when no broker URL is configured — callers should fall back to
26
+ * the local SQLite store. Throws when a URL is configured but no token is
27
+ * available, matching the TUI behavior.
28
+ */
29
+ export declare function resolveAuthBrokerConfig(options?: ResolveAuthBrokerConfigOptions): Promise<AuthBrokerClientConfig | null>;
30
+ /**
31
+ * Create an AuthStorage instance, using the broker when configured and falling
32
+ * back to the local SQLite store otherwise. This is the single source of truth
33
+ * for the TUI and the catalog generator.
34
+ */
35
+ export declare function discoverAuthStorage(options?: DiscoverAuthStorageOptions): Promise<AuthStorage>;
@@ -1,4 +1,5 @@
1
1
  export * from "./client";
2
+ export * from "./discover";
2
3
  export * from "./refresher";
3
4
  export * from "./remote-store";
4
5
  export * from "./server";
@@ -0,0 +1,25 @@
1
+ import type { CapturedHttpErrorResponse } from "../utils/http-inspector";
2
+ /** @internal */
3
+ export type OpenAIReasoningEffortFallback = string | null;
4
+ /** @internal */
5
+ export interface OpenAIReasoningEffortFallbackState {
6
+ reasoningEffortFallbacks: Map<string, OpenAIReasoningEffortFallback>;
7
+ }
8
+ /** @internal */
9
+ export declare function createOpenAIReasoningEffortFallbackState(): OpenAIReasoningEffortFallbackState;
10
+ /** @internal */
11
+ export declare function clearOpenAIReasoningEffortFallbackState(state: OpenAIReasoningEffortFallbackState): void;
12
+ /** @internal */
13
+ export declare function getOpenAIReasoningEffortFallback(state: OpenAIReasoningEffortFallbackState | undefined, key: string): OpenAIReasoningEffortFallback | undefined;
14
+ /** @internal */
15
+ export declare function rememberOpenAIReasoningEffortFallback(state: OpenAIReasoningEffortFallbackState | undefined, key: string, fallback: OpenAIReasoningEffortFallback): void;
16
+ /** @internal */
17
+ export declare function createOpenAIReasoningEffortFallbackKey(endpoint: "chat-completions" | "responses" | "azure-responses", baseUrl: string | undefined, wireModelId: string | undefined): string;
18
+ /** @internal */
19
+ export declare function readOpenAIReasoningEffort(params: unknown): string | undefined;
20
+ /** @internal */
21
+ export declare function applyOpenAIReasoningEffortFallback(params: unknown, fallback: OpenAIReasoningEffortFallback): boolean;
22
+ /** @internal */
23
+ export declare function resolveOpenAIReasoningEffortFallback(error: unknown, captured: CapturedHttpErrorResponse | undefined, params: unknown, options?: {
24
+ explicitDisable?: boolean;
25
+ }): OpenAIReasoningEffortFallback | undefined;
@@ -1,5 +1,6 @@
1
1
  import type { Context, Model, OpenAICompat, ProviderSessionState, ServiceTier, StreamFunction, StreamOptions, Tool, ToolChoice } from "../types";
2
2
  import { type OpenAIResponsesToolChoice } from "../utils/tool-choice";
3
+ import { type OpenAIReasoningEffortFallbackState } from "./openai-reasoning-fallback";
3
4
  import type { Tool as OpenAITool, ResponseCreateParamsStreaming, ResponseInput } from "./openai-responses-wire";
4
5
  import { type OpenAIStrictToolsScope, type OpenAIStrictToolsState } from "./openai-shared";
5
6
  export interface OpenAIResponsesOptions extends StreamOptions {
@@ -55,7 +56,7 @@ export interface OpenAIResponsesOptions extends StreamOptions {
55
56
  */
56
57
  extraBody?: Record<string, unknown>;
57
58
  }
58
- interface OpenAIResponsesProviderSessionState extends ProviderSessionState, OpenAIStrictToolsState {
59
+ interface OpenAIResponsesProviderSessionState extends ProviderSessionState, OpenAIStrictToolsState, OpenAIReasoningEffortFallbackState {
59
60
  nativeHistoryReplayWarmed: boolean;
60
61
  /** Stateful `previous_response_id` chain baselines, keyed by baseUrl/model/session. */
61
62
  chains: Map<string, OpenAIResponsesChainState>;
@@ -549,8 +549,8 @@ export interface Tool<TParameters extends TSchema = TSchema> {
549
549
  * Illustrative calls/notes; the AI layer renders them into an `<examples>`
550
550
  * block in the model's native tool-call syntax and appends to the wire
551
551
  * description. Author `call`/`bad`/`good` as plain argument objects WITHOUT
552
- * `_i` — when intent tracing injects `_i` into the schema, the renderer adds
553
- * a placeholder `_i` automatically. Type each tool's `examples` against its
552
+ * `i` — when intent tracing injects `i` into the schema, the renderer adds
553
+ * a placeholder `i` automatically. Type each tool's `examples` against its
554
554
  * own schema (e.g. `readonly ToolExample<typeof schema["type"]>[]`).
555
555
  */
556
556
  examples?: readonly ToolExample[];
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.1.0",
4
+ "version": "16.1.2",
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,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "@oh-my-pi/pi-catalog": "16.1.0",
42
- "@oh-my-pi/pi-utils": "16.1.0",
43
- "@oh-my-pi/pi-wire": "16.1.0",
41
+ "@oh-my-pi/pi-catalog": "16.1.2",
42
+ "@oh-my-pi/pi-utils": "16.1.2",
43
+ "@oh-my-pi/pi-wire": "16.1.2",
44
44
  "arktype": "^2.2.0",
45
45
  "partial-json": "^0.1.7",
46
46
  "zod": "^4"
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Broker-aware auth-storage discovery used by both the coding-agent runtime and
3
+ * the catalog model generator. Keeps the precedence logic (env → config.yml →
4
+ * token file → local SQLite) in one place so build-time tooling sees the same
5
+ * credentials as the TUI.
6
+ */
7
+ import * as path from "node:path";
8
+ import {
9
+ getAgentDbPath,
10
+ getAgentDir,
11
+ getAuthBrokerSnapshotCachePath,
12
+ getConfigRootDir,
13
+ isEnoent,
14
+ logger,
15
+ } from "@oh-my-pi/pi-utils";
16
+ import { YAML } from "bun";
17
+ import { AuthStorage } from "../auth-storage";
18
+ import { AuthBrokerClient } from "./client";
19
+ import { RemoteAuthCredentialStore } from "./remote-store";
20
+ import { readAuthBrokerSnapshotCache, writeAuthBrokerSnapshotCache } from "./snapshot-cache";
21
+ import { DEFAULT_SNAPSHOT_CACHE_TTL_MS, type SnapshotResponse } from "./types";
22
+
23
+ export interface AuthBrokerClientConfig {
24
+ url: string;
25
+ token: string;
26
+ }
27
+
28
+ export interface ResolveAuthBrokerConfigOptions {
29
+ agentDir?: string;
30
+ configValueResolver?: (config: string) => Promise<string | undefined>;
31
+ }
32
+
33
+ export interface DiscoverAuthStorageOptions {
34
+ agentDir?: string;
35
+ configValueResolver?: (config: string) => Promise<string | undefined>;
36
+ cachePath?: string;
37
+ sourceLabel?: string;
38
+ }
39
+
40
+ /** Path to the local bearer token file. Created by `omp auth-broker token`. */
41
+ export function getAuthBrokerTokenFilePath(): string {
42
+ return path.join(getConfigRootDir(), "auth-broker.token");
43
+ }
44
+
45
+ /**
46
+ * Default resolver for config values: checks `process.env` first, then treats
47
+ * the value as a literal. Does NOT execute `!command` syntax; such values are
48
+ * left unresolved so the caller can fall back to the token file.
49
+ */
50
+ async function defaultResolveConfigValue(config: string): Promise<string | undefined> {
51
+ if (config.startsWith("!")) return undefined;
52
+ const envValue = process.env[config];
53
+ return envValue || config;
54
+ }
55
+
56
+ async function readTokenFile(): Promise<string | null> {
57
+ try {
58
+ const raw = await Bun.file(getAuthBrokerTokenFilePath()).text();
59
+ const trimmed = raw.trim();
60
+ return trimmed.length > 0 ? trimmed : null;
61
+ } catch (err) {
62
+ if (isEnoent(err)) return null;
63
+ logger.warn("auth-broker token file unreadable", { error: String(err) });
64
+ return null;
65
+ }
66
+ }
67
+
68
+ interface ConfigSnapshot {
69
+ url?: string;
70
+ token?: string;
71
+ }
72
+
73
+ async function readConfigYaml(agentDir: string): Promise<ConfigSnapshot> {
74
+ const configPath = path.join(agentDir, "config.yml");
75
+ try {
76
+ const raw = await Bun.file(configPath).text();
77
+ const parsed = YAML.parse(raw);
78
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
79
+ const record = parsed as Record<string, unknown>;
80
+ const url = typeof record["auth.broker.url"] === "string" ? (record["auth.broker.url"] as string) : undefined;
81
+ const token =
82
+ typeof record["auth.broker.token"] === "string" ? (record["auth.broker.token"] as string) : undefined;
83
+ return { url, token };
84
+ } catch (err) {
85
+ if (isEnoent(err)) return {};
86
+ logger.warn("auth-broker config.yml unreadable", { error: String(err) });
87
+ return {};
88
+ }
89
+ }
90
+
91
+ function resolveSnapshotTtlMs(): number {
92
+ const raw = process.env.OMP_AUTH_BROKER_SNAPSHOT_TTL_MS;
93
+ if (raw === undefined) return DEFAULT_SNAPSHOT_CACHE_TTL_MS;
94
+ const value = raw.trim();
95
+ if (value === "") return DEFAULT_SNAPSHOT_CACHE_TTL_MS;
96
+ const ttlMs = Number(value);
97
+ if (Number.isFinite(ttlMs) && ttlMs >= 0) return ttlMs;
98
+ logger.warn("Invalid OMP_AUTH_BROKER_SNAPSHOT_TTL_MS; using default", { value: raw });
99
+ return DEFAULT_SNAPSHOT_CACHE_TTL_MS;
100
+ }
101
+
102
+ /**
103
+ * Resolve broker connection configuration using the same precedence as the TUI:
104
+ *
105
+ * 1. `OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN` env vars.
106
+ * 2. `auth.broker.url` / `auth.broker.token` in `<agentDir>/config.yml`.
107
+ * 3. `<config-root>/auth-broker.token` file (paired with a URL from env/config).
108
+ *
109
+ * Returns `null` when no broker URL is configured — callers should fall back to
110
+ * the local SQLite store. Throws when a URL is configured but no token is
111
+ * available, matching the TUI behavior.
112
+ */
113
+ export async function resolveAuthBrokerConfig(
114
+ options: ResolveAuthBrokerConfigOptions = {},
115
+ ): Promise<AuthBrokerClientConfig | null> {
116
+ const agentDir = options.agentDir ?? getAgentDir();
117
+ const resolveConfig = options.configValueResolver ?? defaultResolveConfigValue;
118
+
119
+ const envUrl = process.env.OMP_AUTH_BROKER_URL;
120
+ const envToken = process.env.OMP_AUTH_BROKER_TOKEN;
121
+
122
+ let url = envUrl && envUrl.length > 0 ? envUrl : undefined;
123
+ let configToken: string | undefined;
124
+ if (!url || !envToken) {
125
+ const fromConfig = await readConfigYaml(agentDir);
126
+ if (!url && fromConfig.url) {
127
+ const resolved = await resolveConfig(fromConfig.url);
128
+ if (resolved && resolved.length > 0) url = resolved;
129
+ }
130
+ if (fromConfig.token) {
131
+ const resolved = await resolveConfig(fromConfig.token);
132
+ if (resolved && resolved.length > 0) configToken = resolved;
133
+ }
134
+ }
135
+ if (!url) return null;
136
+
137
+ const token =
138
+ (envToken && envToken.length > 0 ? envToken : undefined) ?? configToken ?? (await readTokenFile()) ?? undefined;
139
+ if (!token) {
140
+ throw new Error(
141
+ `OMP_AUTH_BROKER_URL is set (${url}) but no bearer token is available. ` +
142
+ `Set OMP_AUTH_BROKER_TOKEN, the \`auth.broker.token\` config entry, or place one at ${getAuthBrokerTokenFilePath()}.`,
143
+ );
144
+ }
145
+ return { url, token };
146
+ }
147
+
148
+ /**
149
+ * Create an AuthStorage instance, using the broker when configured and falling
150
+ * back to the local SQLite store otherwise. This is the single source of truth
151
+ * for the TUI and the catalog generator.
152
+ */
153
+ export async function discoverAuthStorage(options: DiscoverAuthStorageOptions = {}): Promise<AuthStorage> {
154
+ const agentDir = options.agentDir ?? getAgentDir();
155
+ const brokerConfig = await resolveAuthBrokerConfig({
156
+ agentDir,
157
+ configValueResolver: options.configValueResolver,
158
+ });
159
+
160
+ if (brokerConfig) {
161
+ const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token });
162
+ const cachePath = options.cachePath ?? getAuthBrokerSnapshotCachePath();
163
+ const ttlMs = resolveSnapshotTtlMs();
164
+ const persist =
165
+ ttlMs > 0
166
+ ? (snapshot: SnapshotResponse): void => {
167
+ void writeAuthBrokerSnapshotCache({
168
+ path: cachePath,
169
+ token: brokerConfig.token,
170
+ url: brokerConfig.url,
171
+ snapshot,
172
+ }).catch(error => {
173
+ logger.debug("auth-broker snapshot cache write failed", { error: String(error) });
174
+ });
175
+ }
176
+ : undefined;
177
+
178
+ let initialSnapshot: SnapshotResponse | undefined;
179
+ if (ttlMs > 0) {
180
+ initialSnapshot =
181
+ (await readAuthBrokerSnapshotCache({
182
+ path: cachePath,
183
+ token: brokerConfig.token,
184
+ url: brokerConfig.url,
185
+ ttlMs,
186
+ }).catch(error => {
187
+ logger.debug("auth-broker snapshot cache read failed", { error: String(error) });
188
+ return null;
189
+ })) ?? undefined;
190
+ }
191
+ if (!initialSnapshot) {
192
+ const initialResult = await client.fetchSnapshot();
193
+ if (initialResult.status !== 200) throw new Error("Auth broker returned no initial snapshot");
194
+ initialSnapshot = initialResult.snapshot;
195
+ persist?.(initialSnapshot);
196
+ }
197
+ const store = new RemoteAuthCredentialStore({
198
+ client,
199
+ initialSnapshot,
200
+ onSnapshot: persist,
201
+ });
202
+ const storage = new AuthStorage(store, {
203
+ configValueResolver: options.configValueResolver,
204
+ sourceLabel: options.sourceLabel ?? `broker ${brokerConfig.url}`,
205
+ });
206
+ await storage.reload();
207
+ return storage;
208
+ }
209
+
210
+ const dbPath = getAgentDbPath(agentDir);
211
+ const storage = await AuthStorage.create(dbPath, {
212
+ configValueResolver: options.configValueResolver,
213
+ sourceLabel: options.sourceLabel ?? `local ${dbPath}`,
214
+ });
215
+ await storage.reload();
216
+ return storage;
217
+ }
@@ -1,4 +1,5 @@
1
1
  export * from "./client";
2
+ export * from "./discover";
2
3
  export * from "./refresher";
3
4
  export * from "./remote-store";
4
5
  export * from "./server";
@@ -9,7 +9,7 @@ export function renderToolExamples(tool: InbandTool, dialect: Dialect, intentFie
9
9
  if (!examples?.length) return "";
10
10
  const definition = getDialectDefinition(dialect);
11
11
  const renderCall = (args: Record<string, unknown>): string => {
12
- // When intent tracing injects `_i` into the schema, examples must show a
12
+ // When intent tracing injects `i` into the schema, examples must show a
13
13
  // placeholder so the model learns to emit it. Keep it first, matching the
14
14
  // schema injection order.
15
15
  const finalArgs = intentField ? { [intentField]: INTENT_PLACEHOLDER, ...args } : args;
package/src/dialect/pi.md CHANGED
@@ -23,7 +23,7 @@ Call with a verbatim body — everything between `«` and `»` is taken literall
23
23
 
24
24
  Argument values:
25
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"`).
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
27
  - Numbers, booleans, and `null` are JSON literals (`offset=50`, `force=true`).
28
28
  - Arrays and objects are inline JSON (`paths=["src","test"]`).
29
29
  - The body fence holds the call's first long/multi-line string parameter; its key is implied, never written.
@@ -50,7 +50,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
50
50
  import { isFoundryEnabled } from "../utils/foundry";
51
51
  import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
52
52
  import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
53
- import { parseStreamingJsonThrottled } from "../utils/json-parse";
53
+ import { parseJsonWithRepair, parseStreamingJsonThrottled } from "../utils/json-parse";
54
54
  import { notifyProviderResponse } from "../utils/provider-response";
55
55
  import { isCopilotTransientModelError } from "../utils/retry";
56
56
  import { COMBINATOR_KEYS, NO_STRICT, toolWireSchema } from "../utils/schema";
@@ -1752,14 +1752,25 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1752
1752
  const finalJson =
1753
1753
  block.partialJson.length > 0 ? block.partialJson : JSON.stringify(block.arguments ?? {});
1754
1754
  try {
1755
- block.arguments = JSON.parse(finalJson) as ToolCall["arguments"];
1755
+ block.arguments = parseJsonWithRepair(finalJson) as ToolCall["arguments"];
1756
1756
  } catch (parseError) {
1757
1757
  // Non-fatal: keep the best-effort arguments recovered by the throttled streaming
1758
1758
  // parser instead of failing the turn on malformed/truncated tool-argument JSON.
1759
1759
  reportAnthropicEnvelopeAnomaly(
1760
1760
  `tool_use ${block.id} arguments are not valid JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
1761
1761
  );
1762
- block.arguments = (block.arguments ?? {}) as ToolCall["arguments"];
1762
+ const recoveredKeys = Object.keys(block.arguments ?? {});
1763
+ if (recoveredKeys.length === 0) {
1764
+ const maxLen = 512;
1765
+ const truncatedJson =
1766
+ finalJson.length <= maxLen
1767
+ ? finalJson
1768
+ : `${finalJson.slice(0, maxLen)}… [truncated ${finalJson.length - maxLen} chars]`;
1769
+ block.arguments = {
1770
+ __parseError: parseError instanceof Error ? parseError.message : String(parseError),
1771
+ __rawJson: truncatedJson,
1772
+ };
1773
+ }
1763
1774
  }
1764
1775
  delete (block as { partialJson?: string }).partialJson;
1765
1776
  delete (block as { lastParseLen?: number }).lastParseLen;
@@ -18,9 +18,15 @@ import {
18
18
  getOpenAIStreamIdleTimeoutMs,
19
19
  iterateWithIdleTimeout,
20
20
  } from "../utils/idle-iterator";
21
- import { postOpenAIStream } from "../utils/openai-http";
21
+ import { OpenAIHttpError, postOpenAIStream } from "../utils/openai-http";
22
22
  import { sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
23
23
  import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice";
24
+ import {
25
+ applyOpenAIReasoningEffortFallback,
26
+ createOpenAIReasoningEffortFallbackKey,
27
+ type OpenAIReasoningEffortFallback,
28
+ resolveOpenAIReasoningEffortFallback,
29
+ } from "./openai-reasoning-fallback";
24
30
  import type { ResponseCreateParamsStreaming, ResponseStreamEvent } from "./openai-responses-wire";
25
31
  import {
26
32
  applyCommonResponsesSamplingParams,
@@ -135,29 +141,52 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
135
141
  url,
136
142
  body: params,
137
143
  };
138
- let requestTimeout: NodeJS.Timeout | undefined;
139
- if (requestTimeoutMs !== undefined) {
140
- requestTimeout = setTimeout(() => abortTracker.abortLocally(firstEventTimeoutAbortError), requestTimeoutMs);
141
- }
144
+ const reasoningEffortFallbackKey = createOpenAIReasoningEffortFallbackKey(
145
+ "azure-responses",
146
+ url,
147
+ typeof params.model === "string" ? params.model : model.id,
148
+ );
149
+ const attemptedReasoningEffortFallbacks = new Set<string>();
142
150
  let openaiStream: AsyncIterable<ResponseStreamEvent>;
143
- try {
144
- const headersWithTimeout = { ...headers };
151
+ while (true) {
152
+ let requestTimeout: NodeJS.Timeout | undefined;
145
153
  if (requestTimeoutMs !== undefined) {
146
- headersWithTimeout["X-Stainless-Timeout"] = Math.floor(requestTimeoutMs / 1000).toString();
154
+ requestTimeout = setTimeout(
155
+ () => abortTracker.abortLocally(firstEventTimeoutAbortError),
156
+ requestTimeoutMs,
157
+ );
158
+ }
159
+ try {
160
+ const headersWithTimeout = { ...headers };
161
+ if (requestTimeoutMs !== undefined) {
162
+ headersWithTimeout["X-Stainless-Timeout"] = Math.floor(requestTimeoutMs / 1000).toString();
163
+ }
164
+ const handle = await postOpenAIStream<ResponseStreamEvent>({
165
+ url,
166
+ headers: headersWithTimeout,
167
+ body: params,
168
+ signal: requestSignal,
169
+ fetch: options?.fetch,
170
+ // Watchdog armed → no retries, so they cannot silently extend the deadline.
171
+ maxAttempts: requestTimeoutMs !== undefined ? 1 : undefined,
172
+ onSseEvent: rawSseObserver,
173
+ });
174
+ openaiStream = handle.events;
175
+ break;
176
+ } catch (error) {
177
+ const capturedErrorResponse = error instanceof OpenAIHttpError ? error.captured : undefined;
178
+ const reasoningEffortFallback: OpenAIReasoningEffortFallback | undefined = !requestSignal.aborted
179
+ ? resolveOpenAIReasoningEffortFallback(error, capturedErrorResponse, params)
180
+ : undefined;
181
+ if (reasoningEffortFallback === undefined) throw error;
182
+ const retryMarker = `${reasoningEffortFallbackKey}:${String(reasoningEffortFallback)}`;
183
+ if (attemptedReasoningEffortFallbacks.has(retryMarker)) throw error;
184
+ attemptedReasoningEffortFallbacks.add(retryMarker);
185
+ applyOpenAIReasoningEffortFallback(params, reasoningEffortFallback);
186
+ rawRequestDump.body = params;
187
+ } finally {
188
+ if (requestTimeout !== undefined) clearTimeout(requestTimeout);
147
189
  }
148
- const handle = await postOpenAIStream<ResponseStreamEvent>({
149
- url,
150
- headers: headersWithTimeout,
151
- body: params,
152
- signal: requestSignal,
153
- fetch: options?.fetch,
154
- // Watchdog armed → no retries, so they cannot silently extend the deadline.
155
- maxAttempts: requestTimeoutMs !== undefined ? 1 : undefined,
156
- onSseEvent: rawSseObserver,
157
- });
158
- openaiStream = handle.events;
159
- } finally {
160
- if (requestTimeout !== undefined) clearTimeout(requestTimeout);
161
190
  }
162
191
  stream.push({ type: "start", partial: output });
163
192
 
@@ -56,6 +56,17 @@ import type {
56
56
  ChatCompletionTool,
57
57
  ChatCompletionToolMessageParam,
58
58
  } from "./openai-chat-wire";
59
+ import {
60
+ applyOpenAIReasoningEffortFallback,
61
+ clearOpenAIReasoningEffortFallbackState,
62
+ createOpenAIReasoningEffortFallbackKey,
63
+ createOpenAIReasoningEffortFallbackState,
64
+ getOpenAIReasoningEffortFallback,
65
+ type OpenAIReasoningEffortFallback,
66
+ type OpenAIReasoningEffortFallbackState,
67
+ rememberOpenAIReasoningEffortFallback,
68
+ resolveOpenAIReasoningEffortFallback,
69
+ } from "./openai-reasoning-fallback";
59
70
  import {
60
71
  applyChatCompletionsCompatPolicy,
61
72
  applyChatCompletionsToolStream,
@@ -444,14 +455,19 @@ type BuiltOpenAICompletionTools = {
444
455
 
445
456
  const OPENAI_COMPLETIONS_PROVIDER_SESSION_STATE_PREFIX = "openai-completions:";
446
457
 
447
- type OpenAICompletionsProviderSessionState = ProviderSessionState & OpenAIStrictToolsState;
458
+ type OpenAICompletionsProviderSessionState = ProviderSessionState &
459
+ OpenAIStrictToolsState &
460
+ OpenAIReasoningEffortFallbackState;
448
461
 
449
462
  function createOpenAICompletionsProviderSessionState(): OpenAICompletionsProviderSessionState {
450
463
  const strictToolsState = createOpenAIStrictToolsState();
464
+ const reasoningEffortFallbackState = createOpenAIReasoningEffortFallbackState();
451
465
  const state: OpenAICompletionsProviderSessionState = {
452
466
  ...strictToolsState,
467
+ ...reasoningEffortFallbackState,
453
468
  close: () => {
454
469
  clearOpenAIStrictToolsState(state);
470
+ clearOpenAIReasoningEffortFallbackState(state);
455
471
  },
456
472
  };
457
473
  return state;
@@ -581,6 +597,10 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
581
597
  );
582
598
  const premiumRequestsTotal = copilotPremiumRequests;
583
599
  let appliedStrictTools = false;
600
+ const requestReasoningEffortFallbacks = new Map<string, OpenAIReasoningEffortFallback>();
601
+ const attemptedReasoningEffortFallbacks = new Set<string>();
602
+ let activeReasoningEffortFallbackKey: string | undefined;
603
+ let activeRequestParams: OpenAICompletionsParams | undefined;
584
604
  const providerSessionState = getOpenAICompletionsProviderSessionState(
585
605
  model,
586
606
  baseUrl,
@@ -601,6 +621,19 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
601
621
  effectiveToolStrictModeOverride,
602
622
  );
603
623
  appliedStrictTools = strictToolsApplied;
624
+ const reasoningEffortFallbackKey = createOpenAIReasoningEffortFallbackKey(
625
+ "chat-completions",
626
+ trimmedBaseUrl,
627
+ params.model,
628
+ );
629
+ const requestReasoningEffortFallback = requestReasoningEffortFallbacks.has(reasoningEffortFallbackKey)
630
+ ? requestReasoningEffortFallbacks.get(reasoningEffortFallbackKey)
631
+ : getOpenAIReasoningEffortFallback(providerSessionState, reasoningEffortFallbackKey);
632
+ if (requestReasoningEffortFallback !== undefined) {
633
+ applyOpenAIReasoningEffortFallback(params, requestReasoningEffortFallback);
634
+ }
635
+ activeReasoningEffortFallbackKey = reasoningEffortFallbackKey;
636
+ activeRequestParams = params;
604
637
  options?.onPayload?.(params);
605
638
  rawRequestDump = {
606
639
  provider: model.provider,
@@ -650,7 +683,24 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
650
683
  });
651
684
  } catch (error) {
652
685
  const capturedErrorResponse = error instanceof OpenAIHttpError ? error.captured : undefined;
653
- if (
686
+ const reasoningEffortFallback =
687
+ activeReasoningEffortFallbackKey && activeRequestParams && !requestSignal.aborted
688
+ ? resolveOpenAIReasoningEffortFallback(error, capturedErrorResponse, activeRequestParams, {
689
+ explicitDisable: options?.disableReasoning === true && options.reasoning === undefined,
690
+ })
691
+ : undefined;
692
+ if (reasoningEffortFallback !== undefined && activeReasoningEffortFallbackKey) {
693
+ const retryMarker = `${activeReasoningEffortFallbackKey}:${String(reasoningEffortFallback)}`;
694
+ if (attemptedReasoningEffortFallbacks.has(retryMarker)) throw error;
695
+ attemptedReasoningEffortFallbacks.add(retryMarker);
696
+ requestReasoningEffortFallbacks.set(activeReasoningEffortFallbackKey, reasoningEffortFallback);
697
+ openaiStream = await createCompletionsStream();
698
+ rememberOpenAIReasoningEffortFallback(
699
+ providerSessionState,
700
+ activeReasoningEffortFallbackKey,
701
+ reasoningEffortFallback,
702
+ );
703
+ } else if (
654
704
  isOpenRouterAnthropicModel(model) &&
655
705
  !disableStrictTools &&
656
706
  isCompiledGrammarTooLargeStrictError(error, capturedErrorResponse)
@@ -0,0 +1,269 @@
1
+ import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
2
+ import type { CapturedHttpErrorResponse } from "../utils/http-inspector";
3
+
4
+ /** @internal */
5
+ export type OpenAIReasoningEffortFallback = string | null;
6
+
7
+ /** @internal */
8
+ export interface OpenAIReasoningEffortFallbackState {
9
+ reasoningEffortFallbacks: Map<string, OpenAIReasoningEffortFallback>;
10
+ }
11
+
12
+ const ENABLED_REASONING_VALUES = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
13
+ const KNOWN_REASONING_VALUE: Readonly<Record<string, true>> = {
14
+ none: true,
15
+ minimal: true,
16
+ low: true,
17
+ medium: true,
18
+ high: true,
19
+ xhigh: true,
20
+ max: true,
21
+ };
22
+ const REASONING_VALUE_RANK: Readonly<Record<string, number>> = {
23
+ minimal: 0,
24
+ low: 1,
25
+ medium: 2,
26
+ high: 3,
27
+ xhigh: 4,
28
+ max: 5,
29
+ };
30
+
31
+ /** @internal */
32
+ export function createOpenAIReasoningEffortFallbackState(): OpenAIReasoningEffortFallbackState {
33
+ return { reasoningEffortFallbacks: new Map() };
34
+ }
35
+
36
+ /** @internal */
37
+ export function clearOpenAIReasoningEffortFallbackState(state: OpenAIReasoningEffortFallbackState): void {
38
+ state.reasoningEffortFallbacks.clear();
39
+ }
40
+
41
+ /** @internal */
42
+ export function getOpenAIReasoningEffortFallback(
43
+ state: OpenAIReasoningEffortFallbackState | undefined,
44
+ key: string,
45
+ ): OpenAIReasoningEffortFallback | undefined {
46
+ return state?.reasoningEffortFallbacks.get(key);
47
+ }
48
+
49
+ /** @internal */
50
+ export function rememberOpenAIReasoningEffortFallback(
51
+ state: OpenAIReasoningEffortFallbackState | undefined,
52
+ key: string,
53
+ fallback: OpenAIReasoningEffortFallback,
54
+ ): void {
55
+ state?.reasoningEffortFallbacks.set(key, fallback);
56
+ }
57
+
58
+ /** @internal */
59
+ export function createOpenAIReasoningEffortFallbackKey(
60
+ endpoint: "chat-completions" | "responses" | "azure-responses",
61
+ baseUrl: string | undefined,
62
+ wireModelId: string | undefined,
63
+ ): string {
64
+ return `${endpoint}:${baseUrl ?? ""}:${wireModelId ?? ""}`;
65
+ }
66
+
67
+ function isRecord(value: unknown): value is Record<string, unknown> {
68
+ return typeof value === "object" && value !== null && !Array.isArray(value);
69
+ }
70
+
71
+ /** @internal */
72
+ export function readOpenAIReasoningEffort(params: unknown): string | undefined {
73
+ if (!isRecord(params)) return undefined;
74
+ if (typeof params.reasoning_effort === "string") return params.reasoning_effort;
75
+ const reasoning = params.reasoning;
76
+ return isRecord(reasoning) && typeof reasoning.effort === "string" ? reasoning.effort : undefined;
77
+ }
78
+
79
+ function deleteReasoningEffort(reasoning: Record<string, unknown>, parent: Record<string, unknown>): boolean {
80
+ if (typeof reasoning.effort !== "string") return false;
81
+ delete reasoning.effort;
82
+ for (const key in reasoning) {
83
+ if (key !== "effort") return true;
84
+ }
85
+ delete parent.reasoning;
86
+ return true;
87
+ }
88
+
89
+ /** @internal */
90
+ export function applyOpenAIReasoningEffortFallback(params: unknown, fallback: OpenAIReasoningEffortFallback): boolean {
91
+ if (!isRecord(params)) return false;
92
+ let changed = false;
93
+ if (typeof params.reasoning_effort === "string") {
94
+ if (fallback === null) {
95
+ delete params.reasoning_effort;
96
+ } else {
97
+ params.reasoning_effort = fallback;
98
+ }
99
+ changed = true;
100
+ }
101
+ const reasoning = params.reasoning;
102
+ if (isRecord(reasoning) && typeof reasoning.effort === "string") {
103
+ if (fallback === null) {
104
+ changed = deleteReasoningEffort(reasoning, params) || changed;
105
+ } else {
106
+ reasoning.effort = fallback;
107
+ changed = true;
108
+ }
109
+ }
110
+ return changed;
111
+ }
112
+
113
+ function capturedStringField(
114
+ captured: CapturedHttpErrorResponse | undefined,
115
+ field: "code" | "message" | "param" | "type",
116
+ ) {
117
+ const body = isRecord(captured?.bodyJson) ? captured.bodyJson : undefined;
118
+ const error = isRecord(body?.error) ? body.error : undefined;
119
+ if (typeof error?.[field] === "string") return error[field];
120
+ return typeof body?.[field] === "string" ? body[field] : undefined;
121
+ }
122
+
123
+ function collectMessageParts(error: unknown, captured: CapturedHttpErrorResponse | undefined): string {
124
+ const parts = [
125
+ error instanceof Error ? error.message : undefined,
126
+ capturedStringField(captured, "message"),
127
+ capturedStringField(captured, "param"),
128
+ capturedStringField(captured, "code"),
129
+ capturedStringField(captured, "type"),
130
+ captured?.bodyText,
131
+ ].filter((value): value is string => typeof value === "string" && value.trim().length > 0);
132
+ return parts.join("\n");
133
+ }
134
+
135
+ const REASONING_EFFORT_FIELD_PATTERN = /reasoning[_. ]effort|reasoning value/i;
136
+
137
+ function mentionsReasoningEffort(error: unknown, captured: CapturedHttpErrorResponse | undefined): boolean {
138
+ const param = capturedStringField(captured, "param");
139
+ const code = capturedStringField(captured, "code");
140
+ const type = capturedStringField(captured, "type");
141
+ const message = collectMessageParts(error, captured);
142
+ return (
143
+ REASONING_EFFORT_FIELD_PATTERN.test(param ?? "") ||
144
+ REASONING_EFFORT_FIELD_PATTERN.test(code ?? "") ||
145
+ REASONING_EFFORT_FIELD_PATTERN.test(type ?? "") ||
146
+ REASONING_EFFORT_FIELD_PATTERN.test(message)
147
+ );
148
+ }
149
+
150
+ function isInvalidReasoningEffortError(
151
+ error: unknown,
152
+ captured: CapturedHttpErrorResponse | undefined,
153
+ currentEffort: string,
154
+ ): boolean {
155
+ const status = extractHttpStatusFromError(error) ?? captured?.status;
156
+ if (status !== 400 && status !== 422) return false;
157
+ if (!mentionsReasoningEffort(error, captured)) return false;
158
+ const message = collectMessageParts(error, captured);
159
+ if (/reasoning[_ ]content/i.test(message) && !REASONING_EFFORT_FIELD_PATTERN.test(message)) return false;
160
+ if (/invalid[^\n]*(?:reasoning[_. ]effort|reasoning value)/i.test(message)) return true;
161
+ if (
162
+ /(?:reasoning[_. ]effort|reasoning value)[^\n]*(?:invalid|unsupported|not supported|must be|expected)/i.test(
163
+ message,
164
+ )
165
+ ) {
166
+ return true;
167
+ }
168
+ if (/(?:unsupported|not supported)[^\n]*(?:reasoning[_. ]effort|reasoning value)/i.test(message)) {
169
+ return true;
170
+ }
171
+ return new RegExp(
172
+ `(?:invalid|unsupported|not supported)[^\\n]*["'\`]${escapeRegExp(currentEffort)}["'\`]`,
173
+ "i",
174
+ ).test(message);
175
+ }
176
+
177
+ function escapeRegExp(value: string): string {
178
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
179
+ }
180
+
181
+ function parseKnownReasoningValues(text: string): Set<string> {
182
+ const values = new Set<string>();
183
+ const quotedPattern = /["'`](none|minimal|low|medium|high|xhigh|max)["'`]/gi;
184
+ let quotedMatch = quotedPattern.exec(text);
185
+ while (quotedMatch !== null) {
186
+ values.add(quotedMatch[1]!.toLowerCase());
187
+ quotedMatch = quotedPattern.exec(text);
188
+ }
189
+ const allowedMatch = /(?:must be|one of|allowed values?|supported values?(?: are)?|expected)([^.\n]+)/i.exec(text);
190
+ if (allowedMatch) {
191
+ const allowedText = allowedMatch[1]!;
192
+ const barePattern = /\b(none|minimal|low|medium|high|xhigh|max)\b/gi;
193
+ let bareMatch = barePattern.exec(allowedText);
194
+ while (bareMatch !== null) {
195
+ values.add(bareMatch[1]!.toLowerCase());
196
+ bareMatch = barePattern.exec(allowedText);
197
+ }
198
+ }
199
+ return values;
200
+ }
201
+
202
+ function parseAllowedReasoningValues(message: string, currentEffort: string): Set<string> | undefined {
203
+ const values = parseKnownReasoningValues(message);
204
+ const hasAllowedCue = /must be|one of|allowed values?|supported values?|expected/i.test(message);
205
+ values.delete(currentEffort.toLowerCase());
206
+ if (!hasAllowedCue && values.size === 0) return undefined;
207
+ return values;
208
+ }
209
+
210
+ function orderedEnabledAllowedValues(allowed: Set<string>): string[] {
211
+ return ENABLED_REASONING_VALUES.filter(value => allowed.has(value));
212
+ }
213
+
214
+ function lowestEnabledAllowedValue(allowed: Set<string>): string | undefined {
215
+ for (const value of ENABLED_REASONING_VALUES) {
216
+ if (allowed.has(value)) return value;
217
+ }
218
+ return undefined;
219
+ }
220
+
221
+ function nearestEnabledReasoningFallback(currentEffort: string, allowed: Set<string>): string | undefined {
222
+ const current = currentEffort.toLowerCase();
223
+ const allowedEnabled = orderedEnabledAllowedValues(allowed);
224
+ if (allowedEnabled.length === 0) return undefined;
225
+ if (current === "minimal" && allowedEnabled.includes("low")) return "low";
226
+ if (current === "xhigh" && allowedEnabled.includes("max")) return "max";
227
+ if (current === "xhigh" && allowedEnabled.includes("high")) return "high";
228
+ if (current === "max" && allowedEnabled.includes("xhigh")) return "xhigh";
229
+ const currentRank = REASONING_VALUE_RANK[current];
230
+ if (currentRank === undefined) return undefined;
231
+ let best: string | undefined;
232
+ let bestDistance = Number.POSITIVE_INFINITY;
233
+ let bestRank = Number.NEGATIVE_INFINITY;
234
+ for (const candidate of allowedEnabled) {
235
+ if (candidate === current) continue;
236
+ const candidateRank = REASONING_VALUE_RANK[candidate];
237
+ if (candidateRank === undefined) continue;
238
+ const distance = Math.abs(candidateRank - currentRank);
239
+ if (distance < bestDistance || (distance === bestDistance && candidateRank > bestRank)) {
240
+ best = candidate;
241
+ bestDistance = distance;
242
+ bestRank = candidateRank;
243
+ }
244
+ }
245
+ return best;
246
+ }
247
+
248
+ /** @internal */
249
+ export function resolveOpenAIReasoningEffortFallback(
250
+ error: unknown,
251
+ captured: CapturedHttpErrorResponse | undefined,
252
+ params: unknown,
253
+ options?: { explicitDisable?: boolean },
254
+ ): OpenAIReasoningEffortFallback | undefined {
255
+ const currentEffort = readOpenAIReasoningEffort(params);
256
+ if (!currentEffort || !KNOWN_REASONING_VALUE[currentEffort.toLowerCase()]) return undefined;
257
+ if (!isInvalidReasoningEffortError(error, captured, currentEffort)) return undefined;
258
+ const message = collectMessageParts(error, captured);
259
+ const allowed = parseAllowedReasoningValues(message, currentEffort);
260
+ const normalizedCurrent = currentEffort.toLowerCase();
261
+ if (allowed === undefined) return null;
262
+ if (options?.explicitDisable) {
263
+ if (normalizedCurrent !== "none" && allowed.has("none")) return "none";
264
+ const fallback = lowestEnabledAllowedValue(allowed);
265
+ return fallback && fallback !== normalizedCurrent ? fallback : null;
266
+ }
267
+ if (normalizedCurrent === "none") return null;
268
+ return nearestEnabledReasoningFallback(normalizedCurrent, allowed) ?? null;
269
+ }
@@ -44,6 +44,17 @@ import {
44
44
  type OpenAIResponsesToolChoice,
45
45
  } from "../utils/tool-choice";
46
46
  import { compactGrammarDefinition } from "./grammar";
47
+ import {
48
+ applyOpenAIReasoningEffortFallback,
49
+ clearOpenAIReasoningEffortFallbackState,
50
+ createOpenAIReasoningEffortFallbackKey,
51
+ createOpenAIReasoningEffortFallbackState,
52
+ getOpenAIReasoningEffortFallback,
53
+ type OpenAIReasoningEffortFallback,
54
+ type OpenAIReasoningEffortFallbackState,
55
+ rememberOpenAIReasoningEffortFallback,
56
+ resolveOpenAIReasoningEffortFallback,
57
+ } from "./openai-reasoning-fallback";
47
58
  import type {
48
59
  Tool as OpenAITool,
49
60
  ResponseCreateParamsStreaming,
@@ -140,7 +151,10 @@ const OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE =
140
151
  /** Consecutive stale-previous-response failures before chaining is disabled for the session. */
141
152
  const OPENAI_RESPONSES_CHAIN_STALE_FAILURE_LIMIT = 3;
142
153
 
143
- interface OpenAIResponsesProviderSessionState extends ProviderSessionState, OpenAIStrictToolsState {
154
+ interface OpenAIResponsesProviderSessionState
155
+ extends ProviderSessionState,
156
+ OpenAIStrictToolsState,
157
+ OpenAIReasoningEffortFallbackState {
144
158
  nativeHistoryReplayWarmed: boolean;
145
159
  /** Stateful `previous_response_id` chain baselines, keyed by baseUrl/model/session. */
146
160
  chains: Map<string, OpenAIResponsesChainState>;
@@ -164,14 +178,17 @@ interface OpenAIResponsesChainState {
164
178
 
165
179
  function createOpenAIResponsesProviderSessionState(): OpenAIResponsesProviderSessionState {
166
180
  const strictToolsState = createOpenAIStrictToolsState();
181
+ const reasoningEffortFallbackState = createOpenAIReasoningEffortFallbackState();
167
182
  const state: OpenAIResponsesProviderSessionState = {
168
183
  ...strictToolsState,
184
+ ...reasoningEffortFallbackState,
169
185
  nativeHistoryReplayWarmed: false,
170
186
  chains: new Map(),
171
187
  close: () => {
172
188
  state.nativeHistoryReplayWarmed = false;
173
189
  state.chains.clear();
174
190
  clearOpenAIStrictToolsState(state);
191
+ clearOpenAIReasoningEffortFallbackState(state);
175
192
  },
176
193
  };
177
194
  return state;
@@ -385,6 +402,26 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
385
402
  const { trailingScaffoldingItems } = builtParams;
386
403
  let activeParams = params;
387
404
  let activeTrailingScaffoldingItems = trailingScaffoldingItems;
405
+ const resolvedBaseUrl = (baseUrl ?? "https://api.openai.com/v1").replace(/\/+$/, "");
406
+ const requestReasoningEffortFallbacks = new Map<string, OpenAIReasoningEffortFallback>();
407
+ const attemptedReasoningEffortFallbacks = new Set<string>();
408
+ let pendingReasoningEffortFallback: { key: string; fallback: OpenAIReasoningEffortFallback } | undefined;
409
+ let activeReasoningEffortFallbackKey: string | undefined;
410
+ let activeRequestParams: OpenAIResponsesSamplingParams | undefined;
411
+ const applyReasoningEffortFallbackForRequest = (requestParams: OpenAIResponsesSamplingParams): string => {
412
+ const fallbackKey = createOpenAIReasoningEffortFallbackKey(
413
+ "responses",
414
+ resolvedBaseUrl,
415
+ typeof requestParams.model === "string" ? requestParams.model : model.id,
416
+ );
417
+ const requestReasoningEffortFallback = requestReasoningEffortFallbacks.has(fallbackKey)
418
+ ? requestReasoningEffortFallbacks.get(fallbackKey)
419
+ : getOpenAIReasoningEffortFallback(providerSessionState, fallbackKey);
420
+ if (requestReasoningEffortFallback !== undefined) {
421
+ applyOpenAIReasoningEffortFallback(requestParams, requestReasoningEffortFallback);
422
+ }
423
+ return fallbackKey;
424
+ };
388
425
  if (isOpenAIResponsesStatefulEnabled(options, baseUrl) && routingSessionId && providerSessionState) {
389
426
  chainState = getOpenAIResponsesChainState(providerSessionState, model, baseUrl, routingSessionId);
390
427
  if (!chainState.disabled) {
@@ -392,6 +429,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
392
429
  params.store = true;
393
430
  }
394
431
  }
432
+ applyReasoningEffortFallbackForRequest(params);
395
433
  let chained: OpenAIResponsesChainedParams =
396
434
  chainState && !chainState.disabled
397
435
  ? buildOpenAIResponsesChainedParams(params, trailingScaffoldingItems, chainState)
@@ -402,12 +440,13 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
402
440
  options?.streamFirstEventTimeoutMs ?? getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs);
403
441
  const requestTimeoutMs =
404
442
  firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
405
- const requestUrl = `${(baseUrl ?? "https://api.openai.com/v1").replace(/\/+$/, "")}/responses`;
443
+ const requestUrl = `${resolvedBaseUrl}/responses`;
406
444
  const applyPayloadReplacement = async (requestParams: OpenAIResponsesSamplingParams) => {
407
445
  const replacementPayload = await options?.onPayload?.(requestParams, model);
408
- return replacementPayload !== undefined
409
- ? (replacementPayload as OpenAIResponsesSamplingParams)
410
- : requestParams;
446
+ const payload =
447
+ replacementPayload !== undefined ? (replacementPayload as OpenAIResponsesSamplingParams) : requestParams;
448
+ applyReasoningEffortFallbackForRequest(payload);
449
+ return payload;
411
450
  };
412
451
  chained = { ...chained, params: await applyPayloadReplacement(chained.params) };
413
452
  rawRequestDump = {
@@ -418,8 +457,14 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
418
457
  url: requestUrl,
419
458
  body: chained.params,
420
459
  };
421
- const openResponsesStream = (requestParams: OpenAIResponsesSamplingParams) =>
422
- callWithCopilotModelRetry(
460
+ const openResponsesStream = (requestParams: OpenAIResponsesSamplingParams) => {
461
+ activeReasoningEffortFallbackKey = createOpenAIReasoningEffortFallbackKey(
462
+ "responses",
463
+ resolvedBaseUrl,
464
+ typeof requestParams.model === "string" ? requestParams.model : model.id,
465
+ );
466
+ activeRequestParams = requestParams;
467
+ return callWithCopilotModelRetry(
423
468
  async () => {
424
469
  let requestTimeout: NodeJS.Timeout | undefined;
425
470
  if (requestTimeoutMs !== undefined) {
@@ -458,6 +503,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
458
503
  },
459
504
  { provider: model.provider, signal: requestSignal },
460
505
  );
506
+ };
461
507
  let openaiStream: AsyncIterable<ResponseStreamEvent>;
462
508
  let strictRetryAvailable = true;
463
509
  let activeStrictToolsApplied = builtParams.strictToolsApplied;
@@ -465,9 +511,37 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
465
511
  while (true) {
466
512
  try {
467
513
  openaiStream = await openResponsesStream(chained.params);
514
+ if (pendingReasoningEffortFallback) {
515
+ rememberOpenAIReasoningEffortFallback(
516
+ providerSessionState,
517
+ pendingReasoningEffortFallback.key,
518
+ pendingReasoningEffortFallback.fallback,
519
+ );
520
+ pendingReasoningEffortFallback = undefined;
521
+ }
468
522
  break;
469
523
  } catch (error) {
470
524
  const capturedErrorResponse = error instanceof OpenAIHttpError ? error.captured : undefined;
525
+ const reasoningEffortFallback =
526
+ activeReasoningEffortFallbackKey && activeRequestParams && !requestSignal.aborted
527
+ ? resolveOpenAIReasoningEffortFallback(error, capturedErrorResponse, activeRequestParams, {
528
+ explicitDisable: options?.disableReasoning === true && options.reasoning === undefined,
529
+ })
530
+ : undefined;
531
+ if (reasoningEffortFallback !== undefined && activeReasoningEffortFallbackKey) {
532
+ const retryMarker = `${activeReasoningEffortFallbackKey}:${String(reasoningEffortFallback)}`;
533
+ if (attemptedReasoningEffortFallbacks.has(retryMarker)) throw error;
534
+ attemptedReasoningEffortFallbacks.add(retryMarker);
535
+ requestReasoningEffortFallbacks.set(activeReasoningEffortFallbackKey, reasoningEffortFallback);
536
+ applyOpenAIReasoningEffortFallback(chained.params, reasoningEffortFallback);
537
+ applyOpenAIReasoningEffortFallback(activeParams, reasoningEffortFallback);
538
+ rawRequestDump.body = chained.params;
539
+ pendingReasoningEffortFallback = {
540
+ key: activeReasoningEffortFallbackKey,
541
+ fallback: reasoningEffortFallback,
542
+ };
543
+ continue;
544
+ }
471
545
  const compiledGrammarTooLarge =
472
546
  isOpenRouterAnthropicModel(model) &&
473
547
  isCompiledGrammarTooLargeStrictError(error, capturedErrorResponse);
package/src/types.ts CHANGED
@@ -656,8 +656,8 @@ export interface Tool<TParameters extends TSchema = TSchema> {
656
656
  * Illustrative calls/notes; the AI layer renders them into an `<examples>`
657
657
  * block in the model's native tool-call syntax and appends to the wire
658
658
  * description. Author `call`/`bad`/`good` as plain argument objects WITHOUT
659
- * `_i` — when intent tracing injects `_i` into the schema, the renderer adds
660
- * a placeholder `_i` automatically. Type each tool's `examples` against its
659
+ * `i` — when intent tracing injects `i` into the schema, the renderer adds
660
+ * a placeholder `i` automatically. Type each tool's `examples` against its
661
661
  * own schema (e.g. `readonly ToolExample<typeof schema["type"]>[]`).
662
662
  */
663
663
  examples?: readonly ToolExample[];
@@ -1291,6 +1291,18 @@ function truncateArgsForError(value: unknown): unknown {
1291
1291
  */
1292
1292
  export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall["arguments"] {
1293
1293
  const originalArgs = toolCall.arguments;
1294
+ if (originalArgs && typeof originalArgs === "object" && "__parseError" in originalArgs) {
1295
+ const parseError = originalArgs.__parseError;
1296
+ const rawJson = String(originalArgs.__rawJson ?? "");
1297
+ const maxLen = 512;
1298
+ const truncatedRawJson =
1299
+ rawJson.length <= maxLen
1300
+ ? rawJson
1301
+ : `${rawJson.slice(0, maxLen)}… [truncated ${rawJson.length - maxLen} chars]`;
1302
+ throw new Error(
1303
+ `Validation failed for tool "${toolCall.name}": Tool call arguments are not valid JSON.\nParse Error: ${parseError}\nRaw JSON:\n${truncatedRawJson}`,
1304
+ );
1305
+ }
1294
1306
  const ctx = getValidationContext(tool);
1295
1307
  const { json } = ctx;
1296
1308