@oh-my-pi/pi-ai 16.2.6 → 16.2.7

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.
Files changed (30) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/types/auth-storage.d.ts +11 -10
  3. package/dist/types/providers/__tests__/kimi-code-thinking.test.d.ts +1 -0
  4. package/dist/types/providers/google-auth.d.ts +8 -0
  5. package/dist/types/providers/google-interactions.d.ts +65 -0
  6. package/dist/types/providers/google-shared.d.ts +20 -5
  7. package/dist/types/providers/google-types.d.ts +5 -0
  8. package/dist/types/providers/openai-codex/request-transformer.d.ts +1 -1
  9. package/dist/types/providers/openai-codex-responses.d.ts +1 -1
  10. package/dist/types/providers/openai-shared.d.ts +3 -3
  11. package/dist/types/types.d.ts +80 -30
  12. package/dist/types/utils/leaked-thinking-stream.d.ts +29 -0
  13. package/package.json +4 -4
  14. package/src/auth-storage.ts +56 -52
  15. package/src/providers/__tests__/kimi-code-thinking.test.ts +112 -0
  16. package/src/providers/anthropic.ts +11 -10
  17. package/src/providers/google-auth.ts +25 -0
  18. package/src/providers/google-gemini-cli.ts +90 -41
  19. package/src/providers/google-interactions.ts +753 -0
  20. package/src/providers/google-shared.ts +74 -13
  21. package/src/providers/google-types.ts +5 -1
  22. package/src/providers/google-vertex.ts +117 -26
  23. package/src/providers/google.ts +61 -19
  24. package/src/providers/openai-chat-server.ts +2 -2
  25. package/src/providers/openai-codex/request-transformer.ts +17 -4
  26. package/src/providers/openai-codex-responses.ts +1 -1
  27. package/src/providers/openai-shared.ts +5 -11
  28. package/src/stream.ts +21 -6
  29. package/src/types.ts +164 -51
  30. package/src/utils/leaked-thinking-stream.ts +260 -0
@@ -14,6 +14,7 @@ import type {
14
14
  FetchImpl,
15
15
  ImageContent,
16
16
  Model,
17
+ ServiceTier,
17
18
  StopReason,
18
19
  StreamOptions,
19
20
  TextContent,
@@ -21,6 +22,7 @@ import type {
21
22
  Tool,
22
23
  ToolCall,
23
24
  } from "../types";
25
+ import { shouldSendServiceTier } from "../types";
24
26
  import { normalizeSystemPrompts } from "../utils";
25
27
  import { AssistantMessageEventStream } from "../utils/event-stream";
26
28
  import type { RawHttpRequestDump } from "../utils/http-inspector";
@@ -73,6 +75,21 @@ export interface GoogleSharedStreamOptions extends StreamOptions {
73
75
  budgetTokens?: number;
74
76
  level?: GoogleThinkingLevel;
75
77
  };
78
+ /** Gemini/Vertex serving tier (`flex`/`priority`); other values are omitted. */
79
+ serviceTier?: ServiceTier;
80
+ /**
81
+ * Continues a Gemini Interactions API conversation from a stored interaction.
82
+ * When set on the direct Google provider, the request uses `/interactions`
83
+ * with `previous_interaction_id` instead of the legacy generateContent stream.
84
+ */
85
+ previousInteractionId?: string;
86
+ /**
87
+ * Uses the Gemini Interactions API for direct Google requests, storing the
88
+ * returned interaction id on the assistant response for follow-up turns.
89
+ */
90
+ useInteractionsApi?: boolean;
91
+ /** Overrides Interactions API request storage; default is the API default (`true`). */
92
+ storeInteraction?: boolean;
76
93
  }
77
94
 
