@oh-my-pi/pi-ai 16.4.8 → 16.5.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.5.0] - 2026-07-13
6
+
7
+ ### Added
8
+
9
+ - Added diagnostic response headers to auth-gateway inference endpoints, including request IDs (x-request-id/request-id), LiteLLM model metadata (x-litellm-model-id/x-litellm-model-api-base), and performance/cost metrics (x-litellm-response-cost, x-litellm-response-duration-ms, openai-processing-ms) on non-streaming responses.
10
+
11
+ ### Changed
12
+
13
+ - Updated Google and Google Vertex providers to always use streamGenerateContent requests.
14
+
15
+ ### Fixed
16
+
17
+ - Fixed empty provider responses (such as from Cloud Code Assist API) being classified as non-retryable, allowing session retries and model-fallback chains to engage instead of failing the turn.
18
+
19
+ ### Removed
20
+
21
+ - Removed automatic /interactions chaining for follow-up turns in Google provider calls, along with the useInteractionsApi, storeInteraction, and previousInteractionId stream options.
22
+
5
23
  ## [16.4.6] - 2026-07-12
6
24
 
7
25
  ### Added
@@ -1,4 +1,21 @@
1
- export declare function json(status: number, body: unknown): Response;
1
+ import type { Api, AssistantMessage, Model } from "../types.js";
2
+ export declare function json(status: number, body: unknown, headers?: Record<string, string>): Response;
3
+ /**
4
+ * Diagnostic response headers for translated inference requests, mirroring the
5
+ * names existing gateway-aware clients already parse: `x-request-id` /
6
+ * `request-id` (surfaced as `_request_id` by the OpenAI and Anthropic SDKs,
7
+ * matches the gateway log line), LiteLLM's model-resolution and cost headers,
8
+ * and OpenAI's `openai-processing-ms`. Model/request-id headers are always
9
+ * present; `message` — the final assistant message, available only on
10
+ * non-streaming responses — adds the computed cost, and `startedAt` the wall
11
+ * time. Streaming responses send headers before usage exists, so they carry
12
+ * only the identity headers.
13
+ */
14
+ export declare function gatewayResponseHeaders(model: Model<Api>, info: {
15
+ requestId: string;
16
+ message?: AssistantMessage;
17
+ startedAt?: number;
18
+ }): Record<string, string>;
2
19
  export declare function resolvePeer(req: Request): string;
3
20
  /**
4
21
  * Constant-time byte comparison. Falls back to a manual XOR accumulator if
@@ -22,11 +22,3 @@ export declare function getVertexAccessToken(options?: {
22
22
  }): Promise<string>;
23
23
  /** Test seam: clears every cached token. */
24
24
  export declare function __resetVertexTokenCache(): void;
25
- /**
26
- * Sync best-effort probe for a usable Vertex bearer credential source — an explicit access-token
27
- * env var, `GOOGLE_APPLICATION_CREDENTIALS`, a user ADC file, or a GCP runtime whose metadata
28
- * server can mint ADC (GCE/Cloud Run/App Engine/Functions). Lets callers prefer the bearer
29
- * Interactions transport only when ADC is actually reachable, without paying the async
30
- * metadata-probe cost for API-key-only setups.
31
- */
32
- export declare function hasVertexBearerCredentialsHint(): boolean;
@@ -37,19 +37,6 @@ export interface GoogleSharedStreamOptions extends StreamOptions {
37
37
  hideThinkingSummary?: boolean;
38
38
  /** Gemini/Vertex serving tier (`flex`/`priority`); other values are omitted. */
39
39
  serviceTier?: ServiceTier;
40
- /**
41
- * Continues a Gemini Interactions API conversation from a stored interaction.
42
- * When set on the direct Google provider, the request uses `/interactions`
43
- * with `previous_interaction_id` instead of the legacy generateContent stream.
44
- */
45
- previousInteractionId?: string;
46
- /**
47
- * Uses the Gemini Interactions API for direct Google requests, storing the
48
- * returned interaction id on the assistant response for follow-up turns.
49
- */
50
- useInteractionsApi?: boolean;
51
- /** Overrides Interactions API request storage; default is the API default (`true`). */
52
- storeInteraction?: boolean;
53
40
  }
