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

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.4",
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.4",
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
 
@@ -291,11 +292,21 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
291
292
  if (event.delta.stop_reason) {
292
293
  output.stopReason = mapStopReason(event.delta.stop_reason);
293
294
  }
294
- output.usage.input = event.usage.input_tokens || 0;
295
- output.usage.output = event.usage.output_tokens || 0;
296
- output.usage.cacheRead = event.usage.cache_read_input_tokens || 0;
297
- output.usage.cacheWrite = event.usage.cache_creation_input_tokens || 0;
298
- // Anthropic doesn't provide total_tokens, compute from components
295
+ // message_delta.usage only contains output_tokens (cumulative), not input_tokens
296
+ // Preserve input token counts from message_start, only update output
297
+ if (event.usage.output_tokens !== undefined && event.usage.output_tokens !== null) {
298
+ output.usage.output = event.usage.output_tokens;
299
+ }
300
+ // These fields may or may not be present in message_delta
301
+ if (event.usage.cache_read_input_tokens !== undefined && event.usage.cache_read_input_tokens !== null) {
302
+ output.usage.cacheRead = event.usage.cache_read_input_tokens;
303
+ }
304
+ if (
305
+ event.usage.cache_creation_input_tokens !== undefined &&
306
+ event.usage.cache_creation_input_tokens !== null
307
+ ) {
308
+ output.usage.cacheWrite = event.usage.cache_creation_input_tokens;
309
+ }
299
310
  output.usage.totalTokens =
300
311
  output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
301
312
  calculateCost(model, output.usage);
@@ -420,6 +431,7 @@ function createClient(
420
431
  apiKey: string,
421
432
  extraBetas: string[],
422
433
  stream: boolean,
434
+ extraHeaders?: Record<string, string>,
423
435
  ): { client: Anthropic; isOAuthToken: boolean } {
424
436
  const oauthToken = isOAuthToken(apiKey);
425
437
 
@@ -438,7 +450,7 @@ function createClient(
438
450
  isOAuth: oauthToken,
439
451
  extraBetas: mergedBetas,
440
452
  stream,
441
- modelHeaders: model.headers,
453
+ modelHeaders: { ...(model.headers ?? {}), ...(extraHeaders ?? {}) },
442
454
  });
443
455
 