78
95
  /**
@@ -126,11 +143,9 @@ function resolveThoughtSignature(isSameProviderAndModel: boolean, signature: str
126
143
  return isSameProviderAndModel && isValidThoughtSignature(signature) ? signature : undefined;
127
144
  }
128
145
 
129
- /**
130
- * Claude models via Google APIs require explicit tool call IDs in function calls/responses.
131
- */
132
- export function requiresToolCallId(modelId: string): boolean {
133
- return modelId.startsWith("claude-");
146
+ function supportsFunctionPartId<T extends GoogleApiType>(model: Model<T>): boolean {
147
+ if (model.api === "google-vertex") return false;
148
+ return model.id.startsWith("claude-") || (model.api === "google-generative-ai" && isGemini3Model(model.id));
134
149
  }
135
150
 
136
151
  function getGeminiMajorVersion(modelId: string): number | undefined {
@@ -156,8 +171,9 @@ function isGemini3Model(modelId: string): boolean {
156
171
  */
157
172
  export function convertMessages<T extends GoogleApiType>(model: Model<T>, context: Context): Content[] {
158
173
  const contents: Content[] = [];
174
+ const emittedToolCallNames = new Map<string, string>();
175
+
159
176
  const normalizeToolCallId = (id: string): string => {
160
- if (!requiresToolCallId(model.id)) return id;
161
177
  return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);
162
178
  };
163
179
 
@@ -243,6 +259,7 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
243
259
  });
244
260
  }
245
261
  } else if (block.type === "toolCall") {
262
+ emittedToolCallNames.set(block.id, block.name);
246
263
  const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature);
247
264
  const effectiveSignature =
248
265
  thoughtSignature || (isGemini3Model(model.id) ? SKIP_THOUGHT_SIGNATURE : undefined);
@@ -251,11 +268,11 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
251
268
  functionCall: {
252
269
  name: block.name,
253
270
  args: block.arguments ?? {},
254
- ...(requiresToolCallId(model.id) ? { id: block.id } : {}),
271
+ ...(supportsFunctionPartId(model) ? { id: block.id } : {}),
255
272
  },
256
273
  };
257
274
  if (model.provider === "google-vertex" && part?.functionCall?.id) {
258
- delete part.functionCall.id; // Vertex AI does not support 'id' in functionCall
275
+ delete part.functionCall.id; // Vertex AI GenerateContent rejects 'id' in functionCall parts.
259
276
  }
260
277
  if (effectiveSignature) {
261
278
  part.thoughtSignature = effectiveSignature;
@@ -301,10 +318,11 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
301
318
  },
302
319
  }));
303
320
 
304
- const includeId = requiresToolCallId(model.id);
321
+ const includeId = supportsFunctionPartId(model);
322
+ const emittedName = emittedToolCallNames.get(msg.toolCallId);
305
323
  const functionResponsePart: Part = {
306
324
  functionResponse: {
307
- name: msg.toolName,
325
+ name: emittedName ?? msg.toolName,
308
326
  response: msg.isError ? { error: responseValue } : { output: responseValue },
309
327
  ...(hasImages && modelSupportsMultimodalFunctionResponse && { parts: imageParts }),
310
328
  ...(includeId ? { id: msg.toolCallId } : {}),
@@ -312,7 +330,7 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
312
330
  };
313
331
 
314
332
  if (model.provider === "google-vertex" && functionResponsePart.functionResponse?.id) {
315
- delete functionResponsePart.functionResponse.id; // Vertex AI does not support 'id' in functionResponse
333
+ delete functionResponsePart.functionResponse.id; // Vertex AI GenerateContent rejects 'id' in functionResponse parts.
316
334
  }
317
335
 
318
336
  // Cloud Code Assist API requires all function responses to be in a single user turn.
@@ -529,6 +547,24 @@ export function pushToolCallEvents(
529
547
  * `text_start` / `thinking_start` event. `onBeforeStartEvent` lets the SSE consumer
530
548
  * inject its `ensureStarted()` first-token side effect into the canonical event order.
531
549
  */
550
+ export function startTextOrThinkingBlock(
551
+ isThinking: true,
552
+ output: AssistantMessage,
553
+ stream: AssistantMessageEventStream,
554
+ onBeforeStartEvent?: () => void,
555
+ ): ThinkingContent;
556
+ export function startTextOrThinkingBlock(
557
+ isThinking: false,
558
+ output: AssistantMessage,
559
+ stream: AssistantMessageEventStream,
560
+ onBeforeStartEvent?: () => void,
561
+ ): TextContent;
562
+ export function startTextOrThinkingBlock(
563
+ isThinking: boolean,
564
+ output: AssistantMessage,
565
+ stream: AssistantMessageEventStream,
566
+ onBeforeStartEvent?: () => void,
567
+ ): TextContent | ThinkingContent;
532
568
  export function startTextOrThinkingBlock(
533
569
  isThinking: boolean,
534
570
  output: AssistantMessage,
@@ -791,6 +827,14 @@ export function buildGoogleGenerateContentParams<T extends "google-generative-ai
791
827
  ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools, model) }),