54
41
  /**
55
42
  * Determines whether a streamed Gemini `Part` should be treated as "thinking".
@@ -264,22 +264,6 @@ export interface StreamOptions {
264
264
  providerSessionState?: Map<string, ProviderSessionState>;
265
265
  /** Canonical Codex compaction classification; ignored by other providers. */
266
266
  codexCompaction?: CodexCompactionRequestContext;
267
- /**
268
- * Force Gemini model-mode Interactions API transport for providers that support it.
269
- * When unset, those providers may still use Interactions to continue known
270
- * server-side conversation lineage via `previousInteractionId` or stored state.
271
- */
272
- useInteractionsApi?: boolean;
273
- /**
274
- * Whether supported Interactions transports should store server-side conversation
275
- * state and return response ids for follow-up turns. Defaults to true.
276
- */
277
- storeInteraction?: boolean;
278
- /**
279
- * Explicit Interactions response id to continue. Mutually exclusive with
280
- * `storeInteraction: false` because the follow-up itself must be storable.
281
- */
282
- previousInteractionId?: string;
283
267
  /**
284
268
  * Optional per-provider concurrent request cap for LLM stream calls. Keys are
285
269
  * provider ids (`model.provider`); positive numeric values cap in-flight
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "16.4.8",
4
+ "version": "16.5.0",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,15 +37,15 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@bufbuild/protobuf": "^2.12.0",
41
- "@oh-my-pi/pi-catalog": "16.4.8",
42
- "@oh-my-pi/pi-utils": "16.4.8",
43
- "@oh-my-pi/pi-wire": "16.4.8",
44
- "arktype": "2.2.2",
40
+ "@bufbuild/protobuf": "^2.12.1",
41
+ "@oh-my-pi/pi-catalog": "16.5.0",
42
+ "@oh-my-pi/pi-utils": "16.5.0",
43
+ "@oh-my-pi/pi-wire": "16.5.0",
44
+ "arktype": "2.2.3",
45
45
  "zod": "^4"
46
46
  },
47
47
  "devDependencies": {
48
- "@bufbuild/protoc-gen-es": "^2.12.0",
48
+ "@bufbuild/protoc-gen-es": "^2.12.1",
49
49
  "@types/bun": "^1.3.14"
50
50
  },
51
51
  "engines": {
@@ -5,19 +5,50 @@
5
5
  * and peer-resolution logic.
6
6
  */
7
7
  import { timingSafeEqual as nodeTimingSafeEqual } from "node:crypto";
8
+ import type { Api, AssistantMessage, Model } from "../types";
8
9
 
9
10
  const JSON_HEADERS = {
10
11
  "Content-Type": "application/json",
11
12
  "X-Content-Type-Options": "nosniff",
12
13
  } as const;
13
14
 
