From 6b64ed0ff6dce76c55d5eff44ce0ae348ce937dc Mon Sep 17 00:00:00 2001 From: can1357 Date: Tue, 30 Jun 2026 04:48:01 +0200 Subject: [PATCH] feat(ai/providers): added global fallback for regional vertex endpoints - Introduce `fallbackUrl` to allow retrying requests on the global Vertex endpoint when a 404 is encountered on a regional endpoint. - Correctly track tool call names to facilitate accurate function response mapping. - Update `serviceTier` type definitions to accurately reflect allowed values. - Refine vertex endpoint location resolution to ensure better compatibility with ambient regional settings. --- packages/ai/CHANGELOG.md | 4 +- packages/ai/src/providers/google-shared.ts | 61 +++++++++++++++++----- packages/ai/src/providers/google-types.ts | 5 +- packages/ai/src/providers/google-vertex.ts | 22 ++++++-- 4 files changed, 69 insertions(+), 23 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 3de8e047d..5e64524fc 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -15,11 +15,11 @@ ### Fixed +- Improved Vertex AI reliability by automatically falling back to global endpoints on 404 errors + - Fixed safety setting application for Google Vertex AI models - Ensured Gemini service tier is correctly passed through to the API - Corrected priority request accounting for supported providers -### Fixed - - Fixed Kimi Code's Anthropic-compatible request path to keep thinking enabled and downgrade forced tool choice for Kimi K2.7 Code title generation. ([#3852](https://github.com/can1357/oh-my-pi/issues/3852)) ## [16.2.6] - 2026-06-29 diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index f2fe100d8..ae5cd9d0b 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -130,11 +130,9 @@ function resolveThoughtSignature(isSameProviderAndModel: boolean, signature: str return isSameProviderAndModel && isValidThoughtSignature(signature) ? signature : undefined; } -/** - * Claude models via Google APIs require explicit tool call IDs in function calls/responses. - */ -export function requiresToolCallId(modelId: string): boolean { - return modelId.startsWith("claude-"); +function supportsFunctionPartId(model: Model): boolean { + if (model.api === "google-vertex") return false; + return model.id.startsWith("claude-") || (model.api === "google-generative-ai" && isGemini3Model(model.id)); } function getGeminiMajorVersion(modelId: string): number | undefined { @@ -160,8 +158,9 @@ function isGemini3Model(modelId: string): boolean { */ export function convertMessages(model: Model, context: Context): Content[] { const contents: Content[] = []; + const emittedToolCallNames = new Map(); + const normalizeToolCallId = (id: string): string => { - if (!requiresToolCallId(model.id)) return id; return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64); }; @@ -247,6 +246,7 @@ export function convertMessages(model: Model, contex }); } } else if (block.type === "toolCall") { + emittedToolCallNames.set(block.id, block.name); const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature); const effectiveSignature = thoughtSignature || (isGemini3Model(model.id) ? SKIP_THOUGHT_SIGNATURE : undefined); @@ -255,11 +255,11 @@ export function convertMessages(model: Model, contex functionCall: { name: block.name, args: block.arguments ?? {}, - ...(requiresToolCallId(model.id) ? { id: block.id } : {}), + ...(supportsFunctionPartId(model) ? { id: block.id } : {}), }, }; if (model.provider === "google-vertex" && part?.functionCall?.id) { - delete part.functionCall.id; // Vertex AI does not support 'id' in functionCall + delete part.functionCall.id; // Vertex AI GenerateContent rejects 'id' in functionCall parts. } if (effectiveSignature) { part.thoughtSignature = effectiveSignature; @@ -305,10 +305,11 @@ export function convertMessages(model: Model, contex }, })); - const includeId = requiresToolCallId(model.id); + const includeId = supportsFunctionPartId(model); + const emittedName = emittedToolCallNames.get(msg.toolCallId); const functionResponsePart: Part = { functionResponse: { - name: msg.toolName, + name: emittedName ?? msg.toolName, response: msg.isError ? { error: responseValue } : { output: responseValue }, ...(hasImages && modelSupportsMultimodalFunctionResponse && { parts: imageParts }), ...(includeId ? { id: msg.toolCallId } : {}), @@ -316,7 +317,7 @@ export function convertMessages(model: Model, contex }; if (model.provider === "google-vertex" && functionResponsePart.functionResponse?.id) { - delete functionResponsePart.functionResponse.id; // Vertex AI does not support 'id' in functionResponse + delete functionResponsePart.functionResponse.id; // Vertex AI GenerateContent rejects 'id' in functionResponse parts. } // Cloud Code Assist API requires all function responses to be in a single user turn. @@ -533,6 +534,24 @@ export function pushToolCallEvents( * `text_start` / `thinking_start` event. `onBeforeStartEvent` lets the SSE consumer * inject its `ensureStarted()` first-token side effect into the canonical event order. */ +export function startTextOrThinkingBlock( + isThinking: true, + output: AssistantMessage, + stream: AssistantMessageEventStream, + onBeforeStartEvent?: () => void, +): ThinkingContent; +export function startTextOrThinkingBlock( + isThinking: false, + output: AssistantMessage, + stream: AssistantMessageEventStream, + onBeforeStartEvent?: () => void, +): TextContent; +export function startTextOrThinkingBlock( + isThinking: boolean, + output: AssistantMessage, + stream: AssistantMessageEventStream, + onBeforeStartEvent?: () => void, +): TextContent | ThinkingContent; export function startTextOrThinkingBlock( isThinking: boolean, output: AssistantMessage, @@ -864,6 +883,8 @@ export interface GoogleGenAIRequestPlan { url: string; headers: Record; fetch?: FetchImpl; + /** Optional URL retried once when {@link url} returns 404 (regional Vertex endpoint missing a global-only model). */ + fallbackUrl?: string; } export function streamGoogleGenAI(args: { @@ -918,8 +939,8 @@ export function streamGoogleGenAI> => { - const response = await fetchImpl(plan.url, { + const openStreamAt = async (requestUrl: string): Promise> => { + const response = await fetchImpl(requestUrl, { method: "POST", headers: { ...plan.headers, "Content-Type": "application/json", Accept: "text/event-stream" }, body: bodyJson, @@ -941,6 +962,20 @@ export function streamGoogleGenAI; }; + // A regional Vertex endpoint 404s for models published only on the + // global endpoint; retry global once so a stale/ambient region never + // breaks a request that worked before regional routing existed. + const openStream = async (): Promise> => { + if (!plan.fallbackUrl) return openStreamAt(plan.url); + try { + return await openStreamAt(plan.url); + } catch (error) { + if (error instanceof AIError.GoogleApiError && error.status === 404) { + return openStreamAt(plan.fallbackUrl); + } + throw error; + } + }; let body = await openStream(); stream.push({ type: "start", partial: output }); diff --git a/packages/ai/src/providers/google-types.ts b/packages/ai/src/providers/google-types.ts index a5bb299ec..0c5c2fe9f 100644 --- a/packages/ai/src/providers/google-types.ts +++ b/packages/ai/src/providers/google-types.ts @@ -9,9 +9,6 @@ * - `POST {generativelanguage,aiplatform}.googleapis.com/.../models/{model}:streamGenerateContent?alt=sse` * - The Cloud Code Assist endpoint used by `google-gemini-cli.ts` */ - -import type { ServiceTier } from "../types"; - /** Mirror of `@google/genai`'s `FinishReason` string enum. */ export type FinishReason = | "FINISH_REASON_UNSPECIFIED" @@ -137,7 +134,7 @@ export interface GenerateContentConfig { * Gemini/Vertex serving tier. Serialized to the request body root as * `serviceTier` (camelCase) by the transformer in `google-shared.ts`. */ - serviceTier?: ServiceTier; + serviceTier?: "auto" | "default" | "flex" | "scale" | "priority"; abortSignal?: AbortSignal; } diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index 996e763c3..878286a53 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -63,10 +63,19 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = ( } if (apiKey) { - const url = `https://aiplatform.googleapis.com/${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent?alt=sse`; + // Explicit `location` is a deliberate residency choice: honor it and let + // a 404 surface. An ambient env-derived region falls back to the global + // endpoint so a stray GOOGLE_*_LOCATION never breaks a previously-working + // global-only request. + const explicitLocation = options?.location; + const location = explicitLocation ?? resolveAmbientLocation() ?? "global"; + const host = resolveEndpointHost(location); + const path = `${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent?alt=sse`; + const useGlobalFallback = !explicitLocation && host !== "aiplatform.googleapis.com"; return { params, - url, + url: `https://${host}/${path}`, + fallbackUrl: useGlobalFallback ? `https://aiplatform.googleapis.com/${path}` : undefined, headers: { ...baseHeaders, "x-goog-api-key": apiKey, @@ -110,9 +119,14 @@ function resolveProject(options?: GoogleVertexOptions): string { function resolveEndpointHost(location: string): string { return location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`; } +function resolveAmbientLocation(): string | undefined { + return $env.GOOGLE_VERTEX_LOCATION || $env.GOOGLE_CLOUD_LOCATION || $env.VERTEX_LOCATION || undefined; +} +function resolveOptionalLocation(options?: GoogleVertexOptions): string | undefined { + return options?.location || resolveAmbientLocation(); +} function resolveLocation(options?: GoogleVertexOptions): string { - const location = - options?.location || $env.GOOGLE_VERTEX_LOCATION || $env.GOOGLE_CLOUD_LOCATION || $env.VERTEX_LOCATION; + const location = resolveOptionalLocation(options); if (!location) { throw new AIError.ConfigurationError( "Vertex AI requires a location. Set GOOGLE_VERTEX_LOCATION/GOOGLE_CLOUD_LOCATION/VERTEX_LOCATION or pass location in options.",