@oh-my-pi/pi-ai 6.8.2 → 6.8.3

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/README.md CHANGED
@@ -607,6 +607,28 @@ context.messages.push({ role: "user", content: "Please continue" });
607
607
  const continuation = await complete(model, context);
608
608
  ```
609
609
 
610
+ ### Common Stream Options
611
+
612
+ All providers accept the base `StreamOptions` (in addition to provider-specific options):
613
+
614
+ - `apiKey`: Override the provider API key
615
+ - `headers`: Extra request headers merged on top of model-defined headers
616
+ - `sessionId`: Provider-specific session identifier (prompt caching/routing)
617
+ - `signal`: Abort in-flight requests
618
+ - `onPayload`: Callback invoked with the provider request payload just before sending
619
+
620
+ Example:
621
+
622
+ ```typescript
623
+ const response = await complete(model, context, {
624
+ apiKey: "sk-live",
625
+ headers: { "X-Debug-Trace": "true" },
626
+ onPayload: (payload) => {
627
+ console.log("request payload", payload);
628
+ },
629
+ });
630
+ ```
631
+
610
632
  ## APIs, Models, and Providers
611
633
 
612
634
  The library implements 4 API interfaces, each with its own streaming function and options:
@@ -987,6 +1009,15 @@ import {
987
1009
  } from "@oh-my-pi/pi-ai";
988
1010
  ```
989
1011
 
1012
+ `loginOpenAICodex` accepts an optional `originator` value used in the OAuth flow:
1013
+
1014
+ ```typescript
1015
+ await loginOpenAICodex({
1016
+ onAuth: ({ url }) => console.log(url),
1017
+ originator: "my-cli",
1018
+ });
1019
+ ```
1020
+
990
1021
  ### Login Flow Example
991
1022
 
