@oh-my-pi/pi-ai 16.1.6 → 16.1.8

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.8] - 2026-06-20
6
+
7
+ ### Changed
8
+
9
+ - 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.
10
+
11
+ ### Fixed
12
+
13
+ - 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))
14
+ - 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))
15
+ - 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))
16
+ - 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))
17
+ - 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.
18
+
5
19
  ## [16.1.4] - 2026-06-19
6
20
 
7
21
  ### Added
@@ -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 {
@@ -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
  };
@@ -142,6 +142,7 @@ declare const ALL: ({
142
142
  readonly id: "nvidia";
143
143
  readonly name: "NVIDIA";
144
144
  readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
145
+ readonly appendApiKeyLogin: true;
145
146
  } | {
146
147
  readonly id: "ollama";
147
148
  readonly name: "Ollama (Local OpenAI-compatible)";
@@ -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
  /**
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.6",
4
+ "version": "16.1.8",
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.6",
42
- "@oh-my-pi/pi-utils": "16.1.6",
43
- "@oh-my-pi/pi-wire": "16.1.6",
41
+ "@oh-my-pi/pi-catalog": "16.1.8",
42
+ "@oh-my-pi/pi-utils": "16.1.8",
43
+ "@oh-my-pi/pi-wire": "16.1.8",
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, {
@@ -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;
@@ -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
@@ -673,6 +673,30 @@ function mapOpenAiToolChoice(choice?: ToolChoice): OpenAICompletionsOptions["too
673
673
  return undefined;
674
674
  }
675
675
 
676
+ type ReasoningEffortMapCompat = {
677
+ reasoningEffortMap?: Partial<Record<Effort, string>>;
678
+ };
679
+
680
+ function getCompatReasoningEffortMap<TApi extends Api>(
681
+ model: Model<TApi>,
682
+ ): Partial<Record<Effort, string>> | undefined {
683
+ const compat = model.compat;
684
+ if (compat === undefined || typeof compat !== "object" || !("reasoningEffortMap" in compat)) {
685
+ return undefined;
686
+ }
687
+ return (compat as ReasoningEffortMapCompat).reasoningEffortMap;
688
+ }
689
+
690
+ function resolveSupportedMappedReasoningEffort<TApi extends Api>(
691
+ model: Model<TApi>,
692
+ reasoning: Effort,
693
+ ): Effort | undefined {
694
+ const mapped = getCompatReasoningEffortMap(model)?.[reasoning];
695
+ if (!mapped) return undefined;
696
+ const mappedEffort = mapped as Effort;
697
+ return model.thinking?.efforts.includes(mappedEffort) ? mappedEffort : undefined;
698
+ }
699
+
676
700
  function resolveOpenAiReasoningEffort<TApi extends Api>(
677
701
  model: Model<TApi>,
678
702
  options?: SimpleStreamOptions,
@@ -687,6 +711,11 @@ function resolveOpenAiReasoningEffort<TApi extends Api>(
687
711
  // defeat the gate and surface a confusing "Compaction failed: Thinking effort
688
712
  // high is not supported by..." to the user.
689
713
  if (!model.thinking) return undefined;
714
+ if (model.thinking.efforts.includes(reasoning)) return reasoning;
715
+ const mappedReasoning = resolveSupportedMappedReasoningEffort(model, reasoning);
716
+ if (mappedReasoning) return mappedReasoning;
717
+ if (getCompatReasoningEffortMap(model)?.[reasoning] !== undefined) return reasoning;
718
+ if (model.thinking.effortMap?.[reasoning] !== undefined) return reasoning;
690
719
  return requireSupportedEffort(model, reasoning);
691
720
  }
692
721
 
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
  /**