792
828
  };
793
829
 
830
+ // Gemini API (google-generative-ai) reads the tier from the request body;
831
+ // Vertex AI ignores a body field and requires the
832
+ // `X-Vertex-AI-LLM-Shared-Request-Type` header instead (added in
833
+ // streamGoogleVertex), so only emit the body field for the direct API.
834
+ if (model.provider === "google" && shouldSendServiceTier(options.serviceTier, model.provider)) {
835
+ config.serviceTier = options.serviceTier;
836
+ }
837
+
794
838
  if (context.tools && context.tools.length > 0 && options.toolChoice) {
795
839
  const choice = options.toolChoice;
796
840
  if (typeof choice === "string") {
@@ -852,6 +896,8 @@ export interface GoogleGenAIRequestPlan {
852
896
  url: string;
853
897
  headers: Record<string, string>;
854
898
  fetch?: FetchImpl;
899
+ /** Optional URL retried once when {@link url} returns 404 (regional Vertex endpoint missing a global-only model). */
900
+ fallbackUrl?: string;
855
901
  }
856
902
 
857
903
  export function streamGoogleGenAI<T extends "google-generative-ai" | "google-vertex">(args: {
@@ -906,8 +952,8 @@ export function streamGoogleGenAI<T extends "google-generative-ai" | "google-ver
906
952
 
907
953
  const bodyJson = JSON.stringify(paramsToWireBody(params));
908
954
  const fetchImpl = plan.fetch ?? options?.fetch ?? (globalThis.fetch.bind(globalThis) as FetchImpl);
909
- const openStream = async (): Promise<ReadableStream<Uint8Array>> => {
910
- const response = await fetchImpl(plan.url, {
955
+ const openStreamAt = async (requestUrl: string): Promise<ReadableStream<Uint8Array>> => {
956
+ const response = await fetchImpl(requestUrl, {
911
957
  method: "POST",
912
958
  headers: { ...plan.headers, "Content-Type": "application/json", Accept: "text/event-stream" },
913
959
  body: bodyJson,
@@ -929,6 +975,20 @@ export function streamGoogleGenAI<T extends "google-generative-ai" | "google-ver
929
975
  }
930
976
  return response.body as ReadableStream<Uint8Array>;
931
977
  };
978
+ // A regional Vertex endpoint 404s for models published only on the
979
+ // global endpoint; retry global once so a stale/ambient region never
980
+ // breaks a request that worked before regional routing existed.
981
+ const openStream = async (): Promise<ReadableStream<Uint8Array>> => {
982
+ if (!plan.fallbackUrl) return openStreamAt(plan.url);
983
+ try {
984
+ return await openStreamAt(plan.url);
985
+ } catch (error) {
986
+ if (error instanceof AIError.GoogleApiError && error.status === 404) {
987
+ return openStreamAt(plan.fallbackUrl);
988
+ }
989
+ throw error;
990
+ }
991
+ };
932
992
 
933
993
  let body = await openStream();
934
994
  stream.push({ type: "start", partial: output });
@@ -1007,6 +1067,7 @@ function paramsToWireBody(params: GenerateContentParameters): Record<string, unk
1007
1067
  if (config.toolConfig !== undefined) body.toolConfig = config.toolConfig;
1008
1068
  if (config.safetySettings !== undefined) body.safetySettings = config.safetySettings;
1009
1069
  if (config.cachedContent !== undefined) body.cachedContent = config.cachedContent;
1070
+ if (config.serviceTier !== undefined) body.serviceTier = config.serviceTier;
1010
1071
 
1011
1072
  const gen: Record<string, unknown> = {};
1012
1073
  if (config.temperature !== undefined) gen.temperature = config.temperature;
@@ -9,7 +9,6 @@
9
9
  * - `POST {generativelanguage,aiplatform}.googleapis.com/.../models/{model}:streamGenerateContent?alt=sse`
10
10
  * - The Cloud Code Assist endpoint used by `google-gemini-cli.ts`
11
11
  */
12
-
13
12
  /** Mirror of `@google/genai`'s `FinishReason` string enum. */
14
13
  export type FinishReason =
15
14
  | "FINISH_REASON_UNSPECIFIED"
@@ -131,6 +130,11 @@ export interface GenerateContentConfig {
131
130
  safetySettings?: Array<Record<string, unknown>>;
132
131
  cachedContent?: string;
133
132
  thinkingConfig?: ThinkingConfig;
133
+ /**
134
+ * Gemini/Vertex serving tier. Serialized to the request body root as
135
+ * `serviceTier` (camelCase) by the transformer in `google-shared.ts`.
136
+ */
137
+ serviceTier?: "auto" | "default" | "flex" | "scale" | "priority";
134
138
  abortSignal?: AbortSignal;
135
139
  }
136
140
 
@@ -2,7 +2,13 @@ import { $env } from "@oh-my-pi/pi-utils";
2
2
  import * as AIError from "../error";
3
3
  import type { Context, Model, StreamFunction } from "../types";
4
4
  import type { AssistantMessageEventStream } from "../utils/event-stream";
5
- import { getVertexAccessToken } from "./google-auth";
5
+ import { getVertexAccessToken, hasVertexBearerCredentialsHint } from "./google-auth";
6
+ import {
7
+ type GoogleInteractionsPlan,
8
+ modelSupportsInteractions,
9
+ resolveInteractionDispatch,
10
+ streamGoogleInteractions,
11
+ } from "./google-interactions";
6
12
  import {
7
13
  buildGoogleGenerateContentParams,
8
14
  type GoogleGenAIRequestPlan,
@@ -16,48 +22,128 @@ export interface GoogleVertexOptions extends GoogleSharedStreamOptions {
16
22
  }
17
23
 
18
24
  const API_VERSION = "v1";
25
+ const INTERACTIONS_API_VERSION = "v1beta1";
26
+ const INTERACTIONS_API_REVISION = "2026-05-20";
19
27
 
20
28
  export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
21
29
  model: Model<"google-vertex">,
22
30
  context: Context,
23
31
  options?: GoogleVertexOptions,
24
- ): AssistantMessageEventStream =>
25
- streamGoogleGenAI({
26
- model,
27
- options,
28
- api: "google-vertex",
29
- retainTextSignature: true,
30
- prepare: async (): Promise<GoogleGenAIRequestPlan> => {
31
- const apiKey = resolveApiKey(options);
32
- const params = buildGoogleGenerateContentParams(model, context, options ?? {});
33
- const baseHeaders: Record<string, string> = {
34
- ...(model.headers ?? {}),
35
- ...(options?.headers ?? {}),
36
- };
32
+ ): AssistantMessageEventStream => {
33
+ const runGenerateContent = (): AssistantMessageEventStream =>
34
+ streamGoogleGenAI({
35
+ model,
36
+ options,
37
+ api: "google-vertex",
38
+ retainTextSignature: true,
39
+ prepare: async (): Promise<GoogleGenAIRequestPlan> => {
40
+ const apiKey = resolveApiKey(options);
41
+ const params = buildGoogleGenerateContentParams(model, context, options ?? {});
42
+ params.config ||= {};
43
+ if (!params.config.safetySettings) {
44
+ params.config.safetySettings = [
45
+ {
46
+ category: "HARM_CATEGORY_HATE_SPEECH",
47
+ threshold: "OFF",
48
+ },
49
+ {
50
+ category: "HARM_CATEGORY_DANGEROUS_CONTENT",
51
+ threshold: "OFF",
52
+ },
53
+ {
54
+ category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
55
+ threshold: "OFF",
56
+ },
57
+ {
58
+ category: "HARM_CATEGORY_HARASSMENT",
59
+ threshold: "OFF",
60
+ },
61
+ ];
62
+ }
63
+ const baseHeaders: Record<string, string> = {
64
+ ...(model.headers ?? {}),
65
+ ...(options?.headers ?? {}),
66
+ };
67
+ // Vertex AI ignores a `serviceTier` request-body field (unlike the direct
68
+ // Gemini API); priority must travel as a request header. Only `priority`
69
+ // has a documented Vertex request control — `flex` has none, so it's a no-op.
70
+ if (options?.serviceTier === "priority") {
71
+ baseHeaders["X-Vertex-AI-LLM-Shared-Request-Type"] = "priority";
72
+ }
37
73
 
38
- if (apiKey) {
39
- const url = `https://aiplatform.googleapis.com/${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent?alt=sse`;
74
+ if (apiKey) {
75
+ // Explicit `location` is a deliberate residency choice: honor it and let
76
+ // a 404 surface. An ambient env-derived region falls back to the global
77
+ // endpoint so a stray GOOGLE_*_LOCATION never breaks a previously-working
78
+ // global-only request.
79
+ const explicitLocation = options?.location;
80
+ const location = explicitLocation ?? resolveAmbientLocation() ?? "global";
81
+ const host = resolveEndpointHost(location);
82
+ const path = `${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent?alt=sse`;
83
+ const useGlobalFallback = !explicitLocation && host !== "aiplatform.googleapis.com";
84
+ return {
85
+ params,
86
+ url: `https://${host}/${path}`,
87
+ fallbackUrl: useGlobalFallback ? `https://aiplatform.googleapis.com/${path}` : undefined,
88
+ headers: {
89
+ ...baseHeaders,
90
+ "x-goog-api-key": apiKey,
91
+ },
92
+ fetch: options?.fetch,
93
+ };
94
+ }
95
+
96
+ const project = resolveProject(options);
97
+ const location = resolveLocation(options);
98
+ const accessToken = await getVertexAccessToken({ signal: options?.signal, fetch: options?.fetch });
99
+ const host = resolveEndpointHost(location);
100
+ const url = `https://${host}/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent?alt=sse`;
40
101
  return {
41
102
  params,
42
103
  url,
43
- headers: { ...baseHeaders, "x-goog-api-key": apiKey },
104
+ headers: { ...baseHeaders, Authorization: `Bearer ${accessToken}` },
44
105
  fetch: options?.fetch,
45
106
  };
46
- }
107
+ },
108
+ });
47
109
 
110
+ // Default Gemini 3+ onto Interactions whenever a bearer credential source exists (ADC file,
111
+ // `GOOGLE_APPLICATION_CREDENTIALS`, or an explicit access-token env). Interactions needs bearer
112
+ // auth, so express API-key-only setups stay on generateContent — and an express key, when
113
+ // present, still serves the generateContent fallback. Interactions always targets the official
114
+ // global `aiplatform` host; the fallback also recovers ids the endpoint rejects.
115
+ const { useInteractions, auto, anchor, state } = resolveInteractionDispatch({
116
+ context,
117
+ options,
118
+ provider: model.provider,
119
+ autoEligible: modelSupportsInteractions(model) && hasVertexBearerCredentialsHint(),
120
+ });
121
+ if (!useInteractions) return runGenerateContent();
122
+
123
+ return streamGoogleInteractions({
124
+ model,
125
+ context,
126
+ options,
127
+ api: "google-vertex",
128
+ anchor,
129
+ state,
130
+ prepare: async (): Promise<GoogleInteractionsPlan> => {
48
131
  const project = resolveProject(options);
49
- const location = resolveLocation(options);
50
132
  const accessToken = await getVertexAccessToken({ signal: options?.signal, fetch: options?.fetch });
51
- const host = resolveEndpointHost(location);
52
- const url = `https://${host}/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent?alt=sse`;
53
133
  return {
54
- params,
55
- url,
56
- headers: { ...baseHeaders, Authorization: `Bearer ${accessToken}` },
134
+ url: `https://aiplatform.googleapis.com/${INTERACTIONS_API_VERSION}/projects/${project}/locations/global/interactions`,
135
+ headers: {
136
+ ...(model.headers ?? {}),
137
+ ...(options?.headers ?? {}),
138
+ Authorization: `Bearer ${accessToken}`,
139
+ "Api-Revision": INTERACTIONS_API_REVISION,
140
+ },
57
141
  fetch: options?.fetch,
58
142
  };
59
143
  },
144
+ fallback: auto ? runGenerateContent : undefined,
60
145
  });
146
+ };
61
147
 
62
148
  function resolveApiKey(options?: GoogleVertexOptions): string | undefined {
63
149
  // options.apiKey may contain sentinel values like "<authenticated>" or "N/A"
@@ -80,9 +166,14 @@ function resolveProject(options?: GoogleVertexOptions): string {
80
166
  function resolveEndpointHost(location: string): string {
81
167
  return location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`;
82
168
  }
169
+ function resolveAmbientLocation(): string | undefined {
170
+ return $env.GOOGLE_VERTEX_LOCATION || $env.GOOGLE_CLOUD_LOCATION || $env.VERTEX_LOCATION || undefined;
171
+ }
172
+ function resolveOptionalLocation(options?: GoogleVertexOptions): string | undefined {
173
+ return options?.location || resolveAmbientLocation();
174
+ }
83
175
  function resolveLocation(options?: GoogleVertexOptions): string {
84
- const location =
85
- options?.location || $env.GOOGLE_VERTEX_LOCATION || $env.GOOGLE_CLOUD_LOCATION || $env.VERTEX_LOCATION;
176
+ const location = resolveOptionalLocation(options);
86
177
  if (!location) {
87
178
  throw new AIError.ConfigurationError(
88
179
  "Vertex AI requires a location. Set GOOGLE_VERTEX_LOCATION/GOOGLE_CLOUD_LOCATION/VERTEX_LOCATION or pass location in options.",
@@ -2,6 +2,7 @@ import * as AIError from "../error";
2
2
  import { getEnvApiKey } from "../stream";
3
3
  import type { Context, Model, StreamFunction } from "../types";
4
4
  import type { AssistantMessageEventStream } from "../utils/event-stream";
5
+ import { modelSupportsInteractions, resolveInteractionDispatch, streamGoogleInteractions } from "./google-interactions";
5
6
  import {
6
7
  buildGoogleGenerateContentParams,
7
8
  type GoogleGenAIRequestPlan,
@@ -17,29 +18,70 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = (
17
18
  model: Model<"google-generative-ai">,
18
19
  context: Context,
19
20
  options?: GoogleOptions,
20
- ): AssistantMessageEventStream =>
21
- streamGoogleGenAI({
21
+ ): AssistantMessageEventStream => {
22
+ const apiKey = options?.apiKey || getEnvApiKey(model.provider);
23
+ if (!apiKey) {
24
+ throw new AIError.MissingApiKeyError(
25
+ undefined,
26
+ "Google Generative AI requires an API key (GEMINI_API_KEY or options.apiKey).",
27
+ );
28
+ }
29
+
30
+ const runGenerateContent = (): AssistantMessageEventStream =>
31
+ streamGoogleGenAI({
32
+ model,
33
+ options,
34
+ api: "google-generative-ai",
35
+ prepare: (): GoogleGenAIRequestPlan => {
36
+ const params = buildGoogleGenerateContentParams(model, context, options ?? {});
37
+ // `model.baseUrl` already includes the API version segment when set (mirrors the
38
+ // `apiVersion: ""` reset that the SDK relied on for custom base URLs).
39
+ const base = model.baseUrl?.trim() || DEFAULT_GENERATIVE_LANGUAGE_BASE;
40
+ const url = `${base}/models/${model.id}:streamGenerateContent?alt=sse`;
41
+ const headers: Record<string, string> = {
42
+ "x-goog-api-key": apiKey,
43
+ ...(model.headers ?? {}),
44
+ ...(options?.headers ?? {}),
45
+ };
46
+ return { params, url, headers, fetch: options?.fetch };
47
+ },
48
+ });
49
+
50
+ // Default Gemini 3+ on the official endpoint onto Interactions (custom proxy base URLs keep
51
+ // generateContent, which serves the full catalog). The fallback recovers ids the endpoint rejects.
52
+ const trimmedBase = model.baseUrl?.trim();
53
+ let officialEndpoint = !trimmedBase;
54
+ if (trimmedBase) {
55
+ try {
56
+ officialEndpoint = new URL(trimmedBase).hostname === "generativelanguage.googleapis.com";
57
+ } catch {
58
+ officialEndpoint = false;
59
+ }
60
+ }
61
+ const { useInteractions, auto, anchor, state } = resolveInteractionDispatch({
62
+ context,
63
+ options,
64
+ provider: model.provider,
65
+ autoEligible: officialEndpoint && modelSupportsInteractions(model),
66
+ });
67
+ if (!useInteractions) return runGenerateContent();
68
+
69
+ return streamGoogleInteractions({
22
70
  model,
71
+ context,
23
72
  options,
24
73
  api: "google-generative-ai",
25
- prepare: (): GoogleGenAIRequestPlan => {
26
- const apiKey = options?.apiKey || getEnvApiKey(model.provider);
27
- if (!apiKey) {
28
- throw new AIError.MissingApiKeyError(
29
- undefined,
30
- "Google Generative AI requires an API key (GEMINI_API_KEY or options.apiKey).",
31
- );
32
- }
33
- const params = buildGoogleGenerateContentParams(model, context, options ?? {});
34
- // `model.baseUrl` already includes the API version segment when set (mirrors the
35
- // `apiVersion: ""` reset that the SDK relied on for custom base URLs).
36
- const base = model.baseUrl?.trim() || DEFAULT_GENERATIVE_LANGUAGE_BASE;
37
- const url = `${base}/models/${model.id}:streamGenerateContent?alt=sse`;
38
- const headers: Record<string, string> = {
74
+ anchor,
75
+ state,
76
+ prepare: () => ({
77
+ url: `${trimmedBase || DEFAULT_GENERATIVE_LANGUAGE_BASE}/interactions`,
78
+ headers: {
39
79
  "x-goog-api-key": apiKey,
40
80
  ...(model.headers ?? {}),
41
81
  ...(options?.headers ?? {}),
42
- };
43
- return { params, url, headers, fetch: options?.fetch };
44
- },
82
+ },
83
+ fetch: options?.fetch,
84
+ }),
85
+ fallback: auto ? runGenerateContent : undefined,
45
86
  });
87
+ };
@@ -13,7 +13,7 @@ import type {
13
13
  Context,
14
14
  ImageContent,
15
15
  Message,
16
- ResolvedServiceTier,
16
+ ServiceTier,
17
17
  StopReason,
18
18
  TextContent,
19
19
  Tool,
@@ -38,7 +38,7 @@ function isReasoningEffort(value: unknown): value is ReasoningEffort {
38
38
  return value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh";
39
39
  }
40
40
 
41
- function isServiceTier(value: unknown): value is ResolvedServiceTier {
41
+ function isServiceTier(value: unknown): value is ServiceTier {
42
42
  return value === "auto" || value === "default" || value === "flex" || value === "scale" || value === "priority";
43
43
  }
44
44
 
@@ -1,4 +1,5 @@
1
1
  import type { Effort } from "@oh-my-pi/pi-catalog/effort";
2
+ import { supportsAllTurnsReasoningContext } from "@oh-my-pi/pi-catalog/identity";
2
3
  import { requireSupportedEffort } from "@oh-my-pi/pi-catalog/model-thinking";
3
4
  import type { Api, Model } from "../../types";
4
5
 
@@ -14,7 +15,7 @@ export interface ReasoningConfig {
14
15
  export interface CodexRequestOptions {
15
16
  reasoningEffort?: ReasoningConfig["effort"];
16
17
  reasoningSummary?: ReasoningConfig["summary"] | null;
17
- /** Explicit `reasoning.context` override; defaults to `all_turns` for every Codex request when unset. */
18
+ /** Explicit `reasoning.context` override; defaults to `all_turns` when unset. The `all_turns` value is gated to gpt-5.4+ Codex models older ids reject it, so it is suppressed and `context` omitted. */
18
19
  reasoningContext?: CodexReasoningContext;
19
20
  textVerbosity?: "low" | "medium" | "high";
20
21
  include?: string[];
@@ -254,9 +255,21 @@ export async function transformRequestBody(
254
255
  ...body.reasoning,
255
256
  ...reasoningConfig,
256
257
  };
257
- // Default reasoning replay to `all_turns` for every Codex request,
258
- // mirroring codex-rs; an explicit `reasoningContext` overrides it.
259
- body.reasoning.context = options.reasoningContext ?? "all_turns";
258
+ // Default reasoning replay to `all_turns`, mirroring codex-rs; an
259
+ // explicit `reasoningContext` overrides the default. The `all_turns`
260
+ // value is only accepted from gpt-5.4 onward — earlier Codex ids
261
+ // (gpt-5.1-codex, gpt-5.3-codex, gpt-5.3-codex-spark) reject it with
262
+ // "Unsupported value: 'all_turns' is not supported with this model".
263
+ // For those, drop `context` so the server applies its `current_turn`
264
+ // default. The version gate is authoritative: even an explicit
265
+ // `all_turns` override is suppressed on unsupported models, while
266
+ // `current_turn`/`auto` (universally supported) always pass through.
267
+ const context = options.reasoningContext ?? "all_turns";
268
+ if (context === "all_turns" && !supportsAllTurnsReasoningContext(model.id)) {
269
+ delete body.reasoning.context;
270
+ } else {
271
+ body.reasoning.context = context;
272
+ }
260
273
  } else {
261
274
  delete body.reasoning;
262
275
  }
@@ -104,7 +104,7 @@ import { transformMessages } from "./transform-messages";
104
104
  export interface OpenAICodexResponsesOptions extends StreamOptions {
105
105
  reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
106
106
  reasoningSummary?: "auto" | "concise" | "detailed" | null;
107
- /** `reasoning.context` replay scope; defaults to `all_turns` for every Codex request when unset. */
107
+ /** `reasoning.context` replay scope; defaults to `all_turns` when unset. The `all_turns` value is gated to gpt-5.4+ Codex models older ids reject it, so it is suppressed and `context` omitted. */
108
108
  reasoningContext?: CodexReasoningContext;
109
109
  textVerbosity?: "low" | "medium" | "high";
110
110
  include?: string[];
@@ -39,8 +39,6 @@ import {
39
39
  type Model,
40
40
  OPENAI_MAX_OUTPUT_TOKENS,
41
41
  type Provider,
42
- type ResolvedServiceTier,
43
- resolveServiceTier,
44
42
  type ServiceTier,
45
43
  type StopReason,
46
44
  type StreamOptions,
@@ -269,14 +267,13 @@ export function resolveOpenAIRequestSetup(
269
267
  }
270
268
 
271
269
  export function applyOpenAIServiceTier(
272
- params: { service_tier?: ResolvedServiceTier | "auto" | "default" | null | undefined },
270
+ params: { service_tier?: ServiceTier | null | undefined },
273
271
  serviceTier: ServiceTier | null | undefined,
274
272
  provider: Provider | undefined,
275
273
  ): void {
276
274
  if (!shouldSendServiceTier(serviceTier, provider)) return;
277
- const resolved = resolveServiceTier(serviceTier, provider);
278
- if (resolved === "flex" || resolved === "scale" || resolved === "priority") {
279
- params.service_tier = resolved;
275
+ if (serviceTier === "flex" || serviceTier === "scale" || serviceTier === "priority") {
276
+ params.service_tier = serviceTier;
280
277
  }
281
278
  }
282
279
 
@@ -315,10 +312,7 @@ export function applyOpenAIResponsesServiceTierCost(
315
312
  // The response echo is authoritative when present (OpenAI may downgrade a
316
313
  // requested priority/flex turn to default under load); only fall back to the
317
314
  // requested tier when the response omits the echo entirely.
318
- const served =
319
- typeof responseServiceTier === "string"
320
- ? responseServiceTier
321
- : resolveServiceTier(requestServiceTier, model.provider);
315
+ const served = typeof responseServiceTier === "string" ? responseServiceTier : (requestServiceTier ?? undefined);
322
316
  const multiplier = getOpenAIResponsesServiceTierCostMultiplier(served);
323
317
  if (multiplier === 1) return;
324
318
  usage.cost.input *= multiplier;
@@ -623,7 +617,7 @@ export type OpenAICompletionsParams = Omit<ChatCompletionCreateParamsStreaming,
623
617
  chat_template_kwargs?: { enable_thinking?: boolean; preserve_thinking?: boolean };
624
618
  reasoning?: { effort?: string } | { enabled: false };
625
619
  reasoning_effort?: string | null;
626
- service_tier?: ResolvedServiceTier;
620
+ service_tier?: ServiceTier;
627
621
  tool_stream?: boolean;
628
622
  provider?: OpenAICompat["openRouterRouting"];
629
623
  providerOptions?: { gateway?: { only?: string[]; order?: string[] } };