992
1023
  ```typescript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "6.8.2",
3
+ "version": "6.8.3",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -17,7 +17,7 @@
17
17
  "test": "bun test"
18
18
  },
19
19
  "dependencies": {
20
- "@oh-my-pi/pi-utils": "6.8.2",
20
+ "@oh-my-pi/pi-utils": "6.8.3",
21
21
  "@anthropic-ai/sdk": "0.71.2",
22
22
  "@aws-sdk/client-bedrock-runtime": "^3.968.0",
23
23
  "@bufbuild/protobuf": "^2.10.2",
@@ -93,14 +93,16 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
93
93
  profile: options.profile,
94
94
  });
95
95
 
96
- const command = new ConverseStreamCommand({
96
+ const commandInput = {
97
97
  modelId: model.id,
98
98
  messages: convertMessages(context, model),
99
99
  system: buildSystemPrompt(context.systemPrompt, model),
100
100
  inferenceConfig: { maxTokens: options.maxTokens, temperature: options.temperature },
101
101
  toolConfig: convertToolConfig(context.tools, options.toolChoice),
102
102
  additionalModelRequestFields: buildAdditionalModelRequestFields(model, options),
103
- });
103
+ };
104
+ options?.onPayload?.(commandInput);
105
+ const command = new ConverseStreamCommand(commandInput);
104
106
 
105
107
  const response = await client.send(command, { abortSignal: options.signal });
106
108
 
@@ -161,8 +161,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
161
161
  try {
162
162
  const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? "";
163
163
  const extraBetas = normalizeExtraBetas(options?.betas);
164
- const { client, isOAuthToken } = createClient(model, apiKey, extraBetas, true);
164
+ const { client, isOAuthToken } = createClient(model, apiKey, extraBetas, true, options?.headers);
165
165
  const params = buildParams(model, context, isOAuthToken, options);
166
+ options?.onPayload?.(params);
166
167
  const anthropicStream = client.messages.stream({ ...params, stream: true }, { signal: options?.signal });
167
168
  stream.push({ type: "start", partial: output });
168
169
 
@@ -420,6 +421,7 @@ function createClient(
420
421
  apiKey: string,
421
422
  extraBetas: string[],
422
423
  stream: boolean,
424
+ extraHeaders?: Record<string, string>,
423
425
  ): { client: Anthropic; isOAuthToken: boolean } {
424
426
  const oauthToken = isOAuthToken(apiKey);
425
427
 
@@ -438,7 +440,7 @@ function createClient(
438
440
  isOAuth: oauthToken,
439
441
  extraBetas: mergedBetas,
440
442
  stream,
441
- modelHeaders: model.headers,
443
+ modelHeaders: { ...(model.headers ?? {}), ...(extraHeaders ?? {}) },
442
444
  });
443
445
 
444
446
  const clientOptions: ConstructorParameters<typeof Anthropic>[0] = {
@@ -2027,6 +2027,8 @@ function buildGrpcRequest(
2027
2027
  conversationId: state.conversationId,
2028
2028
  });
2029
2029
 
2030
+ options?.onPayload?.(runRequest);
2031
+
2030
2032
  // Tools are sent later via requestContext (exec handshake)
2031
2033
 
2032
2034
  if (options?.customSystemPrompt) {
@@ -410,6 +410,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
410
410
  const endpoints = baseUrl ? [baseUrl] : isAntigravity ? ANTIGRAVITY_ENDPOINT_FALLBACKS : [DEFAULT_ENDPOINT];
411
411
 
412
412
  const requestBody = buildRequest(model, context, projectId, options, isAntigravity);
413
+ options?.onPayload?.(requestBody);
413
414
  const headers = isAntigravity ? ANTIGRAVITY_HEADERS : GEMINI_CLI_HEADERS;
414
415
 
415
416
  const requestHeaders = {
@@ -418,6 +419,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
418
419
  Accept: "text/event-stream",
419
420
  ...headers,
420
421
  ...(isClaudeThinkingModel(model.id) ? { "anthropic-beta": CLAUDE_THINKING_BETA_HEADER } : {}),
422
+ ...(options?.headers ?? {}),
421
423
  };
422
424
  const requestBodyJson = JSON.stringify(requestBody);
423
425
 
@@ -85,6 +85,7 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
85
85
  const location = resolveLocation(options);
86
86
  const client = createClient(model, project, location);
87
87
  const params = buildParams(model, context, options);
88
+ options?.onPayload?.(params);
88
89
  const googleStream = await client.models.generateContentStream(params);
89
90
 
90
91
  stream.push({ type: "start", partial: output });
@@ -75,6 +75,7 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = (
75
75
  const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
76
76
  const client = createClient(model, apiKey);
77
77
  const params = buildParams(model, context, options);
78
+ options?.onPayload?.(params);
78
79
  const googleStream = await client.models.generateContentStream(params);
79
80
 
80
81
  stream.push({ type: "start", partial: output });
@@ -55,6 +55,29 @@ const CODEX_MAX_RETRIES = 2;
55
55
  const CODEX_RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
56
56
  const CODEX_RETRY_DELAY_MS = 500;
57
57
 
58
+ /** Fast deterministic hash to shorten long strings */
59
+ function shortHash(str: string): string {
60
+ let h1 = 0xdeadbeef;
61
+ let h2 = 0x41c6ce57;
62
+ for (let i = 0; i < str.length; i++) {
63
+ const ch = str.charCodeAt(i);
64
+ h1 = Math.imul(h1 ^ ch, 2654435761);
65
+ h2 = Math.imul(h2 ^ ch, 1597334677);
66
+ }
67
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
68
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
69
+ return (h2 >>> 0).toString(36) + (h1 >>> 0).toString(36);
70
+ }
71
+
72
+ function normalizeResponsesToolCallId(id: string): { callId: string; itemId: string } {
73
+ const [callId, itemId] = id.split("|");
74
+ if (callId && itemId) {
75
+ return { callId, itemId };
76
+ }
77
+ const hash = shortHash(id);
78
+ return { callId: `call_${hash}`, itemId: `item_${hash}` };
79
+ }
80
+
58
81
  export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"> = (
59
82
  model: Model<"openai-codex-responses">,
60
83
  context: Context,
@@ -128,9 +151,15 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
128
151
  };
129
152
 
130
153
  const transformedBody = await transformRequestBody(params, codexOptions, systemPrompt);
154
+ options?.onPayload?.(transformedBody);
131
155
 
132
156
  const reasoningEffort = transformedBody.reasoning?.effort ?? null;
133
- const headers = createCodexHeaders(model.headers, accountId, apiKey, options?.sessionId);
157
+ const headers = createCodexHeaders(
158
+ { ...(model.headers ?? {}), ...(options?.headers ?? {}) },
159
+ accountId,
160
+ apiKey,
161
+ options?.sessionId,
162
+ );
134
163
  logCodexDebug("codex request", {
135
164
  url,
136
165
  model: params.model,
@@ -508,19 +537,6 @@ function getAccountId(accessToken: string): string {
508
537
  return accountId;
509
538
  }
510
539
 
511
- function shortHash(str: string): string {
512
- let h1 = 0xdeadbeef;
513
- let h2 = 0x41c6ce57;
514
- for (let i = 0; i < str.length; i++) {
515
- const ch = str.charCodeAt(i);
516
- h1 = Math.imul(h1 ^ ch, 2654435761);
517
- h2 = Math.imul(h2 ^ ch, 1597334677);
518
- }
519
- h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
520
- h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
521
- return (h2 >>> 0).toString(36) + (h1 >>> 0).toString(36);
522
- }
523
-
524
540
  function convertMessages(model: Model<"openai-codex-responses">, context: Context): ResponseInput {
525
541
  const messages: ResponseInput = [];
526
542
 
@@ -583,10 +599,11 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
583
599
  } satisfies ResponseOutputMessage);
584
600
  } else if (block.type === "toolCall" && msg.stopReason !== "error") {
585
601
  const toolCall = block as ToolCall;
602
+ const normalized = normalizeResponsesToolCallId(toolCall.id);
586
603
  output.push({
587
604
  type: "function_call",
588
- id: toolCall.id.split("|")[1],
589
- call_id: toolCall.id.split("|")[0],
605
+ id: normalized.itemId,
606
+ call_id: normalized.callId,
590
607
  name: toolCall.name,
591
608
  arguments: JSON.stringify(toolCall.arguments),
592
609
  });
@@ -600,11 +617,12 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
600
617
  .map((c) => (c as { text: string }).text)
601
618
  .join("\n");
602
619
  const hasImages = msg.content.some((c) => c.type === "image");
620
+ const normalized = normalizeResponsesToolCallId(msg.toolCallId);
603
621
 
604
622
  const hasText = textResult.length > 0;
605
623
  messages.push({
606
624
  type: "function_call_output",
607
- call_id: msg.toolCallId.split("|")[0],
625
+ call_id: normalized.callId,
608
626
  output: sanitizeSurrogates(hasText ? textResult : "(see attached image)"),
609
627
  });
610
628
 
@@ -101,8 +101,9 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
101
101
 
102
102
  try {
103
103
  const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
104
- const client = createClient(model, context, apiKey);
104
+ const client = createClient(model, context, apiKey, options?.headers);
105
105
  const params = buildParams(model, context, options);
106
+ options?.onPayload?.(params);
106
107
  const openaiStream = await client.chat.completions.create(params, { signal: options?.signal });
107
108
  stream.push({ type: "start", partial: output });
108
109
 
@@ -319,7 +320,12 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
319
320
  return stream;
320
321
  };
321
322
 
322
- function createClient(model: Model<"openai-completions">, context: Context, apiKey?: string) {
323
+ function createClient(
324
+ model: Model<"openai-completions">,
325
+ context: Context,
326
+ apiKey?: string,
327
+ extraHeaders?: Record<string, string>,
328
+ ) {
323
329
  if (!apiKey) {
324
330
  if (!process.env.OPENAI_API_KEY) {
325
331
  throw new Error(
@@ -329,7 +335,7 @@ function createClient(model: Model<"openai-completions">, context: Context, apiK
329
335
  apiKey = process.env.OPENAI_API_KEY;
330
336
  }
331
337
 
332
- const headers = { ...model.headers };
338
+ const headers = { ...(model.headers ?? {}), ...(extraHeaders ?? {}) };
333
339
  if (model.provider === "github-copilot") {
334
340
  // Copilot expects X-Initiator to indicate whether the request is user-initiated
335
341
  // or agent-initiated (e.g. follow-up after assistant/tool messages). If there is
@@ -50,6 +50,11 @@ export interface OpenAIResponsesOptions extends StreamOptions {
50
50
  reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
51
51
  reasoningSummary?: "auto" | "detailed" | "concise" | null;
52
52
  serviceTier?: ResponseCreateParamsStreaming["service_tier"];
53
+ /**
54
+ * Enforce strict tool call/result pairing when building Responses API inputs.
55
+ * Azure OpenAI Responses API requires tool results to have a matching tool call.
56
+ */
57
+ strictResponsesPairing?: boolean;
53
58
  }
54
59
 
55
60
  /**
@@ -85,8 +90,9 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
85
90
  try {
86
91
  // Create OpenAI client
87
92
  const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
88
- const client = createClient(model, context, apiKey);
93
+ const client = createClient(model, context, apiKey, options?.headers);
89
94
  const params = buildParams(model, context, options);
95
+ options?.onPayload?.(params);
90
96
  const openaiStream = await client.responses.create(
91
97
  params,
92
98
  options?.signal ? { signal: options.signal } : undefined,
@@ -317,7 +323,12 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
317
323
  return stream;
318
324
  };
319
325
 
320
- function createClient(model: Model<"openai-responses">, context: Context, apiKey?: string) {
326
+ function createClient(
327
+ model: Model<"openai-responses">,
328
+ context: Context,
329
+ apiKey?: string,
330
+ extraHeaders?: Record<string, string>,
331
+ ) {
321
332
  if (!apiKey) {
322
333
  if (!process.env.OPENAI_API_KEY) {
323
334
  throw new Error(
@@ -327,7 +338,7 @@ function createClient(model: Model<"openai-responses">, context: Context, apiKey
327
338
  apiKey = process.env.OPENAI_API_KEY;
328
339
  }
329
340
 
330
- const headers = { ...model.headers };
341
+ const headers = { ...(model.headers ?? {}), ...(extraHeaders ?? {}) };
331
342
  if (model.provider === "github-copilot") {
332
343
  // Copilot expects X-Initiator to indicate whether the request is user-initiated
333
344
  // or agent-initiated (e.g. follow-up after assistant/tool messages). If there is
@@ -362,7 +373,8 @@ function createClient(model: Model<"openai-responses">, context: Context, apiKey
362
373
  }
363
374
 
364
375
  function buildParams(model: Model<"openai-responses">, context: Context, options?: OpenAIResponsesOptions) {
365
- const messages = convertMessages(model, context);
376
+ const strictResponsesPairing = options?.strictResponsesPairing ?? isAzureOpenAIBaseUrl(model.baseUrl ?? "");
377
+ const messages = convertMessages(model, context, strictResponsesPairing);
366
378
 
367
379
  const params: ResponseCreateParamsStreaming = {
368
380
  model: model.id,
@@ -413,8 +425,26 @@ function buildParams(model: Model<"openai-responses">, context: Context, options
413
425
  return params;
414
426
  }
415
427
 
416
- function convertMessages(model: Model<"openai-responses">, context: Context): ResponseInput {
428
+ function normalizeResponsesToolCallId(id: string): { callId: string; itemId: string } {
429
+ const [callId, itemId] = id.split("|");
430
+ if (callId && itemId) {
431
+ return { callId, itemId };
432
+ }
433
+ const hash = shortHash(id);
434
+ return { callId: `call_${hash}`, itemId: `item_${hash}` };
435
+ }
436
+
437
+ function isAzureOpenAIBaseUrl(baseUrl: string): boolean {
438
+ return baseUrl.includes(".openai.azure.com") || baseUrl.includes("azure.com/openai");
439
+ }
440
+
441
+ function convertMessages(
442
+ model: Model<"openai-responses">,
443
+ context: Context,
444
+ strictResponsesPairing: boolean,
445
+ ): ResponseInput {
417
446
  const messages: ResponseInput = [];
447
+ const knownCallIds = new Set<string>();
418
448
 
419
449
  const transformedMessages = transformMessages(context.messages, model);
420
450
 
@@ -487,10 +517,12 @@ function convertMessages(model: Model<"openai-responses">, context: Context): Re
487
517
  // Do not submit toolcall blocks if the completion had an error (i.e. abort)
488
518
  } else if (block.type === "toolCall" && msg.stopReason !== "error") {
489
519
  const toolCall = block as ToolCall;
520
+ const normalized = normalizeResponsesToolCallId(toolCall.id);
521
+ knownCallIds.add(normalized.callId);
490
522
  output.push({
491
523
  type: "function_call",
492
- id: toolCall.id.split("|")[1],
493
- call_id: toolCall.id.split("|")[0],
524
+ id: normalized.itemId,
525
+ call_id: normalized.callId,
494
526
  name: toolCall.name,
495
527
  arguments: JSON.stringify(toolCall.arguments),
496
528
  });
@@ -505,12 +537,16 @@ function convertMessages(model: Model<"openai-responses">, context: Context): Re
505
537
  .map((c) => (c as any).text)
506
538
  .join("\n");
507
539
  const hasImages = msg.content.some((c) => c.type === "image");
540
+ const normalized = normalizeResponsesToolCallId(msg.toolCallId);
541
+ if (strictResponsesPairing && !knownCallIds.has(normalized.callId)) {
542
+ continue;
543
+ }
508
544
 
509
545
  // Always send function_call_output with text (or placeholder if only images)
510
546
  const hasText = textResult.length > 0;
511
547
  messages.push({
512
548
  type: "function_call_output",
513
- call_id: msg.toolCallId.split("|")[0],
549
+ call_id: normalized.callId,
514
550
  output: sanitizeSurrogates(hasText ? textResult : "(see attached image)"),
515
551
  });
516
552
 
@@ -9,9 +9,34 @@ function normalizeToolCallId(id: string): string {
9
9
  return id.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 40);
10
10
  }
11
11
 
12
+ /** Fast deterministic hash to shorten long strings */
13
+ function shortHash(str: string): string {
14
+ let h1 = 0xdeadbeef;
15
+ let h2 = 0x41c6ce57;
16
+ for (let i = 0; i < str.length; i++) {
17
+ const ch = str.charCodeAt(i);
18
+ h1 = Math.imul(h1 ^ ch, 2654435761);
19
+ h2 = Math.imul(h2 ^ ch, 1597334677);
20
+ }
21
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
22
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
23
+ return (h2 >>> 0).toString(36) + (h1 >>> 0).toString(36);
24
+ }
25
+
26
+ function normalizeResponsesToolCallId(id: string): string {
27
+ const [callId, itemId] = id.split("|");
28
+ if (callId && itemId) {
29
+ return id;
30
+ }
31
+ const hash = shortHash(id);
32
+ return `call_${hash}|item_${hash}`;
33
+ }
34
+
12
35
  export function transformMessages<TApi extends Api>(messages: Message[], model: Model<TApi>): Message[] {
13
36
  // Build a map of original tool call IDs to normalized IDs for github-copilot cross-API switches
14
37
  const toolCallIdMap = new Map<string, string>();
38
+ const skippedToolCallIds = new Set<string>();
39
+ const needsResponsesToolCallIds = model.api === "openai-responses" || model.api === "openai-codex-responses";
15
40
 
16
41
  // First pass: transform messages (thinking blocks, tool call ID normalization)
17
42
  const transformed = messages.flatMap<Message>((msg): Message[] => {
@@ -22,20 +47,39 @@ export function transformMessages<TApi extends Api>(messages: Message[], model:
22
47
 
23
48
  // Handle toolResult messages - normalize toolCallId if we have a mapping
24
49
  if (msg.role === "toolResult") {
50
+ if (skippedToolCallIds.has(msg.toolCallId)) {
51
+ return [];
52
+ }
25
53
  const normalizedId = toolCallIdMap.get(msg.toolCallId);
26
54
  if (normalizedId && normalizedId !== msg.toolCallId) {
27
55
  return [{ ...msg, toolCallId: normalizedId }];
28
56
  }
57
+ if (needsResponsesToolCallIds) {
58
+ return [{ ...msg, toolCallId: normalizeResponsesToolCallId(msg.toolCallId) }];
59
+ }
29
60
  return [msg];
30
61
  }
31
62
 
32
63
  // Assistant messages need transformation check
33
64
  if (msg.role === "assistant") {
34
65
  const assistantMsg = msg as AssistantMessage;
66
+ const isSameProviderApi = assistantMsg.provider === model.provider && assistantMsg.api === model.api;
67
+ const isErroredAssistant = assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted";
68
+ if (!isSameProviderApi && isErroredAssistant) {
69
+ for (const block of assistantMsg.content) {
70
+ if (block.type === "toolCall") {
71
+ skippedToolCallIds.add(block.id);
72
+ }
73
+ }
74
+ return [];
75
+ }
35
76
 
36
77
  // If message is from the same provider and API, keep as is
37
- if (assistantMsg.provider === model.provider && assistantMsg.api === model.api) {
38
- if (assistantMsg.stopReason === "error" && assistantMsg.content.length === 0) {
78
+ if (isSameProviderApi) {
79
+ if (
80
+ (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") &&
81
+ assistantMsg.content.length === 0
82
+ ) {
39
83
  return [];
40
84
  }
41
85
  return [msg];
@@ -64,12 +108,20 @@ export function transformMessages<TApi extends Api>(messages: Message[], model:
64
108
  };
65
109
  }
66
110
  // Normalize tool call IDs when target API requires strict format
67
- if (block.type === "toolCall" && needsToolCallIdNormalization) {
111
+ if (block.type === "toolCall") {
68
112
  const toolCall = block as ToolCall;
69
- const normalizedId = normalizeToolCallId(toolCall.id);
70
- if (normalizedId !== toolCall.id) {
71
- toolCallIdMap.set(toolCall.id, normalizedId);
72
- return { ...toolCall, id: normalizedId };
113
+ if (needsResponsesToolCallIds) {
114
+ const normalizedId = normalizeResponsesToolCallId(toolCall.id);
115
+ if (normalizedId !== toolCall.id) {
116
+ toolCallIdMap.set(toolCall.id, normalizedId);
117
+ return { ...toolCall, id: normalizedId };
118
+ }
119
+ } else if (needsToolCallIdNormalization) {
120
+ const normalizedId = normalizeToolCallId(toolCall.id);
121
+ if (normalizedId !== toolCall.id) {
122
+ toolCallIdMap.set(toolCall.id, normalizedId);
123
+ return { ...toolCall, id: normalizedId };
124
+ }
73
125
  }
74
126
  }
75
127
  // All other blocks pass through unchanged
package/src/stream.ts CHANGED
@@ -79,10 +79,17 @@ export function getEnvApiKey(provider: any): string | undefined {
79
79
  // 1. AWS_PROFILE - named profile from ~/.aws/credentials
80
80
  // 2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY - standard IAM keys
81
81
  // 3. AWS_BEARER_TOKEN_BEDROCK - Bedrock API keys (bearer token)
82
+ // 4. AWS_CONTAINER_CREDENTIALS_* - ECS/Task IAM role credentials
83
+ // 5. AWS_WEB_IDENTITY_TOKEN_FILE + AWS_ROLE_ARN - IRSA (EKS) web identity
84
+ const hasEcsCredentials =
85
+ !!process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || !!process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI;
86
+ const hasWebIdentity = !!process.env.AWS_WEB_IDENTITY_TOKEN_FILE && !!process.env.AWS_ROLE_ARN;
82
87
  if (
83
88
  process.env.AWS_PROFILE ||
84
89
  (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) ||
85
- process.env.AWS_BEARER_TOKEN_BEDROCK
90
+ process.env.AWS_BEARER_TOKEN_BEDROCK ||
91
+ hasEcsCredentials ||
92
+ hasWebIdentity
86
93
  ) {
87
94
  return "<authenticated>";
88
95
  }
@@ -252,7 +259,9 @@ function mapOptionsForApi<TApi extends Api>(
252
259
  maxTokens: options?.maxTokens || Math.min(model.maxTokens, 32000),
253
260
  signal: options?.signal,
254
261
  apiKey: apiKey || options?.apiKey,
262
+ headers: options?.headers,
255
263
  sessionId: options?.sessionId,
264
+ onPayload: options?.onPayload,
256
265
  execHandlers: options?.execHandlers,
257
266
  };
258
267
 
package/src/types.ts CHANGED
@@ -96,12 +96,22 @@ export interface StreamOptions {
96
96
  maxTokens?: number;
97
97
  signal?: AbortSignal;
98
98
  apiKey?: string;
99
+ /**
100
+ * Additional headers to include in provider requests.
101
+ * These are merged on top of model-defined headers.
102
+ */
103
+ headers?: Record<string, string>;
99
104
  /**
100
105
  * Optional session identifier for providers that support session-based caching.
101
106
  * Providers can use this to enable prompt caching, request routing, or other
102
107
  * session-aware features. Ignored by providers that don't support it.
103
108
  */
104
109
  sessionId?: string;
110
+ /**
111
+ * Optional hook to observe the provider request payload before it is sent.
112
+ * The payload format is provider-specific.
113
+ */
114
+ onPayload?: (payload: unknown) => void;
105
115
  /** Cursor exec/MCP tool handlers (cursor-agent only). */
106
116
  execHandlers?: CursorExecHandlers;
107
117
  }
@@ -30,6 +30,7 @@ export {
30
30
  export { loginAntigravity, refreshAntigravityToken } from "./google-antigravity";
31
31
  // Google Gemini CLI
32
32
  export { loginGeminiCli, refreshGoogleCloudToken } from "./google-gemini-cli";
33
+ export type { OpenAICodexLoginOptions } from "./openai-codex";
33
34
  // OpenAI Codex (ChatGPT OAuth)
34
35
  export { loginOpenAICodex, refreshOpenAICodexToken } from "./openai-codex";
35
36
 
@@ -49,6 +49,7 @@ class OpenAICodexOAuthFlow extends OAuthCallbackFlow {
49
49
  constructor(
50
50
  ctrl: OAuthController,
51
51
  private readonly pkce: PKCE,
52
+ private readonly originator: string,
52
53
  ) {
53
54
  super(ctrl, CALLBACK_PORT, CALLBACK_PATH);
54
55
  }
@@ -67,7 +68,7 @@ class OpenAICodexOAuthFlow extends OAuthCallbackFlow {
67
68
  state,
68
69
  id_token_add_organizations: "true",
69
70
  codex_cli_simplified_flow: "true",
70
- originator: "opencode",
71
+ originator: this.originator,
71
72
  });
72
73
 
73
74
  const url = `${AUTHORIZE_URL}?${searchParams.toString()}`;
@@ -122,9 +123,15 @@ async function exchangeCodeForToken(code: string, verifier: string, redirectUri:
122
123
  /**
123
124
  * Login with OpenAI Codex OAuth
124
125
  */
125
- export async function loginOpenAICodex(ctrl: OAuthController): Promise<OAuthCredentials> {
126
+ export type OpenAICodexLoginOptions = OAuthController & {
127
+ /** Optional originator value for OpenAI Codex OAuth. Default: "opencode". */
128
+ originator?: string;
129
+ };
130
+
131
+ export async function loginOpenAICodex(options: OpenAICodexLoginOptions): Promise<OAuthCredentials> {
126
132
  const pkce = await generatePKCE();
127
- const flow = new OpenAICodexOAuthFlow(ctrl, pkce);
133
+ const originator = options.originator?.trim() || "opencode";
134
+ const flow = new OpenAICodexOAuthFlow(options, pkce, originator);
128
135
 
129
136
  return flow.login();
130
137
  }