@oh-my-pi/pi-ai 6.8.1 → 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.1",
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.1",
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
 
@@ -753,7 +755,12 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
753
755
 
754
756
  if (emptyAttempt > 0) {
755
757
  const backoffMs = EMPTY_STREAM_BASE_DELAY_MS * 2 ** (emptyAttempt - 1);
756
- await abortableSleep(backoffMs, options?.signal);
758
+ try {
759
+ await abortableSleep(backoffMs, options?.signal);
760
+ } catch {
761
+ // Normalize AbortError to expected message for consistent error handling
762
+ throw new Error("Request was aborted");
763
+ }
757
764
 
758
765
  if (!requestUrl) {
759
766
  throw new Error("Missing request URL");
@@ -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/storage.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { Database } from "bun:sqlite";
7
- import { existsSync, mkdirSync } from "node:fs";
7
+ import { chmodSync, existsSync, mkdirSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import { dirname, join } from "node:path";
10
10
  import type { OAuthCredentials } from "./utils/oauth/types";
@@ -88,13 +88,19 @@ export class CliAuthStorage {
88
88
  private deleteByProviderStmt: ReturnType<Database["prepare"]>;
89
89
 
90
90
  constructor(dbPath: string = getAgentDbPath()) {
91
- // Ensure directory exists
91
+ // Ensure directory exists with secure permissions
92
92
  const dir = dirname(dbPath);
93
93
  if (!existsSync(dir)) {
94
- mkdirSync(dir, { recursive: true });
94
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
95
95
  }
96
96
 
97
97
  this.db = new Database(dbPath);
98
+ // Harden database file permissions to prevent credential leakage
99
+ try {
100
+ chmodSync(dbPath, 0o600);
101
+ } catch {
102
+ // Ignore chmod failures (e.g., Windows)
103
+ }
98
104
  this.initializeSchema();
99
105
 
100
106
  this.insertStmt = this.db.prepare(
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
  }
@@ -197,17 +197,24 @@ export abstract class OAuthCallbackFlow {
197
197
  });
198
198
 
199
199
  // Manual input race (if supported)
200
+ // Errors from manual input should not abort the flow - only successful input wins the race
200
201
  if (this.ctrl.onManualCodeInput) {
201
- const manualPromise = this.ctrl.onManualCodeInput().then((input): CallbackResult => {
202
- const parsed = parseCallbackInput(input);
203
- if (!parsed.code) {
204
- throw new Error("No authorization code found in input");
205
- }
206
- if (expectedState && parsed.state && parsed.state !== expectedState) {
207
- throw new Error("State mismatch - possible CSRF attack");
208
- }
209
- return { code: parsed.code, state: parsed.state ?? "" };
210
- });
202
+ const manualPromise = this.ctrl
203
+ .onManualCodeInput()
204
+ .then((input): CallbackResult => {
205
+ const parsed = parseCallbackInput(input);
206
+ if (!parsed.code) {
207
+ throw new Error("No authorization code found in input");
208
+ }
209
+ if (expectedState && parsed.state && parsed.state !== expectedState) {
210
+ throw new Error("State mismatch - possible CSRF attack");
211
+ }
212
+ return { code: parsed.code, state: parsed.state ?? "" };
213
+ })
214
+ .catch((): Promise<CallbackResult> => {
215
+ // On manual input error, wait forever - let callback or abort signal win
216
+ return new Promise(() => {});
217
+ });
211
218
 
212
219
  return Promise.race([callbackPromise, manualPromise]);
213
220
  }
@@ -174,20 +174,32 @@ async function pollForGitHubAccessToken(
174
174
  if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") {
175
175
  const err = (raw as DeviceTokenErrorResponse).error;
176
176
  if (err === "authorization_pending") {
177
- await abortableSleep(intervalMs, signal);
177
+ try {
178
+ await abortableSleep(intervalMs, signal);
179
+ } catch {
180
+ throw new Error("Login cancelled");
181
+ }
178
182
  continue;
179
183
  }
180
184
 
181
185
  if (err === "slow_down") {
182
186
  intervalMs += 5000;
183
- await abortableSleep(intervalMs, signal);
187
+ try {
188
+ await abortableSleep(intervalMs, signal);
189
+ } catch {
190
+ throw new Error("Login cancelled");
191
+ }
184
192
  continue;
185
193
  }
186
194
 
187
195
  throw new Error(`Device flow failed: ${err}`);
188
196
  }
189
197
 
190
- await abortableSleep(intervalMs, signal);
198
+ try {
199
+ await abortableSleep(intervalMs, signal);
200
+ } catch {
201
+ throw new Error("Login cancelled");
202
+ }
191
203
  }
192
204
 
193
205
  throw new Error("Device flow timed out");
@@ -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
 
@@ -2,7 +2,7 @@
2
2
  * OpenAI Codex (ChatGPT OAuth) flow
3
3
  */
4
4
 
5
- import { OAuthCallbackFlow, parseCallbackInput } from "./callback-server";
5
+ import { OAuthCallbackFlow } from "./callback-server";
6
6
  import { generatePKCE } from "./pkce";
7
7
  import type { OAuthController, OAuthCredentials } from "./types";
8
8
 
@@ -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,31 +123,17 @@ 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
- const pkce = await generatePKCE();
127
- const flow = new OpenAICodexOAuthFlow(ctrl, pkce);
128
- const redirectUri = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
129
-
130
- try {
131
- return await flow.login();
132
- } catch (error) {
133
- if (!ctrl.onPrompt) {
134
- throw error;
135
- }
136
-
137
- ctrl.onProgress?.("Callback server failed, falling back to manual input");
138
-
139
- const input = await ctrl.onPrompt({
140
- message: "Paste the authorization code (or full redirect URL):",
141
- });
126
+ export type OpenAICodexLoginOptions = OAuthController & {
127
+ /** Optional originator value for OpenAI Codex OAuth. Default: "opencode". */
128
+ originator?: string;
129
+ };
142
130
 
143
- const parsed = parseCallbackInput(input);
144
- if (!parsed.code) {
145
- throw new Error("No authorization code found in input");
146
- }
131
+ export async function loginOpenAICodex(options: OpenAICodexLoginOptions): Promise<OAuthCredentials> {
132
+ const pkce = await generatePKCE();
133
+ const originator = options.originator?.trim() || "opencode";
134
+ const flow = new OpenAICodexOAuthFlow(options, pkce, originator);
147
135
 
148
- return exchangeCodeForToken(parsed.code, pkce.verifier, redirectUri);
149
- }
136
+ return flow.login();
150
137
  }
151
138
 
152
139
  /**