@oh-my-pi/pi-ai 5.0.0 → 5.1.0

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.
@@ -5,7 +5,7 @@
5
5
  import { type Content, FinishReason, FunctionCallingConfigMode, type Part, type Schema } from "@google/genai";
6
6
  import type { Context, ImageContent, Model, StopReason, TextContent, Tool } from "../types";
7
7
  import { sanitizeSurrogates } from "../utils/sanitize-unicode";
8
- import { transformMessages } from "./transorm-messages";
8
+ import { transformMessages } from "./transform-messages";
9
9
 
10
10
  type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vertex";
11
11
 
@@ -42,6 +42,29 @@ export function retainThoughtSignature(existing: string | undefined, incoming: s
42
42
  return existing;
43
43
  }
44
44
 
45
+ // Thought signatures must be base64 for Google APIs (TYPE_BYTES).
46
+ const base64SignaturePattern = /^[A-Za-z0-9+/]+={0,2}$/;
47
+
48
+ function isValidThoughtSignature(signature: string | undefined): boolean {
49
+ if (!signature) return false;
50
+ if (signature.length % 4 !== 0) return false;
51
+ return base64SignaturePattern.test(signature);
52
+ }
53
+
54
+ /**
55
+ * Only keep signatures from the same provider/model and with valid base64.
56
+ */
57
+ function resolveThoughtSignature(isSameProviderAndModel: boolean, signature: string | undefined): string | undefined {
58
+ return isSameProviderAndModel && isValidThoughtSignature(signature) ? signature : undefined;
59
+ }
60
+
61
+ /**
62
+ * Claude models via Google APIs require explicit tool call IDs in function calls/responses.
63
+ */
64
+ export function requiresToolCallId(modelId: string): boolean {
65
+ return modelId.startsWith("claude-");
66
+ }
67
+
45
68
  /**
46
69
  * Convert internal messages to Gemini Content[] format.
47
70
  */
@@ -85,17 +108,22 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
85
108
  if (block.type === "text") {
86
109
  // Skip empty text blocks - they can cause issues with some models (e.g. Claude via Antigravity)
87
110
  if (!block.text || block.text.trim() === "") continue;
88
- parts.push({ text: sanitizeSurrogates(block.text) });
111
+ const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.textSignature);
112
+ parts.push({
113
+ text: sanitizeSurrogates(block.text),
114
+ ...(thoughtSignature && { thoughtSignature }),
115
+ });
89
116
  } else if (block.type === "thinking") {
90
117
  // Skip empty thinking blocks
91
118
  if (!block.thinking || block.thinking.trim() === "") continue;
92
119
  // Only keep as thinking block if same provider AND same model
93
120
  // Otherwise convert to plain text (no tags to avoid model mimicking them)
94
121
  if (isSameProviderAndModel) {
122
+ const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thinkingSignature);
95
123
  parts.push({
96
124
  thought: true,
97
125
  text: sanitizeSurrogates(block.thinking),
98
- ...(block.thinkingSignature && { thoughtSignature: block.thinkingSignature }),
126
+ ...(thoughtSignature && { thoughtSignature }),
99
127
  });
100
128
  } else {
101
129
  parts.push({
@@ -105,16 +133,17 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
105
133
  } else if (block.type === "toolCall") {
106
134
  const part: Part = {
107
135
  functionCall: {
108
- id: block.id,
109
136
  name: block.name,
110
137
  args: block.arguments,
138
+ ...(requiresToolCallId(model.id) ? { id: block.id } : {}),
111
139
  },
112
140
  };
113
141
  if (model.provider === "google-vertex" && part?.functionCall?.id) {
114
142
  delete part.functionCall.id; // Vertex AI does not support 'id' in functionCall
115
143
  }
116
- if (block.thoughtSignature) {
117
- part.thoughtSignature = block.thoughtSignature;
144
+ const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature);
145
+ if (thoughtSignature) {
146
+ part.thoughtSignature = thoughtSignature;
118
147
  }
119
148
  parts.push(part);
120
149
  }
@@ -151,13 +180,14 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
151
180
  },
152
181
  }));
153
182
 
