@oh-my-pi/pi-ai 16.1.7 → 16.1.9

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,41 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.9] - 2026-06-21
6
+
7
+ ### Added
8
+
9
+ - Added `llama.cpp` to the interactive `/login` provider list, accepting an optional API key while defaulting to local no-auth mode.
10
+
11
+ ### Changed
12
+
13
+ - Optimized generated AI tool schemas by collapsing verbose `anyOf` unions into standard `enum` types
14
+
15
+ ### Fixed
16
+
17
+ - Fixed tool-call argument validation dropping nested keys that were accidentally double-encoded
18
+ - Fixed the `moonshot` provider being locked to the international Kimi host (`api.moonshot.ai`): OpenAI-completions requests now honor a `MOONSHOT_BASE_URL` override so users can reach the Kimi China platform (`api.moonshot.cn`), which rejects keys issued for the international endpoint. ([#2883](https://github.com/can1357/oh-my-pi/issues/2883))
19
+ - Fixed tool-call argument validation dropping fields whose object keys were accidentally JSON-encoded a second time (e.g. `{ "\"op\"": "done" }`), which surfaced as spurious missing-required errors. A schema-agnostic pre-validation pass now recursively unwraps such double-encoded keys — through arrays and nested objects, and again after a JSON-string container is parsed — before the unrecognized-key repair can delete them.
20
+
21
+ ### Removed
22
+
23
+ - Removed the `setNextRequestDebugPath`, `clearNextRequestDebugPath`, and `getNextRequestDebugPath` utility functions for request debugging, as request/response recording now relies exclusively on the `PI_REQ_DEBUG` environment variable.
24
+ - Removed Wafer Pass (`wafer-pass`) login support; Wafer Serverless remains available as `wafer-serverless`.
25
+
26
+ ## [16.1.8] - 2026-06-20
27
+
28
+ ### Changed
29
+
30
+ - Changed OpenAI Responses and Codex Responses custom grammar tool requests to leave `parallel_tool_calls` unset instead of forcing serial tool calls; Codex `responsesLite` still disables parallel tool calls when tools are present.
31
+
32
+ ### Fixed
33
+
34
+ - Fixed Bedrock `/btw` and other no-tool ephemeral turns failing after prior tool calls by sending the required sentinel `toolConfig` whenever replayed history contains `toolUse`/`toolResult` blocks. ([#3124](https://github.com/can1357/oh-my-pi/issues/3124))
35
+ - Fixed Anthropic Messages pre-content TLS `bad record MAC` server transport errors surfacing before the provider retry loop exhausts its budget. ([#3134](https://github.com/can1357/oh-my-pi/issues/3134))
36
+ - Fixed API-key login flows replacing existing stored keys for the same provider, so providers such as NVIDIA NIM can keep multiple active keys available for session-level rotation. ([#2923](https://github.com/can1357/oh-my-pi/issues/2923))
37
+ - Fixed `openai-codex-responses` forwarding sampling controls (`temperature`, `top_p`, `top_k`, `min_p`, `presence_penalty`, `repetition_penalty`) into the Codex request body — the ChatGPT-subscription Codex backend rejects each of them with a 400 `{"detail":"Unsupported parameter: temperature"}`, so any caller setting non-default `StreamOptions` saw every turn fail. The provider now drops the full sampling set (matching codex-rs), and the auth-gateway's defensive strip on both `buildStreamOptions` and the pi-native path was widened from `{temperature, topP}` to the same set plus `stopSequences`/`frequencyPenalty`. ([#3117](https://github.com/can1357/oh-my-pi/issues/3117))
38
+ - Fixed Anthropic Messages retry classification for transient TLS/server-error failures such as `tls: bad record MAC (type=server_error)`. These pre-content transport blips are now retried inside the provider loop before the session sees an error banner.
39
+
5
40
  ## [16.1.4] - 2026-06-19
6
41
 
7
42
  ### Added
package/README.md CHANGED
@@ -62,7 +62,6 @@ Unified LLM API with automatic model discovery, provider configuration, token an
62
62
  - **Hugging Face Inference**
63
63
  - **xAI**
64
64
  - **Venice** (requires `VENICE_API_KEY`)
65
- - **Wafer Pass** (requires `WAFER_PASS_API_KEY`; flat-rate subscription, includes GLM-5.1 and Qwen3.5-397B-A17B)
66
65
  - **Wafer Serverless** (requires `WAFER_SERVERLESS_API_KEY`; pay-as-you-go)
67
66
  - **OpenRouter**
68
67
  - **Kilo Gateway** (supports OAuth `/login kilo` or `KILO_API_KEY`)
@@ -34,12 +34,6 @@ export interface RequestBody {
34
34
  input?: InputItem[];
35
35
  tools?: unknown;
36
36
  tool_choice?: unknown;
37
- temperature?: number;
38
- top_p?: number;
39
- top_k?: number;
40
- min_p?: number;
41
- presence_penalty?: number;
42
- repetition_penalty?: number;
43
37
  reasoning?: Partial<ReasoningConfig>;
44
38
  text?: {
45
39
  verbosity?: "low" | "medium" | "high";
@@ -1,5 +1,5 @@
1
1
  import type { Context, Model, ProviderSessionState, ServiceTier, StreamFunction, StreamOptions, Tool, ToolChoice } from "../types";
2
- import { type CodexReasoningContext } from "./openai-codex/request-transformer";
2
+ import { type CodexReasoningContext, type RequestBody } from "./openai-codex/request-transformer";
3
3
  import type { ResponseInput } from "./openai-responses-wire";
4
4
  export interface OpenAICodexResponsesOptions extends StreamOptions {
5
5
  reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
@@ -49,6 +49,8 @@ export interface OpenAICodexWebSocketDebugStats {
49
49
  }
50
50
  /** @internal Exported for tests. */
51
51
  export declare function normalizeCodexToolChoice(choice: ToolChoice | undefined, tools?: Tool[], model?: Model<"openai-codex-responses">): string | Record<string, unknown> | undefined;
52
+ /** @internal Exported for tests. */
53
+ export declare function buildTransformedCodexRequestBody(model: Model<"openai-codex-responses">, context: Context, options: OpenAICodexResponsesOptions | undefined, promptCacheKey?: string | undefined): Promise<RequestBody>;
52
54
  export declare const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses">;
53
55
  export declare function prewarmOpenAICodexResponses(model: Model<"openai-codex-responses">, options?: Pick<OpenAICodexResponsesOptions, "apiKey" | "headers" | "sessionId" | "signal" | "preferWebsockets" | "providerSessionState" | "responsesLite">): Promise<void>;
54
56
  export interface OpenAICodexTransportDetails {
@@ -0,0 +1,8 @@
1
+ import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
2
+ export declare function loginLlamaCpp(options: OAuthController): Promise<string>;
3
+ export declare const llamaCppProvider: {
4
+ readonly id: "llama.cpp";
5
+ readonly name: "llama.cpp (Local OpenAI-compatible)";
6
+ readonly envKeys: "LLAMA_CPP_API_KEY";
7
+ readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
8
+ };
@@ -4,4 +4,5 @@ export declare const nvidiaProvider: {
4
4
  readonly id: "nvidia";
5
5
  readonly name: "NVIDIA";
6
6
  readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
7
+ readonly appendApiKeyLogin: true;
7
8
  };
@@ -1,2 +1 @@
1
- export declare const loginWaferPass: (options: import("./types").OAuthController) => Promise<string>;
2
1
  export declare const loginWaferServerless: (options: import("./types").OAuthController) => Promise<string>;
@@ -112,6 +112,11 @@ declare const ALL: ({
112
112
  readonly id: "litellm";
113
113
  readonly name: "LiteLLM";
114
114
  readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
115
+ } | {
116
+ readonly id: "llama.cpp";
117
+ readonly name: "llama.cpp (Local OpenAI-compatible)";
118
+ readonly envKeys: "LLAMA_CPP_API_KEY";
119
+ readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
115
120
  } | {
116
121
  readonly id: "lm-studio";
117
122
  readonly name: "LM Studio (Local OpenAI-compatible)";
@@ -142,6 +147,7 @@ declare const ALL: ({
142
147
  readonly id: "nvidia";
143
148
  readonly name: "NVIDIA";
144
149
  readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
150
+ readonly appendApiKeyLogin: true;
145
151
  } | {
146
152
  readonly id: "ollama";
147
153
  readonly name: "Ollama (Local OpenAI-compatible)";
@@ -225,10 +231,6 @@ declare const ALL: ({
225
231
  readonly id: "vllm";
226
232
  readonly name: "vLLM (Local OpenAI-compatible)";
227
233
  readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
228
- } | {
229
- readonly id: "wafer-pass";
230
- readonly name: "Wafer Pass (flat-rate subscription)";
231
- readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
232
234
  } | {
233
235
  readonly id: "wafer-serverless";
234
236
  readonly name: "Wafer Serverless (pay-as-you-go)";
@@ -40,6 +40,8 @@ export interface ProviderDefinition {
40
40
  readonly showInLoginList?: boolean;
41
41
  readonly envKeys?: KeyResolver;
42
42
  readonly login?: (callbacks: OAuthLoginCallbacks) => Promise<OAuthCredentials | string>;
43
+ /** String-returning login appends API keys instead of replacing the provider's existing key. */
44
+ readonly appendApiKeyLogin?: boolean;
43
45
  readonly refreshToken?: (credentials: OAuthCredentials) => Promise<OAuthCredentials>;
44
46
  readonly getApiKey?: (credentials: OAuthCredentials) => string;
45
47
  /** Store OAuth credentials under a different provider id (e.g. `openai-codex-device` ⇒ `openai-codex`). */
@@ -93,10 +93,12 @@ export type ResolvedServiceTier = Exclude<ServiceTier, "openai-only" | "claude-o
93
93
  */
94
94
  export declare function resolveServiceTier(serviceTier: ServiceTier | null | undefined, provider: Provider | undefined): ResolvedServiceTier | undefined;
95
95
  /**
96
- * True when the (possibly scoped) tier should be sent as OpenAI's
97
- * `service_tier` request field for the given provider. Non-OpenAI
98
- * providers, unsupported tiers (`"auto"`, `"default"`), and scope
99
- * mismatches all return false.
96
+ * True when the (possibly scoped) tier should be sent on the wire as the
97
+ * `service_tier` request field for the given provider. OpenAI / OpenAI-Codex
98
+ * accept `flex`/`scale`/`priority`; Fireworks Serverless realizes only its
99
+ * Priority serving path (`service_tier: "priority"`) on the OpenAI-compatible
100
+ * chat-completions endpoint. Unsupported tiers (`"auto"`, `"default"`), other
101
+ * providers, and scope mismatches all return false.
100
102
  */
101
103
  export declare function shouldSendServiceTier(serviceTier: ServiceTier | null | undefined, provider: Provider | undefined): boolean;
102
104
  /**
@@ -22,9 +22,6 @@ export interface RequestDebugSession {
22
22
  wrapResponse(response: Response): Promise<Response>;
23
23
  }
24
24
  export declare function isRequestDebugEnabled(): boolean;
25
- export declare function setNextRequestDebugPath(requestPath: string): void;
26
- export declare function clearNextRequestDebugPath(): void;
27
- export declare function getNextRequestDebugPath(): string | undefined;
28
25
  export declare function wrapFetchForRequestDebug(fetchImpl: FetchImpl): FetchImpl;
29
26
  export declare function withRequestDebugFetch<T extends {
30
27
  fetch?: FetchImpl;
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.7",
4
+ "version": "16.1.9",
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.7",
42
- "@oh-my-pi/pi-utils": "16.1.7",
43
- "@oh-my-pi/pi-wire": "16.1.7",
41
+ "@oh-my-pi/pi-catalog": "16.1.9",
42
+ "@oh-my-pi/pi-utils": "16.1.9",
43
+ "@oh-my-pi/pi-wire": "16.1.9",
44
44
  "arktype": "^2.2.0",
45
45
  "partial-json": "^0.1.7",
46
46
  "zod": "^4"
@@ -120,20 +120,20 @@ function deriveSessionId(modelId: string, context: Context): string {
120
120
  function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: AbortSignal): SimpleStreamOptions {
121
121
  const opts: SimpleStreamOptions = { signal };
122
122
  const { options } = parsed;
123
- // Codex backend rejects `temperature` / `top_p` (per-model defaults only),
124
- // so we drop them silently for that one provider. Every other unsupported
125
- // option is just ignored by `streamSimple` if the underlying provider
126
- // doesn't honour it.
123
+ // Codex backend rejects every sampling control with
124
+ // `Unsupported parameter: …` (#3117). Strip the full set for that one
125
+ // provider; everything else is harmless to forward `streamSimple` ignores
126
+ // what the underlying provider doesn't honour.
127
127
  const isCodex = api === "openai-codex-responses";
128
128
  if (options.maxOutputTokens !== undefined) opts.maxTokens = options.maxOutputTokens;
129
129
  if (options.temperature !== undefined && !isCodex) opts.temperature = options.temperature;
130
130
  if (options.topP !== undefined && !isCodex) opts.topP = options.topP;
131
- if (options.topK !== undefined) opts.topK = options.topK;
132
- if (options.minP !== undefined) opts.minP = options.minP;
133
- if (options.stopSequences !== undefined) opts.stopSequences = options.stopSequences;
134
- if (options.presencePenalty !== undefined) opts.presencePenalty = options.presencePenalty;
135
- if (options.frequencyPenalty !== undefined) opts.frequencyPenalty = options.frequencyPenalty;
136
- if (options.repetitionPenalty !== undefined) opts.repetitionPenalty = options.repetitionPenalty;
131
+ if (options.topK !== undefined && !isCodex) opts.topK = options.topK;
132
+ if (options.minP !== undefined && !isCodex) opts.minP = options.minP;
133
+ if (options.stopSequences !== undefined && !isCodex) opts.stopSequences = options.stopSequences;
134
+ if (options.presencePenalty !== undefined && !isCodex) opts.presencePenalty = options.presencePenalty;
135
+ if (options.frequencyPenalty !== undefined && !isCodex) opts.frequencyPenalty = options.frequencyPenalty;
136
+ if (options.repetitionPenalty !== undefined && !isCodex) opts.repetitionPenalty = options.repetitionPenalty;
137
137
  if (options.metadata !== undefined) opts.metadata = options.metadata;
138
138
  if (options.headers !== undefined) opts.headers = { ...(opts.headers ?? {}), ...options.headers };
139
139
  if (options.toolChoice !== undefined) {
@@ -662,8 +662,8 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
662
662
 
663
663
  // Build the SimpleStreamOptions actually handed to `streamSimple`. We
664
664
  // trust the client's options (already allow-listed by `parseRequest`) and
665
- // only inject server-controlled fields. The codex temperature/topP strip
666
- // matches `buildStreamOptions` — Codex rejects them with a 400.
665
+ // only inject server-controlled fields. The codex sampling strip mirrors
666
+ // `buildStreamOptions` — Codex rejects every one with a 400 (#3117).
667
667
  const streamOpts: SimpleStreamOptions = { ...parsed.options, apiKey, signal: controller.signal };
668
668
  streamOpts.apiKey = buildGatewayApiKeyResolver(
669
669
  bootOpts.storage,
@@ -677,6 +677,12 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
677
677
  if (model.api === "openai-codex-responses") {
678
678
  delete streamOpts.temperature;
679
679
  delete streamOpts.topP;
680
+ delete streamOpts.topK;
681
+ delete streamOpts.minP;
682
+ delete streamOpts.stopSequences;
683
+ delete streamOpts.presencePenalty;
684
+ delete streamOpts.frequencyPenalty;
685
+ delete streamOpts.repetitionPenalty;
680
686
  }
681
687
  // Merge gateway-captured passthrough headers under the client's own
682
688
  // headers — the client's values win when they collide.
@@ -1825,10 +1825,6 @@ export class AuthStorage {
1825
1825
  onPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;
1826
1826
  },
1827
1827
  ): Promise<void> {
1828
- const saveApiKeyCredential = async (apiKey: string): Promise<void> => {
1829
- const newCredential: ApiKeyCredential = { type: "api_key", key: apiKey };
1830
- await this.set(provider, newCredential);
1831
- };
1832
1828
  const manualCodeInput = () => ctrl.onPrompt({ message: "Paste the authorization code (or full redirect URL):" });
1833
1829
  // Built-in registry first, then runtime-registered extension providers.
1834
1830
  const def = getProviderDefinition(provider) ?? getOAuthProvider(provider);
@@ -1848,7 +1844,20 @@ export class AuthStorage {
1848
1844
  if (!result) {
1849
1845
  return;
1850
1846
  }
1851
- await saveApiKeyCredential(result);
1847
+ const newCredential: ApiKeyCredential = { type: "api_key", key: result };
1848
+ const appendApiKeyLogin = "appendApiKeyLogin" in def && def.appendApiKeyLogin === true;
1849
+ const stored = appendApiKeyLogin
1850
+ ? this.#store.upsertAuthCredentialRemote
1851
+ ? await this.#store.upsertAuthCredentialRemote(provider, newCredential)
1852
+ : this.#store.upsertAuthCredentialForProvider(provider, newCredential)
1853
+ : this.#store.replaceAuthCredentialsRemote
1854
+ ? await this.#store.replaceAuthCredentialsRemote(provider, [newCredential])
1855
+ : this.#store.replaceAuthCredentialsForProvider(provider, [newCredential]);
1856
+ this.#setStoredCredentials(
1857
+ provider,
1858
+ stored.map(entry => ({ id: entry.id, credential: entry.credential })),
1859
+ );
1860
+ this.#resetProviderAssignments(provider);
1852
1861
  return;
1853
1862
  }
1854
1863
  const newCredential: OAuthCredential = { type: "oauth", ...result };
@@ -142,6 +142,28 @@ interface WireToolConfig {
142
142
  toolChoice?: WireToolChoice;
143
143
  }
144
144
 
145
+ /**
146
+ * Bedrock validates that requests carrying any `toolUse`/`toolResult` history
147
+ * include a `toolConfig`. For no-tool ephemeral turns (`/btw`, IRC auto-replies)
148
+ * we have nothing real to send, so we inject this placeholder. Its presence is
149
+ * tracked by a per-request flag — never the wire name — so callers who happen
150
+ * to register a real tool literally called `__no_tools__` are not affected.
151
+ */
152
+ const NO_TOOLS_SENTINEL_NAME = "__no_tools__";
153
+
154
+ const NO_TOOLS_SENTINEL: WireToolSpec = {
155
+ toolSpec: {
156
+ name: NO_TOOLS_SENTINEL_NAME,
157
+ description: "Placeholder required by Bedrock validation. Do not call; answer with text.",
158
+ inputSchema: { json: { type: "object", properties: {} } },
159
+ },
160
+ };
161
+
162
+ interface BedrockToolPlan {
163
+ toolConfig: WireToolConfig | undefined;
164
+ sentinelInjected: boolean;
165
+ }
166
+
145
167
  interface ConverseStreamRequest {
146
168
  messages: WireMessage[];
147
169
  system?: SystemContent[];
@@ -222,10 +244,10 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
222
244
 
223
245
  try {
224
246
  const cacheRetention = resolveCacheRetention(options.cacheRetention);
225
- const historyHasToolBlocks = context.messages.some(
226
- m => m.role === "toolResult" || (m.role === "assistant" && m.content.some(b => b.type === "toolCall")),
227
- );
228
- const toolConfig = convertToolConfig(context.tools, options.toolChoice, historyHasToolBlocks);
247
+ const convertedMessages = convertMessages(context, model, cacheRetention);
248
+ const toolPlan = planToolConfig(context.tools, options.toolChoice, convertedMessages);
249
+ const toolConfig = toolPlan.toolConfig;
250
+ const sentinelInjected = toolPlan.sentinelInjected;
229
251
  let additionalModelRequestFields = buildAdditionalModelRequestFields(model, options);
230
252
 
231
253
  // Bedrock rejects thinking + forced tool_choice ("any" or specific tool).
@@ -236,7 +258,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
236
258
  }
237
259
 
238
260
  const commandInput: ConverseStreamRequest = {
239
- messages: convertMessages(context, model, cacheRetention),
261
+ messages: convertedMessages,
240
262
  system: buildSystemPrompt(context.systemPrompt, model, cacheRetention),
241
263
  inferenceConfig: {
242
264
  maxTokens: options.maxTokens,
@@ -369,7 +391,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
369
391
  }
370
392
  case "contentBlockStart": {
371
393
  if (!firstTokenTime) firstTokenTime = Date.now();
372
- handleContentBlockStart(payload as ContentBlockStartEvent, blocks, output, stream);
394
+ handleContentBlockStart(payload as ContentBlockStartEvent, blocks, output, stream, sentinelInjected);
373
395
  break;
374
396
  }
375
397
  case "contentBlockDelta": {
@@ -383,7 +405,10 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
383
405
  }
384
406
  case "messageStop": {
385
407
  const ev = payload as MessageStopEvent;
386
- output.stopReason = mapStopReason(ev.stopReason);
408
+ // A sentinel-only request must never surface a tool-use stop:
409
+ // no real tool exists for the agent to dispatch.
410
+ output.stopReason =
411
+ sentinelInjected && ev.stopReason === "tool_use" ? "stop" : mapStopReason(ev.stopReason);
387
412
  if (output.stopReason === "error") {
388
413
  output.errorMessage = `Generation failed with stop reason: ${ev.stopReason ?? "unknown"}`;
389
414
  }
@@ -462,10 +487,16 @@ function handleContentBlockStart(
462
487
  blocks: Block[],
463
488
  output: AssistantMessage,
464
489
  stream: AssistantMessageEventStream,
490
+ sentinelInjected: boolean,
465
491
  ): void {
466
492
  const index = event.contentBlockIndex;
467
493
  const start = event.start;
468
494
 
495
+ // Drop the sentinel call only when we injected it ourselves. A caller that
496
+ // registers a real tool named `__no_tools__` would otherwise lose its
497
+ // legitimate tool-use events on normal turns.
498
+ if (sentinelInjected && start?.toolUse?.name === NO_TOOLS_SENTINEL_NAME) return;
499
+
469
500
  if (start?.toolUse) {
470
501
  const block: Block = {
471
502
  type: "toolCall",
@@ -784,28 +815,48 @@ function convertMessages(
784
815
  return result;
785
816
  }
786
817
 
787
- function convertToolConfig(
788
- tools: Tool[] | undefined,
789
- toolChoice: BedrockOptions["toolChoice"],
790
- historyHasToolBlocks: boolean,
791
- ): WireToolConfig | undefined {
792
- if (!tools?.length) return undefined;
818
+ function messagesHaveToolBlocks(messages: WireMessage[]): boolean {
819
+ for (const message of messages) {
820
+ for (const block of message.content) {
821
+ if ("toolUse" in block || "toolResult" in block) return true;
822
+ }
823
+ }
824
+ return false;
825
+ }
793
826
 
794
- const bedrockTools: WireToolSpec[] = tools.map(tool => ({
827
+ function convertToolSpec(tool: Tool): WireToolSpec {
828
+ return {
795
829
  toolSpec: {
796
830
  name: tool.name,
797
831
  description: tool.description || "",
798
832
  inputSchema: { json: toolWireSchema(tool) },
799
833
  },
800
- }));
834
+ };
835
+ }
836
+
837
+ function planToolConfig(
838
+ tools: Tool[] | undefined,
839
+ toolChoice: BedrockOptions["toolChoice"],
840
+ messages: WireMessage[],
841
+ ): BedrockToolPlan {
842
+ const activeTools = tools ?? [];
843
+ const hasTools = activeTools.length > 0;
844
+ const historyHasToolBlocks = messagesHaveToolBlocks(messages);
801
845
 
802
- // Bedrock rejects requests whose history contains toolUse/toolResult blocks without a
803
- // toolConfig. With prior tool use we must keep the tool specs and merely omit the choice
804
- // (there is no "none" choice on Converse); dropping toolConfig entirely would 400.
805
846
  if (toolChoice === "none") {
806
- return historyHasToolBlocks ? { tools: bedrockTools } : undefined;
847
+ if (!historyHasToolBlocks) return { toolConfig: undefined, sentinelInjected: false };
848
+ if (!hasTools) {
849
+ return {
850
+ toolConfig: { tools: [NO_TOOLS_SENTINEL], toolChoice: { auto: {} } },
851
+ sentinelInjected: true,
852
+ };
853
+ }
854
+ return { toolConfig: { tools: activeTools.map(convertToolSpec) }, sentinelInjected: false };
807
855
  }
808
856
 
857
+ if (!hasTools) return { toolConfig: undefined, sentinelInjected: false };
858
+
859
+ const bedrockTools = activeTools.map(convertToolSpec);
809
860
  let bedrockToolChoice: WireToolChoice | undefined;
810
861
  switch (toolChoice) {
811
862
  case "auto":
@@ -820,7 +871,7 @@ function convertToolConfig(
820
871
  }
821
872
  }
822
873
 
823
- return { tools: bedrockTools, toolChoice: bedrockToolChoice };
874
+ return { toolConfig: { tools: bedrockTools, toolChoice: bedrockToolChoice }, sentinelInjected: false };
824
875
  }
825
876
 
826
877
  function mapStopReason(reason: string | undefined): StopReason {
@@ -1485,6 +1485,10 @@ function isProviderRetryableStreamEnvelopeError(error: unknown): boolean {
1485
1485
  return /stream event order|before message_start/i.test(error.message);
1486
1486
  }
1487
1487
 
1488
+ function isAnthropicTransientTransportMessage(message: string): boolean {
1489
+ return message.includes("tls: bad record mac") || message.includes("type=server_error");
1490
+ }
1491
+
1488
1492
  export function isProviderRetryableError(error: unknown, provider?: string): boolean {
1489
1493
  if (!(error instanceof Error)) return false;
1490
1494
  if (provider === "github-copilot" && isCopilotTransientModelError(error)) return true;
@@ -1501,7 +1505,8 @@ export function isProviderRetryableError(error: unknown, provider?: string): boo
1501
1505
  const msg = error.message.toLowerCase();
1502
1506
  if (
1503
1507
  isUnexpectedSocketCloseMessage(msg) ||
1504
- /rate.?limit|too many requests|overloaded|service.?unavailable|internal_error|stream error.*received from peer|1302|timed?\s*out while waiting for the first event|timeout waiting for first/i.test(
1508
+ isAnthropicTransientTransportMessage(msg) ||
1509
+ /rate.?limit|too many requests|overloaded|service.?unavailable|internal_error|server_error|bad record mac|stream error.*received from peer|1302|timed?\s*out while waiting for the first event|timeout waiting for first/i.test(
1505
1510
  msg,
1506
1511
  ) ||
1507
1512
  isTransientStreamParseError(error) ||
@@ -41,12 +41,10 @@ export interface RequestBody {
41
41
  input?: InputItem[];
42
42
  tools?: unknown;
43
43
  tool_choice?: unknown;
44
- temperature?: number;
45
- top_p?: number;
46
- top_k?: number;
47
- min_p?: number;
48
- presence_penalty?: number;
49
- repetition_penalty?: number;
44
+ // Sampling controls (temperature/top_p/top_k/min_p/presence_penalty/
45
+ // repetition_penalty/frequency_penalty/stop) are intentionally absent: the
46
+ // Codex backend rejects every one with a 400 `Unsupported parameter`, so
47
+ // the transformer never sets them (#3117).
50
48
  reasoning?: Partial<ReasoningConfig>;
51
49
  text?: {
52
50
  verbosity?: "low" | "medium" | "high";
@@ -714,7 +714,8 @@ async function buildCodexRequestContext(
714
714
  };
715
715
  }
716
716
 
717
- async function buildTransformedCodexRequestBody(
717
+ /** @internal Exported for tests. */
718
+ export async function buildTransformedCodexRequestBody(
718
719
  model: Model<"openai-codex-responses">,
719
720
  context: Context,
720
721
  options: OpenAICodexResponsesOptions | undefined,
@@ -729,25 +730,12 @@ async function buildTransformedCodexRequestBody(
729
730
 
730
731
  // `maxTokens` is intentionally not forwarded: transformRequestBody strips
731
732
  // `max_output_tokens`/`max_completion_tokens` (the Codex backend rejects
732
- // caller-supplied output caps).
733
- if (options?.temperature !== undefined) {
734
- params.temperature = options.temperature;
735
- }
736
- if (options?.topP !== undefined) {
737
- params.top_p = options.topP;
738
- }
739
- if (options?.topK !== undefined) {
740
- params.top_k = options.topK;
741
- }
742
- if (options?.minP !== undefined) {
743
- params.min_p = options.minP;
744
- }
745
- if (options?.presencePenalty !== undefined) {
746
- params.presence_penalty = options.presencePenalty;
747
- }
748
- if (options?.repetitionPenalty !== undefined) {
749
- params.repetition_penalty = options.repetitionPenalty;
750
- }
733
+ // caller-supplied output caps). Sampling controls (`temperature`, `top_p`,
734
+ // `top_k`, `min_p`, `presence_penalty`, `repetition_penalty`,
735
+ // `frequency_penalty`, `stop`) are likewise refused with
736
+ // `{"detail":"Unsupported parameter: temperature"}` etc., so we drop
737
+ // everything from `StreamOptions` rather than forwarding any of them.
738
+ // (#3117 — codex-rs sends none of these either.)
751
739
  applyOpenAIServiceTier(params, options?.serviceTier, model.provider);
752
740
  if (context.tools && context.tools.length > 0) {
753
741
  params.tools = convertOpenAICodexResponsesTools(context.tools, model);
@@ -757,16 +745,6 @@ async function buildTransformedCodexRequestBody(
757
745
  params.tool_choice = toolChoice;
758
746
  }
759
747
  }
760
- // When a custom-tool is active, force serial tool-calling. OpenAI's
761
- // `parallel_tool_calls` is request-scoped — disabling it here affects
762
- // every tool in the turn, not just the custom one. That's coarser
763
- // than spec §1's "supports_parallel_tool_calls = false" (which
764
- // strictly targets `apply_patch`), but the platform API offers no
765
- // per-tool flag.
766
- const emittedTools = params.tools as CodexToolPayload[];
767
- if (emittedTools.some(t => t.type === "custom")) {
768
- params.parallel_tool_calls = false;
769
- }
770
748
  }
771
749
 
772
750
  const systemPrompts = normalizeSystemPrompts(context.systemPrompt);
@@ -175,6 +175,19 @@ function normalizeMistralToolId(id: string, isMistral: boolean): string {
175
175
  // #1488). The default route forwards `delta.content` (including DSML
176
176
  // envelope leaks) which `StreamMarkupHealing` heals into a structured call
177
177
  // client-side.
178
+ function resolveOpenAICompletionsRoutingEffort(
179
+ model: Model<"openai-completions">,
180
+ effort: Effort | undefined,
181
+ ): Effort | undefined {
182
+ if (!effort) return undefined;
183
+ if (model.thinking?.efforts.includes(effort)) return effort;
184
+ const compatMappedEffort = model.compat.reasoningEffortMap?.[effort] as Effort | undefined;
185
+ if (compatMappedEffort && model.thinking?.efforts.includes(compatMappedEffort)) return compatMappedEffort;
186
+ const thinkingMappedEffort = model.thinking?.effortMap?.[effort] as Effort | undefined;
187
+ if (thinkingMappedEffort && model.thinking?.efforts.includes(thinkingMappedEffort)) return thinkingMappedEffort;
188
+ return effort;
189
+ }
190
+
178
191
  function resolveOpenAICompletionsModelId(
179
192
  model: Model<"openai-completions">,
180
193
  options: OpenAICompletionsOptions | undefined,
@@ -182,8 +195,9 @@ function resolveOpenAICompletionsModelId(
182
195
  // Effort-tier variants route per request effort (off → bare id, efforts →
183
196
  // the thinking backing id); catalog variants (Copilot long-context `-1m`
184
197
  // entries) pin via `requestModelId`; everything else serializes `model.id`.
185
- const effort =
198
+ const requestedEffort =
186
199
  options?.reasoning && !options.disableReasoning && model.reasoning ? (options.reasoning as Effort) : undefined;
200
+ const effort = resolveOpenAICompletionsRoutingEffort(model, requestedEffort);
187
201
  const wireId = resolveWireModelId(model, effort);
188
202
  return applyWireModelIdTransform(wireId, model.compat.wireModelIdMode, options?.openrouterVariant);
189
203
  }
@@ -862,15 +862,6 @@ export function buildParams(
862
862
  params.tool_choice = toolChoice;
863
863
  }
864
864
  }
865
- // The apply_patch spec §1 marks only `apply_patch` itself as
866
- // `supports_parallel_tool_calls = false`. OpenAI's Responses API
867
- // exposes `parallel_tool_calls` as a request-scoped flag, not a
868
- // per-tool one, so when a custom grammar tool is in the list we
869
- // disable parallelism for the whole turn. Slightly coarser than
870
- // the spec requires — but the platform API offers no finer knob.
871
- if (params.tools.some(t => (t as { type?: string }).type === "custom")) {
872
- params.parallel_tool_calls = false;
873
- }
874
865
  }
875
866
 
876
867
  const reasoningPolicy = resolveOpenAICompatPolicy(model, {
@@ -155,6 +155,16 @@ export function resolveOpenAIRequestSetup(
155
155
 
156
156
  let copilotPremiumRequests: number | undefined;
157
157
  let baseUrl = model.baseUrl;
158
+ if (model.provider === "moonshot") {
159
+ // Bundled `moonshot` catalog models hardcode the international endpoint
160
+ // (`api.moonshot.ai`). MOONSHOT_BASE_URL lets users redirect the provider
161
+ // at the China platform (`api.moonshot.cn`), which only accepts China keys
162
+ // and rejects the international host. (#2883)
163
+ const moonshotBaseUrl = $env.MOONSHOT_BASE_URL?.trim();
164
+ if (moonshotBaseUrl) {
165
+ baseUrl = moonshotBaseUrl;
166
+ }
167
+ }
158
168
  if (model.provider === "github-copilot") {
159
169
  apiKey = parseGitHubCopilotApiKey(rawApiKey).accessToken;
160
170
  const copilot = buildCopilotDynamicHeaders({
@@ -0,0 +1,34 @@
1
+ import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
2
+ import type { ProviderDefinition } from "./types";
3
+
4
+ const PROVIDER_ID = "llama.cpp";
5
+ const AUTH_URL = "https://github.com/ggml-org/llama.cpp#quick-start";
6
+ const DEFAULT_LOCAL_BASE_URL = "http://127.0.0.1:8080";
7
+ const DEFAULT_LOCAL_TOKEN = "llama-cpp-local";
8
+
9
+ export async function loginLlamaCpp(options: OAuthController): Promise<string> {
10
+ if (!options.onPrompt) {
11
+ throw new Error(`${PROVIDER_ID} login requires onPrompt callback`);
12
+ }
13
+ options.onAuth?.({
14
+ url: AUTH_URL,
15
+ instructions: `Paste your llama.cpp API key if your server requires auth. Leave empty for local no-auth mode (default base URL: ${DEFAULT_LOCAL_BASE_URL}; set LLAMA_CPP_BASE_URL to customize).`,
16
+ });
17
+ const apiKey = await options.onPrompt({
18
+ message: "Paste your llama.cpp API key (optional for local no-auth)",
19
+ placeholder: DEFAULT_LOCAL_TOKEN,
20
+ allowEmpty: true,
21
+ });
22
+ if (options.signal?.aborted) {
23
+ throw new Error("Login cancelled");
24
+ }
25
+ const trimmed = apiKey.trim();
26
+ return trimmed || DEFAULT_LOCAL_TOKEN;
27
+ }
28
+
29
+ export const llamaCppProvider = {
30
+ id: PROVIDER_ID,
31
+ name: "llama.cpp (Local OpenAI-compatible)",
32
+ envKeys: "LLAMA_CPP_API_KEY",
33
+ login: (cb: OAuthLoginCallbacks) => loginLlamaCpp(cb),
34
+ } as const satisfies ProviderDefinition;
@@ -39,6 +39,7 @@ export async function loginNvidia(options: OAuthController): Promise<string> {
39
39
  baseUrl: API_BASE_URL,
40
40
  model: VALIDATION_MODEL,
41
41
  signal: options.signal,
42
+ fetch: options.fetch,
42
43
  });
43
44
  } catch (error) {
44
45
  const message = error instanceof Error ? error.message : String(error);
@@ -57,4 +58,5 @@ export const nvidiaProvider = {
57
58
  id: "nvidia",
58
59
  name: "NVIDIA",
59
60
  login: (cb: OAuthLoginCallbacks) => loginNvidia(cb),
61
+ appendApiKeyLogin: true,
60
62
  } as const satisfies ProviderDefinition;
@@ -1,41 +1,15 @@
1
1
  /**
2
- * Wafer login flows.
2
+ * Wafer Serverless login flow.
3
3
  *
4
- * Wafer (https://wafer.ai) exposes a single OpenAI-compatible base URL
5
- * (`https://pass.wafer.ai/v1`) for two SKUs:
6
- *
7
- * - **Wafer Pass** — flat-rate subscription. The key authorizes models whose
8
- * catalog entries carry `wafer.tier = "pass_included"`.
9
- * - **Wafer Serverless** — pay-as-you-go. Superset of Pass; the same `/v1/models`
10
- * endpoint returns the full per-account model list.
11
- *
12
- * Both SKUs issue `wfr_…` keys. The key prefix alone does not distinguish
13
- * tiers — the entitlement is per-account on the server side — so we expose
14
- * two parallel logins / env vars (`WAFER_PASS_API_KEY`, `WAFER_SERVERLESS_API_KEY`)
15
- * mirroring the firepass/fireworks split, letting users with both
16
- * subscriptions switch between them without re-pasting.
17
- *
18
- * Validation uses the shared `/v1/models` endpoint, which works for both
19
- * tiers and is cheap (no token spend).
4
+ * Wafer (https://wafer.ai) exposes a pay-as-you-go OpenAI-compatible SKU at
5
+ * `https://pass.wafer.ai/v1`. Keys use the `wfr_…` prefix and are validated
6
+ * against `/v1/models`, which is cheap (no token spend).
20
7
  */
21
8
  import { createApiKeyLogin } from "../api-key-login";
22
9
 
23
10
  const WAFER_AUTH_URL = "https://wafer.ai/dashboard";
24
11
  const WAFER_MODELS_URL = "https://pass.wafer.ai/v1/models";
25
12
 
26
- export const loginWaferPass = createApiKeyLogin({
27
- providerLabel: "Wafer Pass",
28
- authUrl: WAFER_AUTH_URL,
29
- instructions: "Create or copy your Wafer Pass API key from the Wafer dashboard",
30
- promptMessage: "Paste your Wafer Pass API key",
31
- placeholder: "wfr_...",
32
- validation: {
33
- kind: "models-endpoint",
34
- provider: "Wafer Pass",
35
- modelsUrl: WAFER_MODELS_URL,
36
- },
37
- });
38
-
39
13
  export const loginWaferServerless = createApiKeyLogin({
40
14
  providerLabel: "Wafer Serverless",
41
15
  authUrl: WAFER_AUTH_URL,
@@ -22,6 +22,7 @@ import { kagiProvider } from "./kagi";
22
22
  import { kiloProvider } from "./kilo";
23
23
  import { kimiCodeProvider } from "./kimi-code";
24
24
  import { litellmProvider } from "./litellm";
25
+ import { llamaCppProvider } from "./llama-cpp";
25
26
  import { lmStudioProvider } from "./lm-studio";
26
27
  import { minimaxProvider } from "./minimax";
27
28
  import { minimaxCodeProvider } from "./minimax-code";
@@ -50,7 +51,6 @@ import { umansProvider } from "./umans";
50
51
  import { veniceProvider } from "./venice";
51
52
  import { vercelAiGatewayProvider } from "./vercel-ai-gateway";
52
53
  import { vllmProvider } from "./vllm";
53
- import { waferPassProvider } from "./wafer-pass";
54
54
  import { waferServerlessProvider } from "./wafer-serverless";
55
55
  import { xaiProvider } from "./xai";
56
56
  import { xaiOauthProvider } from "./xai-oauth";
@@ -95,7 +95,6 @@ const ALL = [
95
95
  xiaomiTokenPlanAmsProvider,
96
96
  xiaomiTokenPlanCnProvider,
97
97
  firepassProvider,
98
- waferPassProvider,
99
98
  deepseekProvider,
100
99
  moonshotProvider,
101
100
  cerebrasProvider,
@@ -122,6 +121,7 @@ const ALL = [
122
121
  ollamaProvider,
123
122
  ollamaCloudProvider,
124
123
  lmStudioProvider,
124
+ llamaCppProvider,
125
125
  vllmProvider,
126
126
  openaiProvider,
127
127
  googleProvider,
@@ -44,6 +44,8 @@ export interface ProviderDefinition {
44
44
  readonly envKeys?: KeyResolver;
45
45
  // --- interactive login (OAuthProviderInterface-compatible) ---
46
46
  readonly login?: (callbacks: OAuthLoginCallbacks) => Promise<OAuthCredentials | string>;
47
+ /** String-returning login appends API keys instead of replacing the provider's existing key. */
48
+ readonly appendApiKeyLogin?: boolean;
47
49
  readonly refreshToken?: (credentials: OAuthCredentials) => Promise<OAuthCredentials>;
48
50
  readonly getApiKey?: (credentials: OAuthCredentials) => string;
49
51
  /** Store OAuth credentials under a different provider id (e.g. `openai-codex-device` ⇒ `openai-codex`). */
package/src/stream.ts CHANGED
@@ -160,7 +160,6 @@ type KeyResolver = string | (() => string | undefined);
160
160
  const LEGACY_ENV_KEYS: Record<string, KeyResolver> = {
161
161
  // Non-provider / search-tool keys and API-name keys not modeled as registry provider defs.
162
162
  "azure-openai-responses": "AZURE_OPENAI_API_KEY",
163
- "llama.cpp": "LLAMA_CPP_API_KEY",
164
163
  exa: "EXA_API_KEY",
165
164
  jina: "JINA_API_KEY",
166
165
  brave: "BRAVE_API_KEY",
@@ -673,6 +672,30 @@ function mapOpenAiToolChoice(choice?: ToolChoice): OpenAICompletionsOptions["too
673
672
  return undefined;
674
673
  }
675
674
 
675
+ type ReasoningEffortMapCompat = {
676
+ reasoningEffortMap?: Partial<Record<Effort, string>>;
677
+ };
678
+
679
+ function getCompatReasoningEffortMap<TApi extends Api>(
680
+ model: Model<TApi>,
681
+ ): Partial<Record<Effort, string>> | undefined {
682
+ const compat = model.compat;
683
+ if (compat === undefined || typeof compat !== "object" || !("reasoningEffortMap" in compat)) {
684
+ return undefined;
685
+ }
686
+ return (compat as ReasoningEffortMapCompat).reasoningEffortMap;
687
+ }
688
+
689
+ function resolveSupportedMappedReasoningEffort<TApi extends Api>(
690
+ model: Model<TApi>,
691
+ reasoning: Effort,
692
+ ): Effort | undefined {
693
+ const mapped = getCompatReasoningEffortMap(model)?.[reasoning];
694
+ if (!mapped) return undefined;
695
+ const mappedEffort = mapped as Effort;
696
+ return model.thinking?.efforts.includes(mappedEffort) ? mappedEffort : undefined;
697
+ }
698
+
676
699
  function resolveOpenAiReasoningEffort<TApi extends Api>(
677
700
  model: Model<TApi>,
678
701
  options?: SimpleStreamOptions,
@@ -687,6 +710,11 @@ function resolveOpenAiReasoningEffort<TApi extends Api>(
687
710
  // defeat the gate and surface a confusing "Compaction failed: Thinking effort
688
711
  // high is not supported by..." to the user.
689
712
  if (!model.thinking) return undefined;
713
+ if (model.thinking.efforts.includes(reasoning)) return reasoning;
714
+ const mappedReasoning = resolveSupportedMappedReasoningEffort(model, reasoning);
715
+ if (mappedReasoning) return mappedReasoning;
716
+ if (getCompatReasoningEffortMap(model)?.[reasoning] !== undefined) return reasoning;
717
+ if (model.thinking.effortMap?.[reasoning] !== undefined) return reasoning;
690
718
  return requireSupportedEffort(model, reasoning);
691
719
  }
692
720
 
package/src/types.ts CHANGED
@@ -141,18 +141,25 @@ export function resolveServiceTier(
141
141
  }
142
142
 
143
143
  /**
144
- * True when the (possibly scoped) tier should be sent as OpenAI's
145
- * `service_tier` request field for the given provider. Non-OpenAI
146
- * providers, unsupported tiers (`"auto"`, `"default"`), and scope
147
- * mismatches all return false.
144
+ * True when the (possibly scoped) tier should be sent on the wire as the
145
+ * `service_tier` request field for the given provider. OpenAI / OpenAI-Codex
146
+ * accept `flex`/`scale`/`priority`; Fireworks Serverless realizes only its
147
+ * Priority serving path (`service_tier: "priority"`) on the OpenAI-compatible
148
+ * chat-completions endpoint. Unsupported tiers (`"auto"`, `"default"`), other
149
+ * providers, and scope mismatches all return false.
148
150
  */
149
151
  export function shouldSendServiceTier(
150
152
  serviceTier: ServiceTier | null | undefined,
151
153
  provider: Provider | undefined,
152
154
  ): boolean {
153
- if (provider !== "openai" && provider !== "openai-codex") return false;
154
155
  const resolved = resolveServiceTier(serviceTier, provider);
155
- return resolved === "flex" || resolved === "scale" || resolved === "priority";
156
+ if (provider === "openai" || provider === "openai-codex") {
157
+ return resolved === "flex" || resolved === "scale" || resolved === "priority";
158
+ }
159
+ if (provider === "fireworks") {
160
+ return resolved === "priority";
161
+ }
162
+ return false;
156
163
  }
157
164
 
158
165
  /**
@@ -1,6 +1,5 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import * as fs from "node:fs/promises";
3
- import * as path from "node:path";
4
3
  import type { FetchImpl } from "../types";
5
4
 
6
5
  const REQUEST_DEBUG_ENV = "PI_REQ_DEBUG";
@@ -9,7 +8,6 @@ const textEncoder = new TextEncoder();
9
8
  const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
10
9
 
11
10
  let nextSessionId = 1;
12
- let nextRequestDebugPath: string | undefined;
13
11
 
14
12
  type DebugFetch = FetchImpl & { [DEBUG_FETCH_MARKER]?: true };
15
13
  type RequestBodyInit = NonNullable<RequestInit["body"]>;
@@ -55,25 +53,7 @@ function isRequestDebugEnvEnabled(): boolean {
55
53
  }
56
54
 
57
55
  export function isRequestDebugEnabled(): boolean {
58
- return isRequestDebugEnvEnabled() || nextRequestDebugPath !== undefined;
59
- }
60
-
61
- export function setNextRequestDebugPath(requestPath: string): void {
62
- nextRequestDebugPath = requestPath;
63
- }
64
-
65
- export function clearNextRequestDebugPath(): void {
66
- nextRequestDebugPath = undefined;
67
- }
68
-
69
- export function getNextRequestDebugPath(): string | undefined {
70
- return nextRequestDebugPath;
71
- }
72
-
73
- function consumeNextRequestDebugPath(): string | undefined {
74
- const requestPath = nextRequestDebugPath;
75
- nextRequestDebugPath = undefined;
76
- return requestPath;
56
+ return isRequestDebugEnvEnabled();
77
57
  }
78
58
 
79
59
  export function wrapFetchForRequestDebug(fetchImpl: FetchImpl): FetchImpl {
@@ -249,19 +229,6 @@ function copyResponseMetadata(target: Response, source: Response): void {
249
229
  }
250
230
 
251
231
  async function reserveRequestDebugFile(): Promise<ReservedRequestDebugFile> {
252
- const explicitPath = consumeNextRequestDebugPath();
253
- if (explicitPath) {
254
- await fs.mkdir(path.dirname(explicitPath), { recursive: true });
255
- const handle = await fs.open(explicitPath, "w");
256
- return {
257
- id: nextSessionId++,
258
- requestPath: explicitPath,
259
- responsePath: `${explicitPath}.res.log`,
260
- handle,
261
- overwrite: true,
262
- };
263
- }
264
-
265
232
  for (;;) {
266
233
  const id = nextSessionId++;
267
234
  const requestPath = `rr-session-${id}.json`;
@@ -388,6 +388,73 @@ function inferBareEnumScalarType(obj: Record<string, unknown>): void {
388
388
  if (inferred !== undefined) obj.type = inferred;
389
389
  }
390
390
 
391
+ /**
392
+ * ArkType serializes a *described* literal union — `type.enumerated(...).describe(d)`
393
+ * or a `"a" | "b"` union carrying a description — as an `anyOf` of
394
+ * `{ const, description }` branches that repeat the description on every branch
395
+ * *and* the union root. The meta is distributed across the union's constituents
396
+ * at the type level (each `unit` node inherits it), so the duplication is baked
397
+ * in before serialization rather than added by this pipeline.
398
+ *
399
+ * Collapse such a homogeneous all-`const` union into one typed
400
+ * `{ type, enum, description }` node: a shorter wire and a single description in
401
+ * the place providers expect it. The collapse is conservative — applied only
402
+ * when it is lossless:
403
+ * - every branch is a bare `{ const }` (optionally `{ const, description }`),
404
+ * - all branch values share one scalar JSON type (so `enum` gets a `type`,
405
+ * which Gemini/Vertex require),
406
+ * - branch descriptions are either all absent or all identical,
407
+ * so a union whose branches carry *distinct* per-variant descriptions is left
408
+ * untouched (a flat `enum` has nowhere to keep them). The union root's own
409
+ * description wins when present; otherwise the shared branch description is kept.
410
+ */
411
+ function collapseConstUnionAnyOf(obj: Record<string, unknown>): void {
412
+ // `hasSchemaDefiningSibling` already rejects a sibling `enum`/`const`/etc.; it
413
+ // does not list `type`, so guard it here — collapsing would overwrite a
414
+ // wrapper `type` constraint paired with the `anyOf`.
415
+ if (hasSchemaDefiningSibling(obj) || "type" in obj) return;
416
+ const variants = obj.anyOf;
417
+ if (!Array.isArray(variants) || variants.length < 2) return;
418
+
419
+ const values: unknown[] = [];
420
+ let branchDescription: string | undefined;
421
+ let describedCount = 0;
422
+ for (const variant of variants) {
423
+ if (!isSchemaRecord(variant) || !Object.hasOwn(variant, "const")) return;
424
+ for (const key in variant) {
425
+ if (key !== "const" && key !== "description") return; // extra constraints — not a bare const
426
+ }
427
+ const desc = variant.description;
428
+ if (typeof desc === "string") {
429
+ if (describedCount === 0) branchDescription = desc;
430
+ else if (desc !== branchDescription) return; // distinct per-variant descriptions — preserve them
431
+ describedCount++;
432
+ }
433
+ values.push(variant.const);
434
+ }
435
+ if (describedCount !== 0 && describedCount !== variants.length) return; // mixed described/undescribed
436
+ // A shared branch description that disagrees with the union root's own
437
+ // description would be silently dropped by the collapse — keep the anyOf so
438
+ // neither annotation is lost. (Equal descriptions, the ArkType case, collapse.)
439
+ if (
440
+ describedCount === variants.length &&
441
+ typeof obj.description === "string" &&
442
+ obj.description !== branchDescription
443
+ ) {
444
+ return;
445
+ }
446
+
447
+ const scalarType = homogeneousEnumScalarType(values);
448
+ if (scalarType === undefined) return; // mixed / non-scalar (incl. null) — leave as anyOf
449
+
450
+ delete obj.anyOf;
451
+ obj.type = scalarType;
452
+ obj.enum = values;
453
+ if (typeof obj.description !== "string" && branchDescription !== undefined) {
454
+ obj.description = branchDescription;
455
+ }
456
+ }
457
+
391
458
  function walk(node: unknown, zodCleanup: boolean): void {
392
459
  if (Array.isArray(node)) {
393
460
  for (const child of node) walk(child, zodCleanup);
@@ -397,6 +464,7 @@ function walk(node: unknown, zodCleanup: boolean): void {
397
464
  const obj = node as Record<string, unknown>;
398
465
  rewriteNullableScalarAnyOf(obj);
399
466
  inferBareEnumScalarType(obj);
467
+ collapseConstUnionAnyOf(obj);
400
468
 
401
469
  if (zodCleanup) {
402
470
  // Drop noise injected for `z.number().int()`.
@@ -834,6 +834,105 @@ function normalizeOptionalNullsForSchema(
834
834
  return { value: changed ? nextValue : value, changed };
835
835
  }
836
836
 
837
+ // ============================================================================
838
+ // Double-encoded object-key normalization (LLM quirk).
839
+ // ============================================================================
840
+ //
841
+ // LLMs occasionally serialize an object key one time too many, so the property
842
+ // NAME arrives as the JSON encoding of the real name — literal quote characters
843
+ // and all (e.g. `{ "\"op\"": "done" }` decodes to the JS key `"op"`). The
844
+ // schema never matches such a key, so it reads as an unrecognized extra and is
845
+ // dropped by the unrecognized-key repair, later surfacing as a spurious
846
+ // missing-required error. We walk the whole value (arrays + nested objects)
847
+ // and rename any key that is itself the JSON encoding of a plain string back to
848
+ // that string.
849
+ // ============================================================================
850
+
851
+ /** Max layers of accidental JSON-encoding to peel off a single object key. */
852
+ const MAX_KEY_DECODE_DEPTH = 3;
853
+
854
+ /**
855
+ * If `key` is the JSON encoding of a plain string (quote-wrapped and
856
+ * `JSON.parse`s to a string), return the decoded string; otherwise null. Peels
857
+ * up to {@link MAX_KEY_DECODE_DEPTH} nested encodings so multiply-encoded keys
858
+ * collapse in one pass. Conservative: any key that is not a quote-wrapped JSON
859
+ * string literal is left untouched.
860
+ */
861
+ function decodeDoubleEncodedKey(key: string): string | null {
862
+ let current = key;
863
+ let decoded: string | null = null;
864
+ for (let depth = 0; depth < MAX_KEY_DECODE_DEPTH; depth += 1) {
865
+ if (current.length < 2 || current[0] !== '"' || current[current.length - 1] !== '"') break;
866
+ let parsed: unknown;
867
+ try {
868
+ parsed = JSON.parse(current);
869
+ } catch {
870
+ break;
871
+ }
872
+ if (typeof parsed !== "string") break;
873
+ current = parsed;
874
+ decoded = current;
875
+ }
876
+ return decoded;
877
+ }
878
+
879
+ /**
880
+ * Recursively unwrap object keys that were accidentally JSON-encoded an extra
881
+ * time. Schema-agnostic by design: such keys are dropped before any schema pass
882
+ * can map them, so this runs first. A key is only renamed when the decoded name
883
+ * differs and does not already exist on the same object — renaming would
884
+ * otherwise clobber a sibling and silently lose data.
885
+ */
886
+ function normalizeDoubleEncodedKeys(value: unknown): { value: unknown; changed: boolean } {
887
+ if (Array.isArray(value)) {
888
+ let changed = false;
889
+ let next = value;
890
+ for (let i = 0; i < value.length; i += 1) {
891
+ const normalized = normalizeDoubleEncodedKeys(value[i]);
892
+ if (!normalized.changed) continue;
893
+ if (!changed) {
894
+ next = [...value];
895
+ changed = true;
896
+ }
897
+ next[i] = normalized.value;
898
+ }
899
+ return { value: changed ? next : value, changed };
900
+ }
901
+
902
+ if (value === null || typeof value !== "object") return { value, changed: false };
903
+
904
+ const source = value as Record<string, unknown>;
905
+ let changed = false;
906
+ const out: Record<string, unknown> = {};
907
+ for (const [key, entry] of Object.entries(source)) {
908
+ const normalizedChild = normalizeDoubleEncodedKeys(entry);
909
+ const nextChild = normalizedChild.changed ? normalizedChild.value : entry;
910
+
911
+ const decodedKey = decodeDoubleEncodedKey(key);
912
+ // `Object.hasOwn` (not `in`) so a decoded `constructor`/`toString` is not
913
+ // mistaken for a collision via the prototype chain.
914
+ const targetKey =
915
+ decodedKey !== null &&
916
+ decodedKey !== key &&
917
+ !Object.hasOwn(source, decodedKey) &&
918
+ !Object.hasOwn(out, decodedKey)
919
+ ? decodedKey
920
+ : key;
921
+
922
+ if (targetKey !== key || normalizedChild.changed) changed = true;
923
+ // `defineProperty` so a decoded `__proto__` key becomes an own property
924
+ // instead of mutating the result object's prototype.
925
+ Object.defineProperty(out, targetKey, {
926
+ value: nextChild,
927
+ writable: true,
928
+ enumerable: true,
929
+ configurable: true,
930
+ });
931
+ }
932
+
933
+ return { value: changed ? out : value, changed };
934
+ }
935
+
837
936
  // ============================================================================
838
937
  // String-encoded array coercion for union(string, array) schemas.
839
938
  // ============================================================================
@@ -922,8 +1021,15 @@ function normalizeStringEncodedArrayUnions(schema: unknown, value: unknown): { v
922
1021
  if (!trimmed.startsWith("[")) return { value, changed: false };
923
1022
  try {
924
1023
  const parsed = JSON.parse(trimmed) as unknown;
925
- if (Array.isArray(parsed) && parsedArrayMatchesArrayBranch(schemaObject, parsed)) {
926
- return { value: parsed, changed: true };
1024
+ if (Array.isArray(parsed)) {
1025
+ // Unwrap any double-encoded object keys inside the parsed array
1026
+ // before the branch-match check; otherwise an `array<object>`
1027
+ // branch fails to validate and the value silently stays on the
1028
+ // string branch.
1029
+ const candidate = normalizeDoubleEncodedKeys(parsed).value as unknown[];
1030
+ if (parsedArrayMatchesArrayBranch(schemaObject, candidate)) {
1031
+ return { value: candidate, changed: true };
1032
+ }
927
1033
  }
928
1034
  } catch {
929
1035
  // Not valid JSON — leave the string alone for the validator to handle.
@@ -1312,6 +1418,18 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
1312
1418
  // placeholders for "no value" even when validation would otherwise pass.
1313
1419
  let normalizedArgs: unknown = originalArgs;
1314
1420
  let changed = false;
1421
+
1422
+ // Unwrap accidentally double-JSON-encoded object keys before any schema
1423
+ // pass. LLMs sometimes emit `{ "\"op\"": "done" }`, so the property name
1424
+ // arrives quote-wrapped; left alone it reads as an unrecognized key, gets
1425
+ // dropped by the coercion repair, and re-surfaces as a missing-required
1426
+ // error. Running first means every later pass sees the corrected names.
1427
+ const keyNormalization = normalizeDoubleEncodedKeys(normalizedArgs);
1428
+ if (keyNormalization.changed) {
1429
+ normalizedArgs = keyNormalization.value;
1430
+ changed = true;
1431
+ }
1432
+
1315
1433
  const initialNormalization = normalizeOptionalNullsForSchema(json, normalizedArgs);
1316
1434
  if (initialNormalization.changed) {
1317
1435
  normalizedArgs = initialNormalization.value;
@@ -1338,6 +1456,15 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
1338
1456
  normalizedArgs = coercion.value;
1339
1457
  changed = true;
1340
1458
 
1459
+ // `coerceArgsFromIssues` may have just parsed a JSON-string container at
1460
+ // the root or a nested field, exposing double-encoded keys the initial
1461
+ // pass could not reach. Re-unwrap before the unrecognized-key repair on
1462
+ // the next validation pass would delete them.
1463
+ const keyNormalizationPass = normalizeDoubleEncodedKeys(normalizedArgs);
1464
+ if (keyNormalizationPass.changed) {
1465
+ normalizedArgs = keyNormalizationPass.value;
1466
+ }
1467
+
1341
1468
  const nullNormalization = normalizeOptionalNullsForSchema(json, normalizedArgs);
1342
1469
  if (nullNormalization.changed) {
1343
1470
  normalizedArgs = nullNormalization.value;
@@ -1,6 +0,0 @@
1
- import type { OAuthLoginCallbacks } from "./oauth/types";
2
- export declare const waferPassProvider: {
3
- readonly id: "wafer-pass";
4
- readonly name: "Wafer Pass (flat-rate subscription)";
5
- readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
6
- };
@@ -1,12 +0,0 @@
1
- import type { OAuthLoginCallbacks } from "./oauth/types";
2
- import type { ProviderDefinition } from "./types";
3
-
4
- export const waferPassProvider = {
5
- id: "wafer-pass",
6
- name: "Wafer Pass (flat-rate subscription)",
7
- login: async (cb: OAuthLoginCallbacks) => {
8
- // Lazy import: keep heavy OAuth flow modules out of the eager registry graph.
9
- const { loginWaferPass } = await import("./oauth/wafer");
10
- return loginWaferPass(cb);
11
- },
12
- } as const satisfies ProviderDefinition;