14
- export function json(status: number, body: unknown): Response {
15
+ export function json(status: number, body: unknown, headers?: Record<string, string>): Response {
15
16
  return new Response(JSON.stringify(body) ?? "null", {
16
17
  status,
17
- headers: JSON_HEADERS,
18
+ headers: headers ? { ...JSON_HEADERS, ...headers } : JSON_HEADERS,
18
19
  });
19
20
  }
20
21
 
22
+ /**
23
+ * Diagnostic response headers for translated inference requests, mirroring the
24
+ * names existing gateway-aware clients already parse: `x-request-id` /
25
+ * `request-id` (surfaced as `_request_id` by the OpenAI and Anthropic SDKs,
26
+ * matches the gateway log line), LiteLLM's model-resolution and cost headers,
27
+ * and OpenAI's `openai-processing-ms`. Model/request-id headers are always
28
+ * present; `message` — the final assistant message, available only on
29
+ * non-streaming responses — adds the computed cost, and `startedAt` the wall
30
+ * time. Streaming responses send headers before usage exists, so they carry
31
+ * only the identity headers.
32
+ */
33
+ export function gatewayResponseHeaders(
34
+ model: Model<Api>,
35
+ info: { requestId: string; message?: AssistantMessage; startedAt?: number },
36
+ ): Record<string, string> {
37
+ const headers: Record<string, string> = {
38
+ "x-request-id": info.requestId,
39
+ "request-id": info.requestId,
40
+ "x-litellm-model-id": model.id,
41
+ };
42
+ if (model.baseUrl) headers["x-litellm-model-api-base"] = model.baseUrl;
43
+ if (info.message) headers["x-litellm-response-cost"] = info.message.usage.cost.total.toString();
44
+ if (info.startedAt !== undefined) {
45
+ const elapsed = (performance.now() - info.startedAt).toFixed(0);
46
+ headers["x-litellm-response-duration-ms"] = elapsed;
47
+ headers["openai-processing-ms"] = elapsed;
48
+ }
49
+ return headers;
50
+ }
51
+
21
52
  export function resolvePeer(req: Request): string {
22
53
  const fwd = req.headers.get("x-forwarded-for");
23
54
  if (fwd) return fwd.split(",")[0].trim();
@@ -165,6 +196,8 @@ const CORS_HEADERS: Record<string, string> = {
165
196
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
166
197
  "Access-Control-Allow-Headers":
167
198
  "authorization, content-type, anthropic-version, anthropic-beta, openai-organization, openai-project, x-stainless-*, x-api-key",
199
+ "Access-Control-Expose-Headers":
200
+ "x-request-id, request-id, x-litellm-model-id, x-litellm-model-api-base, x-litellm-response-cost, x-litellm-response-duration-ms, openai-processing-ms",
168
201
  "Access-Control-Max-Age": "86400",
169
202
  };
170
203
 
@@ -32,7 +32,15 @@ import { completeSimple, streamSimple } from "../stream";
32
32
  import type { Api, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "../types";
33
33
  import { deterministicUuid } from "../utils/deterministic-id";
34
34
  import { parseBind } from "../utils/parse-bind";
35
- import { captureRequestHeaders, corsHeaders, isAuthorized, json, resolvePeer, withCors } from "./http";
35
+ import {
36
+ captureRequestHeaders,
37
+ corsHeaders,
38
+ gatewayResponseHeaders,
39
+ isAuthorized,
40
+ json,
41
+ resolvePeer,
42
+ withCors,
43
+ } from "./http";
36
44
  import type {
37
45
  AuthGatewayServerHandle,
38
46
  AuthGatewayServerOptions,
@@ -334,6 +342,8 @@ async function handleFormatEndpoint(
334
342
  req: Request,
335
343
  peer: string,
336
344
  ): Promise<Response> {
345
+ const startedAt = performance.now();
346
+ const requestId = crypto.randomUUID();
337
347
  const controller = mirrorRequestAbort(req);
338
348
  if (controller.signal.aborted) return clientClosedResponse(route);
339
349
 
@@ -430,6 +440,7 @@ async function handleFormatEndpoint(
430
440
  );
431
441
 
432
442
  logger.info("auth-gateway request", {
443
+ requestId,
433
444
  format: route.label,
434
445
  model: parsed.modelId,
435
446
  resolvedProvider: model.provider,
@@ -458,7 +469,11 @@ async function handleFormatEndpoint(
458
469
  const classified = classifyGatewayError(errorMessage);
459
470
  return route.module.formatError(classified.status, classified.type, errorMessage);
460
471
  }
461
- return json(200, route.module.encodeResponse(message, parsed.modelId));
472
+ return json(
473
+ 200,
474
+ route.module.encodeResponse(message, parsed.modelId),
475
+ gatewayResponseHeaders(model, { requestId, message, startedAt }),
476
+ );
462
477
  } catch (error) {
463
478
  if (controller.signal.aborted) return clientClosedResponse(route);
464
479
  const classified = classifyGatewayError(error);
@@ -493,6 +508,7 @@ async function handleFormatEndpoint(
493
508
  return new Response(sseStream, {
494
509
  status: 200,
495
510
  headers: {
511
+ ...gatewayResponseHeaders(model, { requestId }),
496
512
  "Content-Type": "text/event-stream; charset=utf-8",
497
513
  "Cache-Control": "no-cache",
498
514
  Connection: "keep-alive",
@@ -519,6 +535,8 @@ async function handleFormatEndpoint(
519
535
  * path.
520
536
  */
521
537
  async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response> {
538
+ const startedAt = performance.now();
539
+ const requestId = crypto.randomUUID();
522
540
  const controller = mirrorRequestAbort(req);
523
541
  const aborted = (): Response => piNative.formatError(499, "request_aborted", "client closed request");
524
542
  if (controller.signal.aborted) return aborted();
@@ -605,6 +623,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
605
623
  streamOpts.sessionId ??= sessionId;
606
624
 
607
625
  logger.info("auth-gateway request", {
626
+ requestId,
608
627
  format: "pi-native",
609
628
  model: parsed.modelId,
610
629
  resolvedProvider: model.provider,
@@ -633,7 +652,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
633
652
  const classified = classifyGatewayError(errorMessage);
634
653
  return piNative.formatError(classified.status, classified.type, errorMessage);
635
654
  }
636
- return json(200, { message });
655
+ return json(200, { message }, gatewayResponseHeaders(model, { requestId, message, startedAt }));
637
656
  } catch (error) {
638
657
  if (controller.signal.aborted) return aborted();
639
658
  const classified = classifyGatewayError(error);
@@ -664,6 +683,7 @@ async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, pe
664
683
  return new Response(sseStream, {
665
684
  status: 200,
666
685
  headers: {
686
+ ...gatewayResponseHeaders(model, { requestId }),
667
687
  "Content-Type": "text/event-stream; charset=utf-8",
668
688
  "Cache-Control": "no-cache",
669
689
  Connection: "keep-alive",
@@ -39,10 +39,11 @@ export class ProviderResponseError extends Error {
39
39
  // A safety filter block is a terminal provider finish, not a transient fault.
40
40
  if (this.kind === "content-blocked") attach(this, create(Flag.ProviderFinishError));
41
41
  // An incomplete stream (connection dropped / truncated before any terminal
42
- // event) never produced a finish reason — the request didn't complete, so it
43
- // is safe to retry. The retry layer's replay-unsafe guard still blocks a
44
- // retry when partial tool output was already emitted.
45
- else if (this.kind === "incomplete-stream") attach(this, create(Flag.Transient));
42
+ // event) or an empty body never produced any content — the request didn't
43
+ // complete, so it is safe to retry and eligible for model fallback. The
44
+ // retry layer's replay-unsafe guard still blocks a retry when partial tool
45
+ // output was already emitted.
46
+ else if (this.kind === "incomplete-stream" || this.kind === "empty-body") attach(this, create(Flag.Transient));
46
47
  }
47
48
  }
48
49
 
@@ -13,7 +13,6 @@
13
13
  */
14
14
 
15
15
  import { Buffer } from "node:buffer";
16
- import * as fs from "node:fs";
17
16
  import * as os from "node:os";
18
17
  import * as path from "node:path";
19
18
  import { $envpos, isEnoent, logger } from "@oh-my-pi/pi-utils";
@@ -329,22 +328,3 @@ export function __resetVertexTokenCache(): void {
329
328
  tokenCache.clear();
330
329
  inflight.clear();
331
330
  }
332
-
333
- /**
334
- * Sync best-effort probe for a usable Vertex bearer credential source — an explicit access-token
335
- * env var, `GOOGLE_APPLICATION_CREDENTIALS`, a user ADC file, or a GCP runtime whose metadata
336
- * server can mint ADC (GCE/Cloud Run/App Engine/Functions). Lets callers prefer the bearer
337
- * Interactions transport only when ADC is actually reachable, without paying the async
338
- * metadata-probe cost for API-key-only setups.
339
- */
340
- export function hasVertexBearerCredentialsHint(): boolean {
341
- if (Bun.env.GOOGLE_CLOUD_ACCESS_TOKEN || Bun.env.CLOUDSDK_AUTH_ACCESS_TOKEN) return true;
342
- if (Bun.env.GOOGLE_APPLICATION_CREDENTIALS) return true;
343
- // GCP-hosted runtimes expose ADC via the metadata server; these env vars mark those runtimes.
344
- if (Bun.env.K_SERVICE || Bun.env.FUNCTION_TARGET || Bun.env.GAE_ENV || Bun.env.GCE_METADATA_HOST) return true;
345
- try {
346
- return fs.existsSync(userAdcPath());
347
- } catch {
348
- return false;
349
- }
350
- }
@@ -79,19 +79,6 @@ export interface GoogleSharedStreamOptions extends StreamOptions {
79
79
  hideThinkingSummary?: boolean;
80
80
  /** Gemini/Vertex serving tier (`flex`/`priority`); other values are omitted. */
81
81
  serviceTier?: ServiceTier;
82
- /**
83
- * Continues a Gemini Interactions API conversation from a stored interaction.
84
- * When set on the direct Google provider, the request uses `/interactions`
85
- * with `previous_interaction_id` instead of the legacy generateContent stream.
86
- */
87
- previousInteractionId?: string;
88
- /**
89
- * Uses the Gemini Interactions API for direct Google requests, storing the
90
- * returned interaction id on the assistant response for follow-up turns.
91
- */
92
- useInteractionsApi?: boolean;
93
- /** Overrides Interactions API request storage; default is the API default (`true`). */
94
- storeInteraction?: boolean;
95
82
  }
96
83
 
97
84
  /**
@@ -2,13 +2,7 @@ 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, hasVertexBearerCredentialsHint } from "./google-auth";
6
- import {
7
- type GoogleInteractionsPlan,
8
- modelSupportsInteractions,
9
- resolveInteractionDispatch,
10
- streamGoogleInteractions,
11
- } from "./google-interactions";
5
+ import { getVertexAccessToken } from "./google-auth";
12
6
  import {
13
7
  buildGoogleGenerateContentParams,
14
8
  type GoogleGenAIRequestPlan,
@@ -22,126 +16,86 @@ export interface GoogleVertexOptions extends GoogleSharedStreamOptions {
22
16
  }
23
17
 
24
18
  const API_VERSION = "v1";
25
- const INTERACTIONS_API_VERSION = "v1beta1";
26
- const INTERACTIONS_API_REVISION = "2026-05-20";
27
19
 
28
20
  export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
29
21
  model: Model<"google-vertex">,
30
22
  context: Context,
31
23
  options?: GoogleVertexOptions,
32
24
  ): 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
- }
73
-
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
- }
25
+ return 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
+ params.config ||= {};
34
+ if (!params.config.safetySettings) {
35
+ params.config.safetySettings = [
36
+ {
37
+ category: "HARM_CATEGORY_HATE_SPEECH",
38
+ threshold: "OFF",
39
+ },
40
+ {
41
+ category: "HARM_CATEGORY_DANGEROUS_CONTENT",
42
+ threshold: "OFF",
43
+ },
44
+ {
45
+ category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
46
+ threshold: "OFF",
47
+ },
48
+ {
49
+ category: "HARM_CATEGORY_HARASSMENT",
50
+ threshold: "OFF",
51
+ },
52
+ ];
53
+ }
54
+ const baseHeaders: Record<string, string> = {
55
+ ...(model.headers ?? {}),
56
+ ...(options?.headers ?? {}),
57
+ };
58
+ // Vertex AI ignores a `serviceTier` request-body field (unlike the direct
59
+ // Gemini API); priority must travel as a request header. Only `priority`
60
+ // has a documented Vertex request control `flex` has none, so it's a no-op.
61
+ if (options?.serviceTier === "priority") {
62
+ baseHeaders["X-Vertex-AI-LLM-Shared-Request-Type"] = "priority";
63
+ }
95
64
 
96
- const project = resolveProject(options);
97
- const location = resolveLocation(options);
98
- const accessToken = await getVertexAccessToken({ signal: options?.signal, fetch: options?.fetch });
65
+ if (apiKey) {
66
+ // Explicit `location` is a deliberate residency choice: honor it and let
67
+ // a 404 surface. An ambient env-derived region falls back to the global
68
+ // endpoint so a stray GOOGLE_*_LOCATION never breaks a previously-working
69
+ // global-only request.
70
+ const explicitLocation = options?.location;
71
+ const location = explicitLocation ?? resolveAmbientLocation() ?? "global";
99
72
  const host = resolveEndpointHost(location);
100
- const url = `https://${host}/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent?alt=sse`;
73
+ const path = `${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent?alt=sse`;
74
+ const useGlobalFallback = !explicitLocation && host !== "aiplatform.googleapis.com";
101
75
  return {
102
76
  params,
103
- url,
104
- headers: { ...baseHeaders, Authorization: `Bearer ${accessToken}` },
77
+ url: `https://${host}/${path}`,
78
+ fallbackUrl: useGlobalFallback ? `https://aiplatform.googleapis.com/${path}` : undefined,
79
+ headers: {
80
+ ...baseHeaders,
81
+ "x-goog-api-key": apiKey,
82
+ },
105
83
  fetch: options?.fetch,
106
84
  };
107
- },
108
- });
85
+ }
109
86
 
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> => {
131
87
  const project = resolveProject(options);
88
+ const location = resolveLocation(options);
132
89
  const accessToken = await getVertexAccessToken({ signal: options?.signal, fetch: options?.fetch });
90
+ const host = resolveEndpointHost(location);
91
+ const url = `https://${host}/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent?alt=sse`;
133
92
  return {
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
- },
93
+ params,
94
+ url,
95
+ headers: { ...baseHeaders, Authorization: `Bearer ${accessToken}` },
141
96
  fetch: options?.fetch,
142
97
  };
143
98
  },
144
- fallback: auto ? runGenerateContent : undefined,
145
99
  });
146
100
  };
147
101
 
@@ -2,7 +2,6 @@ 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";
6
5
  import {
7
6
  buildGoogleGenerateContentParams,
8
7
  type GoogleGenAIRequestPlan,
@@ -27,61 +26,22 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = (
27
26
  );
28
27
  }
29
28
 
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({
29
+ return streamGoogleGenAI({
70
30
  model,
71
- context,
72
31
  options,
73
32
  api: "google-generative-ai",
74
- anchor,
75
- state,
76
- prepare: () => ({
77
- url: `${trimmedBase || DEFAULT_GENERATIVE_LANGUAGE_BASE}/interactions`,
78
- headers: {
33
+ prepare: (): GoogleGenAIRequestPlan => {
34
+ const params = buildGoogleGenerateContentParams(model, context, options ?? {});
35
+ // `model.baseUrl` already includes the API version segment when set (mirrors the
36
+ // `apiVersion: ""` reset that the SDK relied on for custom base URLs).
37
+ const base = model.baseUrl?.trim() || DEFAULT_GENERATIVE_LANGUAGE_BASE;
38
+ const url = `${base}/models/${model.id}:streamGenerateContent?alt=sse`;
39
+ const headers: Record<string, string> = {
79
40
  "x-goog-api-key": apiKey,
80
41
  ...(model.headers ?? {}),
81
42
  ...(options?.headers ?? {}),
82
- },
83
- fetch: options?.fetch,
84
- }),
85
- fallback: auto ? runGenerateContent : undefined,
43
+ };
44
+ return { params, url, headers, fetch: options?.fetch };
45
+ },
86
46
  });
87
47
  };
@@ -425,7 +425,7 @@ export async function transformRequestBody(
425
425
 
426
426
  body.text = {
427
427
  ...body.text,
428
- verbosity: options.textVerbosity || "high",
428
+ verbosity: options.textVerbosity || "medium",
429
429
  };
430
430
 
431
431
  const include = Array.isArray(options.include) ? [...options.include] : [];