183
+ const includeId = requiresToolCallId(model.id);
154
184
  const functionResponsePart: Part = {
155
185
  functionResponse: {
156
- id: msg.toolCallId,
157
186
  name: msg.toolName,
158
187
  response: msg.isError ? { error: responseValue } : { output: responseValue },
159
188
  // Nest images inside functionResponse.parts for Gemini 3
160
189
  ...(hasImages && supportsMultimodalFunctionResponse && { parts: imageParts }),
190
+ ...(includeId ? { id: msg.toolCallId } : {}),
161
191
  },
162
192
  };
163
193
 
@@ -39,7 +39,7 @@ import { getCodexInstructions } from "./openai-codex/prompts/codex";
39
39
  import { buildCodexSystemPrompt } from "./openai-codex/prompts/system-prompt";
40
40
  import { type CodexRequestOptions, type RequestBody, transformRequestBody } from "./openai-codex/request-transformer";
41
41
  import { parseCodexError, parseCodexSseStream } from "./openai-codex/response-handler";
42
- import { transformMessages } from "./transorm-messages";
42
+ import { transformMessages } from "./transform-messages";
43
43
 
44
44
  export interface OpenAICodexResponsesOptions extends StreamOptions {
45
45
  reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
@@ -28,7 +28,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
28
28
  import { parseStreamingJson } from "../utils/json-parse";
29
29
  import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
30
30
  import { sanitizeSurrogates } from "../utils/sanitize-unicode";
31
- import { transformMessages } from "./transorm-messages";
31
+ import { transformMessages } from "./transform-messages";
32
32
 
33
33
  /**
34
34
  * Normalize tool call ID for Mistral.
@@ -366,6 +366,7 @@ function createClient(model: Model<"openai-completions">, context: Context, apiK
366
366
  function buildParams(model: Model<"openai-completions">, context: Context, options?: OpenAICompletionsOptions) {
367
367
  const compat = getCompat(model);
368
368
  const messages = convertMessages(model, context, compat);
369
+ maybeAddOpenRouterAnthropicCacheControl(model, messages);
369
370
 
370
371
  const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
371
372
  model: model.id,
@@ -404,13 +405,51 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio
404
405
  params.tool_choice = options.toolChoice;
405
406
  }
406
407
 
407
- if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) {
408
+ if (compat.thinkingFormat === "zai" && model.reasoning) {
409
+ // Z.ai uses binary thinking: { type: "enabled" | "disabled" }
410
+ // Must explicitly disable since z.ai defaults to thinking enabled
411
+ (params as any).thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" };
412
+ } else if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) {
413
+ // OpenAI-style reasoning_effort
408
414
  params.reasoning_effort = options.reasoningEffort;
409
415
  }
410
416
 
411
417
  return params;
412
418
  }
413
419
 
420
+ function maybeAddOpenRouterAnthropicCacheControl(
421
+ model: Model<"openai-completions">,
422
+ messages: ChatCompletionMessageParam[],
423
+ ): void {
424
+ if (model.provider !== "openrouter" || !model.id.startsWith("anthropic/")) return;
425
+
426
+ // Anthropic-style caching requires cache_control on a text part. Add a breakpoint
427
+ // on the last user/assistant message (walking backwards until we find text content).
428
+ for (let i = messages.length - 1; i >= 0; i--) {
429
+ const msg = messages[i];
430
+ if (msg.role !== "user" && msg.role !== "assistant") continue;
431
+
432
+ const content = msg.content;
433
+ if (typeof content === "string") {
434
+ msg.content = [
435
+ Object.assign({ type: "text" as const, text: content }, { cache_control: { type: "ephemeral" } }),
436
+ ];
437
+ return;
438
+ }
439
+
440
+ if (!Array.isArray(content)) continue;
441
+
442
+ // Find last text part and add cache_control
443
+ for (let j = content.length - 1; j >= 0; j--) {
444
+ const part = content[j];
445
+ if (part?.type === "text") {
446
+ Object.assign(part, { cache_control: { type: "ephemeral" } });
447
+ return;
448
+ }
449
+ }
450
+ }
451
+ }
452
+
414
453
  function convertMessages(
415
454
  model: Model<"openai-completions">,
416
455
  context: Context,
@@ -645,11 +684,14 @@ function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"]): Sto
645
684
  * Returns a fully resolved OpenAICompat object with all fields set.
646
685
  */
647
686
  function detectCompatFromUrl(baseUrl: string): Required<OpenAICompat> {
687
+ const isZai = baseUrl.includes("api.z.ai");
688
+
648
689
  const isNonStandard =
649
690
  baseUrl.includes("cerebras.ai") ||
650
691
  baseUrl.includes("api.x.ai") ||
651
692
  baseUrl.includes("mistral.ai") ||
652
- baseUrl.includes("chutes.ai");
693
+ baseUrl.includes("chutes.ai") ||
694
+ isZai;
653
695
 
654
696
  const useMaxTokens = baseUrl.includes("mistral.ai") || baseUrl.includes("chutes.ai");
655
697
 
@@ -660,13 +702,14 @@ function detectCompatFromUrl(baseUrl: string): Required<OpenAICompat> {
660
702
  return {
661
703
  supportsStore: !isNonStandard,
662
704
  supportsDeveloperRole: !isNonStandard,
663
- supportsReasoningEffort: !isGrok,
705
+ supportsReasoningEffort: !isGrok && !isZai,
664
706
  supportsUsageInStreaming: true,
665
707
  maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens",
666
708
  requiresToolResultName: isMistral,
667
709
  requiresAssistantAfterToolResult: false, // Mistral no longer requires this as of Dec 2024
668
710
  requiresThinkingAsText: isMistral,
669
711
  requiresMistralToolIds: isMistral,
712
+ thinkingFormat: isZai ? "zai" : "openai",
670
713
  };
671
714
  }
672
715
 
@@ -689,5 +732,6 @@ function getCompat(model: Model<"openai-completions">): Required<OpenAICompat> {
689
732
  model.compat.requiresAssistantAfterToolResult ?? detected.requiresAssistantAfterToolResult,
690
733
  requiresThinkingAsText: model.compat.requiresThinkingAsText ?? detected.requiresThinkingAsText,
691
734
  requiresMistralToolIds: model.compat.requiresMistralToolIds ?? detected.requiresMistralToolIds,
735
+ thinkingFormat: model.compat.thinkingFormat ?? detected.thinkingFormat,
692
736
  };
693
737
  }
@@ -29,7 +29,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
29
29
  import { parseStreamingJson } from "../utils/json-parse";
30
30
  import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
31
31
  import { sanitizeSurrogates } from "../utils/sanitize-unicode";
32
- import { transformMessages } from "./transorm-messages";
32
+ import { transformMessages } from "./transform-messages";
33
33
 
34
34
  /** Fast deterministic hash to shorten long strings */
35
35
  function shortHash(str: string): string {
@@ -49,6 +49,7 @@ function shortHash(str: string): string {
49
49
  export interface OpenAIResponsesOptions extends StreamOptions {
50
50
  reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
51
51
  reasoningSummary?: "auto" | "detailed" | "concise" | null;
52
+ serviceTier?: ResponseCreateParamsStreaming["service_tier"];
52
53
  }
53
54
 
54
55
  /**
@@ -86,7 +87,10 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
86
87
  const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
87
88
  const client = createClient(model, context, apiKey);
88
89
  const params = buildParams(model, context, options);
89
- const openaiStream = await client.responses.create(params, { signal: options?.signal });
90
+ const openaiStream = await client.responses.create(
91
+ params,
92
+ options?.signal ? { signal: options.signal } : undefined,
93
+ );
90
94
  stream.push({ type: "start", partial: output });
91
95
 
92
96
  let currentItem: ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | null = null;
@@ -364,6 +368,7 @@ function buildParams(model: Model<"openai-responses">, context: Context, options
364
368
  model: model.id,
365
369
  input: messages,
366
370
  stream: true,
371
+ prompt_cache_key: options?.sessionId,
367
372
  };
368
373
 
369
374
  if (options?.maxTokens) {
@@ -374,6 +379,10 @@ function buildParams(model: Model<"openai-responses">, context: Context, options
374
379
  params.temperature = options?.temperature;
375
380
  }
376
381
 
382
+ if (options?.serviceTier !== undefined) {
383
+ params.service_tier = options.serviceTier;
384
+ }
385
+
377
386
  if (context.tools) {
378
387
  params.tools = convertTools(context.tools);
379
388
  }
@@ -1,11 +1,11 @@
1
1
  import type { Api, AssistantMessage, Message, Model, ToolCall, ToolResultMessage } from "../types";
2
2
 
3
3
  /**
4
- * Normalize tool call ID for GitHub Copilot cross-API compatibility.
4
+ * Normalize tool call ID for cross-provider compatibility.
5
5
  * OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
6
- * Other APIs (Claude, etc.) require max 40 chars and only alphanumeric + underscore + hyphen.
6
+ * Anthropic APIs require IDs matching ^[a-zA-Z0-9_-]+$ (max 64 chars).
7
7
  */
8
- function normalizeCopilotToolCallId(id: string): string {
8
+ function normalizeToolCallId(id: string): string {
9
9
  return id.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 40);
10
10
  }
11
11
 
@@ -38,11 +38,17 @@ export function transformMessages<TApi extends Api>(messages: Message[], model:
38
38
  return msg;
39
39
  }
40
40
 
41
- // Check if we need to normalize tool call IDs (github-copilot cross-API)
42
- const needsToolCallIdNormalization =
41
+ // Check if we need to normalize tool call IDs
42
+ // Anthropic APIs require IDs matching ^[a-zA-Z0-9_-]+$ (max 64 chars)
43
+ // OpenAI Responses API generates IDs with `|` and 450+ chars
44
+ // GitHub Copilot routes to Anthropic for Claude models
45
+ const targetRequiresStrictIds = model.api === "anthropic-messages" || model.provider === "github-copilot";
46
+ const crossProviderSwitch = assistantMsg.provider !== model.provider;
47
+ const copilotCrossApiSwitch =
43
48
  assistantMsg.provider === "github-copilot" &&
44
49
  model.provider === "github-copilot" &&
45
50
  assistantMsg.api !== model.api;
51
+ const needsToolCallIdNormalization = targetRequiresStrictIds && (crossProviderSwitch || copilotCrossApiSwitch);
46
52
 
47
53
  // Transform message from different provider/model
48
54
  const transformedContent = assistantMsg.content.flatMap((block) => {
@@ -54,10 +60,10 @@ export function transformMessages<TApi extends Api>(messages: Message[], model:
54
60
  text: block.thinking,
55
61
  };
56
62
  }
57
- // Normalize tool call IDs for github-copilot cross-API switches
63
+ // Normalize tool call IDs when target API requires strict format
58
64
  if (block.type === "toolCall" && needsToolCallIdNormalization) {
59
65
  const toolCall = block as ToolCall;
60
- const normalizedId = normalizeCopilotToolCallId(toolCall.id);
66
+ const normalizedId = normalizeToolCallId(toolCall.id);
61
67
  if (normalizedId !== toolCall.id) {
62
68
  toolCallIdMap.set(toolCall.id, normalizedId);
63
69
  return { ...toolCall, id: normalizedId };
package/src/stream.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { supportsXhigh } from "./models";
5
+ import { type BedrockOptions, streamBedrock } from "./providers/amazon-bedrock";
5
6
  import { type AnthropicOptions, streamAnthropic } from "./providers/anthropic";
6
7
  import { type CursorOptions, streamCursor } from "./providers/cursor";
7
8
  import { type GoogleOptions, streamGoogle } from "./providers/google";
@@ -73,6 +74,20 @@ export function getEnvApiKey(provider: any): string | undefined {
73
74
  }
74
75
  }
75
76
 
77
+ if (provider === "amazon-bedrock") {
78
+ // Amazon Bedrock supports multiple credential sources:
79
+ // 1. AWS_PROFILE - named profile from ~/.aws/credentials
80
+ // 2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY - standard IAM keys
81
+ // 3. AWS_BEARER_TOKEN_BEDROCK - Bedrock API keys (bearer token)
82
+ if (
83
+ process.env.AWS_PROFILE ||
84
+ (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) ||
85
+ process.env.AWS_BEARER_TOKEN_BEDROCK
86
+ ) {
87
+ return "<authenticated>";
88
+ }
89
+ }
90
+
76
91
  const envMap: Record<string, string> = {
77
92
  openai: "OPENAI_API_KEY",
78
93
  google: "GEMINI_API_KEY",
@@ -80,8 +95,10 @@ export function getEnvApiKey(provider: any): string | undefined {
80
95
  cerebras: "CEREBRAS_API_KEY",
81
96
  xai: "XAI_API_KEY",
82
97
  openrouter: "OPENROUTER_API_KEY",
98
+ "vercel-ai-gateway": "AI_GATEWAY_API_KEY",
83
99
  zai: "ZAI_API_KEY",
84
100
  mistral: "MISTRAL_API_KEY",
101
+ minimax: "MINIMAX_API_KEY",
85
102
  opencode: "OPENCODE_API_KEY",
86
103
  cursor: "CURSOR_ACCESS_TOKEN",
87
104
  };
@@ -98,6 +115,9 @@ export function stream<TApi extends Api>(
98
115
  // Vertex AI uses Application Default Credentials, not API keys
99
116
  if (model.api === "google-vertex") {
100
117
  return streamGoogleVertex(model as Model<"google-vertex">, context, options as GoogleVertexOptions);
118
+ } else if (model.api === "bedrock-converse-stream") {
119
+ // Bedrock doesn't have any API keys instead it sources credentials from standard AWS env variables or from given AWS profile.
120
+ return streamBedrock(model as Model<"bedrock-converse-stream">, context, (options || {}) as BedrockOptions);
101
121
  }
102
122
 
103
123
  const apiKey = options?.apiKey || getEnvApiKey(model.provider);
@@ -159,6 +179,10 @@ export function streamSimple<TApi extends Api>(
159
179
  if (model.api === "google-vertex") {
160
180
  const providerOptions = mapOptionsForApi(model, options, undefined);
161
181
  return stream(model, context, providerOptions);
182
+ } else if (model.api === "bedrock-converse-stream") {
183
+ // Bedrock doesn't have any API keys instead it sources credentials from standard AWS env variables or from given AWS profile.
184
+ const providerOptions = mapOptionsForApi(model, options, undefined);
185
+ return stream(model, context, providerOptions);
162
186
  }
163
187
 
164
188
  const apiKey = options?.apiKey || getEnvApiKey(model.provider);
@@ -258,6 +282,13 @@ function mapOptionsForApi<TApi extends Api>(
258
282
  }
259
283
  }
260
284
 
285
+ case "bedrock-converse-stream":
286
+ return {
287
+ ...base,
288
+ reasoning: options?.reasoning,
289
+ thinkingBudgets: options?.thinkingBudgets,
290
+ } satisfies BedrockOptions;
291
+
261
292
  case "openai-completions":
262
293
  return {
263
294
  ...base,
package/src/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { BedrockOptions } from "./providers/amazon-bedrock";
1
2
  import type { AnthropicOptions } from "./providers/anthropic";
2
3
  import type { CursorOptions } from "./providers/cursor";
3
4
  import type {
@@ -32,6 +33,7 @@ export type Api =
32
33
  | "openai-responses"
33
34
  | "openai-codex-responses"
34
35
  | "anthropic-messages"
36
+ | "bedrock-converse-stream"
35
37
  | "google-generative-ai"
36
38
  | "google-gemini-cli"
37
39
  | "google-vertex"
@@ -39,6 +41,7 @@ export type Api =
39
41
 
40
42
  export interface ApiOptionsMap {
41
43
  "anthropic-messages": AnthropicOptions;
44
+ "bedrock-converse-stream": BedrockOptions;
42
45
  "openai-completions": OpenAICompletionsOptions;
43
46
  "openai-responses": OpenAIResponsesOptions;
44
47
  "openai-codex-responses": OpenAICodexResponsesOptions;
@@ -61,6 +64,7 @@ const _exhaustive: _CheckExhaustive = true;
61
64
  export type OptionsForApi<TApi extends Api> = ApiOptionsMap[TApi];
62
65
 
63
66
  export type KnownProvider =
67
+ | "amazon-bedrock"
64
68
  | "anthropic"
65
69
  | "google"
66
70
  | "google-gemini-cli"
@@ -74,8 +78,10 @@ export type KnownProvider =
74
78
  | "groq"
75
79
  | "cerebras"
76
80
  | "openrouter"
81
+ | "vercel-ai-gateway"
77
82
  | "zai"
78
83
  | "mistral"
84
+ | "minimax"
79
85
  | "opencode";
80
86
  export type Provider = KnownProvider | string;
81
87
 
@@ -269,6 +275,8 @@ export interface OpenAICompat {
269
275
  requiresThinkingAsText?: boolean;
270
276
  /** Whether tool call IDs must be normalized to Mistral format (exactly 9 alphanumeric chars). Default: auto-detected from URL. */
271
277
  requiresMistralToolIds?: boolean;
278
+ /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "zai" uses thinking: { type: "enabled" }. Default: "openai". */
279
+ thinkingFormat?: "openai" | "zai";
272
280
  }
273
281
 
274
282
  // Model interface for the unified model system