444
456
  const clientOptions: ConstructorParameters<typeof Anthropic>[0] = {
@@ -466,16 +478,13 @@ export type AnthropicSystemBlock = {
466
478
  };
467
479
 
468
480
  type CacheControlBlock = {
469
- cache_control?: { type: "ephemeral" };
481
+ cache_control?: { type: "ephemeral" } | null;
470
482
  };
471
483
 
472
- type CacheControlMode = "none" | "toolBlocks" | "userText";
473
-
474
484
  const cacheControlEphemeral = { type: "ephemeral" as const };
475
485
 
476
486
  type SystemBlockOptions = {
477
487
  includeClaudeCodeInstruction?: boolean;
478
- includeCacheControl?: boolean;
479
488
  extraInstructions?: string[];
480
489
  };
481
490
 
@@ -483,17 +492,15 @@ export function buildAnthropicSystemBlocks(
483
492
  systemPrompt: string | undefined,
484
493
  options: SystemBlockOptions = {},
485
494
  ): AnthropicSystemBlock[] | undefined {
486
- const { includeClaudeCodeInstruction = false, includeCacheControl = true, extraInstructions = [] } = options;
495
+ const { includeClaudeCodeInstruction = false, extraInstructions = [] } = options;
487
496
  const blocks: AnthropicSystemBlock[] = [];
488
497
  const sanitizedPrompt = systemPrompt ? sanitizeSurrogates(systemPrompt) : "";
489
498
  const hasClaudeCodeInstruction = sanitizedPrompt.includes(claudeCodeSystemInstruction);
490
- const cacheControl = includeCacheControl ? { type: "ephemeral" as const } : undefined;
491
499
 
492
500
  if (includeClaudeCodeInstruction && !hasClaudeCodeInstruction) {
493
501
  blocks.push({
494
502
  type: "text",
495
503
  text: claudeCodeSystemInstruction,
496
- ...(cacheControl ? { cache_control: cacheControl } : {}),
497
504
  });
498
505
  }
499
506
 
@@ -503,7 +510,6 @@ export function buildAnthropicSystemBlocks(
503
510
  blocks.push({
504
511
  type: "text",
505
512
  text: trimmed,
506
- ...(cacheControl ? { cache_control: cacheControl } : {}),
507
513
  });
508
514
  }
509
515
 
@@ -511,7 +517,6 @@ export function buildAnthropicSystemBlocks(
511
517
  blocks.push({
512
518
  type: "text",
513
519
  text: sanitizedPrompt,
514
- ...(cacheControl ? { cache_control: cacheControl } : {}),
515
520
  });
516
521
  }
517
522
 
@@ -546,11 +551,9 @@ function buildParams(
546
551
  isOAuthToken: boolean,
547
552
  options?: AnthropicOptions,
548
553
  ): MessageCreateParamsStreaming {
549
- const hasTools = Boolean(context.tools?.length);
550
- const cacheControlMode = resolveCacheControlMode(context.messages, hasTools && isOAuthToken);
551
554
  const params: MessageCreateParamsStreaming = {
552
555
  model: model.id,
553
- messages: convertMessages(context.messages, model, isOAuthToken, cacheControlMode),
556
+ messages: convertMessages(context.messages, model, isOAuthToken),
554
557
  max_tokens: options?.maxTokens || (model.maxTokens / 3) | 0,
555
558
  stream: true,
556
559
  };
@@ -558,7 +561,6 @@ function buildParams(
558
561
  const includeClaudeCodeSystem = !model.id.startsWith("claude-3-5-haiku");
559
562
  const systemBlocks = buildAnthropicSystemBlocks(context.systemPrompt, {
560
563
  includeClaudeCodeInstruction: includeClaudeCodeSystem,
561
- includeCacheControl: cacheControlMode !== "none",
562
564
  });
563
565
  if (systemBlocks) {
564
566
  params.system = systemBlocks;
@@ -596,6 +598,8 @@ function buildParams(
596
598
  ensureMaxTokensForThinking(params, model);
597
599
  }
598
600
 
601
+ applyPromptCaching(params);
602
+
599
603
  return params;
600
604
  }
601
605
 
@@ -605,75 +609,141 @@ function sanitizeToolCallId(id: string): string {
605
609
  return id.replace(/[^a-zA-Z0-9_-]/g, "_");
606
610
  }
607
611
 
608
- function resolveCacheControlMode(messages: Message[], includeCacheControl: boolean): CacheControlMode {
609
- if (!includeCacheControl) return "none";
612
+ function stripCacheControl<T extends CacheControlBlock>(blocks: T[]): void {
613
+ for (const block of blocks) {
614
+ if ("cache_control" in block) {
615
+ delete block.cache_control;
616
+ }
617
+ }
618
+ }
619
+
620
+ function applyCacheControlToLastBlock<T extends CacheControlBlock>(blocks: T[]): void {
621
+ if (blocks.length === 0) return;
622
+ const lastIndex = blocks.length - 1;
623
+ blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cacheControlEphemeral };
624
+ }
610
625
 
611
- for (const message of messages) {
612
- if (message.role === "toolResult") return "toolBlocks";
613
- if (message.role === "assistant") {
614
- const hasToolCall = message.content.some((block) => block.type === "toolCall");
615
- if (hasToolCall) return "toolBlocks";
626
+ function applyCacheControlToLastTextBlock(blocks: Array<ContentBlockParam & CacheControlBlock>): void {
627
+ if (blocks.length === 0) return;
628
+ for (let i = blocks.length - 1; i >= 0; i--) {
629
+ if (blocks[i].type === "text") {
630
+ blocks[i] = { ...blocks[i], cache_control: cacheControlEphemeral };
631
+ return;
616
632
  }
617
633
  }
634
+ applyCacheControlToLastBlock(blocks);
635
+ }
636
+
637
+ function applyPromptCaching(params: MessageCreateParamsStreaming): void {
638
+ // Anthropic allows max 4 cache breakpoints
639
+ const MAX_CACHE_BREAKPOINTS = 4;
618
640
 
619
- return "userText";
641
+ // First, strip ALL existing cache_control to ensure clean slate
642
+ if (params.tools) {
643
+ for (const tool of params.tools) {
644
+ delete (tool as CacheControlBlock).cache_control;
645
+ }
646
+ }
647
+
648
+ if (params.system && Array.isArray(params.system)) {
649
+ stripCacheControl(params.system);
650
+ }
651
+
652
+ for (const message of params.messages) {
653
+ if (Array.isArray(message.content)) {
654
+ stripCacheControl(message.content as Array<ContentBlockParam & CacheControlBlock>);
655
+ }
656
+ }
657
+
658
+ let cacheBreakpointsUsed = 0;
659
+
660
+ // Cache hierarchy order: tools -> system -> messages
661
+ // See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
662
+
663
+ // 1. Cache tools - place breakpoint on last tool definition
664
+ if (params.tools && params.tools.length > 0) {
665
+ applyCacheControlToLastBlock(params.tools as Array<CacheControlBlock>);
666
+ cacheBreakpointsUsed++;
667
+ }
668
+
669
+ if (cacheBreakpointsUsed >= MAX_CACHE_BREAKPOINTS) return;
670
+
671
+ // 2. Cache system prompt
672
+ if (params.system && Array.isArray(params.system) && params.system.length > 0) {
673
+ applyCacheControlToLastBlock(params.system);
674
+ cacheBreakpointsUsed++;
675
+ }
676
+
677
+ if (cacheBreakpointsUsed >= MAX_CACHE_BREAKPOINTS) return;
678
+
679
+ // 3. Cache penultimate user message for conversation history caching
680
+ const userIndexes = params.messages
681
+ .map((message, index) => (message.role === "user" ? index : -1))
682
+ .filter((index) => index >= 0);
683
+
684
+ if (userIndexes.length >= 2) {
685
+ const penultimateUserIndex = userIndexes[userIndexes.length - 2];
686
+ const penultimateUser = params.messages[penultimateUserIndex];
687
+ if (penultimateUser) {
688
+ if (typeof penultimateUser.content === "string") {
689
+ penultimateUser.content = [
690
+ { type: "text", text: penultimateUser.content, cache_control: cacheControlEphemeral },
691
+ ];
692
+ cacheBreakpointsUsed++;
693
+ } else if (Array.isArray(penultimateUser.content) && penultimateUser.content.length > 0) {
694
+ applyCacheControlToLastTextBlock(penultimateUser.content as Array<ContentBlockParam & CacheControlBlock>);
695
+ cacheBreakpointsUsed++;
696
+ }
697
+ }
698
+ }
699
+
700
+ if (cacheBreakpointsUsed >= MAX_CACHE_BREAKPOINTS) return;
701
+
702
+ // 4. Cache final user message for current turn (enables cache hit on next request)
703
+ if (userIndexes.length >= 1) {
704
+ const lastUserIndex = userIndexes[userIndexes.length - 1];
705
+ const lastUser = params.messages[lastUserIndex];
706
+ if (lastUser) {
707
+ if (typeof lastUser.content === "string") {
708
+ lastUser.content = [{ type: "text", text: lastUser.content, cache_control: cacheControlEphemeral }];
709
+ } else if (Array.isArray(lastUser.content) && lastUser.content.length > 0) {
710
+ applyCacheControlToLastTextBlock(lastUser.content as Array<ContentBlockParam & CacheControlBlock>);
711
+ }
712
+ }
713
+ }
620
714
  }
621
715
 
622
716
  function convertMessages(
623
717
  messages: Message[],
624
718
  model: Model<"anthropic-messages">,
625
719
  isOAuthToken: boolean,
626
- cacheControlMode: CacheControlMode,
627
720
  ): MessageParam[] {
628
721
  const params: MessageParam[] = [];
629
- const applyToolCacheControl = cacheControlMode === "toolBlocks";
630
- const applyUserTextCacheControl = cacheControlMode === "userText";
631
- const withCacheControl = <T extends object>(block: T, enabled: boolean): T | (T & CacheControlBlock) => {
632
- if (!enabled) return block;
633
- return { ...block, cache_control: cacheControlEphemeral };
634
- };
635
722
 
636
723
  // Transform messages for cross-provider compatibility
637
724
  const transformedMessages = transformMessages(messages, model);
638
-
639
725
  for (let i = 0; i < transformedMessages.length; i++) {
640
726
  const msg = transformedMessages[i];
641
727
 
642
728
  if (msg.role === "user") {
729
+ // Skip messages with undefined/null content
730
+ if (!msg.content) continue;
731
+
643
732
  if (typeof msg.content === "string") {
644
733
  if (msg.content.trim().length > 0) {
645
734
  const text = sanitizeSurrogates(msg.content);
646
- if (applyUserTextCacheControl) {
647
- const blocks: Array<ContentBlockParam & CacheControlBlock> = [
648
- withCacheControl(
649
- {
650
- type: "text",
651
- text,
652
- },
653
- true,
654
- ),
655
- ];
656
- params.push({
657
- role: "user",
658
- content: blocks,
659
- });
660
- } else {
661
- params.push({
662
- role: "user",
663
- content: text,
664
- });
665
- }
735
+ params.push({
736
+ role: "user",
737
+ content: text,
738
+ });
666
739
  }
667
- } else {
740
+ } else if (Array.isArray(msg.content)) {
668
741
  const blocks: Array<ContentBlockParam & CacheControlBlock> = msg.content.map((item) => {
669
742
  if (item.type === "text") {
670
- return withCacheControl(
671
- {
672
- type: "text",
673
- text: sanitizeSurrogates(item.text),
674
- },
675
- applyUserTextCacheControl,
676
- );
743
+ return {
744
+ type: "text",
745
+ text: sanitizeSurrogates(item.text),
746
+ };
677
747
  }
678
748
  return {
679
749
  type: "image",
@@ -698,6 +768,9 @@ function convertMessages(
698
768
  });
699
769
  }
700
770
  } else if (msg.role === "assistant") {
771
+ // Skip messages with undefined/null content
772
+ if (!msg.content || !Array.isArray(msg.content)) continue;
773
+
701
774
  const blocks: Array<ContentBlockParam & CacheControlBlock> = [];
702
775
 
703
776
  for (const block of msg.content) {
@@ -725,17 +798,12 @@ function convertMessages(
725
798
  });
726
799
  }
727
800
  } else if (block.type === "toolCall") {
728
- blocks.push(
729
- withCacheControl(
730
- {
731
- type: "tool_use",
732
- id: sanitizeToolCallId(block.id),
733
- name: isOAuthToken ? toClaudeCodeName(block.name) : block.name,
734
- input: block.arguments,
735
- },
736
- applyToolCacheControl,
737
- ),
738
- );
801
+ blocks.push({
802
+ type: "tool_use",
803
+ id: sanitizeToolCallId(block.id),
804
+ name: isOAuthToken ? toClaudeCodeName(block.name) : block.name,
805
+ input: block.arguments,
806
+ });
739
807
  }
740
808
  }
741
809
  if (blocks.length === 0) continue;
@@ -748,33 +816,23 @@ function convertMessages(
748
816
  const toolResults: Array<ContentBlockParam & CacheControlBlock> = [];
749
817
 
750
818
  // Add the current tool result
751
- toolResults.push(
752
- withCacheControl(
753
- {
754
- type: "tool_result",
755
- tool_use_id: sanitizeToolCallId(msg.toolCallId),
756
- content: convertContentBlocks(msg.content),
757
- is_error: msg.isError,
758
- },
759
- applyToolCacheControl,
760
- ),
761
- );
819
+ toolResults.push({
820
+ type: "tool_result",
821
+ tool_use_id: sanitizeToolCallId(msg.toolCallId),
822
+ content: convertContentBlocks(msg.content),
823
+ is_error: msg.isError,
824
+ });
762
825
 
763
826
  // Look ahead for consecutive toolResult messages
764
827
  let j = i + 1;
765
828
  while (j < transformedMessages.length && transformedMessages[j].role === "toolResult") {
766
829
  const nextMsg = transformedMessages[j] as ToolResultMessage; // We know it's a toolResult
767
- toolResults.push(
768
- withCacheControl(
769
- {
770
- type: "tool_result",
771
- tool_use_id: sanitizeToolCallId(nextMsg.toolCallId),
772
- content: convertContentBlocks(nextMsg.content),
773
- is_error: nextMsg.isError,
774
- },
775
- applyToolCacheControl,
776
- ),
777
- );
830
+ toolResults.push({
831
+ type: "tool_result",
832
+ tool_use_id: sanitizeToolCallId(nextMsg.toolCallId),
833
+ content: convertContentBlocks(nextMsg.content),
834
+ is_error: nextMsg.isError,
835
+ });
778
836
  j++;
779
837
  }
780
838
 
@@ -782,14 +840,22 @@ function convertMessages(
782
840
  i = j - 1;
783
841
 
784
842
  // Add a single user message with all tool results
785
- params.push({
786
- role: "user",
787
- content: toolResults,
788
- });
843
+ if (toolResults.length > 0) {
844
+ params.push({
845
+ role: "user",
846
+ content: toolResults,
847
+ });
848
+ }
789
849
  }
790
850
  }
791
851
 
792
- return params;
852
+ // Final validation: filter out any messages with invalid content
853
+ return params.filter((msg) => {
854
+ if (!msg.content) return false;
855
+ if (typeof msg.content === "string") return msg.content.length > 0;
856
+ if (Array.isArray(msg.content)) return msg.content.length > 0;
857
+ return false;
858
+ });
793
859
  }
794
860
 
795
861
  function convertTools(tools: Tool[], isOAuthToken: boolean): Anthropic.Messages.Tool[] {
@@ -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
  }
@@ -208,11 +215,11 @@ export const OUTPUT_FALLBACK_BUFFER = 4000;
208
215
  const ANTHROPIC_USE_INTERLEAVED_THINKING = true;
209
216
 
210
217
  const ANTHROPIC_THINKING: Record<ThinkingLevel, number> = {
211
- minimal: 3072,
212
- low: 6144,
213
- medium: 12288,
214
- high: 24576,
215
- xhigh: 49152,
218
+ minimal: 1024,
219
+ low: 4096,
220
+ medium: 8192,
221
+ high: 16384,
222
+ xhigh: 32768,
216
223
  };
217
224
 
218
225
  const GOOGLE_THINKING: Record<ThinkingLevel, number> = {
@@ -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
  }
@@ -14,7 +14,7 @@
14
14
  import templateHtml from "./oauth.html" with { type: "text" };
15
15
  import type { OAuthController, OAuthCredentials } from "./types";
16
16
 
17
- const DEFAULT_TIMEOUT = 120;
17
+ const DEFAULT_TIMEOUT = 120_000;
18
18
  const DEFAULT_HOSTNAME = "localhost";
19
19
  const CALLBACK_PATH = "/callback";
20
20
 
@@ -182,7 +182,7 @@ export abstract class OAuthCallbackFlow {
182
182
  * Wait for OAuth callback or manual input (whichever comes first).
183
183
  */
184
184
  private waitForCallback(expectedState: string): Promise<CallbackResult> {
185
- const timeoutSignal = AbortSignal.timeout(DEFAULT_TIMEOUT * 1000);
185
+ const timeoutSignal = AbortSignal.timeout(DEFAULT_TIMEOUT);
186
186
  const signal = this.ctrl.signal ? AbortSignal.any([this.ctrl.signal, timeoutSignal]) : timeoutSignal;
187
187
 
188
188
  const callbackPromise = new Promise<CallbackResult>((resolve, reject) => {
@@ -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
  }