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.
This commit is contained in:
can1357
2026-06-30 04:48:01 +02:00
parent f18368d1f7
commit 6b64ed0ff6
4 changed files with 69 additions and 23 deletions
+2 -2
View File
@@ -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
+48 -13
View File
@@ -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<T extends GoogleApiType>(model: Model<T>): 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<T extends GoogleApiType>(model: Model<T>, context: Context): Content[] {
const contents: Content[] = [];
const emittedToolCallNames = new Map<string, string>();
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<T extends GoogleApiType>(model: Model<T>, 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<T extends GoogleApiType>(model: Model<T>, 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<T extends GoogleApiType>(model: Model<T>, 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<T extends GoogleApiType>(model: Model<T>, 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<string, string>;
fetch?: FetchImpl;
/** Optional URL retried once when {@link url} returns 404 (regional Vertex endpoint missing a global-only model). */
fallbackUrl?: string;
}
export function streamGoogleGenAI<T extends "google-generative-ai" | "google-vertex">(args: {
@@ -918,8 +939,8 @@ export function streamGoogleGenAI<T extends "google-generative-ai" | "google-ver
const bodyJson = JSON.stringify(paramsToWireBody(params));
const fetchImpl = plan.fetch ?? options?.fetch ?? (globalThis.fetch.bind(globalThis) as FetchImpl);
const openStream = async (): Promise<ReadableStream<Uint8Array>> => {
const response = await fetchImpl(plan.url, {
const openStreamAt = async (requestUrl: string): Promise<ReadableStream<Uint8Array>> => {
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<T extends "google-generative-ai" | "google-ver
}
return response.body as ReadableStream<Uint8Array>;
};
// 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<ReadableStream<Uint8Array>> => {
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 });
+1 -4
View File
@@ -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;
}
+18 -4
View File
@@ -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.",