From b52c236000573967ec1bf2551ef6c6dbd05efe37 Mon Sep 17 00:00:00 2001 From: xdober Date: Thu, 12 Nov 2026 12:59:29 +0800 Subject: [PATCH 01/11] feat(google-vertex): support live Publisher Models discovery and gemini-4-carbon - Add `src/providers/google-vertex-model-discovery.ts` to discover live Vertex AI generative models via `GET /v1beta1/publishers/google/models?pageSize=300` (the maximum page size accepted by `ModelGardenService.ListPublisherModels`), supporting both Vertex API keys (`x-goog-api-key`) and GCP ADC (`Authorization: Bearer` + `x-goog-user-project`) while respecting paused API-key pools and `gcp-sa:` markers. - Wire Vertex discovery auth and native `publisherModels` extraction into `src/oauth/index.ts`, `src/codex/catalog/provider-models.ts`, and `src/server/management/provider-routes.ts`, degrading cleanly without unauthenticated probes when synchronous credentials are unavailable. - Register `gemini-4-carbon` capabilities (1,048,576 context window, 65,536 max output tokens, `["text", "image"]` input modalities, `["low", "medium", "high"]` reasoning efforts) across `scripts/model-metadata.source.json`, `src/generated/model-metadata.ts`, and `src/providers/registry/entries-core.ts`, fill destination `modelReasoningEfforts` in `src/providers/routed-config.ts`, and enable Vertex tool-result image nesting for `gemini-4-carbon` alongside `gemini-4-argon` in `src/adapters/google.ts`. - Add unit coverage in `tests/adapters/google/vertex-catalog.test.ts` and update `tests/adapters/google/google-models-listing.test.ts`. --- scripts/model-metadata.source.json | 19 ++ src/adapters/google.ts | 2 +- src/codex/catalog/provider-models.ts | 23 +- src/generated/model-metadata.ts | 2 +- src/oauth/index.ts | 9 +- .../google-vertex-model-discovery.ts | 208 ++++++++++++++++++ src/providers/registry/entries-core.ts | 13 +- src/providers/routed-config.ts | 4 + src/server/management/provider-routes.ts | 19 +- .../google/google-models-listing.test.ts | 7 +- tests/adapters/google/vertex-catalog.test.ts | 56 +++++ 11 files changed, 333 insertions(+), 29 deletions(-) create mode 100644 src/providers/google-vertex-model-discovery.ts diff --git a/scripts/model-metadata.source.json b/scripts/model-metadata.source.json index 003a4157da7..8c120eb5611 100644 --- a/scripts/model-metadata.source.json +++ b/scripts/model-metadata.source.json @@ -13290,6 +13290,25 @@ "maxLevel": "high" } }, + "gemini-4-carbon": { + "id": "gemini-4-carbon", + "name": "Gemini 4 Carbon", + "api": "google-generative-ai", + "provider": "google", + "baseUrl": "https://generativelanguage.googleapis.com/v1beta", + "reasoning": true, + "input": [ + "text", + "image" + ], + "contextWindow": 1048576, + "maxTokens": 65536, + "thinking": { + "mode": "google-level", + "minLevel": "low", + "maxLevel": "high" + } + }, "gemini-flash-latest": { "id": "gemini-flash-latest", "name": "Gemini Flash Latest", diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 668b66a2d7e..c2321fa2197 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -1072,7 +1072,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte identityModelId, stripRejectedClaudeSdkParagraph, isCloudCodeAssist, - provider.googleMode === "vertex" && parsed.modelId === "gemini-4-argon", + provider.googleMode === "vertex" && (parsed.modelId === "gemini-4-argon" || parsed.modelId === "gemini-4-carbon"), isCloudCodeAssist && /^claude-/i.test(routedModelId), ); lastInjectedCallIds = [...replayedCallIds]; diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index 07583da7f64..5237b1646e8 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -100,7 +100,7 @@ import { type ProviderModelsApiItem, type ResolvedProviderModelDiscovery, } from "../../providers/model-discovery"; -import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery"; +import { extractGoogleNativeModelItems, resolveObservedVertexDiscoveryToken } from "../../providers/google-vertex-model-discovery"; import { applyConfiguredHeadersLast, fetchOllamaShowEnrichment, ollamaShowEnrichable } from "../../providers/ollama-show"; import upstreamModelsSnapshot from "../data/upstream-models.json"; import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission"; @@ -144,6 +144,7 @@ export interface ProviderModelsResult { } export const refreshingModelsAuthResolver: ModelsAuthResolver = { kind: "refreshing" }; +/** Build a synchronous non-refreshing credential resolver from a captured auth-store buffer. */ export function observedModelsAuthResolver( authStoreBuffer: Uint8Array | null, outcomes: CatalogGatherProviderAuthOutcome[], @@ -153,7 +154,12 @@ export function observedModelsAuthResolver( resolve(name, provider) { if (provider.authMode === "forward") return { apiKey: undefined, observed: true }; if (provider.authMode !== "oauth") { - return { apiKey: resolveProviderApiKey(provider.apiKey), observed: true }; + return { + apiKey: effectiveGoogleMode(name, provider) === "vertex" + ? resolveObservedVertexDiscoveryToken(provider) + : resolveProviderApiKey(provider.apiKey), + observed: true, + }; } const observation: OAuthActiveTokenObservation = mayResolveModelsOAuth(name, provider) @@ -681,6 +687,9 @@ export async function fetchProviderModelsWithAuth( "degraded", ); } + if (effectiveGoogleMode(name, prov) === "vertex" && !apiKey) { + return observed(withConfiguredRetention(withVertexDefaultSeed(configured)), "degraded"); + } // The captured request predates any refresh, so a refreshing gather rebuilds it // from the auth it resolved: the token and its origin, together. const request = resolveAuth.kind === "refreshing" && name !== "anthropic2" @@ -844,14 +853,10 @@ export async function fetchProviderModelsWithAuth( markProviderDiscoveryOk(name, live.length); return observed(withConfiguredRetention(azureMetadata ? live : forCache, { warnDrops: true }), "authoritative"); } - const googleAiStudio = effectiveGoogleMode(name, prov) === "ai-studio" - ? extractGoogleAiStudioModelItems(bounded.value, discovery.maxModels) - : undefined; - // Native /v1beta/models wins; a google row served by an OpenAI-compatible + const googleNative = extractGoogleNativeModelItems(effectiveGoogleMode(name, prov), bounded.value, discovery.maxModels); + // Native Google discovery wins; a google row served by an OpenAI-compatible // gateway keeps the generic data[] / top-level-array contract. - const extracted = googleAiStudio?.ok - ? googleAiStudio - : extractProviderModelItems(bounded.value, discovery); + const extracted = googleNative ?? extractProviderModelItems(bounded.value, discovery); if (!extracted.ok) { const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" }); const diagnostic: Record = { diff --git a/src/generated/model-metadata.ts b/src/generated/model-metadata.ts index 596b0859fd9..5618fa464f4 100644 --- a/src/generated/model-metadata.ts +++ b/src/generated/model-metadata.ts @@ -47,7 +47,7 @@ const DATA: Record = { "azure-openai": [["gpt-4.1",1047576,32768,"text,image",0,null,2,8,0.5,0],["gpt-4o",128000,16384,"text,image",0,null,2.5,10,1.25,0],["gpt-4o-mini",128000,16384,"text,image",0,null,0.15,0.6,0.075,0],["o3",200000,100000,"text,image",1,null,2,8,0.5,0],["o3-mini",200000,100000,"text",1,null,1.1,4.4,0.55,0]], "cerebras": [["gemma-4-31b",131072,40960,"text,image",1,null,0.99,1.49,0,0],["gpt-oss-120b",131072,40960,"text",1,null,0.35,0.75,0,0],["llama3.1-8b",32000,8000,"text",0,null,0.1,0.1,0,0],["qwen-3-235b-a22b-instruct-2507",131000,32000,"text",0,null,0.6,1.2,0,0],["qwen-3-coder-480b",131072,32768,"text",0,null,0,0,0,0],["zai-glm-4.6",131072,32768,"text",0,null,0,0,0,0],["zai-glm-4.7",131072,40960,"text",1,null,2.25,2.75,2.25,0]], "deepseek": [["deepseek-v4-flash",1048576,384000,"text",1,null,0.14,0.28,0.0028,0],["deepseek-v4-pro",1048576,384000,"text",1,null,0.435,0.87,0.003625,0]], - "google": [["deep-research-max-preview-04-2026",131072,65536,"text,image",1,null,2,12,0.2,0],["deep-research-preview-04-2026",131072,65536,"text,image",1,null,2,12,0.2,0],["gemini-1.5-flash",1000000,8192,"text,image",0,null,0.075,0.3,0.01875,0],["gemini-1.5-flash-8b",1000000,8192,"text,image",0,null,0.0375,0.15,0.01,0],["gemini-1.5-pro",1000000,8192,"text,image",0,null,1.25,5,0.3125,0],["gemini-2.0-flash",1048576,8192,"text,image",0,null,0.1,0.4,0.025,0],["gemini-2.0-flash-lite",1048576,8192,"text,image",0,null,0.075,0.3,0,0],["gemini-2.5-computer-use-preview-10-2025",131072,65536,"text,image",1,null,1.25,10,0,0],["gemini-2.5-flash",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0],["gemini-2.5-flash-lite",1048576,65536,"text,image",1,null,0.1,0.4,0.01,0],["gemini-2.5-flash-lite-preview-06-17",1048576,65536,"text,image",1,null,0.1,0.4,0.025,0],["gemini-2.5-flash-lite-preview-09-2025",1048576,65536,"text,image",1,null,0.1,0.4,0.025,0],["gemini-2.5-flash-preview-04-17",1048576,65536,"text,image",1,null,0.15,0.6,0.0375,0],["gemini-2.5-flash-preview-05-20",1048576,65536,"text,image",1,null,0.15,0.6,0.0375,0],["gemini-2.5-flash-preview-09-2025",1048576,65536,"text,image",1,null,0.3,2.5,0.075,0],["gemini-2.5-pro",1048576,65536,"text,image",1,null,1.25,10,0.125,0],["gemini-2.5-pro-preview-05-06",1048576,65536,"text,image",1,null,1.25,10,0.31,0],["gemini-2.5-pro-preview-06-05",1048576,65536,"text,image",1,null,1.25,10,0.31,0],["gemini-3-flash-preview",1048576,65536,"text,image",1,null,0.5,3,0.05,0],["gemini-3-pro-preview",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.1-flash-lite",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-3.1-flash-lite-image",65536,65536,"text,image",1,null,0.25,30,0,0],["gemini-3.1-flash-lite-preview",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-3.1-flash-live-preview",131072,65536,"text,image",1,null,0.75,4.5,0,0],["gemini-3.1-pro-preview",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.1-pro-preview-customtools",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.5-flash",1048576,65536,"text,image",1,null,1.5,9,0.15,0],["gemini-3.5-flash-lite",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0],["gemini-3.6-flash",1048576,65536,"text,image",1,null,1.5,7.5,0.15,0],["gemini-3.7-flash",1048576,65536,"text,image",1],["gemini-3.8-flash",1048576,65536,"text,image",1],["gemini-4-argon",1048576,65536,"text,image",1],["gemini-flash-latest",1048576,65536,"text,image",1,null,1.5,9,0.15,0],["gemini-flash-lite-latest",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-live-2.5-flash",128000,8000,"text,image",1,null,0.5,2,0,0],["gemini-live-2.5-flash-preview-native-audio",131072,65536,"text",1,null,0.5,2,0,0],["gemini-robotics-er-1.6-preview",131072,65536,"text,image",1,null,1,5,0,0],["gemma-3-27b-it",131072,8192,"text,image",0,null,0,0,0,0],["gemma-4-26b",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-26b-a4b-it",262144,32768,"text,image",1,null,0,0,0,0],["gemma-4-26b-it",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-31b",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-31b-it",262144,32768,"text,image",1,null,0,0,0,0],["gemma-4-E2B-it",131072,8192,"text,image",1,null,0,0,0,0],["gemma-4-E4B-it",131072,8192,"text,image",1,null,0,0,0,0]], + "google": [["deep-research-max-preview-04-2026",131072,65536,"text,image",1,null,2,12,0.2,0],["deep-research-preview-04-2026",131072,65536,"text,image",1,null,2,12,0.2,0],["gemini-1.5-flash",1000000,8192,"text,image",0,null,0.075,0.3,0.01875,0],["gemini-1.5-flash-8b",1000000,8192,"text,image",0,null,0.0375,0.15,0.01,0],["gemini-1.5-pro",1000000,8192,"text,image",0,null,1.25,5,0.3125,0],["gemini-2.0-flash",1048576,8192,"text,image",0,null,0.1,0.4,0.025,0],["gemini-2.0-flash-lite",1048576,8192,"text,image",0,null,0.075,0.3,0,0],["gemini-2.5-computer-use-preview-10-2025",131072,65536,"text,image",1,null,1.25,10,0,0],["gemini-2.5-flash",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0],["gemini-2.5-flash-lite",1048576,65536,"text,image",1,null,0.1,0.4,0.01,0],["gemini-2.5-flash-lite-preview-06-17",1048576,65536,"text,image",1,null,0.1,0.4,0.025,0],["gemini-2.5-flash-lite-preview-09-2025",1048576,65536,"text,image",1,null,0.1,0.4,0.025,0],["gemini-2.5-flash-preview-04-17",1048576,65536,"text,image",1,null,0.15,0.6,0.0375,0],["gemini-2.5-flash-preview-05-20",1048576,65536,"text,image",1,null,0.15,0.6,0.0375,0],["gemini-2.5-flash-preview-09-2025",1048576,65536,"text,image",1,null,0.3,2.5,0.075,0],["gemini-2.5-pro",1048576,65536,"text,image",1,null,1.25,10,0.125,0],["gemini-2.5-pro-preview-05-06",1048576,65536,"text,image",1,null,1.25,10,0.31,0],["gemini-2.5-pro-preview-06-05",1048576,65536,"text,image",1,null,1.25,10,0.31,0],["gemini-3-flash-preview",1048576,65536,"text,image",1,null,0.5,3,0.05,0],["gemini-3-pro-preview",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.1-flash-lite",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-3.1-flash-lite-image",65536,65536,"text,image",1,null,0.25,30,0,0],["gemini-3.1-flash-lite-preview",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-3.1-flash-live-preview",131072,65536,"text,image",1,null,0.75,4.5,0,0],["gemini-3.1-pro-preview",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.1-pro-preview-customtools",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.5-flash",1048576,65536,"text,image",1,null,1.5,9,0.15,0],["gemini-3.5-flash-lite",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0],["gemini-3.6-flash",1048576,65536,"text,image",1,null,1.5,7.5,0.15,0],["gemini-3.7-flash",1048576,65536,"text,image",1],["gemini-3.8-flash",1048576,65536,"text,image",1],["gemini-4-argon",1048576,65536,"text,image",1],["gemini-4-carbon",1048576,65536,"text,image",1],["gemini-flash-latest",1048576,65536,"text,image",1,null,1.5,9,0.15,0],["gemini-flash-lite-latest",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-live-2.5-flash",128000,8000,"text,image",1,null,0.5,2,0,0],["gemini-live-2.5-flash-preview-native-audio",131072,65536,"text",1,null,0.5,2,0,0],["gemini-robotics-er-1.6-preview",131072,65536,"text,image",1,null,1,5,0,0],["gemma-3-27b-it",131072,8192,"text,image",0,null,0,0,0,0],["gemma-4-26b",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-26b-a4b-it",262144,32768,"text,image",1,null,0,0,0,0],["gemma-4-26b-it",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-31b",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-31b-it",262144,32768,"text,image",1,null,0,0,0,0],["gemma-4-E2B-it",131072,8192,"text,image",1,null,0,0,0,0],["gemma-4-E4B-it",131072,8192,"text,image",1,null,0,0,0,0]], "minimax": [["MiniMax-M2",196608,128000,"text",1,null,0.3,1.2,0,0],["MiniMax-M2.1",204800,131072,"text",1,null,0.3,1.2,0,0],["MiniMax-M2.5",204800,131072,"text",1,null,0.3,1.2,0.03,0.375],["MiniMax-M2.5-highspeed",204800,131072,"text",1,null,0.6,2.4,0.06,0.375],["MiniMax-M2.5-lightning",204800,32000,"text",1,null,0.3,2.4,0,0],["MiniMax-M2.7",204800,131072,"text",1,null,0.3,1.2,0.06,0.375],["MiniMax-M2.7-highspeed",204800,131072,"text",1,null,0.6,2.4,0.06,0.375],["minimax-m3",512000,128000,"text,image",1,null,0.6,2.4,0.12,0],["MiniMax-M3",1000000,128000,"text,image,video",1,null,0.3,1.2,0.06,0],["MiniMax-M3.1-Flash",1000000,524288,"text,image,video",1,null,0.15,0.6,0.015,0]], "mistral": [["codestral-latest",256000,4096,"text",0,null,0.3,0.9,0,0],["devstral-2512",262144,262144,"text",0,null,0.4,2,0,0],["devstral-latest",262144,262144,"text",0,null,0.4,2,0,0],["devstral-medium-2507",128000,128000,"text",0,null,0.4,2,0,0],["devstral-medium-latest",262144,262144,"text",0,null,0.4,2,0,0],["devstral-small-2505",128000,128000,"text",0,null,0.1,0.3,0,0],["devstral-small-2507",128000,128000,"text",0,null,0.1,0.3,0,0],["labs-devstral-small-2512",256000,256000,"text,image",0,null,0,0,0,0],["magistral-medium-latest",128000,16384,"text",1,null,2,5,0,0],["magistral-small",128000,128000,"text",1,null,0.5,1.5,0,0],["ministral-3b-latest",128000,128000,"text",0,null,0.04,0.04,0,0],["ministral-8b-latest",128000,128000,"text",0,null,0.1,0.1,0,0],["mistral-large-2411",131072,16384,"text",0,null,2,6,0,0],["mistral-large-2512",262144,262144,"text,image",0,null,0.5,1.5,0,0],["mistral-large-latest",262144,262144,"text,image",0,null,0.5,1.5,0,0],["mistral-medium-2505",131072,131072,"text,image",0,null,0.4,2,0,0],["mistral-medium-2508",262144,262144,"text,image",0,null,0.4,2,0,0],["mistral-medium-2604",262144,262144,"text,image",1,null,1.5,7.5,0,0],["mistral-medium-latest",262144,262144,"text,image",1,null,1.5,7.5,0,0],["mistral-nemo",128000,128000,"text",0,null,0.15,0.15,0,0],["mistral-small-2506",128000,16384,"text,image",0,null,0.1,0.3,0,0],["mistral-small-2603",256000,256000,"text,image",1,null,0.15,0.6,0,0],["mistral-small-latest",256000,256000,"text,image",1,null,0.15,0.6,0,0],["open-mistral-7b",8000,8000,"text",0,null,0.25,0.25,0,0],["open-mistral-nemo",128000,128000,"text",0,null,0.15,0.15,0,0],["open-mixtral-8x22b",64000,64000,"text",0,null,2,6,0,0],["open-mixtral-8x7b",32000,32000,"text",0,null,0.7,0.7,0,0],["pixtral-12b",128000,128000,"text,image",0,null,0.15,0.15,0,0],["pixtral-large-latest",128000,128000,"text,image",0,null,2,6,0,0]], "moonshot": [["kimi-k2.5",262144,65536,"text,image",1,null,0,0,0,0]], diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 7e67e9a9651..61d11b0cbb5 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -61,6 +61,7 @@ import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys"; import { preservableApiKeyPool } from "./api-key-pool-preserve"; import { effectiveGoogleMode, getProviderRegistryEntry, mergeRegistryStaticHeaders, providerMatchesRegistryTransport } from "../providers/registry"; import { providerModelsUrl, resolveProviderModelDiscoveryUrl } from "../providers/model-discovery"; +import { buildVertexModelsDiscoveryRequest, resolveVertexDiscoveryToken } from "../providers/google-vertex-model-discovery"; import { resolveProviderTransport } from "../providers/xai-transport"; import { detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect"; import { logOAuthEvent } from "./log"; @@ -1139,6 +1140,7 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf return undefined; } } + if (effectiveGoogleMode(name, prov) === "vertex") return resolveVertexDiscoveryToken(prov); return resolveProviderApiKey(prov.apiKey); } @@ -1174,6 +1176,7 @@ export interface ModelsRequestObservedAuth { readonly oauthApiBaseUrl?: string; } +/** Build the provider-specific live model-discovery HTTP request (method, URL, and headers). */ export function buildModelsRequest( prov: OcxProviderConfig, apiKey: string | undefined, @@ -1227,11 +1230,13 @@ export function buildModelsRequest( headers, }); } + if (effectiveGoogleMode(providerName, effectiveProvider) === "vertex") { + return guarded(buildVertexModelsDiscoveryRequest(effectiveProvider, apiKey, headers, discoveryUrl)); + } if (effectiveGoogleMode(providerName, effectiveProvider) === "ai-studio") { // Generative Language API: API key goes in x-goog-api-key (never Authorization: Bearer), // models live under /v1beta (v1 misses preview models), and pageSize maxes at 1000 — - // enough to list everything without a pageToken loop. Vertex/antigravity keep the - // generic branch (they fall back to their static model lists). + // enough to list everything without a pageToken loop. if (apiKey) headers["x-goog-api-key"] = apiKey; return guarded({ url: discoveryUrl(`${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000`), headers }); } diff --git a/src/providers/google-vertex-model-discovery.ts b/src/providers/google-vertex-model-discovery.ts new file mode 100644 index 00000000000..c8ee29b58a7 --- /dev/null +++ b/src/providers/google-vertex-model-discovery.ts @@ -0,0 +1,208 @@ +import { getModelMetadata, getModelMetadataCaseInsensitive } from "../generated/model-metadata"; +import { + GCP_CREDENTIAL_MARKER_PREFIX, + gcpCredentialMarkerAccount, + getVertexAccessTokenWithAuthority, +} from "../lib/gcp-adc"; +import type { OcxProviderConfig } from "../types"; +import { resolveProviderApiKey } from "./api-key-resolve"; +import { extractGoogleAiStudioModelItems } from "./google-ai-studio-model-discovery"; +import { + extractModelEnvelopeRows, + isValidModelDiscoveryModelId, + type ProviderModelItemsResult, + type ProviderModelsApiItem, +} from "./model-discovery"; + +const VERTEX_PUBLISHER_MODEL_PREFIX = "publishers/google/models/"; +const VERTEX_GENERATIVE_MODEL_ID = /^(?:gemini|gemma)-/i; +const VERTEX_NON_CHAT_SKU = /(?:^|-)embedding(?:-|$)|-tts(?:-|$)|-transcribe(?:-|$)|(?:^|-)live(?:-|$)/i; +const VERTEX_TRAILING_API_VERSION = /\/v1(?:beta1?)?$/i; + +/** + * Whether every configured API key (or the actively selected pool key) is paused on a Vertex row. + */ +function isVertexApiKeyPoolPaused(provider: OcxProviderConfig | undefined): boolean { + if (!provider || provider.authMode === "oauth" || provider.authMode === "forward" || provider.authMode === "local") { + return false; + } + const pool = provider.apiKeyPool; + if (!pool?.length) return false; + if (pool.every(entry => entry.disabled === true)) return true; + const ref = provider._apiKeyAttempt?.reference ?? provider.apiKey; + if (!ref?.trim()) return false; + return pool.some(entry => entry.disabled === true && entry.key === ref) + && !pool.some(entry => !entry.disabled && entry.key === ref); +} + +/** + * Whether a Vertex provider row authenticates discovery with an API key rather than GCP ADC. + * + * Mirrors `resolveVertexApiKey` in `src/adapters/google.ts`: an explicit `gcp-sa:` + * marker opts into OS-store ADC and never falls back to `GOOGLE_CLOUD_API_KEY`. + */ +export function usesVertexApiKeyForDiscovery(provider: OcxProviderConfig): boolean { + if (typeof provider.apiKey === "string" && provider.apiKey.startsWith(GCP_CREDENTIAL_MARKER_PREFIX)) { + return false; + } + const resolved = resolveProviderApiKey(provider.apiKey)?.trim(); + if (resolved && resolved !== "N/A" && !resolved.startsWith("<")) { + return true; + } + return Boolean(process.env.GOOGLE_CLOUD_API_KEY?.trim()); +} + +/** + * Synchronous Vertex discovery credential snapshot for passive (`observed`) catalog gathers. + * + * ADC token exchange is async, so passive gathers without an API key return `undefined` and + * either reuse a fresh model cache or degrade cleanly without sending an unauthenticated GET. + */ +export function resolveObservedVertexDiscoveryToken(provider: OcxProviderConfig): string | undefined { + if (provider.authMode === "forward" || isVertexApiKeyPoolPaused(provider)) return undefined; + if (!usesVertexApiKeyForDiscovery(provider)) return undefined; + const resolved = resolveProviderApiKey(provider.apiKey)?.trim(); + if (resolved && resolved !== "N/A" && !resolved.startsWith("<")) return resolved; + return process.env.GOOGLE_CLOUD_API_KEY?.trim() || undefined; +} + +/** + * Resolve the Vertex model-discovery credential (API key or GCP ADC access token). + * + * Paused key pools never fall back to `GOOGLE_CLOUD_API_KEY` or ambient host ADC. + */ +export async function resolveVertexDiscoveryToken(provider: OcxProviderConfig): Promise { + if (provider.authMode === "forward" || isVertexApiKeyPoolPaused(provider)) return undefined; + if (usesVertexApiKeyForDiscovery(provider)) { + return resolveObservedVertexDiscoveryToken(provider); + } + try { + const markerAccount = gcpCredentialMarkerAccount(provider.apiKey); + const { token } = await getVertexAccessTokenWithAuthority( + markerAccount ? { markerAccount } : undefined, + ); + return token; + } catch { + return undefined; + } +} + +/** + * Build the Vertex AI Publisher Models discovery request (`GET /v1beta1/publishers/google/models`). + * + * URL construction is strictly independent of `apiKey` so `captureModelsRequest` sentinel checks + * remain invariant across `undefined` and `REQUEST_CREDENTIAL_SENTINEL`. + */ +export function buildVertexModelsDiscoveryRequest( + effectiveProvider: OcxProviderConfig, + apiKey: string | undefined, + headers: Record, + resolveUrl: (defaultUrl: string) => string, +): { url: string; headers: Record } { + const base = effectiveProvider.baseUrl.replace(/\/+$/, "").replace(VERTEX_TRAILING_API_VERSION, ""); + if (usesVertexApiKeyForDiscovery(effectiveProvider)) { + if (apiKey) headers["x-goog-api-key"] = apiKey; + } else { + if (apiKey) headers.Authorization = `Bearer ${apiKey}`; + const project = effectiveProvider.project?.trim() + || process.env.GOOGLE_CLOUD_PROJECT?.trim() + || process.env.GCLOUD_PROJECT?.trim(); + if (project && project.toLowerCase() !== "auto") { + headers["x-goog-user-project"] = project; + } + } + // Vertex ModelGardenService.ListPublisherModels enforces a maximum pageSize of 300 + // (values > 300 return HTTP 400 INVALID_ARGUMENT). + return { + url: resolveUrl(`${base}/v1beta1/publishers/google/models?pageSize=300`), + headers, + }; +} + +/** + * Supported reasoning effort levels for known Vertex AI Gemini model IDs. + */ +function vertexGeminiReasoningEfforts(modelId: string): readonly string[] | undefined { + const lower = modelId.toLowerCase(); + if (lower.includes("image")) return undefined; + if ( + lower === "gemini-4-carbon" + || lower === "gemini-4-argon" + || lower === "gemini-3.8-flash" + || lower === "gemini-3.7-flash" + || lower.startsWith("gemini-3.1-pro") + || lower === "gemini-3-pro" + || lower === "gemini-3-pro-preview" + ) { + return ["low", "medium", "high"]; + } + if (lower === "gemini-3.6-flash" || lower === "gemini-3.5-flash") { + return ["minimal", "low", "medium", "high"]; + } + return undefined; +} + +/** + * Extracts and normalizes generative Gemini/Gemma model items from a Vertex AI + * `/v1beta1/publishers/google/models` response payload (`{ publisherModels: [...] }`). + * + * Strips the `publishers/google/models/` prefix, filters out non-`generateContent` SKUs + * (embeddings, TTS, transcription, Live API, Imagen/Veo), and enriches known Gemini rows + * with vendored token/modality/reasoning metadata. + */ +export function extractGoogleVertexModelItems( + value: unknown, + maxModels: number, +): ProviderModelItemsResult { + const envelope = extractModelEnvelopeRows(value, maxModels, ["publisherModels"]); + if (!envelope.ok) return envelope; + + const items: ProviderModelsApiItem[] = []; + const seen = new Set(); + for (const raw of envelope.rows) { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + continue; + } + const name = Reflect.get(raw, "name"); + if (!isValidModelDiscoveryModelId(name)) continue; + const id = name.startsWith(VERTEX_PUBLISHER_MODEL_PREFIX) + ? name.slice(VERTEX_PUBLISHER_MODEL_PREFIX.length) + : name; + if (!isValidModelDiscoveryModelId(id) || seen.has(id)) continue; + if (!VERTEX_GENERATIVE_MODEL_ID.test(id) || VERTEX_NON_CHAT_SKU.test(id)) continue; + seen.add(id); + + const meta = getModelMetadata("google", id) ?? getModelMetadataCaseInsensitive("google", id); + const efforts = vertexGeminiReasoningEfforts(id); + items.push({ + id, + owned_by: "google", + ...(meta?.contextWindow !== undefined + ? { context_length: meta.contextWindow, max_input_tokens: meta.contextWindow } + : {}), + ...(meta?.maxTokens !== undefined ? { max_output_tokens: meta.maxTokens } : {}), + ...(meta?.input?.length ? { input_modalities: [...meta.input] } : {}), + ...(efforts ? { reasoning_efforts: [...efforts] } : {}), + }); + } + return { ok: true, items, rawCount: envelope.rows.length }; +} + +/** + * Dispatch native Google model-list extraction for AI Studio (`models[]`) or Vertex AI + * (`publisherModels[]`), returning `undefined` when the payload uses an OpenAI-compatible + * gateway envelope (`data[]` or top-level array) so callers fall back cleanly. + */ +export function extractGoogleNativeModelItems( + googleMode: "ai-studio" | "vertex" | "cloud-code-assist" | null, + value: unknown, + maxModels: number, +): ProviderModelItemsResult | undefined { + const result = googleMode === "ai-studio" + ? extractGoogleAiStudioModelItems(value, maxModels) + : googleMode === "vertex" + ? extractGoogleVertexModelItems(value, maxModels) + : undefined; + if (!result) return undefined; + return result.ok || result.reason === "too_many_models" ? result : undefined; +} diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index 7e6cbe430fb..47251008a56 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -1214,14 +1214,15 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ { id: "google", label: "Google Gemini", adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", authKind: "key", featured: true, dashboardUrl: "https://aistudio.google.com/apikey", defaultModel: "gemini-3.5-flash", models: ["gemini-4-argon", "gemini-3.8-flash", "gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview", "gemini-3.7-flash"], - modelContextWindows: { "gemini-4-argon": 1_048_576, "gemini-3.8-flash": 1_048_576, "gemini-3.6-flash": 1_048_576, "gemini-3.5-flash": 1_000_000, "gemini-3.5-flash-lite": 1_048_576, "gemini-3.7-flash": 1_048_576 }, - modelInputModalities: { "gemini-4-argon": ["text", "image"], "gemini-3.8-flash": ["text", "image"], "gemini-3.6-flash": ["text", "image"], "gemini-3.5-flash-lite": ["text", "image"], "gemini-3.7-flash": ["text", "image"] }, + modelContextWindows: { "gemini-4-carbon": 1_048_576, "gemini-4-argon": 1_048_576, "gemini-3.8-flash": 1_048_576, "gemini-3.6-flash": 1_048_576, "gemini-3.5-flash": 1_000_000, "gemini-3.5-flash-lite": 1_048_576, "gemini-3.7-flash": 1_048_576 }, + modelInputModalities: { "gemini-4-carbon": ["text", "image"], "gemini-4-argon": ["text", "image"], "gemini-3.8-flash": ["text", "image"], "gemini-3.6-flash": ["text", "image"], "gemini-3.5-flash-lite": ["text", "image"], "gemini-3.7-flash": ["text", "image"] }, modelReasoningEfforts: { // 3.7 and 3.8 omit `minimal`: Google documents it as a validation error on both model // pages, so advertising it hands the user a rung the API rejects. 3.5/3.6 keep theirs — // their pages still list it, and this unit has no evidence to change them. // Gemini 4 Argon (GA 2026-10-13): low/medium(default)/high; `minimal` and thinking_budget // return HTTP 400 per ai.google.dev/gemini-api/docs/models/gemini-4-argon. + "gemini-4-carbon": ["low", "medium", "high"], "gemini-4-argon": ["low", "medium", "high"], "gemini-3.8-flash": ["low", "medium", "high"], "gemini-3.7-flash": ["low", "medium", "high"], @@ -1233,7 +1234,13 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ }, // 2026-07-10: defaultModel is frozen pending Vertex-specific Tier-2 evidence; Gemini API // evidence from ai.google.dev does not establish Vertex publisher availability. - { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] }, + { + id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"], + modelDiscovery: { path: "/v1beta1/publishers/google/models", query: { pageSize: "300" } }, + modelContextWindows: { "gemini-4-carbon": 1_048_576, "gemini-4-argon": 1_048_576, "gemini-3.8-flash": 1_048_576, "gemini-3.6-flash": 1_048_576, "gemini-3.5-flash": 1_048_576, "gemini-3.5-flash-lite": 1_048_576 }, + modelInputModalities: { "gemini-4-carbon": ["text", "image"], "gemini-4-argon": ["text", "image"], "gemini-3.8-flash": ["text", "image"], "gemini-3.6-flash": ["text", "image"], "gemini-3.5-flash": ["text", "image"], "gemini-3.5-flash-lite": ["text", "image"] }, + modelReasoningEfforts: { "gemini-4-carbon": ["low", "medium", "high"], "gemini-4-argon": ["low", "medium", "high"], "gemini-3.8-flash": ["low", "medium", "high"], "gemini-3.7-flash": ["low", "medium", "high"], "gemini-3.6-flash": ["minimal", "low", "medium", "high"], "gemini-3.5-flash": ["minimal", "low", "medium", "high"], "gemini-3.1-pro-preview": ["low", "medium", "high"] }, + }, // Antigravity discovers models with a POST to the CCA `:fetchAvailableModels` RPC, which // `buildModelsRequest` already built by hand. Declaring it here changes no request URL — the // relative path resolves to the same destination — but it lets `isRegistryModelDiscoveryUrl` diff --git a/src/providers/routed-config.ts b/src/providers/routed-config.ts index 9a434844fb2..42069637d22 100644 --- a/src/providers/routed-config.ts +++ b/src/providers/routed-config.ts @@ -131,6 +131,7 @@ function snapshotVertexEnvironment(provider: OcxProviderConfig): OcxProviderConf }; } +/** Resolve registry defaults, destination capability maps, and credential snapshots for a provider row. */ export function routedProviderConfig(providerName: string, provider: OcxProviderConfig): OcxProviderConfig { provider = { ...provider, _apiKeyAttempt: provider._apiKeyAttempt ?? captureProviderApiKeySelection(provider) }; const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName); @@ -156,6 +157,9 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider ...provider, ...(inputModalities ? { inputModalities } : {}), apiKey: usableResolvedApiKey(provider.apiKey), + ...(destination?.modelReasoningEfforts ? { + modelReasoningEfforts: mapFill(destination.modelReasoningEfforts, provider.modelReasoningEfforts)[0], + } : {}), ...(destination?.modelDefaultReasoningEfforts ? { modelDefaultReasoningEfforts: mapFill(destination.modelDefaultReasoningEfforts, provider.modelDefaultReasoningEfforts)[0], } : {}), diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 6df092de541..089ba5b69ec 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -71,7 +71,7 @@ import { readBoundedDiscoveryJson, resolveProviderModelDiscovery, } from "../../providers/model-discovery"; -import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery"; +import { extractGoogleNativeModelItems } from "../../providers/google-vertex-model-discovery"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { getCachedProviderRoutingQuota } from "../../providers/quota-routing-cache"; @@ -897,6 +897,7 @@ function providerRoutingQuota(config: OcxConfig, name: string, now: number): Pro return { state, updatedAt: quota.updatedAt, validUntil }; } +/** Handle management API endpoints for provider CRUD, connectivity probes, and quota inspection. */ export async function handleProviderRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, principal, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; @@ -1671,7 +1672,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise undefined) : undefined; const apiKey = prov.authMode === "oauth" ? snapshot?.accessToken : await resolveModelsAuthToken(name, prov); - if (prov.authMode === "oauth" && !apiKey) { + if ((prov.authMode === "oauth" || effectiveGoogleMode(name, prov) === "vertex") && !apiKey) { return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified (not logged in)" }); } if (prov.adapter === "zed") return (await import("./zed-provider-probe")).probeZedProvider(prov, apiKey, snapshot?.providerUserId); @@ -1787,17 +1788,15 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise : undefined; - const isAiStudio = effectiveGoogleMode(name, prov) === "ai-studio"; - const googleAiStudio = !ccaModels && isAiStudio - ? extractGoogleAiStudioModelItems(bounded.value, discovery.maxModels) + const googleNative = !ccaModels + ? extractGoogleNativeModelItems(effectiveGoogleMode(name, prov), bounded.value, discovery.maxModels) : undefined; const extracted = ccaModels ? undefined - : googleAiStudio?.ok - ? googleAiStudio - : Array.isArray(bounded.value) || Array.isArray(record?.data) - ? extractProviderModelItems(bounded.value, discovery) - : extractModelEnvelopeRows(bounded.value, discovery.maxModels, ["models"]); + : googleNative + ?? (Array.isArray(bounded.value) || Array.isArray(record?.data) + ? extractProviderModelItems(bounded.value, discovery) + : extractModelEnvelopeRows(bounded.value, discovery.maxModels, ["models"])); if (extracted && !extracted.ok) { return jsonResponse({ ok: false, diff --git a/tests/adapters/google/google-models-listing.test.ts b/tests/adapters/google/google-models-listing.test.ts index 5b6f9b3c1b7..dfea199222e 100644 --- a/tests/adapters/google/google-models-listing.test.ts +++ b/tests/adapters/google/google-models-listing.test.ts @@ -59,9 +59,10 @@ describe("buildModelsRequest google routing", () => { }); test("google-vertex without googleMode resolves vertex via registry, not ai-studio", () => { - const prov = { adapter: "google", authMode: "key", baseUrl: "https://aiplatform.googleapis.com" } as OcxProviderConfig; - const { url } = buildModelsRequest(prov, "gk-123", "google-vertex"); - expect(url).toBe("https://aiplatform.googleapis.com/models"); + const prov = { adapter: "google", authMode: "key", apiKey: "gk-123", baseUrl: "https://aiplatform.googleapis.com" } as OcxProviderConfig; + const { url, headers } = buildModelsRequest(prov, "gk-123", "google-vertex"); + expect(url).toBe("https://aiplatform.googleapis.com/v1beta1/publishers/google/models?pageSize=300"); + expect(headers["x-goog-api-key"]).toBe("gk-123"); }); }); diff --git a/tests/adapters/google/vertex-catalog.test.ts b/tests/adapters/google/vertex-catalog.test.ts index 8136aa89296..2524c4bc626 100644 --- a/tests/adapters/google/vertex-catalog.test.ts +++ b/tests/adapters/google/vertex-catalog.test.ts @@ -208,4 +208,60 @@ describe("Vertex catalog configuration", () => { 'Provider model discovery for "vertex-throw" threw TypeError [urlClass=vertex-aiplatform, fallback=configured]', ); }); + + test("discovers Vertex publisherModels including gemini-4-carbon with full capabilities and filters non-chat SKUs", async () => { + let seenUrl = ""; + let seenApiKeyHeader = ""; + globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => { + seenUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const headers = new Headers(init?.headers); + seenApiKeyHeader = headers.get("x-goog-api-key") ?? ""; + return Promise.resolve(Response.json({ + publisherModels: [ + { name: "publishers/google/models/gemini-4-carbon", versionId: "default", launchStage: "GA" }, + { name: "publishers/google/models/gemini-4-argon", versionId: "default", launchStage: "GA" }, + { name: "publishers/google/models/gemini-embedding-2", versionId: "default", launchStage: "GA" }, + { name: "publishers/google/models/gemini-2.5-flash-tts", versionId: "default", launchStage: "GA" }, + { name: "publishers/google/models/gemini-live-2.5-flash", versionId: "default", launchStage: "GA" }, + { name: "publishers/google/models/imagen-4.0-generate-001", versionId: "001", launchStage: "GA" }, + ], + })); + }) as typeof fetch; + + const config: OcxConfig = { + providers: { + google: { + adapter: "google", + googleMode: "vertex", + baseUrl: "https://aiplatform.googleapis.com", + project: "demo-vertex-project", + location: "global", + apiKey: "vertex-api-key", + liveModels: true, + }, + }, + }; + const rows = await gatherRoutedModels(config); + expect(seenUrl).toBe("https://aiplatform.googleapis.com/v1beta1/publishers/google/models?pageSize=300"); + expect(seenApiKeyHeader).toBe("vertex-api-key"); + + const carbon = rows.find(row => row.provider === "google" && row.id === "gemini-4-carbon"); + expect(carbon).toMatchObject({ + provider: "google", + id: "gemini-4-carbon", + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high"], + }); + expect(rows.some(row => row.id === "gemini-4-argon")).toBe(true); + for (const excluded of [ + "gemini-embedding-2", + "gemini-2.5-flash-tts", + "gemini-live-2.5-flash", + "imagen-4.0-generate-001", + ]) { + expect(rows.some(row => row.id === excluded)).toBe(false); + } + }); }); From 76b52322274b524b20a3c53234f64619ae5f4eef Mon Sep 17 00:00:00 2001 From: xdober Date: Thu, 12 Nov 2026 13:23:29 +0800 Subject: [PATCH 02/11] refactor(google-vertex): decouple Vertex model discovery from src/oauth Move Vertex model-discovery auth and request construction out of `src/oauth/index.ts` into `src/providers/google-vertex-model-discovery.ts` (`buildVertexProviderModelsRequest`) and wire the catalog/management discovery call sites directly (`src/codex/catalog/gather-capture.ts`, `src/codex/catalog/provider-models.ts`, and `src/server/management/provider-routes.ts`), keeping the OAuth subsystem untouched. --- src/codex/catalog/gather-capture.ts | 8 +++-- src/codex/catalog/provider-models.ts | 13 +++++-- src/oauth/index.ts | 9 ++--- .../google-vertex-model-discovery.ts | 35 +++++++++++++++++++ src/server/management/provider-routes.ts | 17 ++++++--- .../google/google-models-listing.test.ts | 5 ++- 6 files changed, 68 insertions(+), 19 deletions(-) diff --git a/src/codex/catalog/gather-capture.ts b/src/codex/catalog/gather-capture.ts index fd6d5e600a6..db72468f847 100644 --- a/src/codex/catalog/gather-capture.ts +++ b/src/codex/catalog/gather-capture.ts @@ -33,6 +33,7 @@ import { } from "../../oauth"; import { getAccountSet } from "../../oauth/store"; import { mayResolveModelsOAuth } from "../../oauth/model-discovery-auth"; +import { buildVertexProviderModelsRequest } from "../../providers/google-vertex-model-discovery"; import type { OcxConfig, OcxProviderConfig } from "../../types"; import { modelInList } from "../../types"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; @@ -332,6 +333,7 @@ export function captureTrustedOpenAiApiPolicy( }); } +/** Capture credential-free and sentinel-bearing model discovery requests for flight deduplication. */ export function captureModelsRequest( name: string, provider: OcxProviderConfig, @@ -339,8 +341,10 @@ export function captureModelsRequest( workbuddy?: import("../../oauth/types").WorkBuddyOAuthMetadata, ): CapturedModelsRequest { const observed = { oauthApiBaseUrl, workbuddy }; - const withoutCredential = buildModelsRequest(provider, undefined, name, observed); - const withCredential = buildModelsRequest(provider, REQUEST_CREDENTIAL_SENTINEL, name, observed); + const withoutCredential = buildVertexProviderModelsRequest(name, provider, undefined) + ?? buildModelsRequest(provider, undefined, name, observed); + const withCredential = buildVertexProviderModelsRequest(name, provider, REQUEST_CREDENTIAL_SENTINEL) + ?? buildModelsRequest(provider, REQUEST_CREDENTIAL_SENTINEL, name, observed); const method = withoutCredential.method ?? "GET"; if (withoutCredential.url !== withCredential.url || method !== (withCredential.method ?? "GET")) { throw new TypeError(`Provider model discovery URL for ${name} depends on credential bytes.`); diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index 5237b1646e8..7fc5306ef40 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -100,7 +100,11 @@ import { type ProviderModelsApiItem, type ResolvedProviderModelDiscovery, } from "../../providers/model-discovery"; -import { extractGoogleNativeModelItems, resolveObservedVertexDiscoveryToken } from "../../providers/google-vertex-model-discovery"; +import { + extractGoogleNativeModelItems, + resolveObservedVertexDiscoveryToken, + resolveVertexDiscoveryToken, +} from "../../providers/google-vertex-model-discovery"; import { applyConfiguredHeadersLast, fetchOllamaShowEnrichment, ollamaShowEnrichable } from "../../providers/ollama-show"; import upstreamModelsSnapshot from "../data/upstream-models.json"; import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission"; @@ -296,7 +300,12 @@ export async function fetchProviderModelsWithAuth( ...(snapshot.projectId ? { oauthProjectId: snapshot.projectId } : {}), })) .catch(() => ({ apiKey: undefined, observed: false })) - : { apiKey: await resolveModelsAuthToken(name, prov), observed: false } + : { + apiKey: effectiveGoogleMode(name, prov) === "vertex" + ? await resolveVertexDiscoveryToken(prov) + : await resolveModelsAuthToken(name, prov), + observed: false, + } : resolveAuth.resolve(name, prov)); const apiKey = auth.apiKey; if ((prov.authMode === "oauth" && !apiKey) || isProviderApiKeyPaused(prov)) { diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 61d11b0cbb5..7e67e9a9651 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -61,7 +61,6 @@ import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys"; import { preservableApiKeyPool } from "./api-key-pool-preserve"; import { effectiveGoogleMode, getProviderRegistryEntry, mergeRegistryStaticHeaders, providerMatchesRegistryTransport } from "../providers/registry"; import { providerModelsUrl, resolveProviderModelDiscoveryUrl } from "../providers/model-discovery"; -import { buildVertexModelsDiscoveryRequest, resolveVertexDiscoveryToken } from "../providers/google-vertex-model-discovery"; import { resolveProviderTransport } from "../providers/xai-transport"; import { detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect"; import { logOAuthEvent } from "./log"; @@ -1140,7 +1139,6 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf return undefined; } } - if (effectiveGoogleMode(name, prov) === "vertex") return resolveVertexDiscoveryToken(prov); return resolveProviderApiKey(prov.apiKey); } @@ -1176,7 +1174,6 @@ export interface ModelsRequestObservedAuth { readonly oauthApiBaseUrl?: string; } -/** Build the provider-specific live model-discovery HTTP request (method, URL, and headers). */ export function buildModelsRequest( prov: OcxProviderConfig, apiKey: string | undefined, @@ -1230,13 +1227,11 @@ export function buildModelsRequest( headers, }); } - if (effectiveGoogleMode(providerName, effectiveProvider) === "vertex") { - return guarded(buildVertexModelsDiscoveryRequest(effectiveProvider, apiKey, headers, discoveryUrl)); - } if (effectiveGoogleMode(providerName, effectiveProvider) === "ai-studio") { // Generative Language API: API key goes in x-goog-api-key (never Authorization: Bearer), // models live under /v1beta (v1 misses preview models), and pageSize maxes at 1000 — - // enough to list everything without a pageToken loop. + // enough to list everything without a pageToken loop. Vertex/antigravity keep the + // generic branch (they fall back to their static model lists). if (apiKey) headers["x-goog-api-key"] = apiKey; return guarded({ url: discoveryUrl(`${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000`), headers }); } diff --git a/src/providers/google-vertex-model-discovery.ts b/src/providers/google-vertex-model-discovery.ts index c8ee29b58a7..77d89336343 100644 --- a/src/providers/google-vertex-model-discovery.ts +++ b/src/providers/google-vertex-model-discovery.ts @@ -10,9 +10,17 @@ import { extractGoogleAiStudioModelItems } from "./google-ai-studio-model-discov import { extractModelEnvelopeRows, isValidModelDiscoveryModelId, + resolveProviderModelDiscoveryUrl, type ProviderModelItemsResult, type ProviderModelsApiItem, } from "./model-discovery"; +import { + effectiveGoogleMode, + getProviderRegistryEntry, + mergeRegistryStaticHeaders, + providerMatchesRegistryTransport, +} from "./registry"; +import { resolveProviderTransport } from "./xai-transport"; const VERTEX_PUBLISHER_MODEL_PREFIX = "publishers/google/models/"; const VERTEX_GENERATIVE_MODEL_ID = /^(?:gemini|gemma)-/i; @@ -119,6 +127,33 @@ export function buildVertexModelsDiscoveryRequest( }; } +/** + * Build a Vertex AI Publisher Models request when the provider row resolves to non-OAuth `vertex` mode, + * or return `undefined` so callers delegate to the default `buildModelsRequest` builder. + */ +export function buildVertexProviderModelsRequest( + providerName: string, + prov: OcxProviderConfig, + apiKey: string | undefined, +): { method?: "POST"; url: string; headers: Record } | undefined { + if (prov.authMode === "oauth") return undefined; + const effectiveProvider = resolveProviderTransport(providerName, prov); + if (effectiveGoogleMode(providerName, effectiveProvider) !== "vertex") return undefined; + const registryStaticHeaders = providerMatchesRegistryTransport(providerName, effectiveProvider) + ? getProviderRegistryEntry(providerName)?.staticHeaders + : undefined; + const headers: Record = { + ...(mergeRegistryStaticHeaders(registryStaticHeaders, effectiveProvider.headers) ?? {}), + }; + const resolveUrl = (defaultUrl: string): string => resolveProviderModelDiscoveryUrl( + providerName, + prov, + effectiveProvider.baseUrl, + defaultUrl, + ); + return buildVertexModelsDiscoveryRequest(effectiveProvider, apiKey, headers, resolveUrl); +} + /** * Supported reasoning effort levels for known Vertex AI Gemini model IDs. */ diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 089ba5b69ec..5f95a2abe12 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -71,7 +71,11 @@ import { readBoundedDiscoveryJson, resolveProviderModelDiscovery, } from "../../providers/model-discovery"; -import { extractGoogleNativeModelItems } from "../../providers/google-vertex-model-discovery"; +import { + buildVertexProviderModelsRequest, + extractGoogleNativeModelItems, + resolveVertexDiscoveryToken, +} from "../../providers/google-vertex-model-discovery"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { getCachedProviderRoutingQuota } from "../../providers/quota-routing-cache"; @@ -1671,7 +1675,11 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise undefined) : undefined; - const apiKey = prov.authMode === "oauth" ? snapshot?.accessToken : await resolveModelsAuthToken(name, prov); + const apiKey = prov.authMode === "oauth" + ? snapshot?.accessToken + : effectiveGoogleMode(name, prov) === "vertex" + ? await resolveVertexDiscoveryToken(prov) + : await resolveModelsAuthToken(name, prov); if ((prov.authMode === "oauth" || effectiveGoogleMode(name, prov) === "vertex") && !apiKey) { return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified (not logged in)" }); } @@ -1728,9 +1736,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { }); test("google-vertex without googleMode resolves vertex via registry, not ai-studio", () => { - const prov = { adapter: "google", authMode: "key", apiKey: "gk-123", baseUrl: "https://aiplatform.googleapis.com" } as OcxProviderConfig; - const { url, headers } = buildModelsRequest(prov, "gk-123", "google-vertex"); + const prov = { adapter: "google", authMode: "key", baseUrl: "https://aiplatform.googleapis.com" } as OcxProviderConfig; + const { url } = buildModelsRequest(prov, "gk-123", "google-vertex"); expect(url).toBe("https://aiplatform.googleapis.com/v1beta1/publishers/google/models?pageSize=300"); - expect(headers["x-goog-api-key"]).toBe("gk-123"); }); }); From 9087c228342947318b255bc75e472aa25e1fcefc Mon Sep 17 00:00:00 2001 From: xdober Date: Fri, 13 Nov 2026 09:55:36 +0800 Subject: [PATCH 03/11] feat(google-vertex): add gemini-4-hydrogen support and xhigh thinking tier --- scripts/model-metadata.source.json | 19 +++++++++++++++++++ src/adapters/google-wire-compiler.ts | 4 ++-- src/adapters/google.ts | 11 +++++++++-- src/generated/model-metadata.ts | 2 +- .../google-vertex-model-discovery.ts | 3 +++ src/providers/registry/entries-core.ts | 13 ++++++++----- .../google/google-wire-compiler.test.ts | 6 ++++++ tests/adapters/google/vertex-catalog.test.ts | 11 +++++++++++ 8 files changed, 59 insertions(+), 10 deletions(-) diff --git a/scripts/model-metadata.source.json b/scripts/model-metadata.source.json index 8c120eb5611..02309daf532 100644 --- a/scripts/model-metadata.source.json +++ b/scripts/model-metadata.source.json @@ -13309,6 +13309,25 @@ "maxLevel": "high" } }, + "gemini-4-hydrogen": { + "id": "gemini-4-hydrogen", + "name": "Gemini 4 Hydrogen", + "api": "google-generative-ai", + "provider": "google", + "baseUrl": "https://generativelanguage.googleapis.com/v1beta", + "reasoning": true, + "input": [ + "text", + "image" + ], + "contextWindow": 1048576, + "maxTokens": 65536, + "thinking": { + "mode": "google-level", + "minLevel": "low", + "maxLevel": "xhigh" + } + }, "gemini-flash-latest": { "id": "gemini-flash-latest", "name": "Gemini Flash Latest", diff --git a/src/adapters/google-wire-compiler.ts b/src/adapters/google-wire-compiler.ts index ba10a0a8ec4..5f4ccd68fcc 100644 --- a/src/adapters/google-wire-compiler.ts +++ b/src/adapters/google-wire-compiler.ts @@ -12,7 +12,7 @@ import { type JsonObject = Record; const GOOGLE_TOOL_NAME = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/; -const GOOGLE_THINKING_LEVELS = new Set(["minimal", "low", "medium", "high"]); +const GOOGLE_THINKING_LEVELS = new Set(["minimal", "low", "medium", "high", "xhigh"]); export class GoogleToolSchemaPolicyError extends Error { readonly phase = "initial" as const; @@ -169,7 +169,7 @@ function compileGenerationConfig(value: unknown): JsonObject | undefined { const raw = value.thinkingConfig.thinkingLevel.toLowerCase(); const thinkingLevel = GOOGLE_THINKING_LEVELS.has(raw) ? raw - : (["xhigh", "max", "ultra"].includes(raw) ? "high" : undefined); + : (["max", "ultra"].includes(raw) ? "high" : undefined); if (thinkingLevel) thinking.thinkingLevel = thinkingLevel; } const rawBudget = finiteNumber(value.thinkingConfig.thinkingBudget ?? value.thinkingConfig.thinking_budget); diff --git a/src/adapters/google.ts b/src/adapters/google.ts index c2321fa2197..8b7566af479 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -1072,7 +1072,11 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte identityModelId, stripRejectedClaudeSdkParagraph, isCloudCodeAssist, - provider.googleMode === "vertex" && (parsed.modelId === "gemini-4-argon" || parsed.modelId === "gemini-4-carbon"), + provider.googleMode === "vertex" && ( + parsed.modelId === "gemini-4-argon" + || parsed.modelId === "gemini-4-carbon" + || parsed.modelId === "gemini-4-hydrogen" + ), isCloudCodeAssist && /^claude-/i.test(routedModelId), ); lastInjectedCallIds = [...replayedCallIds]; @@ -1100,9 +1104,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // current behavior; Vertex participates only through an explicitly configured ladder (the // seed google-vertex entry ships none). Image models are excluded — thinkingConfig would // suppress the responseModalities fallback below. CCA maps effort on its envelope path. - const thinkingLevel = thinkingEligible + const rawThinkingLevel = thinkingEligible ? mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning) : undefined; + const thinkingLevel = rawThinkingLevel === "xhigh" && configuredEfforts === undefined + ? "high" + : rawThinkingLevel; const includeThoughts = thinkingEligible && provider.showThinkingSummary === true && parsed.options.hideThinkingSummary !== true diff --git a/src/generated/model-metadata.ts b/src/generated/model-metadata.ts index 5618fa464f4..dda9c21fc87 100644 --- a/src/generated/model-metadata.ts +++ b/src/generated/model-metadata.ts @@ -47,7 +47,7 @@ const DATA: Record = { "azure-openai": [["gpt-4.1",1047576,32768,"text,image",0,null,2,8,0.5,0],["gpt-4o",128000,16384,"text,image",0,null,2.5,10,1.25,0],["gpt-4o-mini",128000,16384,"text,image",0,null,0.15,0.6,0.075,0],["o3",200000,100000,"text,image",1,null,2,8,0.5,0],["o3-mini",200000,100000,"text",1,null,1.1,4.4,0.55,0]], "cerebras": [["gemma-4-31b",131072,40960,"text,image",1,null,0.99,1.49,0,0],["gpt-oss-120b",131072,40960,"text",1,null,0.35,0.75,0,0],["llama3.1-8b",32000,8000,"text",0,null,0.1,0.1,0,0],["qwen-3-235b-a22b-instruct-2507",131000,32000,"text",0,null,0.6,1.2,0,0],["qwen-3-coder-480b",131072,32768,"text",0,null,0,0,0,0],["zai-glm-4.6",131072,32768,"text",0,null,0,0,0,0],["zai-glm-4.7",131072,40960,"text",1,null,2.25,2.75,2.25,0]], "deepseek": [["deepseek-v4-flash",1048576,384000,"text",1,null,0.14,0.28,0.0028,0],["deepseek-v4-pro",1048576,384000,"text",1,null,0.435,0.87,0.003625,0]], - "google": [["deep-research-max-preview-04-2026",131072,65536,"text,image",1,null,2,12,0.2,0],["deep-research-preview-04-2026",131072,65536,"text,image",1,null,2,12,0.2,0],["gemini-1.5-flash",1000000,8192,"text,image",0,null,0.075,0.3,0.01875,0],["gemini-1.5-flash-8b",1000000,8192,"text,image",0,null,0.0375,0.15,0.01,0],["gemini-1.5-pro",1000000,8192,"text,image",0,null,1.25,5,0.3125,0],["gemini-2.0-flash",1048576,8192,"text,image",0,null,0.1,0.4,0.025,0],["gemini-2.0-flash-lite",1048576,8192,"text,image",0,null,0.075,0.3,0,0],["gemini-2.5-computer-use-preview-10-2025",131072,65536,"text,image",1,null,1.25,10,0,0],["gemini-2.5-flash",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0],["gemini-2.5-flash-lite",1048576,65536,"text,image",1,null,0.1,0.4,0.01,0],["gemini-2.5-flash-lite-preview-06-17",1048576,65536,"text,image",1,null,0.1,0.4,0.025,0],["gemini-2.5-flash-lite-preview-09-2025",1048576,65536,"text,image",1,null,0.1,0.4,0.025,0],["gemini-2.5-flash-preview-04-17",1048576,65536,"text,image",1,null,0.15,0.6,0.0375,0],["gemini-2.5-flash-preview-05-20",1048576,65536,"text,image",1,null,0.15,0.6,0.0375,0],["gemini-2.5-flash-preview-09-2025",1048576,65536,"text,image",1,null,0.3,2.5,0.075,0],["gemini-2.5-pro",1048576,65536,"text,image",1,null,1.25,10,0.125,0],["gemini-2.5-pro-preview-05-06",1048576,65536,"text,image",1,null,1.25,10,0.31,0],["gemini-2.5-pro-preview-06-05",1048576,65536,"text,image",1,null,1.25,10,0.31,0],["gemini-3-flash-preview",1048576,65536,"text,image",1,null,0.5,3,0.05,0],["gemini-3-pro-preview",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.1-flash-lite",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-3.1-flash-lite-image",65536,65536,"text,image",1,null,0.25,30,0,0],["gemini-3.1-flash-lite-preview",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-3.1-flash-live-preview",131072,65536,"text,image",1,null,0.75,4.5,0,0],["gemini-3.1-pro-preview",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.1-pro-preview-customtools",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.5-flash",1048576,65536,"text,image",1,null,1.5,9,0.15,0],["gemini-3.5-flash-lite",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0],["gemini-3.6-flash",1048576,65536,"text,image",1,null,1.5,7.5,0.15,0],["gemini-3.7-flash",1048576,65536,"text,image",1],["gemini-3.8-flash",1048576,65536,"text,image",1],["gemini-4-argon",1048576,65536,"text,image",1],["gemini-4-carbon",1048576,65536,"text,image",1],["gemini-flash-latest",1048576,65536,"text,image",1,null,1.5,9,0.15,0],["gemini-flash-lite-latest",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-live-2.5-flash",128000,8000,"text,image",1,null,0.5,2,0,0],["gemini-live-2.5-flash-preview-native-audio",131072,65536,"text",1,null,0.5,2,0,0],["gemini-robotics-er-1.6-preview",131072,65536,"text,image",1,null,1,5,0,0],["gemma-3-27b-it",131072,8192,"text,image",0,null,0,0,0,0],["gemma-4-26b",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-26b-a4b-it",262144,32768,"text,image",1,null,0,0,0,0],["gemma-4-26b-it",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-31b",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-31b-it",262144,32768,"text,image",1,null,0,0,0,0],["gemma-4-E2B-it",131072,8192,"text,image",1,null,0,0,0,0],["gemma-4-E4B-it",131072,8192,"text,image",1,null,0,0,0,0]], + "google": [["deep-research-max-preview-04-2026",131072,65536,"text,image",1,null,2,12,0.2,0],["deep-research-preview-04-2026",131072,65536,"text,image",1,null,2,12,0.2,0],["gemini-1.5-flash",1000000,8192,"text,image",0,null,0.075,0.3,0.01875,0],["gemini-1.5-flash-8b",1000000,8192,"text,image",0,null,0.0375,0.15,0.01,0],["gemini-1.5-pro",1000000,8192,"text,image",0,null,1.25,5,0.3125,0],["gemini-2.0-flash",1048576,8192,"text,image",0,null,0.1,0.4,0.025,0],["gemini-2.0-flash-lite",1048576,8192,"text,image",0,null,0.075,0.3,0,0],["gemini-2.5-computer-use-preview-10-2025",131072,65536,"text,image",1,null,1.25,10,0,0],["gemini-2.5-flash",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0],["gemini-2.5-flash-lite",1048576,65536,"text,image",1,null,0.1,0.4,0.01,0],["gemini-2.5-flash-lite-preview-06-17",1048576,65536,"text,image",1,null,0.1,0.4,0.025,0],["gemini-2.5-flash-lite-preview-09-2025",1048576,65536,"text,image",1,null,0.1,0.4,0.025,0],["gemini-2.5-flash-preview-04-17",1048576,65536,"text,image",1,null,0.15,0.6,0.0375,0],["gemini-2.5-flash-preview-05-20",1048576,65536,"text,image",1,null,0.15,0.6,0.0375,0],["gemini-2.5-flash-preview-09-2025",1048576,65536,"text,image",1,null,0.3,2.5,0.075,0],["gemini-2.5-pro",1048576,65536,"text,image",1,null,1.25,10,0.125,0],["gemini-2.5-pro-preview-05-06",1048576,65536,"text,image",1,null,1.25,10,0.31,0],["gemini-2.5-pro-preview-06-05",1048576,65536,"text,image",1,null,1.25,10,0.31,0],["gemini-3-flash-preview",1048576,65536,"text,image",1,null,0.5,3,0.05,0],["gemini-3-pro-preview",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.1-flash-lite",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-3.1-flash-lite-image",65536,65536,"text,image",1,null,0.25,30,0,0],["gemini-3.1-flash-lite-preview",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-3.1-flash-live-preview",131072,65536,"text,image",1,null,0.75,4.5,0,0],["gemini-3.1-pro-preview",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.1-pro-preview-customtools",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.5-flash",1048576,65536,"text,image",1,null,1.5,9,0.15,0],["gemini-3.5-flash-lite",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0],["gemini-3.6-flash",1048576,65536,"text,image",1,null,1.5,7.5,0.15,0],["gemini-3.7-flash",1048576,65536,"text,image",1],["gemini-3.8-flash",1048576,65536,"text,image",1],["gemini-4-argon",1048576,65536,"text,image",1],["gemini-4-carbon",1048576,65536,"text,image",1],["gemini-4-hydrogen",1048576,65536,"text,image",1],["gemini-flash-latest",1048576,65536,"text,image",1,null,1.5,9,0.15,0],["gemini-flash-lite-latest",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-live-2.5-flash",128000,8000,"text,image",1,null,0.5,2,0,0],["gemini-live-2.5-flash-preview-native-audio",131072,65536,"text",1,null,0.5,2,0,0],["gemini-robotics-er-1.6-preview",131072,65536,"text,image",1,null,1,5,0,0],["gemma-3-27b-it",131072,8192,"text,image",0,null,0,0,0,0],["gemma-4-26b",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-26b-a4b-it",262144,32768,"text,image",1,null,0,0,0,0],["gemma-4-26b-it",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-31b",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-31b-it",262144,32768,"text,image",1,null,0,0,0,0],["gemma-4-E2B-it",131072,8192,"text,image",1,null,0,0,0,0],["gemma-4-E4B-it",131072,8192,"text,image",1,null,0,0,0,0]], "minimax": [["MiniMax-M2",196608,128000,"text",1,null,0.3,1.2,0,0],["MiniMax-M2.1",204800,131072,"text",1,null,0.3,1.2,0,0],["MiniMax-M2.5",204800,131072,"text",1,null,0.3,1.2,0.03,0.375],["MiniMax-M2.5-highspeed",204800,131072,"text",1,null,0.6,2.4,0.06,0.375],["MiniMax-M2.5-lightning",204800,32000,"text",1,null,0.3,2.4,0,0],["MiniMax-M2.7",204800,131072,"text",1,null,0.3,1.2,0.06,0.375],["MiniMax-M2.7-highspeed",204800,131072,"text",1,null,0.6,2.4,0.06,0.375],["minimax-m3",512000,128000,"text,image",1,null,0.6,2.4,0.12,0],["MiniMax-M3",1000000,128000,"text,image,video",1,null,0.3,1.2,0.06,0],["MiniMax-M3.1-Flash",1000000,524288,"text,image,video",1,null,0.15,0.6,0.015,0]], "mistral": [["codestral-latest",256000,4096,"text",0,null,0.3,0.9,0,0],["devstral-2512",262144,262144,"text",0,null,0.4,2,0,0],["devstral-latest",262144,262144,"text",0,null,0.4,2,0,0],["devstral-medium-2507",128000,128000,"text",0,null,0.4,2,0,0],["devstral-medium-latest",262144,262144,"text",0,null,0.4,2,0,0],["devstral-small-2505",128000,128000,"text",0,null,0.1,0.3,0,0],["devstral-small-2507",128000,128000,"text",0,null,0.1,0.3,0,0],["labs-devstral-small-2512",256000,256000,"text,image",0,null,0,0,0,0],["magistral-medium-latest",128000,16384,"text",1,null,2,5,0,0],["magistral-small",128000,128000,"text",1,null,0.5,1.5,0,0],["ministral-3b-latest",128000,128000,"text",0,null,0.04,0.04,0,0],["ministral-8b-latest",128000,128000,"text",0,null,0.1,0.1,0,0],["mistral-large-2411",131072,16384,"text",0,null,2,6,0,0],["mistral-large-2512",262144,262144,"text,image",0,null,0.5,1.5,0,0],["mistral-large-latest",262144,262144,"text,image",0,null,0.5,1.5,0,0],["mistral-medium-2505",131072,131072,"text,image",0,null,0.4,2,0,0],["mistral-medium-2508",262144,262144,"text,image",0,null,0.4,2,0,0],["mistral-medium-2604",262144,262144,"text,image",1,null,1.5,7.5,0,0],["mistral-medium-latest",262144,262144,"text,image",1,null,1.5,7.5,0,0],["mistral-nemo",128000,128000,"text",0,null,0.15,0.15,0,0],["mistral-small-2506",128000,16384,"text,image",0,null,0.1,0.3,0,0],["mistral-small-2603",256000,256000,"text,image",1,null,0.15,0.6,0,0],["mistral-small-latest",256000,256000,"text,image",1,null,0.15,0.6,0,0],["open-mistral-7b",8000,8000,"text",0,null,0.25,0.25,0,0],["open-mistral-nemo",128000,128000,"text",0,null,0.15,0.15,0,0],["open-mixtral-8x22b",64000,64000,"text",0,null,2,6,0,0],["open-mixtral-8x7b",32000,32000,"text",0,null,0.7,0.7,0,0],["pixtral-12b",128000,128000,"text,image",0,null,0.15,0.15,0,0],["pixtral-large-latest",128000,128000,"text,image",0,null,2,6,0,0]], "moonshot": [["kimi-k2.5",262144,65536,"text,image",1,null,0,0,0,0]], diff --git a/src/providers/google-vertex-model-discovery.ts b/src/providers/google-vertex-model-discovery.ts index 77d89336343..540128a4fe0 100644 --- a/src/providers/google-vertex-model-discovery.ts +++ b/src/providers/google-vertex-model-discovery.ts @@ -160,6 +160,9 @@ export function buildVertexProviderModelsRequest( function vertexGeminiReasoningEfforts(modelId: string): readonly string[] | undefined { const lower = modelId.toLowerCase(); if (lower.includes("image")) return undefined; + if (lower === "gemini-4-hydrogen") { + return ["low", "medium", "high", "xhigh"]; + } if ( lower === "gemini-4-carbon" || lower === "gemini-4-argon" diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index 47251008a56..3d24d085774 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -1214,14 +1214,15 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ { id: "google", label: "Google Gemini", adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", authKind: "key", featured: true, dashboardUrl: "https://aistudio.google.com/apikey", defaultModel: "gemini-3.5-flash", models: ["gemini-4-argon", "gemini-3.8-flash", "gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview", "gemini-3.7-flash"], - modelContextWindows: { "gemini-4-carbon": 1_048_576, "gemini-4-argon": 1_048_576, "gemini-3.8-flash": 1_048_576, "gemini-3.6-flash": 1_048_576, "gemini-3.5-flash": 1_000_000, "gemini-3.5-flash-lite": 1_048_576, "gemini-3.7-flash": 1_048_576 }, - modelInputModalities: { "gemini-4-carbon": ["text", "image"], "gemini-4-argon": ["text", "image"], "gemini-3.8-flash": ["text", "image"], "gemini-3.6-flash": ["text", "image"], "gemini-3.5-flash-lite": ["text", "image"], "gemini-3.7-flash": ["text", "image"] }, + modelContextWindows: { "gemini-4-hydrogen": 1_048_576, "gemini-4-carbon": 1_048_576, "gemini-4-argon": 1_048_576, "gemini-3.8-flash": 1_048_576, "gemini-3.6-flash": 1_048_576, "gemini-3.5-flash": 1_000_000, "gemini-3.5-flash-lite": 1_048_576, "gemini-3.7-flash": 1_048_576 }, + modelInputModalities: { "gemini-4-hydrogen": ["text", "image"], "gemini-4-carbon": ["text", "image"], "gemini-4-argon": ["text", "image"], "gemini-3.8-flash": ["text", "image"], "gemini-3.6-flash": ["text", "image"], "gemini-3.5-flash-lite": ["text", "image"], "gemini-3.7-flash": ["text", "image"] }, modelReasoningEfforts: { // 3.7 and 3.8 omit `minimal`: Google documents it as a validation error on both model // pages, so advertising it hands the user a rung the API rejects. 3.5/3.6 keep theirs — // their pages still list it, and this unit has no evidence to change them. // Gemini 4 Argon (GA 2026-10-13): low/medium(default)/high; `minimal` and thinking_budget // return HTTP 400 per ai.google.dev/gemini-api/docs/models/gemini-4-argon. + "gemini-4-hydrogen": ["low", "medium", "high", "xhigh"], "gemini-4-carbon": ["low", "medium", "high"], "gemini-4-argon": ["low", "medium", "high"], "gemini-3.8-flash": ["low", "medium", "high"], @@ -1230,6 +1231,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ "gemini-3.5-flash": ["minimal", "low", "medium", "high"], "gemini-3.1-pro-preview": ["low", "medium", "high"], }, + modelDefaultReasoningEfforts: { "gemini-4-hydrogen": "high" }, jawcodeBundle: "google", extraMetadataAliases: ["gemini"], }, // 2026-07-10: defaultModel is frozen pending Vertex-specific Tier-2 evidence; Gemini API @@ -1237,9 +1239,10 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"], modelDiscovery: { path: "/v1beta1/publishers/google/models", query: { pageSize: "300" } }, - modelContextWindows: { "gemini-4-carbon": 1_048_576, "gemini-4-argon": 1_048_576, "gemini-3.8-flash": 1_048_576, "gemini-3.6-flash": 1_048_576, "gemini-3.5-flash": 1_048_576, "gemini-3.5-flash-lite": 1_048_576 }, - modelInputModalities: { "gemini-4-carbon": ["text", "image"], "gemini-4-argon": ["text", "image"], "gemini-3.8-flash": ["text", "image"], "gemini-3.6-flash": ["text", "image"], "gemini-3.5-flash": ["text", "image"], "gemini-3.5-flash-lite": ["text", "image"] }, - modelReasoningEfforts: { "gemini-4-carbon": ["low", "medium", "high"], "gemini-4-argon": ["low", "medium", "high"], "gemini-3.8-flash": ["low", "medium", "high"], "gemini-3.7-flash": ["low", "medium", "high"], "gemini-3.6-flash": ["minimal", "low", "medium", "high"], "gemini-3.5-flash": ["minimal", "low", "medium", "high"], "gemini-3.1-pro-preview": ["low", "medium", "high"] }, + modelContextWindows: { "gemini-4-hydrogen": 1_048_576, "gemini-4-carbon": 1_048_576, "gemini-4-argon": 1_048_576, "gemini-3.8-flash": 1_048_576, "gemini-3.6-flash": 1_048_576, "gemini-3.5-flash": 1_048_576, "gemini-3.5-flash-lite": 1_048_576 }, + modelInputModalities: { "gemini-4-hydrogen": ["text", "image"], "gemini-4-carbon": ["text", "image"], "gemini-4-argon": ["text", "image"], "gemini-3.8-flash": ["text", "image"], "gemini-3.6-flash": ["text", "image"], "gemini-3.5-flash": ["text", "image"], "gemini-3.5-flash-lite": ["text", "image"] }, + modelReasoningEfforts: { "gemini-4-hydrogen": ["low", "medium", "high", "xhigh"], "gemini-4-carbon": ["low", "medium", "high"], "gemini-4-argon": ["low", "medium", "high"], "gemini-3.8-flash": ["low", "medium", "high"], "gemini-3.7-flash": ["low", "medium", "high"], "gemini-3.6-flash": ["minimal", "low", "medium", "high"], "gemini-3.5-flash": ["minimal", "low", "medium", "high"], "gemini-3.1-pro-preview": ["low", "medium", "high"] }, + modelDefaultReasoningEfforts: { "gemini-4-hydrogen": "high" }, }, // Antigravity discovers models with a POST to the CCA `:fetchAvailableModels` RPC, which // `buildModelsRequest` already built by hand. Declaring it here changes no request URL — the diff --git a/tests/adapters/google/google-wire-compiler.test.ts b/tests/adapters/google/google-wire-compiler.test.ts index d576ac4f5e5..4347ce6baa7 100644 --- a/tests/adapters/google/google-wire-compiler.test.ts +++ b/tests/adapters/google/google-wire-compiler.test.ts @@ -156,6 +156,12 @@ describe("Google wire compiler", () => { }); expect(notRequested.body.generationConfig).toEqual({ thinkingConfig: { thinkingLevel: "high" } }); + // Gemini 4 Hydrogen supports xhigh natively on the wire. + const withXhigh = compileGoogleWireBody({ + generationConfig: { thinkingConfig: { thinkingLevel: "xhigh" } }, + }); + expect(withXhigh.body.generationConfig).toEqual({ thinkingConfig: { thinkingLevel: "xhigh" } }); + // Explicit thinkingBudget (or thinking_budget) is preserved when thinkingLevel is absent. const withBudget = compileGoogleWireBody({ generationConfig: { thinkingConfig: { thinkingBudget: 8192, includeThoughts: true } }, diff --git a/tests/adapters/google/vertex-catalog.test.ts b/tests/adapters/google/vertex-catalog.test.ts index 2524c4bc626..320a5fa3729 100644 --- a/tests/adapters/google/vertex-catalog.test.ts +++ b/tests/adapters/google/vertex-catalog.test.ts @@ -218,6 +218,7 @@ describe("Vertex catalog configuration", () => { seenApiKeyHeader = headers.get("x-goog-api-key") ?? ""; return Promise.resolve(Response.json({ publisherModels: [ + { name: "publishers/google/models/gemini-4-hydrogen", versionId: "default", launchStage: "GA" }, { name: "publishers/google/models/gemini-4-carbon", versionId: "default", launchStage: "GA" }, { name: "publishers/google/models/gemini-4-argon", versionId: "default", launchStage: "GA" }, { name: "publishers/google/models/gemini-embedding-2", versionId: "default", launchStage: "GA" }, @@ -245,6 +246,16 @@ describe("Vertex catalog configuration", () => { expect(seenUrl).toBe("https://aiplatform.googleapis.com/v1beta1/publishers/google/models?pageSize=300"); expect(seenApiKeyHeader).toBe("vertex-api-key"); + const hydrogen = rows.find(row => row.provider === "google" && row.id === "gemini-4-hydrogen"); + expect(hydrogen).toMatchObject({ + provider: "google", + id: "gemini-4-hydrogen", + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high", "xhigh"], + defaultReasoningEffort: "high", + }); const carbon = rows.find(row => row.provider === "google" && row.id === "gemini-4-carbon"); expect(carbon).toMatchObject({ provider: "google", From 414be98728f6b6607e4c553e46829139ad84a0a6 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:14:10 +0900 Subject: [PATCH 04/11] fix(google-vertex): close the remaining #8583 review findings in Vertex discovery - Select the discovery credential the way inference does: an explicit keychain:/$ENV reference that cannot be read fails closed instead of reaching GOOGLE_CLOUD_API_KEY or host ADC. - Bound ADC token resolution by the discovery deadline (and the management request). - Exclude Vertex SKUs without function calling (image-output models) by documented tool support, not by the gemini- prefix. - Advertise only the reasoning ladder the adapter serializes, read from the same registry entry routedProviderConfig fills; drop the duplicated Hydrogen ladder. - Follow publisherModels nextPageToken with request, row and byte ceilings, and report an unfinished walk as incomplete instead of publishing a partial roster. --- scripts/test-layout/layout.json | 1 + src/codex/catalog/provider-models.ts | 18 +- .../google-vertex-model-discovery.ts | 258 ++++++++++++++---- src/server/management/provider-routes.ts | 21 +- tests/adapters/google/vertex-catalog.test.ts | 35 +++ .../google/vertex-model-discovery.test.ts | 218 +++++++++++++++ tests/fixtures/test-layout-expected.json | 2 +- 7 files changed, 491 insertions(+), 62 deletions(-) create mode 100644 tests/adapters/google/vertex-model-discovery.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 8d3dcbf23e8..0446411fc9b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1885,6 +1885,7 @@ "vercel-gateway-provider-routing.test.ts": "providers", "version-line.test.ts": "ci-workflows", "vertex-catalog.test.ts": "adapters/google", + "vertex-model-discovery.test.ts": "adapters/google", "vision-anthropic.test.ts": "vision", "vision-backend-union.test.ts": "vision", "vision-cache.test.ts": "vision", diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index 7fc5306ef40..4ecf8720249 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -101,6 +101,7 @@ import { type ResolvedProviderModelDiscovery, } from "../../providers/model-discovery"; import { + collectVertexPublisherModelPages, extractGoogleNativeModelItems, resolveObservedVertexDiscoveryToken, resolveVertexDiscoveryToken, @@ -862,10 +863,23 @@ export async function fetchProviderModelsWithAuth( markProviderDiscoveryOk(name, live.length); return observed(withConfiguredRetention(azureMetadata ? live : forCache, { warnDrops: true }), "authoritative"); } - const googleNative = extractGoogleNativeModelItems(effectiveGoogleMode(name, prov), bounded.value, discovery.maxModels); + const listing = effectiveGoogleMode(name, prov) === "vertex" + ? await collectVertexPublisherModelPages(url, bounded.value, discovery, + (pageUrl, signal) => providerOutboundGet(name, prov, pageUrl, { headers, signal }, outboundDependencies)) + : { ok: true as const, value: bounded.value }; + if (!listing.ok) { + const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" }); + if (shouldLog) { + console.warn( + `[opencodex] Provider model discovery for "${name}" returned an incomplete paginated listing (${listing.diagnostic}) [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`, + ); + } + return observed(models, "degraded"); + } + const googleNative = extractGoogleNativeModelItems(name, prov, listing.value, discovery.maxModels); // Native Google discovery wins; a google row served by an OpenAI-compatible // gateway keeps the generic data[] / top-level-array contract. - const extracted = googleNative ?? extractProviderModelItems(bounded.value, discovery); + const extracted = googleNative ?? extractProviderModelItems(listing.value, discovery); if (!extracted.ok) { const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" }); const diagnostic: Record = { diff --git a/src/providers/google-vertex-model-discovery.ts b/src/providers/google-vertex-model-discovery.ts index 540128a4fe0..f7a8878ad21 100644 --- a/src/providers/google-vertex-model-discovery.ts +++ b/src/providers/google-vertex-model-discovery.ts @@ -5,20 +5,23 @@ import { getVertexAccessTokenWithAuthority, } from "../lib/gcp-adc"; import type { OcxProviderConfig } from "../types"; -import { resolveProviderApiKey } from "./api-key-resolve"; +import { isKeychainReference, resolveProviderApiKey } from "./api-key-resolve"; import { extractGoogleAiStudioModelItems } from "./google-ai-studio-model-discovery"; import { extractModelEnvelopeRows, isValidModelDiscoveryModelId, + readBoundedDiscoveryJson, resolveProviderModelDiscoveryUrl, type ProviderModelItemsResult, type ProviderModelsApiItem, } from "./model-discovery"; +import { MODEL_DISCOVERY_MAX_MODELS, MODEL_DISCOVERY_MAX_RESPONSE_BYTES } from "./model-discovery-limits"; import { effectiveGoogleMode, getProviderRegistryEntry, mergeRegistryStaticHeaders, providerMatchesRegistryTransport, + registryEntryForProviderDestination, } from "./registry"; import { resolveProviderTransport } from "./xai-transport"; @@ -27,6 +30,42 @@ const VERTEX_GENERATIVE_MODEL_ID = /^(?:gemini|gemma)-/i; const VERTEX_NON_CHAT_SKU = /(?:^|-)embedding(?:-|$)|-tts(?:-|$)|-transcribe(?:-|$)|(?:^|-)live(?:-|$)/i; const VERTEX_TRAILING_API_VERSION = /\/v1(?:beta1?)?$/i; +/** Time budget for one Vertex discovery: ADC token resolution, then every follow-up page. */ +export const VERTEX_DISCOVERY_TIMEOUT_MS = 8_000; +/** Request ceiling for one listing, first page included (pageSize=300, so 1,500 rows). */ +export const VERTEX_DISCOVERY_MAX_PAGES = 5; +const VERTEX_PAGE_TOKEN_MAX_LENGTH = 4_096; + +/** + * Vertex SKUs whose model card lists function calling as "Not supported". An agent client sends + * function declarations on every turn, so publishing one of these rows offers a model that cannot + * finish a tool loop. Checked 2026-11-13 against the Gemini Enterprise Agent Platform model cards: + * gemini-3.1-flash-lite-image, gemini-3.1-flash-image, gemini-3-pro-image(-preview) and + * gemini-2.5-flash-image (docs.cloud.google.com/gemini-enterprise-agent-platform/models/gemini/*). + */ +const VERTEX_NO_FUNCTION_CALLING_MODELS = new Set([ + "gemini-3.1-flash-lite-image", + "gemini-3.1-flash-image", + "gemini-3-pro-image", + "gemini-3-pro-image-preview", + "gemini-2.5-flash-image", + "gemini-2.5-flash-image-preview", + "gemini-2.0-flash-preview-image-generation", +]); + +/** + * Image-output Gemini SKUs (`-image` / `-image-generation` segments). Every such card checked + * above lists function calling as unsupported, so an unlisted one stays out until its card says + * otherwise; publishing it would be a guess about tool support rather than evidence. + */ +const VERTEX_IMAGE_OUTPUT_SKU = /-image(?:-|$)/i; + +/** Whether a discovered Vertex publisher model can run a client's function-calling loop. */ +export function vertexModelSupportsFunctionCalling(modelId: string): boolean { + const lower = modelId.toLowerCase(); + return !VERTEX_NO_FUNCTION_CALLING_MODELS.has(lower) && !VERTEX_IMAGE_OUTPUT_SKU.test(lower); +} + /** * Whether every configured API key (or the actively selected pool key) is paused on a Vertex row. */ @@ -43,21 +82,51 @@ function isVertexApiKeyPoolPaused(provider: OcxProviderConfig | undefined): bool && !pool.some(entry => !entry.disabled && entry.key === ref); } +function usableVertexKey(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed !== "N/A" && !trimmed.startsWith("<") ? trimmed : undefined; +} + +/** A `keychain:` or `$ENV` reference names one credential; it never widens to ambient ones. */ +function isExplicitKeyReference(value: string | undefined): value is string { + return typeof value === "string" && (value.startsWith("keychain:") || value.startsWith("$")); +} + /** - * Whether a Vertex provider row authenticates discovery with an API key rather than GCP ADC. + * The credential a Vertex discovery request uses, chosen the way inference chooses it + * (`snapshotVertexEnvironment` in routed-config and `resolveVertexApiKey` in the Google adapter). * - * Mirrors `resolveVertexApiKey` in `src/adapters/google.ts`: an explicit `gcp-sa:` - * marker opts into OS-store ADC and never falls back to `GOOGLE_CLOUD_API_KEY`. + * - `gcp-sa:` selects that stored service account and never `GOOGLE_CLOUD_API_KEY`. + * - An explicit `keychain:` / `$ENV` reference selects that key alone. When it cannot be read, + * `key` is undefined and discovery fails closed: falling through to `GOOGLE_CLOUD_API_KEY` or + * host ADC would publish another account's roster under this provider, and inference rejects + * the same unresolved reference. + * - Otherwise a literal key, then `GOOGLE_CLOUD_API_KEY`, then host ADC. */ -export function usesVertexApiKeyForDiscovery(provider: OcxProviderConfig): boolean { - if (typeof provider.apiKey === "string" && provider.apiKey.startsWith(GCP_CREDENTIAL_MARKER_PREFIX)) { - return false; +export type VertexDiscoveryCredential = + | { kind: "api-key"; key: string | undefined } + | { kind: "adc"; marker?: string }; + +export function selectVertexDiscoveryCredential(provider: OcxProviderConfig): VertexDiscoveryCredential { + const configured = provider.apiKey; + if (typeof configured === "string" && configured.startsWith(GCP_CREDENTIAL_MARKER_PREFIX)) { + return { kind: "adc", marker: configured }; } - const resolved = resolveProviderApiKey(provider.apiKey)?.trim(); - if (resolved && resolved !== "N/A" && !resolved.startsWith("<")) { - return true; + const reference = provider._apiKeyAttempt?.reference; + if (isExplicitKeyReference(configured) || isExplicitKeyReference(reference)) { + if (!isExplicitKeyReference(configured)) return { kind: "api-key", key: usableVertexKey(configured) }; + const readable = !configured.startsWith("keychain:") || isKeychainReference(configured); + return { kind: "api-key", key: readable ? usableVertexKey(resolveProviderApiKey(configured)) : undefined }; } - return Boolean(process.env.GOOGLE_CLOUD_API_KEY?.trim()); + const literal = usableVertexKey(configured); + if (literal) return { kind: "api-key", key: literal }; + const ambientKey = usableVertexKey(process.env.GOOGLE_CLOUD_API_KEY); + return ambientKey ? { kind: "api-key", key: ambientKey } : { kind: "adc" }; +} + +/** Whether a Vertex provider row authenticates discovery with an API key rather than GCP ADC. */ +export function usesVertexApiKeyForDiscovery(provider: OcxProviderConfig): boolean { + return selectVertexDiscoveryCredential(provider).kind === "api-key"; } /** @@ -68,27 +137,27 @@ export function usesVertexApiKeyForDiscovery(provider: OcxProviderConfig): boole */ export function resolveObservedVertexDiscoveryToken(provider: OcxProviderConfig): string | undefined { if (provider.authMode === "forward" || isVertexApiKeyPoolPaused(provider)) return undefined; - if (!usesVertexApiKeyForDiscovery(provider)) return undefined; - const resolved = resolveProviderApiKey(provider.apiKey)?.trim(); - if (resolved && resolved !== "N/A" && !resolved.startsWith("<")) return resolved; - return process.env.GOOGLE_CLOUD_API_KEY?.trim() || undefined; + const credential = selectVertexDiscoveryCredential(provider); + return credential.kind === "api-key" ? credential.key : undefined; } /** * Resolve the Vertex model-discovery credential (API key or GCP ADC access token). * - * Paused key pools never fall back to `GOOGLE_CLOUD_API_KEY` or ambient host ADC. + * `signal` bounds the ADC exchange by the discovery time budget; without it a stalled token + * endpoint would hold discovery through every token retry. Paused key pools, unreadable explicit + * references and malformed `gcp-sa:` markers return `undefined` and never reach ambient credentials. */ -export async function resolveVertexDiscoveryToken(provider: OcxProviderConfig): Promise { +export async function resolveVertexDiscoveryToken( + provider: OcxProviderConfig, + signal: AbortSignal = AbortSignal.timeout(VERTEX_DISCOVERY_TIMEOUT_MS), +): Promise { if (provider.authMode === "forward" || isVertexApiKeyPoolPaused(provider)) return undefined; - if (usesVertexApiKeyForDiscovery(provider)) { - return resolveObservedVertexDiscoveryToken(provider); - } + const credential = selectVertexDiscoveryCredential(provider); + if (credential.kind === "api-key") return credential.key; try { - const markerAccount = gcpCredentialMarkerAccount(provider.apiKey); - const { token } = await getVertexAccessTokenWithAuthority( - markerAccount ? { markerAccount } : undefined, - ); + const markerAccount = gcpCredentialMarkerAccount(credential.marker); + const { token } = await getVertexAccessTokenWithAuthority({ signal, ...(markerAccount ? { markerAccount } : {}) }); return token; } catch { return undefined; @@ -120,7 +189,7 @@ export function buildVertexModelsDiscoveryRequest( } } // Vertex ModelGardenService.ListPublisherModels enforces a maximum pageSize of 300 - // (values > 300 return HTTP 400 INVALID_ARGUMENT). + // (values > 300 return HTTP 400 INVALID_ARGUMENT); later pages follow nextPageToken. return { url: resolveUrl(`${base}/v1beta1/publishers/google/models?pageSize=300`), headers, @@ -154,30 +223,111 @@ export function buildVertexProviderModelsRequest( return buildVertexModelsDiscoveryRequest(effectiveProvider, apiKey, headers, resolveUrl); } +export type VertexPublisherModelListing = + | { ok: true; value: unknown } + | { ok: false; reason: "incomplete"; diagnostic: string }; + +function countingResponse(response: Response, onBytes: (bytes: number) => void): Response { + if (!response.body) return response; + const counted = response.body.pipeThrough(new TransformStream({ + transform(chunk, controller) { + onBytes(chunk.byteLength); + controller.enqueue(chunk); + }, + })); + return new Response(counted, { status: response.status, headers: response.headers }); +} + +function nextVertexPageToken(page: Record): string | undefined | null { + const token = page.nextPageToken; + if (token === undefined || token === "") return undefined; + return typeof token === "string" && token.length <= VERTEX_PAGE_TOKEN_MAX_LENGTH ? token : null; +} + /** - * Supported reasoning effort levels for known Vertex AI Gemini model IDs. + * Follow `nextPageToken` across Vertex `publisherModels` pages and merge them into one envelope. + * + * One listing is bounded three ways: at most `VERTEX_DISCOVERY_MAX_PAGES` requests, at most the + * discovery row limit across every page, and at most the discovery byte limit across every page + * (the first page is counted by its re-serialized size, which never exceeds what was read). When + * a bound, a page request, the shared deadline, or a repeated token stops the walk before the + * last page, the listing is reported incomplete: publishing the pages already read as the + * authoritative roster would drop every model on the pages that were not. + * + * A first page without a `publisherModels` array is returned unchanged for the caller's generic + * envelope handling (an OpenAI-compatible gateway in front of Vertex). */ -function vertexGeminiReasoningEfforts(modelId: string): readonly string[] | undefined { - const lower = modelId.toLowerCase(); - if (lower.includes("image")) return undefined; - if (lower === "gemini-4-hydrogen") { - return ["low", "medium", "high", "xhigh"]; - } - if ( - lower === "gemini-4-carbon" - || lower === "gemini-4-argon" - || lower === "gemini-3.8-flash" - || lower === "gemini-3.7-flash" - || lower.startsWith("gemini-3.1-pro") - || lower === "gemini-3-pro" - || lower === "gemini-3-pro-preview" - ) { - return ["low", "medium", "high"]; - } - if (lower === "gemini-3.6-flash" || lower === "gemini-3.5-flash") { - return ["minimal", "low", "medium", "high"]; +export async function collectVertexPublisherModelPages( + firstUrl: string, + firstValue: unknown, + limits: { maxModels: number; maxResponseBytes: number }, + fetchPage: (url: string, signal: AbortSignal) => Promise, +): Promise { + const first = firstValue !== null && typeof firstValue === "object" && !Array.isArray(firstValue) + ? firstValue as Record + : undefined; + if (!first || !Array.isArray(first.publisherModels)) return { ok: true, value: firstValue }; + let token = nextVertexPageToken(first); + if (token === undefined) return { ok: true, value: firstValue }; + + const maxRows = Math.min(limits.maxModels, MODEL_DISCOVERY_MAX_MODELS); + const maxBytes = Math.min(limits.maxResponseBytes, MODEL_DISCOVERY_MAX_RESPONSE_BYTES); + const rows: unknown[] = [...first.publisherModels]; + let bytes = new TextEncoder().encode(JSON.stringify(firstValue)).byteLength; + const seenTokens = new Set(); + const signal = AbortSignal.timeout(VERTEX_DISCOVERY_TIMEOUT_MS); + const incomplete = (diagnostic: string): VertexPublisherModelListing => + ({ ok: false, reason: "incomplete", diagnostic: `${diagnostic} after ${seenTokens.size + 1} page(s) and ${rows.length} row(s)` }); + + while (token !== undefined) { + if (token === null) return incomplete("returned a malformed nextPageToken"); + if (seenTokens.has(token)) return incomplete("repeated a nextPageToken"); + if (seenTokens.size + 1 >= VERTEX_DISCOVERY_MAX_PAGES) return incomplete(`exceeded the ${VERTEX_DISCOVERY_MAX_PAGES}-request page limit`); + if (rows.length > maxRows) return incomplete(`exceeded the ${maxRows}-row model limit`); + if (bytes >= maxBytes) return incomplete(`exceeded the ${maxBytes}-byte listing limit`); + seenTokens.add(token); + const pageUrl = new URL(firstUrl); + pageUrl.searchParams.set("pageToken", token); + let page: Record | undefined; + try { + const response = await fetchPage(pageUrl.toString(), signal); + if (!response.ok) { + try { void response.body?.cancel().catch(() => undefined); } catch { /* best effort */ } + return incomplete(`page request failed with HTTP ${response.status}`); + } + const read = await readBoundedDiscoveryJson(countingResponse(response, count => { bytes += count; }), maxBytes - bytes); + if (!read.ok) { + return incomplete(read.reason === "response_too_large" ? `exceeded the ${maxBytes}-byte listing limit` : "returned an invalid page"); + } + const value = read.value; + page = value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; + } catch { + return incomplete(signal.aborted ? "exceeded the discovery time budget" : "page request failed"); + } + if (!page || (page.publisherModels !== undefined && !Array.isArray(page.publisherModels))) { + return incomplete("returned a malformed page"); + } + if (Array.isArray(page.publisherModels)) rows.push(...page.publisherModels); + token = nextVertexPageToken(page); } - return undefined; + if (rows.length > maxRows) return incomplete(`exceeded the ${maxRows}-row model limit`); + return { ok: true, value: { publisherModels: rows } }; +} + +type ReasoningLadders = Readonly> | undefined; + +/** + * The reasoning ladders the Google adapter will serialize for this row. `routedProviderConfig` + * fills `modelReasoningEfforts` from the provider's own registry entry when the row matches it, + * and from the destination entry (`google-vertex` for aiplatform hosts) otherwise; the adapter + * then writes `thinkingConfig` only for a laddered model. Reading the same entry keeps discovery + * from advertising a rung the request would silently drop (an unladdered `gemini-3-pro`). + */ +function vertexReasoningLadders(providerName: string, provider: OcxProviderConfig): ReasoningLadders { + const entry = providerMatchesRegistryTransport(providerName, provider) + ? getProviderRegistryEntry(providerName) + : registryEntryForProviderDestination(provider); + return entry?.modelReasoningEfforts; } /** @@ -185,12 +335,13 @@ function vertexGeminiReasoningEfforts(modelId: string): readonly string[] | unde * `/v1beta1/publishers/google/models` response payload (`{ publisherModels: [...] }`). * * Strips the `publishers/google/models/` prefix, filters out non-`generateContent` SKUs - * (embeddings, TTS, transcription, Live API, Imagen/Veo), and enriches known Gemini rows - * with vendored token/modality/reasoning metadata. + * (embeddings, TTS, transcription, Live API, Imagen/Veo) and SKUs without function calling, and + * enriches rows with vendored token/modality metadata and the registry's reasoning ladder. */ export function extractGoogleVertexModelItems( value: unknown, maxModels: number, + ladders?: ReasoningLadders, ): ProviderModelItemsResult { const envelope = extractModelEnvelopeRows(value, maxModels, ["publisherModels"]); if (!envelope.ok) return envelope; @@ -208,10 +359,11 @@ export function extractGoogleVertexModelItems( : name; if (!isValidModelDiscoveryModelId(id) || seen.has(id)) continue; if (!VERTEX_GENERATIVE_MODEL_ID.test(id) || VERTEX_NON_CHAT_SKU.test(id)) continue; + if (!vertexModelSupportsFunctionCalling(id)) continue; seen.add(id); const meta = getModelMetadata("google", id) ?? getModelMetadataCaseInsensitive("google", id); - const efforts = vertexGeminiReasoningEfforts(id); + const efforts = ladders && Object.hasOwn(ladders, id) ? ladders[id] : undefined; items.push({ id, owned_by: "google", @@ -220,7 +372,7 @@ export function extractGoogleVertexModelItems( : {}), ...(meta?.maxTokens !== undefined ? { max_output_tokens: meta.maxTokens } : {}), ...(meta?.input?.length ? { input_modalities: [...meta.input] } : {}), - ...(efforts ? { reasoning_efforts: [...efforts] } : {}), + ...(efforts?.length ? { reasoning_efforts: [...efforts] } : {}), }); } return { ok: true, items, rawCount: envelope.rows.length }; @@ -232,14 +384,16 @@ export function extractGoogleVertexModelItems( * gateway envelope (`data[]` or top-level array) so callers fall back cleanly. */ export function extractGoogleNativeModelItems( - googleMode: "ai-studio" | "vertex" | "cloud-code-assist" | null, + providerName: string, + provider: OcxProviderConfig, value: unknown, maxModels: number, ): ProviderModelItemsResult | undefined { + const googleMode = effectiveGoogleMode(providerName, provider); const result = googleMode === "ai-studio" ? extractGoogleAiStudioModelItems(value, maxModels) : googleMode === "vertex" - ? extractGoogleVertexModelItems(value, maxModels) + ? extractGoogleVertexModelItems(value, maxModels, vertexReasoningLadders(providerName, provider)) : undefined; if (!result) return undefined; return result.ok || result.reason === "too_many_models" ? result : undefined; diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 30a1e206b8a..33171a46100 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -73,8 +73,10 @@ import { } from "../../providers/model-discovery"; import { buildVertexProviderModelsRequest, + collectVertexPublisherModelPages, extractGoogleNativeModelItems, resolveVertexDiscoveryToken, + VERTEX_DISCOVERY_TIMEOUT_MS, } from "../../providers/google-vertex-model-discovery"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; @@ -1655,7 +1657,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise providerOutboundGet(name, prov, pageUrl, { headers, signal: AbortSignal.any([req.signal, signal]) }, outboundDependencies)) + : { ok: true as const, value: bounded.value }; + if (!listing.ok) return jsonResponse({ ok: false, latencyMs, error: `upstream Vertex model listing is incomplete: ${listing.diagnostic}` }); // OpenAI-style lists (and Together top-level arrays) use the same validation/dedupe/filter // as catalog discovery. Google AI Studio parses the native `models[]` envelope and filters to // `generateContent`, while other providers fall back to generic envelope rows if they return `models[]`. - const record = bounded.value !== null && typeof bounded.value === "object" && !Array.isArray(bounded.value) - ? bounded.value as Record + const record = listing.value !== null && typeof listing.value === "object" && !Array.isArray(listing.value) + ? listing.value as Record : undefined; const googleNative = !ccaModels - ? extractGoogleNativeModelItems(effectiveGoogleMode(name, prov), bounded.value, discovery.maxModels) + ? extractGoogleNativeModelItems(name, prov, listing.value, discovery.maxModels) : undefined; const extracted = ccaModels ? undefined : googleNative - ?? (Array.isArray(bounded.value) || Array.isArray(record?.data) - ? extractProviderModelItems(bounded.value, discovery) - : extractModelEnvelopeRows(bounded.value, discovery.maxModels, ["models"])); + ?? (Array.isArray(listing.value) || Array.isArray(record?.data) + ? extractProviderModelItems(listing.value, discovery) + : extractModelEnvelopeRows(listing.value, discovery.maxModels, ["models"])); if (extracted && !extracted.ok) { return jsonResponse({ ok: false, diff --git a/tests/adapters/google/vertex-catalog.test.ts b/tests/adapters/google/vertex-catalog.test.ts index 320a5fa3729..2995c1a7c10 100644 --- a/tests/adapters/google/vertex-catalog.test.ts +++ b/tests/adapters/google/vertex-catalog.test.ts @@ -275,4 +275,39 @@ describe("Vertex catalog configuration", () => { expect(rows.some(row => row.id === excluded)).toBe(false); } }); + test("a paginated Vertex listing publishes every page, and an unfinished one is not published as authoritative", async () => { + const pageTokens: string[] = []; + let failSecondPage = false; + globalThis.fetch = ((input: string | URL | Request) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url); + const token = url.searchParams.get("pageToken"); + if (token) pageTokens.push(token); + if (!token) { + return Promise.resolve(Response.json({ + publisherModels: [{ name: "publishers/google/models/gemini-4-carbon" }], + nextPageToken: "page-2", + })); + } + if (failSecondPage) return Promise.resolve(new Response("unavailable", { status: 503 })); + return Promise.resolve(Response.json({ publisherModels: [{ name: "publishers/google/models/gemini-4-hydrogen" }] })); + }) as typeof fetch; + const config: OcxConfig = { providers: { "paged-vertex": { + adapter: "google", googleMode: "vertex", baseUrl: "https://aiplatform.googleapis.com", + apiKey: "vertex-api-key", liveModels: true, models: ["configured-only"], + } } }; + + const complete = await gatherRoutedModels(config); + expect(pageTokens).toEqual(["page-2"]); + expect(complete.filter(row => row.provider === "paged-vertex").map(row => row.id)) + .toEqual(expect.arrayContaining(["gemini-4-carbon", "gemini-4-hydrogen"])); + + clearModelCache(); + failSecondPage = true; + warn = spyOn(console, "warn").mockImplementation(() => {}); + const incomplete = await gatherRoutedModels(config); + const ids = incomplete.filter(row => row.provider === "paged-vertex").map(row => row.id); + expect(ids).toContain("configured-only"); + expect(ids).not.toContain("gemini-4-carbon"); + expect(warn.mock.calls.map(call => String(call[0])).join("\n")).toContain("incomplete paginated listing"); + }); }); diff --git a/tests/adapters/google/vertex-model-discovery.test.ts b/tests/adapters/google/vertex-model-discovery.test.ts new file mode 100644 index 00000000000..bd09c7bf4ee --- /dev/null +++ b/tests/adapters/google/vertex-model-discovery.test.ts @@ -0,0 +1,218 @@ +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { Buffer } from "node:buffer"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createGoogleAdapter } from "../../../src/adapters/google"; +import { __resetVertexTokenCache } from "../../../src/lib/gcp-adc"; +import { + collectVertexPublisherModelPages, + extractGoogleNativeModelItems, + resolveObservedVertexDiscoveryToken, + resolveVertexDiscoveryToken, + VERTEX_DISCOVERY_MAX_PAGES, +} from "../../../src/providers/google-vertex-model-discovery"; +import { setProviderKeychainEntryFactoryForTests } from "../../../src/providers/key-store"; +import { getProviderRegistryEntry } from "../../../src/providers/registry"; +import { routedProviderConfig } from "../../../src/providers/routed-config"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; +import { removeTreeWithRetry } from "../../helpers/remove-tree"; + +const OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; +const ENV_KEYS = ["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_API_KEY", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT", "CLOUDSDK_CONFIG"]; +const UNREADABLE_REFERENCES = ["keychain:missing-fixture", "${MISSING_VERTEX_TEST_KEY}", "$MISSING_VERTEX_TEST_KEY", "keychain:"]; + +let tmp: string; +let saPath: string; +const realFetch = globalThis.fetch; +const savedEnv: Record = {}; +let tokenCalls = 0; + +function vertex(extra: Partial = {}): OcxProviderConfig { + return { adapter: "google", googleMode: "vertex", authMode: "key", baseUrl: "https://aiplatform.googleapis.com", ...extra } as OcxProviderConfig; +} + +beforeAll(async () => { + tmp = mkdtempSync(join(tmpdir(), "ocx-vertex-discovery-")); + const kp = await globalThis.crypto.subtle.generateKey( + { name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, + true, + ["sign", "verify"], + ); + const pkcs8 = await globalThis.crypto.subtle.exportKey("pkcs8", kp.privateKey); + const pem = `-----BEGIN PRIVATE KEY-----\n${Buffer.from(pkcs8).toString("base64").match(/.{1,64}/g)!.join("\n")}\n-----END PRIVATE KEY-----\n`; + saPath = join(tmp, "sa.json"); + writeFileSync(saPath, JSON.stringify({ type: "service_account", client_email: "svc@example.test", private_key: pem, private_key_id: "k1" })); + for (const key of ENV_KEYS) savedEnv[key] = process.env[key]; +}); + +afterEach(() => { + globalThis.fetch = realFetch; + __resetVertexTokenCache(); + setProviderKeychainEntryFactoryForTests(null); + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; else process.env[key] = savedEnv[key]; + } + tokenCalls = 0; +}); + +afterAll(() => removeTreeWithRetry(tmp)); + +/** Ambient credentials present: a GOOGLE_CLOUD_API_KEY and a host ADC service account that would mint a token. */ +function withAmbientCredentials(): void { + process.env.GOOGLE_CLOUD_API_KEY = "ambient-env-key"; + process.env.GOOGLE_APPLICATION_CREDENTIALS = saPath; + process.env.CLOUDSDK_CONFIG = join(tmp, "no-gcloud"); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url; + if (url === OAUTH_TOKEN_URL) { + tokenCalls++; + return Response.json({ access_token: "ambient-adc-token", expires_in: 3600, token_type: "Bearer" }); + } + return new Response("unexpected", { status: 404 }); + }) as typeof fetch; +} + +describe("Vertex discovery credential selection", () => { + test("an unreadable explicit key reference fails closed instead of using ambient credentials", async () => { + withAmbientCredentials(); + setProviderKeychainEntryFactoryForTests(() => ({ getPassword: () => null, setPassword: () => {}, deletePassword: () => true })); + for (const apiKey of UNREADABLE_REFERENCES) { + const provider = vertex({ apiKey }); + expect(resolveObservedVertexDiscoveryToken(provider)).toBeUndefined(); + expect(await resolveVertexDiscoveryToken(provider)).toBeUndefined(); + // Inference rejects the same reference, so discovery and requests agree on the boundary. + expect(() => routedProviderConfig("google-vertex", provider)).toThrow(); + } + expect(tokenCalls).toBe(0); + }); + + test("the same references use their own key once readable, and no reference keeps the ambient chain", async () => { + withAmbientCredentials(); + setProviderKeychainEntryFactoryForTests(() => ({ getPassword: () => "keychain-vertex-key", setPassword: () => {}, deletePassword: () => true })); + expect(await resolveVertexDiscoveryToken(vertex({ apiKey: "keychain:google-vertex" }))).toBe("keychain-vertex-key"); + process.env.MISSING_VERTEX_TEST_KEY = "env-ref-key"; + try { + expect(await resolveVertexDiscoveryToken(vertex({ apiKey: "$MISSING_VERTEX_TEST_KEY" }))).toBe("env-ref-key"); + } finally { + delete process.env.MISSING_VERTEX_TEST_KEY; + } + expect(await resolveVertexDiscoveryToken(vertex())).toBe("ambient-env-key"); + delete process.env.GOOGLE_CLOUD_API_KEY; + expect(await resolveVertexDiscoveryToken(vertex())).toBe("ambient-adc-token"); + expect(tokenCalls).toBe(1); + }); + + test("ADC resolution honours the discovery deadline", async () => { + process.env.GOOGLE_APPLICATION_CREDENTIALS = saPath; + process.env.CLOUDSDK_CONFIG = join(tmp, "no-gcloud"); + delete process.env.GOOGLE_CLOUD_API_KEY; + globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => new Promise((_resolve, reject) => { + tokenCalls++; + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + })) as typeof fetch; + const started = Date.now(); + expect(await resolveVertexDiscoveryToken(vertex(), AbortSignal.timeout(50))).toBeUndefined(); + expect(Date.now() - started).toBeLessThan(2_000); + expect(tokenCalls).toBeGreaterThan(0); + }); +}); + +function publisher(...ids: string[]): Array<{ name: string }> { + return ids.map(id => ({ name: `publishers/google/models/${id}` })); +} + +describe("Vertex discovery rows", () => { + test("SKUs without function calling are not published, by tool support rather than the gemini- prefix", () => { + const result = extractGoogleNativeModelItems("google-vertex", vertex(), { publisherModels: publisher( + "gemini-3.1-flash-lite-image", + "gemini-3.1-flash-image", + "gemini-3-pro-image", + "gemini-2.5-flash-image", + "gemini-9-flash-image-preview", + "gemini-3.1-flash-lite", + "gemini-4-hydrogen", + ) }, 100); + expect(result?.ok).toBe(true); + expect(result?.ok && result.items.map(item => item.id)).toEqual(["gemini-3.1-flash-lite", "gemini-4-hydrogen"]); + }); + + test("advertised efforts are the registry ladder the adapter serializes", async () => { + const ladders = getProviderRegistryEntry("google-vertex")?.modelReasoningEfforts ?? {}; + const result = extractGoogleNativeModelItems("google-vertex", vertex({ apiKey: "literal-key" }), + { publisherModels: publisher("gemini-4-hydrogen", "gemini-4-carbon", "gemini-3-pro") }, 100); + const items = result?.ok ? result.items : []; + const byId = new Map(items.map(item => [item.id, item])); + expect(byId.get("gemini-4-hydrogen")?.reasoning_efforts).toEqual([...ladders["gemini-4-hydrogen"]!]); + expect(byId.get("gemini-4-carbon")?.reasoning_efforts).toEqual([...ladders["gemini-4-carbon"]!]); + expect(ladders["gemini-3-pro"]).toBeUndefined(); + expect(byId.get("gemini-3-pro")?.reasoning_efforts).toBeUndefined(); + + const adapter = createGoogleAdapter(routedProviderConfig("google-vertex", vertex({ apiKey: "literal-key", project: "p", location: "global" }))); + const request = (modelId: string, effort: string): OcxParsedRequest => ({ + modelId, stream: false, context: { messages: [{ role: "user", content: "hi" }], systemPrompt: [], tools: [] }, + options: { reasoning: effort }, + } as unknown as OcxParsedRequest); + const thinking = async (modelId: string, effort: string) => + JSON.parse((await adapter.buildRequest(request(modelId, effort))).body).generationConfig?.thinkingConfig; + expect(await thinking("gemini-4-hydrogen", "xhigh")).toEqual({ thinkingLevel: "xhigh" }); + expect(await thinking("gemini-3-pro", "high")).toBeUndefined(); + }); +}); + +describe("Vertex publisherModels pagination", () => { + const limits = { maxModels: 2_000, maxResponseBytes: 4 * 1024 * 1024 }; + const firstUrl = "https://aiplatform.googleapis.com/v1beta1/publishers/google/models?pageSize=300"; + + function pages(map: Record, seen: string[] = []) { + return async (url: string): Promise => { + const token = new URL(url).searchParams.get("pageToken") ?? ""; + seen.push(token); + const page = map[token]; + return page === undefined ? new Response("gone", { status: 500 }) : Response.json(page); + }; + } + + test("follows nextPageToken and merges every page", async () => { + const seen: string[] = []; + const listing = await collectVertexPublisherModelPages(firstUrl, { publisherModels: publisher("gemini-a"), nextPageToken: "t1" }, limits, pages({ + t1: { publisherModels: publisher("gemini-b"), nextPageToken: "t2" }, + t2: { publisherModels: publisher("gemini-c") }, + }, seen)); + expect(seen).toEqual(["t1", "t2"]); + expect(listing).toEqual({ ok: true, value: { publisherModels: publisher("gemini-a", "gemini-b", "gemini-c") } }); + }); + + test("a single page passes through unchanged", async () => { + const first = { publisherModels: publisher("gemini-a") }; + const listing = await collectVertexPublisherModelPages(firstUrl, first, limits, () => { throw new Error("no request expected"); }); + expect(listing).toEqual({ ok: true, value: first }); + }); + + test("an unfinished walk is reported incomplete, never published as the whole roster", async () => { + const endless: Record = {}; + for (let page = 1; page <= VERTEX_DISCOVERY_MAX_PAGES + 2; page++) { + endless[`t${page}`] = { publisherModels: publisher(`gemini-${page}`), nextPageToken: `t${page + 1}` }; + } + const seen: string[] = []; + const capped = await collectVertexPublisherModelPages(firstUrl, { publisherModels: [], nextPageToken: "t1" }, limits, pages(endless, seen)); + expect(capped.ok).toBe(false); + expect(seen).toHaveLength(VERTEX_DISCOVERY_MAX_PAGES - 1); + + const failed = await collectVertexPublisherModelPages(firstUrl, { publisherModels: [], nextPageToken: "t1" }, limits, pages({})); + expect(failed).toMatchObject({ ok: false, reason: "incomplete" }); + + const loop = await collectVertexPublisherModelPages(firstUrl, { publisherModels: [], nextPageToken: "t1" }, limits, + pages({ t1: { publisherModels: [], nextPageToken: "t1" } })); + expect(loop.ok).toBe(false); + + const rows = await collectVertexPublisherModelPages(firstUrl, { publisherModels: publisher("gemini-a", "gemini-b"), nextPageToken: "t1" }, + { ...limits, maxModels: 2 }, pages({ t1: { publisherModels: publisher("gemini-c") } })); + expect(rows.ok).toBe(false); + + const big = { publisherModels: publisher(...Array.from({ length: 50 }, (_, i) => `gemini-${i}`)) }; + const bytes = await collectVertexPublisherModelPages(firstUrl, { publisherModels: [], nextPageToken: "t1" }, + { ...limits, maxResponseBytes: 600 }, pages({ t1: big })); + expect(bytes.ok).toBe(false); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 445a604dcc6..98ce7a509b3 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1515,7 +1515,7 @@ "user-cost-overlay-coderabbit-regressions.test.ts": "usage", "user-cost-overlay-live-reconcile.test.ts": "usage", "user-cost-overlay-provider-delete.test.ts": "usage", "v1-hub-state.test.ts": "server", "v2-agent-message-failfast.test.ts": "server", "vercel-gateway-provider-routing.test.ts": "providers", - "version-line.test.ts": "ci-workflows", "vertex-catalog.test.ts": "adapters/google", + "version-line.test.ts": "ci-workflows", "vertex-catalog.test.ts": "adapters/google", "vertex-model-discovery.test.ts": "adapters/google", "vision-anthropic.test.ts": "vision", "vision-backend-union.test.ts": "vision", "vision-cache.test.ts": "vision", "vision-classification-seed-repair.test.ts": "providers", "vision-custom-row-precedence.test.ts": "vision", "vision-eligibility.test.ts": "vision", From 7370c111c2d29fdfd4500e4e32f3d66c5f56dc5e Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:19:22 +0900 Subject: [PATCH 05/11] docs(google-vertex): document Vertex discovery and the Gemini 4 tool-image scope Record the discovery credential, pagination and SKU rules in structure/providers/google.md and the user guide, amend ADR-7279 for the Carbon and Hydrogen extension #8583 made, and pin that extension with the tool-result adjacency test. --- .../src/content/docs/guides/providers.md | 15 +++++++++-- .../ADR-7279-vertex-argon-tool-images.md | 5 ++++ structure/providers/google.md | 27 +++++++++++++++++-- .../google-tool-result-adjacency.test.ts | 6 +++-- 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 5f8ee7555f3..588d4223556 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -276,12 +276,23 @@ eligible Enterprise accounts. Cloud Code Assist serves Argon only to the Antigra OpenCodex sends Argon requests and model discovery with the CLI User-Agent (`antigravity/cli/1.3.4`) and keeps the IDE User-Agent for every other model. -On Vertex AI, `gemini-4-argon` receives images returned by tools, such as screenshots, inside +On Vertex AI, `gemini-4-argon`, `gemini-4-carbon` and `gemini-4-hydrogen` receive images returned by tools, such as screenshots, inside the matching function response. This avoids the upstream 400 error "Requests ending with a model turn are not supported" after an image-producing tool call while preserving the images. Images from duplicate results or results without a matching call are preserved in a separate message after the tool responses, with a marker explaining that they could not be paired. -No configuration change is needed; this handling is specific to Vertex Argon. +No configuration change is needed; this handling is specific to these Gemini 4 models on Vertex. + +A Vertex AI provider with live models lists Google's publisher models and follows every page of +the listing. It shows Gemini and Gemma chat models and leaves out embedding, speech, Live API, +Imagen and Veo models, and image-output Gemini models such as `gemini-3.1-flash-image`, whose +model cards list function calling as unsupported. Discovery uses the provider's own credential: a +`gcp-sa:` service account, a key, or with neither configured, `GOOGLE_CLOUD_API_KEY` and then +Application Default Credentials. If a configured `keychain:` or `$VARIABLE` key cannot be read, +discovery stops instead of trying another credential. When the listing cannot be read completely, +OpenCodex keeps the previous or configured models. Reasoning levels follow the preset: Gemini 4 +Hydrogen offers low, medium, high (the default) and xhigh; Carbon and Argon offer low, medium and +high. For Gemini on Antigravity, including Argon, provider-marked thought summaries stay separate from the answer: Responses uses reasoning summary items, Chat Completions uses diff --git a/structure/decisions/ADR-7279-vertex-argon-tool-images.md b/structure/decisions/ADR-7279-vertex-argon-tool-images.md index 88b7f6940fc..874eda281d2 100644 --- a/structure/decisions/ADR-7279-vertex-argon-tool-images.md +++ b/structure/decisions/ADR-7279-vertex-argon-tool-images.md @@ -14,3 +14,8 @@ - Consequences: no new configuration, retry or shared-history mutation. Orphan results still degrade to marked text and sibling images; existing adjacency and thought-signature repairs remain intact. Revisit the model scope when another route has equivalent upstream evidence. +- Amendment (#8583, 2026-11-13): the scope now also covers Vertex `gemini-4-carbon` and + `gemini-4-hydrogen`. The 400 was not reproduced on those models; the maintainer accepted the + extension because `functionResponse.parts` is the documented multimodal function-response form + for Gemini 3 and later, so the nested shape is valid input for them, and the PR author ran both + models live on Vertex. Other routes are unchanged. diff --git a/structure/providers/google.md b/structure/providers/google.md index 6b36e11c2f4..40d4f9645ee 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -1,6 +1,6 @@ # Google Provider -Created: 2026-10-16 | Updated: 2026-10-18 | Version: 1.2 (Vertex per-key project) +Created: 2026-10-16 | Updated: 2026-11-13 | Version: 1.3 (Vertex model discovery) ## Streaming input accounting @@ -51,6 +51,29 @@ The resolved number then takes the ordinary project path, so the replay namespac project. ADC rejects `"auto"`. `tests/adapters/google/google-vertex-key-project.test.ts` covers these cases. +## Vertex model discovery + +`src/providers/google-vertex-model-discovery.ts` lists a non-OAuth Vertex row's models from +`GET /v1beta1/publishers/google/models?pageSize=300` (300 is the server maximum). It selects the +discovery credential the way inference does: a `gcp-sa:` marker uses that service account; an +explicit `keychain:` or `$ENV` reference uses that key alone, and when it cannot be read +discovery sends nothing and the catalog degrades, matching the routed-config rejection of the same +reference, so `GOOGLE_CLOUD_API_KEY` and host ADC never stand in for it; otherwise a literal key, +then `GOOGLE_CLOUD_API_KEY`, then host ADC. ADC token resolution is bounded by the discovery +deadline (`VERTEX_DISCOVERY_TIMEOUT_MS`), and by the request for the management probe. +`nextPageToken` is followed under one deadline with three ceilings across all pages: five +requests, the discovery row limit and the discovery byte limit. A walk stopped by a ceiling, a +failed page, a malformed or repeated token, or the deadline is reported as an incomplete listing; +the catalog keeps its stale or configured rows and the probe fails, so a partial roster is never +cached as authoritative. Rows keep Gemini and Gemma chat models and drop embeddings, speech, Live, +Imagen/Veo and SKUs without function calling: model cards checked 2026-11-13 list it unsupported +for every image-output Gemini SKU, so `-image` ids stay out unless a card says otherwise. Each +row's `reasoning_efforts` comes from the registry entry that `routedProviderConfig` would fill +for the row (its own entry when the transport matches, else the destination entry), which is the +ladder the adapter serializes; an unladdered model such as `gemini-3-pro` advertises none. +`tests/adapters/google/vertex-model-discovery.test.ts` and +`tests/adapters/google/vertex-catalog.test.ts` cover these boundaries. + ## Vertex credential markers `src/lib/gcp-adc.ts` resolves an explicit `gcp-sa:` through the OS credential store. @@ -192,7 +215,7 @@ later tool results. Single-call and text-only batches keep their existing shape. non-Claude CCA models, AI Studio and non-Argon Vertex retain interleaved image siblings. `tests/adapters/google/google-tool-result-adjacency.test.ts` checks the final compiled request for three parallel calls, reversed results, distinct images and route isolation. -For Vertex `gemini-4-argon`, matched results carry representable data-URL images inside +For Vertex `gemini-4-argon`, `gemini-4-carbon` and `gemini-4-hydrogen`, matched results carry representable data-URL images inside `functionResponse.parts`, preserving each image's call ownership. Sibling images on that route can produce a misleading "Requests ending with a model turn are not supported" error even when the last turn is `user`. When an adjacent orphan batch contains representable images on that route, diff --git a/tests/adapters/google/google-tool-result-adjacency.test.ts b/tests/adapters/google/google-tool-result-adjacency.test.ts index 1c1a1676e05..61b85762315 100644 --- a/tests/adapters/google/google-tool-result-adjacency.test.ts +++ b/tests/adapters/google/google-tool-result-adjacency.test.ts @@ -115,6 +115,7 @@ describe("CCA Claude tool-result image ordering", () => { ["cloud-code-assist", "gemini-4-argon"], ["cloud-code-assist", "gpt-oss-120b-medium"], ["ai-studio", "gemini-4-argon"], + ["ai-studio", "gemini-4-hydrogen"], ["ai-studio", "claude-sonnet-4-6"], ["vertex", "gemini-3.1-pro"], ["vertex", "claude-sonnet-4-6"], @@ -123,8 +124,9 @@ describe("CCA Claude tool-result image ordering", () => { expect(contents.at(-1)?.parts).toEqual([responseA, imageA, responseB, imageB, responseC]); }); - test("Vertex Argon keeps each image nested in its matching response", async () => { - const { contents } = await build(parallelHistory(true), { ...cca, googleMode: "vertex" }, "gemini-4-argon"); + // #8583 extended the Argon contract to Carbon and Hydrogen on Vertex (ADR-7279 amendment). + test.each(["gemini-4-argon", "gemini-4-carbon", "gemini-4-hydrogen"])("Vertex %s keeps each image nested in its matching response", async model => { + const { contents } = await build(parallelHistory(true), { ...cca, googleMode: "vertex" }, model); expect(contents.at(-1)?.parts).toEqual([ { functionResponse: { ...responseA.functionResponse, parts: [imageA] } }, { functionResponse: { ...responseB.functionResponse, parts: [imageB] } }, responseC, From aaf8026f42063b22e48cfd811a80ce7ef1d2c0c7 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:24:55 +0900 Subject: [PATCH 06/11] fix(google-vertex): reject foreign follow-up pages and share one discovery deadline Review follow-up for the #8583 carry: - A follow-up page must be a Vertex page (publisherModels, or an empty / token-only page); a gateway or error body no longer ends the walk as an empty last page. - Count the bytes actually read for page one, and run ADC, the first request and every page under one deadline (tied to the request for the management probe). - Pin that a row's own reasoning ladder still wins over the advertised registry ladder. - Move the Vertex guide text to its own page: guides/providers.md sits at the 2,000-line cap. --- docs-site/astro.config.mjs | 1 + .../src/content/docs/guides/google-vertex.md | 35 +++++++++++++++ .../src/content/docs/guides/providers.md | 19 +------- src/codex/catalog/provider-models.ts | 14 ++++-- .../google-vertex-model-discovery.ts | 44 +++++++++++++------ src/server/management/provider-routes.ts | 20 +++++---- structure/providers/google.md | 19 ++++---- tests/adapters/google/vertex-catalog.test.ts | 12 +++++ .../google/vertex-model-discovery.test.ts | 29 ++++++++++++ 9 files changed, 142 insertions(+), 51 deletions(-) create mode 100644 docs-site/src/content/docs/guides/google-vertex.md diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 49912f94c9d..0eb15fe670b 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -100,6 +100,7 @@ export default defineConfig({ { label: "Response Inspection", translations: { es: "Inspección de respuestas", fr: "Inspection des réponses et réponses volumineuses", ko: "응답 검사와 대용량 응답", "zh-CN": "响应检查与大型响应", "zh-TW": "回應檢查與大型回應", ru: "Проверка ответов и большие ответы", ja: "レスポンスの検査と大きなレスポンス", tr: "Yanıt incelemesi ve büyük yanıtlar" }, slug: "guides/response-inspection" }, { label: "Remote Workspace", translations: { es: "Espacio de trabajo remoto", fr: "Espace de travail distant", ko: "원격 워크스페이스", "zh-CN": "远程工作区", "zh-TW": "遠端工作區", ru: "Удалённая рабочая область", ja: "リモートワークスペース", tr: "Uzak Çalışma Alanı" }, slug: "guides/remote-workspace" }, { label: "Providers", translations: { es: "Proveedores", fr: "Fournisseurs", ko: "프로바이더", "zh-CN": "提供商", "zh-TW": "供應商", ru: "Провайдеры", ja: "プロバイダー", tr: "Sağlayıcılar" }, slug: "guides/providers" }, + { label: "Google Vertex AI", slug: "guides/google-vertex" }, { label: "Factory Droid Bridge", translations: { es: "Puente Factory Droid", fr: "Pont Factory Droid", ko: "Factory Droid 브리지", "zh-CN": "Factory Droid 桥接", "zh-TW": "Factory Droid 橋接", ru: "Мост Factory Droid", ja: "Factory Droid ブリッジ", tr: "Factory Droid köprüsü" }, slug: "guides/factory-droid" }, { label: "Ollama-Compatible Clients", translations: { es: "Clientes compatibles con Ollama", fr: "Clients compatibles Ollama", ko: "Ollama 호환 클라이언트", "zh-CN": "Ollama 兼容客户端", "zh-TW": "Ollama 相容用戶端", ru: "Клиенты, совместимые с Ollama", ja: "Ollama 互換クライアント", tr: "Ollama uyumlu istemciler" }, slug: "guides/ollama-clients" }, { label: "Cursor Private Inference", translations: { es: "Inferencia privada de Cursor", ko: "Cursor Private Inference", fr: "Cursor Private Inference", "zh-CN": "Cursor Private Inference", "zh-TW": "Cursor Private Inference", ru: "Cursor Private Inference", ja: "Cursor Private Inference", tr: "Cursor Private Inference" }, slug: "guides/cursor-private-inference" }, diff --git a/docs-site/src/content/docs/guides/google-vertex.md b/docs-site/src/content/docs/guides/google-vertex.md new file mode 100644 index 00000000000..1378cf4bc7d --- /dev/null +++ b/docs-site/src/content/docs/guides/google-vertex.md @@ -0,0 +1,35 @@ +--- +title: "Google Vertex AI" +description: How a Vertex AI provider discovers its models, which credential discovery uses, and how Gemini 4 models handle tool images. +--- + +This page covers behavior specific to the Google Vertex AI provider (`google-vertex`, or any +Google provider with `googleMode: "vertex"`). Setup and credentials are described in +[Providers](/guides/providers/). + +## Model discovery + +A Vertex AI provider with live models lists Google's publisher models and follows every page of +the listing. It shows Gemini and Gemma chat models and leaves out embedding, speech, Live API, +Imagen and Veo models, and image-output Gemini models such as `gemini-3.1-flash-image`, whose +model cards list function calling as unsupported. + +Discovery uses the provider's own credential: a `gcp-sa:` service account, a key, or with neither +configured, `GOOGLE_CLOUD_API_KEY` and then Application Default Credentials. If a configured +`keychain:` or `$VARIABLE` key cannot be read, discovery stops instead of trying another +credential, the same way requests refuse to start. When the listing cannot be read completely, +OpenCodex keeps the previous or configured models rather than publishing part of the list. + +Reasoning levels follow the preset: Gemini 4 Hydrogen offers low, medium, high (the default) and +xhigh; Gemini 4 Carbon and Argon offer low, medium and high. A model the preset gives no levels +to, such as `gemini-3-pro`, shows none, because requests to it carry no thinking level. + +## Tool images on Gemini 4 + +On Vertex AI, `gemini-4-argon`, `gemini-4-carbon` and `gemini-4-hydrogen` receive images +returned by tools, such as screenshots, inside the matching function response. This avoids the +upstream 400 error "Requests ending with a model turn are not supported" after an +image-producing tool call while preserving the images. Images from duplicate results or results +without a matching call are preserved in a separate message after the tool responses, with a +marker explaining that they could not be paired. No configuration change is needed; this handling +is specific to these Gemini 4 models on Vertex. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 588d4223556..af6610f6228 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -276,23 +276,8 @@ eligible Enterprise accounts. Cloud Code Assist serves Argon only to the Antigra OpenCodex sends Argon requests and model discovery with the CLI User-Agent (`antigravity/cli/1.3.4`) and keeps the IDE User-Agent for every other model. -On Vertex AI, `gemini-4-argon`, `gemini-4-carbon` and `gemini-4-hydrogen` receive images returned by tools, such as screenshots, inside -the matching function response. This avoids the upstream 400 error "Requests ending with a -model turn are not supported" after an image-producing tool call while preserving the images. -Images from duplicate results or results without a matching call are preserved in a separate -message after the tool responses, with a marker explaining that they could not be paired. -No configuration change is needed; this handling is specific to these Gemini 4 models on Vertex. - -A Vertex AI provider with live models lists Google's publisher models and follows every page of -the listing. It shows Gemini and Gemma chat models and leaves out embedding, speech, Live API, -Imagen and Veo models, and image-output Gemini models such as `gemini-3.1-flash-image`, whose -model cards list function calling as unsupported. Discovery uses the provider's own credential: a -`gcp-sa:` service account, a key, or with neither configured, `GOOGLE_CLOUD_API_KEY` and then -Application Default Credentials. If a configured `keychain:` or `$VARIABLE` key cannot be read, -discovery stops instead of trying another credential. When the listing cannot be read completely, -OpenCodex keeps the previous or configured models. Reasoning levels follow the preset: Gemini 4 -Hydrogen offers low, medium, high (the default) and xhigh; Carbon and Argon offer low, medium and -high. +Vertex AI model discovery, its credential rules and how Gemini 4 Argon, Carbon and Hydrogen +receive tool images are described in [Google Vertex AI](/guides/google-vertex/). For Gemini on Antigravity, including Argon, provider-marked thought summaries stay separate from the answer: Responses uses reasoning summary items, Chat Completions uses diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index 4ecf8720249..8ac02f6a298 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -102,9 +102,11 @@ import { } from "../../providers/model-discovery"; import { collectVertexPublisherModelPages, + countDiscoveryResponseBytes, extractGoogleNativeModelItems, resolveObservedVertexDiscoveryToken, resolveVertexDiscoveryToken, + vertexDiscoveryDeadline, } from "../../providers/google-vertex-model-discovery"; import { applyConfiguredHeadersLast, fetchOllamaShowEnrichment, ollamaShowEnrichable } from "../../providers/ollama-show"; import upstreamModelsSnapshot from "../data/upstream-models.json"; @@ -285,6 +287,8 @@ export async function fetchProviderModelsWithAuth( } return observed(configured, "authoritative"); } + // One Vertex deadline covers ADC, the first request and every follow-up page. + const vertexDeadline = effectiveGoogleMode(name, prov) === "vertex" ? vertexDiscoveryDeadline() : undefined; const auth: ModelsAuthResolution = prov.authMode === "oauth" && !mayResolveModelsOAuth(name, prov) ? { apiKey: undefined, observed: resolveAuth.kind === "observed" } : captured.observedAuth ?? (resolveAuth.kind === "refreshing" @@ -303,7 +307,7 @@ export async function fetchProviderModelsWithAuth( .catch(() => ({ apiKey: undefined, observed: false })) : { apiKey: effectiveGoogleMode(name, prov) === "vertex" - ? await resolveVertexDiscoveryToken(prov) + ? await resolveVertexDiscoveryToken(prov, vertexDeadline) : await resolveModelsAuthToken(name, prov), observed: false, } @@ -765,7 +769,7 @@ export async function fetchProviderModelsWithAuth( }, outboundDependencies) : await providerOutboundGet(name, prov, url, { headers, - signal: AbortSignal.timeout(8000), + signal: vertexDeadline ?? AbortSignal.timeout(8000), }, outboundDependencies); const redirectError = await providerRedirectError(res, url); if (redirectError) { @@ -790,7 +794,8 @@ export async function fetchProviderModelsWithAuth( const contentType = ( res.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() || "missing" ).slice(0, 80); - const bounded = await readBoundedDiscoveryJson(res, discovery.maxResponseBytes); + const vertexFirstPage = vertexDeadline ? countDiscoveryResponseBytes(res) : undefined; + const bounded = await readBoundedDiscoveryJson(vertexFirstPage?.response ?? res, discovery.maxResponseBytes); if (!bounded.ok) { const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" }); const diagnostic = bounded.reason === "response_too_large" @@ -864,7 +869,8 @@ export async function fetchProviderModelsWithAuth( return observed(withConfiguredRetention(azureMetadata ? live : forCache, { warnDrops: true }), "authoritative"); } const listing = effectiveGoogleMode(name, prov) === "vertex" - ? await collectVertexPublisherModelPages(url, bounded.value, discovery, + ? await collectVertexPublisherModelPages(url, bounded.value, + { ...discovery, firstPageBytes: vertexFirstPage?.bytes(), signal: vertexDeadline }, (pageUrl, signal) => providerOutboundGet(name, prov, pageUrl, { headers, signal }, outboundDependencies)) : { ok: true as const, value: bounded.value }; if (!listing.ok) { diff --git a/src/providers/google-vertex-model-discovery.ts b/src/providers/google-vertex-model-discovery.ts index f7a8878ad21..2f85bdc25d3 100644 --- a/src/providers/google-vertex-model-discovery.ts +++ b/src/providers/google-vertex-model-discovery.ts @@ -150,7 +150,7 @@ export function resolveObservedVertexDiscoveryToken(provider: OcxProviderConfig) */ export async function resolveVertexDiscoveryToken( provider: OcxProviderConfig, - signal: AbortSignal = AbortSignal.timeout(VERTEX_DISCOVERY_TIMEOUT_MS), + signal: AbortSignal = vertexDiscoveryDeadline(), ): Promise { if (provider.authMode === "forward" || isVertexApiKeyPoolPaused(provider)) return undefined; const credential = selectVertexDiscoveryCredential(provider); @@ -227,15 +227,23 @@ export type VertexPublisherModelListing = | { ok: true; value: unknown } | { ok: false; reason: "incomplete"; diagnostic: string }; -function countingResponse(response: Response, onBytes: (bytes: number) => void): Response { - if (!response.body) return response; +/** Wrap a discovery response so the bytes actually read from its body can be counted. */ +export function countDiscoveryResponseBytes(response: Response): { response: Response; bytes: () => number } { + let total = 0; + if (!response.body) return { response, bytes: () => total }; const counted = response.body.pipeThrough(new TransformStream({ transform(chunk, controller) { - onBytes(chunk.byteLength); + total += chunk.byteLength; controller.enqueue(chunk); }, })); - return new Response(counted, { status: response.status, headers: response.headers }); + return { response: new Response(counted, { status: response.status, headers: response.headers }), bytes: () => total }; +} + +/** The single deadline one Vertex discovery shares across ADC, the first request and every page. */ +export function vertexDiscoveryDeadline(cancel?: AbortSignal): AbortSignal { + const budget = AbortSignal.timeout(VERTEX_DISCOVERY_TIMEOUT_MS); + return cancel ? AbortSignal.any([cancel, budget]) : budget; } function nextVertexPageToken(page: Record): string | undefined | null { @@ -249,10 +257,11 @@ function nextVertexPageToken(page: Record): string | undefined * * One listing is bounded three ways: at most `VERTEX_DISCOVERY_MAX_PAGES` requests, at most the * discovery row limit across every page, and at most the discovery byte limit across every page - * (the first page is counted by its re-serialized size, which never exceeds what was read). When - * a bound, a page request, the shared deadline, or a repeated token stops the walk before the - * last page, the listing is reported incomplete: publishing the pages already read as the - * authoritative roster would drop every model on the pages that were not. + * (`firstPageBytes` is the byte count the caller read for page one). Every page runs under the + * caller's `signal`, the same deadline that bounded ADC and the first request. When a bound, a + * page request, the deadline, a repeated token, or a follow-up page that is not a Vertex page + * stops the walk before the last page, the listing is reported incomplete: publishing the pages + * already read as the authoritative roster would drop every model on the pages that were not. * * A first page without a `publisherModels` array is returned unchanged for the caller's generic * envelope handling (an OpenAI-compatible gateway in front of Vertex). @@ -260,7 +269,7 @@ function nextVertexPageToken(page: Record): string | undefined export async function collectVertexPublisherModelPages( firstUrl: string, firstValue: unknown, - limits: { maxModels: number; maxResponseBytes: number }, + limits: { maxModels: number; maxResponseBytes: number; firstPageBytes?: number; signal?: AbortSignal }, fetchPage: (url: string, signal: AbortSignal) => Promise, ): Promise { const first = firstValue !== null && typeof firstValue === "object" && !Array.isArray(firstValue) @@ -273,9 +282,9 @@ export async function collectVertexPublisherModelPages( const maxRows = Math.min(limits.maxModels, MODEL_DISCOVERY_MAX_MODELS); const maxBytes = Math.min(limits.maxResponseBytes, MODEL_DISCOVERY_MAX_RESPONSE_BYTES); const rows: unknown[] = [...first.publisherModels]; - let bytes = new TextEncoder().encode(JSON.stringify(firstValue)).byteLength; + let bytes = limits.firstPageBytes ?? new TextEncoder().encode(JSON.stringify(firstValue)).byteLength; const seenTokens = new Set(); - const signal = AbortSignal.timeout(VERTEX_DISCOVERY_TIMEOUT_MS); + const signal = limits.signal ?? vertexDiscoveryDeadline(); const incomplete = (diagnostic: string): VertexPublisherModelListing => ({ ok: false, reason: "incomplete", diagnostic: `${diagnostic} after ${seenTokens.size + 1} page(s) and ${rows.length} row(s)` }); @@ -295,7 +304,9 @@ export async function collectVertexPublisherModelPages( try { void response.body?.cancel().catch(() => undefined); } catch { /* best effort */ } return incomplete(`page request failed with HTTP ${response.status}`); } - const read = await readBoundedDiscoveryJson(countingResponse(response, count => { bytes += count; }), maxBytes - bytes); + const counted = countDiscoveryResponseBytes(response); + const read = await readBoundedDiscoveryJson(counted.response, maxBytes - bytes); + bytes += counted.bytes(); if (!read.ok) { return incomplete(read.reason === "response_too_large" ? `exceeded the ${maxBytes}-byte listing limit` : "returned an invalid page"); } @@ -304,7 +315,12 @@ export async function collectVertexPublisherModelPages( } catch { return incomplete(signal.aborted ? "exceeded the discovery time budget" : "page request failed"); } - if (!page || (page.publisherModels !== undefined && !Array.isArray(page.publisherModels))) { + // Google omits an empty repeated field, so an empty Vertex page is `{}` or carries only its + // token. Anything else without `publisherModels` (a gateway `data[]`, an error body) is not a + // Vertex page, and treating it as an empty last page would publish page one as the whole roster. + const vertexPage = page !== undefined && (Array.isArray(page.publisherModels) + || (page.publisherModels === undefined && Object.keys(page).every(key => key === "nextPageToken"))); + if (!page || !vertexPage) { return incomplete("returned a malformed page"); } if (Array.isArray(page.publisherModels)) rows.push(...page.publisherModels); diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 33171a46100..27e0b41117a 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -74,9 +74,10 @@ import { import { buildVertexProviderModelsRequest, collectVertexPublisherModelPages, + countDiscoveryResponseBytes, extractGoogleNativeModelItems, resolveVertexDiscoveryToken, - VERTEX_DISCOVERY_TIMEOUT_MS, + vertexDiscoveryDeadline, } from "../../providers/google-vertex-model-discovery"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; @@ -1654,10 +1655,11 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise undefined) : undefined; + const vertexDeadline = effectiveGoogleMode(name, prov) === "vertex" ? vertexDiscoveryDeadline(req.signal) : undefined; const apiKey = prov.authMode === "oauth" ? snapshot?.accessToken - : effectiveGoogleMode(name, prov) === "vertex" - ? await resolveVertexDiscoveryToken(prov, AbortSignal.any([req.signal, AbortSignal.timeout(VERTEX_DISCOVERY_TIMEOUT_MS)])) + : vertexDeadline + ? await resolveVertexDiscoveryToken(prov, vertexDeadline) : await resolveModelsAuthToken(name, prov); if ((prov.authMode === "oauth" || effectiveGoogleMode(name, prov) === "vertex") && !apiKey) { return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified (not logged in)" }); @@ -1735,7 +1737,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise providerOutboundGet(name, prov, pageUrl, { headers, signal: AbortSignal.any([req.signal, signal]) }, outboundDependencies)) + const listing = !ccaModels && vertexDeadline + ? await collectVertexPublisherModelPages(modelsUrl, bounded.value, + { ...discovery, firstPageBytes: vertexFirstPage?.bytes(), signal: vertexDeadline }, + (pageUrl, signal) => providerOutboundGet(name, prov, pageUrl, { headers, signal }, outboundDependencies)) : { ok: true as const, value: bounded.value }; if (!listing.ok) return jsonResponse({ ok: false, latencyMs, error: `upstream Vertex model listing is incomplete: ${listing.diagnostic}` }); // OpenAI-style lists (and Together top-level arrays) use the same validation/dedupe/filter diff --git a/structure/providers/google.md b/structure/providers/google.md index 40d4f9645ee..f11165d43fc 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -59,18 +59,21 @@ discovery credential the way inference does: a `gcp-sa:` marker uses that servic explicit `keychain:` or `$ENV` reference uses that key alone, and when it cannot be read discovery sends nothing and the catalog degrades, matching the routed-config rejection of the same reference, so `GOOGLE_CLOUD_API_KEY` and host ADC never stand in for it; otherwise a literal key, -then `GOOGLE_CLOUD_API_KEY`, then host ADC. ADC token resolution is bounded by the discovery -deadline (`VERTEX_DISCOVERY_TIMEOUT_MS`), and by the request for the management probe. -`nextPageToken` is followed under one deadline with three ceilings across all pages: five -requests, the discovery row limit and the discovery byte limit. A walk stopped by a ceiling, a -failed page, a malformed or repeated token, or the deadline is reported as an incomplete listing; -the catalog keeps its stale or configured rows and the probe fails, so a partial roster is never -cached as authoritative. Rows keep Gemini and Gemma chat models and drop embeddings, speech, Live, +then `GOOGLE_CLOUD_API_KEY`, then host ADC. One deadline (`vertexDiscoveryDeadline`, 8 s, also +tied to the request for the management probe) covers ADC token resolution, the first request and +every follow-up page. `nextPageToken` is followed with three ceilings across all pages: five +requests, the discovery row limit and the discovery byte limit, counted from the bytes actually +read, page one included. A follow-up page must be a Vertex page: `publisherModels`, or an empty +`{}` / token-only page, since Google omits empty repeated fields. A walk stopped by a ceiling, a +failed or foreign page, a malformed or repeated token, or the deadline is reported as an +incomplete listing; the catalog keeps its stale or configured rows and the probe fails, so a +partial roster is never cached as authoritative. Rows keep Gemini and Gemma chat models and drop embeddings, speech, Live, Imagen/Veo and SKUs without function calling: model cards checked 2026-11-13 list it unsupported for every image-output Gemini SKU, so `-image` ids stay out unless a card says otherwise. Each row's `reasoning_efforts` comes from the registry entry that `routedProviderConfig` would fill for the row (its own entry when the transport matches, else the destination entry), which is the -ladder the adapter serializes; an unladdered model such as `gemini-3-pro` advertises none. +ladder the adapter serializes; an unladdered model such as `gemini-3-pro` advertises none, and a +row's own `modelReasoningEfforts` / `noReasoningModels` still win in the catalog projection. `tests/adapters/google/vertex-model-discovery.test.ts` and `tests/adapters/google/vertex-catalog.test.ts` cover these boundaries. diff --git a/tests/adapters/google/vertex-catalog.test.ts b/tests/adapters/google/vertex-catalog.test.ts index 2995c1a7c10..8fbd7c32bfc 100644 --- a/tests/adapters/google/vertex-catalog.test.ts +++ b/tests/adapters/google/vertex-catalog.test.ts @@ -275,6 +275,18 @@ describe("Vertex catalog configuration", () => { expect(rows.some(row => row.id === excluded)).toBe(false); } }); + test("a configured reasoning ladder wins over the registry ladder discovery advertises", async () => { + globalThis.fetch = (() => Promise.resolve(Response.json({ + publisherModels: [{ name: "publishers/google/models/gemini-4-hydrogen" }, { name: "publishers/google/models/gemini-4-carbon" }], + }))) as typeof fetch; + const rows = await gatherRoutedModels({ providers: { "override-vertex": { + adapter: "google", googleMode: "vertex", baseUrl: "https://aiplatform.googleapis.com", apiKey: "vertex-api-key", liveModels: true, + modelReasoningEfforts: { "gemini-4-hydrogen": ["low", "high"] }, noReasoningModels: ["gemini-4-carbon"], + } } }); + expect(rows.find(row => row.id === "gemini-4-hydrogen")?.reasoningEfforts).toEqual(["low", "high"]); + expect(rows.find(row => row.id === "gemini-4-carbon")?.reasoningEfforts ?? []).toEqual([]); + }); + test("a paginated Vertex listing publishes every page, and an unfinished one is not published as authoritative", async () => { const pageTokens: string[] = []; let failSecondPage = false; diff --git a/tests/adapters/google/vertex-model-discovery.test.ts b/tests/adapters/google/vertex-model-discovery.test.ts index bd09c7bf4ee..bd4d8fb2934 100644 --- a/tests/adapters/google/vertex-model-discovery.test.ts +++ b/tests/adapters/google/vertex-model-discovery.test.ts @@ -189,6 +189,35 @@ describe("Vertex publisherModels pagination", () => { expect(listing).toEqual({ ok: true, value: first }); }); + test("an empty Vertex page continues, while a non-Vertex follow-up envelope is not read as the last page", async () => { + const empty = await collectVertexPublisherModelPages(firstUrl, { publisherModels: publisher("gemini-a"), nextPageToken: "t1" }, limits, + pages({ t1: { nextPageToken: "t2" }, t2: {} })); + expect(empty).toEqual({ ok: true, value: { publisherModels: publisher("gemini-a") } }); + for (const page of [{ data: [{ id: "gemini-b" }] }, { error: { code: 500 } }, [{ name: "x" }]]) { + const listing = await collectVertexPublisherModelPages(firstUrl, { publisherModels: publisher("gemini-a"), nextPageToken: "t1" }, limits, + pages({ t1: page })); + expect(listing).toMatchObject({ ok: false, reason: "incomplete" }); + } + }); + + test("the byte ceiling counts the bytes read for page one, and the caller's deadline bounds every page", async () => { + const small = { publisherModels: publisher("gemini-b") }; + const pageBytes = new TextEncoder().encode(JSON.stringify(small)).byteLength; + const fits = await collectVertexPublisherModelPages(firstUrl, { publisherModels: [], nextPageToken: "t1" }, + { ...limits, maxResponseBytes: pageBytes + 100, firstPageBytes: 50 }, pages({ t1: small })); + expect(fits.ok).toBe(true); + const over = await collectVertexPublisherModelPages(firstUrl, { publisherModels: [], nextPageToken: "t1" }, + { ...limits, maxResponseBytes: pageBytes + 100, firstPageBytes: 150 }, pages({ t1: small })); + expect(over.ok).toBe(false); + + const expired = AbortSignal.abort(); + let pageSignal: AbortSignal | undefined; + const late = await collectVertexPublisherModelPages(firstUrl, { publisherModels: [], nextPageToken: "t1" }, { ...limits, signal: expired }, + async (_url, signal) => { pageSignal = signal; signal.throwIfAborted(); return Response.json(small); }); + expect(pageSignal).toBe(expired); + expect(late).toMatchObject({ ok: false, diagnostic: expect.stringContaining("time budget") }); + }); + test("an unfinished walk is reported incomplete, never published as the whole roster", async () => { const endless: Record = {}; for (let page = 1; page <= VERTEX_DISCOVERY_MAX_PAGES + 2; page++) { From c4183f5305c4a426f73d6084b51a8f748027ff0a Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:34:31 +0900 Subject: [PATCH 07/11] fix(google-vertex): select the discovery credential on the same bytes as inference Security review follow-up for the #8583 carry. Discovery trimmed key values before classifying them and classified an explicit reference before resolving it, so a padded literal or " N/A " could reach GOOGLE_CLOUD_API_KEY / host ADC, and a $ENV or keychain reference resolving to a gcp-sa: marker was sent as an API key. The selector now follows usableResolvedApiKey, snapshotVertexEnvironment and resolveVertexApiKey step by step on untrimmed bytes, and a parity test compares it with the credential the inference adapter sends for 32 rows. Also qualify the Gemini 4 tool-image docs: the 400 was reproduced on Argon only. --- .../src/content/docs/guides/google-vertex.md | 7 +- .../google-vertex-model-discovery.ts | 54 ++++++++------- structure/providers/google.md | 2 +- .../google/vertex-model-discovery.test.ts | 69 ++++++++++++++++++- 4 files changed, 103 insertions(+), 29 deletions(-) diff --git a/docs-site/src/content/docs/guides/google-vertex.md b/docs-site/src/content/docs/guides/google-vertex.md index 1378cf4bc7d..45c841105e5 100644 --- a/docs-site/src/content/docs/guides/google-vertex.md +++ b/docs-site/src/content/docs/guides/google-vertex.md @@ -27,9 +27,10 @@ to, such as `gemini-3-pro`, shows none, because requests to it carry no thinking ## Tool images on Gemini 4 On Vertex AI, `gemini-4-argon`, `gemini-4-carbon` and `gemini-4-hydrogen` receive images -returned by tools, such as screenshots, inside the matching function response. This avoids the -upstream 400 error "Requests ending with a model turn are not supported" after an -image-producing tool call while preserving the images. Images from duplicate results or results +returned by tools, such as screenshots, inside the matching function response. On Argon this +avoids the upstream 400 error "Requests ending with a model turn are not supported" after an +image-producing tool call; Carbon and Hydrogen use the same documented function-response form, +though that error was not reproduced on them. Images from duplicate results or results without a matching call are preserved in a separate message after the tool responses, with a marker explaining that they could not be paired. No configuration change is needed; this handling is specific to these Gemini 4 models on Vertex. diff --git a/src/providers/google-vertex-model-discovery.ts b/src/providers/google-vertex-model-discovery.ts index 2f85bdc25d3..9f509824b37 100644 --- a/src/providers/google-vertex-model-discovery.ts +++ b/src/providers/google-vertex-model-discovery.ts @@ -5,7 +5,7 @@ import { getVertexAccessTokenWithAuthority, } from "../lib/gcp-adc"; import type { OcxProviderConfig } from "../types"; -import { isKeychainReference, resolveProviderApiKey } from "./api-key-resolve"; +import { resolveProviderApiKey } from "./api-key-resolve"; import { extractGoogleAiStudioModelItems } from "./google-ai-studio-model-discovery"; import { extractModelEnvelopeRows, @@ -82,26 +82,26 @@ function isVertexApiKeyPoolPaused(provider: OcxProviderConfig | undefined): bool && !pool.some(entry => !entry.disabled && entry.key === ref); } -function usableVertexKey(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed && trimmed !== "N/A" && !trimmed.startsWith("<") ? trimmed : undefined; -} - /** A `keychain:` or `$ENV` reference names one credential; it never widens to ambient ones. */ function isExplicitKeyReference(value: string | undefined): value is string { return typeof value === "string" && (value.startsWith("keychain:") || value.startsWith("$")); } /** - * The credential a Vertex discovery request uses, chosen the way inference chooses it - * (`snapshotVertexEnvironment` in routed-config and `resolveVertexApiKey` in the Google adapter). + * The credential a Vertex discovery request uses, chosen in the same order and on the same bytes + * as inference, so discovery can never publish another account's roster under this provider: * - * - `gcp-sa:` selects that stored service account and never `GOOGLE_CLOUD_API_KEY`. - * - An explicit `keychain:` / `$ENV` reference selects that key alone. When it cannot be read, - * `key` is undefined and discovery fails closed: falling through to `GOOGLE_CLOUD_API_KEY` or - * host ADC would publish another account's roster under this provider, and inference rejects - * the same unresolved reference. - * - Otherwise a literal key, then `GOOGLE_CLOUD_API_KEY`, then host ADC. + * 1. `usableResolvedApiKey` (routed-config): a `gcp-sa:` marker passes through verbatim; anything + * else is resolved (`keychain:` / `$ENV`) and a blank result counts as no key. No trimming. + * 2. `snapshotVertexEnvironment` (routed-config): an explicit `keychain:` / `$ENV` reference that + * resolved to nothing usable fails closed (`key` undefined), never reaching + * `GOOGLE_CLOUD_API_KEY` or host ADC. A value that is a `gcp-sa:` marker, configured directly + * or reached through a reference, selects ADC for that stored service account. + * 3. `resolveVertexApiKey` (Google adapter): `N/A` and `<...>` placeholders are not keys; then + * `GOOGLE_CLOUD_API_KEY`, then host ADC. + * + * `tests/adapters/google/vertex-model-discovery.test.ts` checks this against the credential the + * inference adapter actually sends for the same rows. */ export type VertexDiscoveryCredential = | { kind: "api-key"; key: string | undefined } @@ -109,18 +109,24 @@ export type VertexDiscoveryCredential = export function selectVertexDiscoveryCredential(provider: OcxProviderConfig): VertexDiscoveryCredential { const configured = provider.apiKey; - if (typeof configured === "string" && configured.startsWith(GCP_CREDENTIAL_MARKER_PREFIX)) { - return { kind: "adc", marker: configured }; + const reference = provider._apiKeyAttempt?.reference ?? configured; + // A routed row already carries the resolved value beside its reference; never resolve it twice. + const alreadyResolved = provider._apiKeyAttempt !== undefined && reference !== configured; + let value: string | undefined; + if (alreadyResolved || (typeof configured === "string" && configured.startsWith(GCP_CREDENTIAL_MARKER_PREFIX))) { + value = configured; + } else { + const resolved = resolveProviderApiKey(configured); + value = typeof resolved === "string" && resolved.trim().length > 0 ? resolved : undefined; } - const reference = provider._apiKeyAttempt?.reference; - if (isExplicitKeyReference(configured) || isExplicitKeyReference(reference)) { - if (!isExplicitKeyReference(configured)) return { kind: "api-key", key: usableVertexKey(configured) }; - const readable = !configured.startsWith("keychain:") || isKeychainReference(configured); - return { kind: "api-key", key: readable ? usableVertexKey(resolveProviderApiKey(configured)) : undefined }; + if (isExplicitKeyReference(reference) + && (!value?.trim() || value === "N/A" || value.startsWith("<") || reference === "keychain:")) { + return { kind: "api-key", key: undefined }; } - const literal = usableVertexKey(configured); - if (literal) return { kind: "api-key", key: literal }; - const ambientKey = usableVertexKey(process.env.GOOGLE_CLOUD_API_KEY); + if (value?.startsWith(GCP_CREDENTIAL_MARKER_PREFIX)) return { kind: "adc", marker: value }; + const key = value && !value.startsWith("<") && value !== "N/A" ? value : undefined; + if (key) return { kind: "api-key", key }; + const ambientKey = process.env.GOOGLE_CLOUD_API_KEY; return ambientKey ? { kind: "api-key", key: ambientKey } : { kind: "adc" }; } diff --git a/structure/providers/google.md b/structure/providers/google.md index f11165d43fc..c5959a55b5b 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -215,7 +215,7 @@ For Cloud Code Assist routed `claude-*` models, the complete matched `functionRe precedes its sibling images in the same user turn; images keep call order, MIME types and bytes. Interleaving an image before the remaining responses makes CCA Claude reject the batch as missing later tool results. Single-call and text-only batches keep their existing shape. CCA Gemini, -non-Claude CCA models, AI Studio and non-Argon Vertex retain interleaved image siblings. +non-Claude CCA models, AI Studio and Vertex models other than Gemini 4 Argon, Carbon and Hydrogen retain interleaved image siblings. `tests/adapters/google/google-tool-result-adjacency.test.ts` checks the final compiled request for three parallel calls, reversed results, distinct images and route isolation. For Vertex `gemini-4-argon`, `gemini-4-carbon` and `gemini-4-hydrogen`, matched results carry representable data-URL images inside diff --git a/tests/adapters/google/vertex-model-discovery.test.ts b/tests/adapters/google/vertex-model-discovery.test.ts index bd4d8fb2934..755cde5bcb5 100644 --- a/tests/adapters/google/vertex-model-discovery.test.ts +++ b/tests/adapters/google/vertex-model-discovery.test.ts @@ -1,6 +1,6 @@ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; import { Buffer } from "node:buffer"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createGoogleAdapter } from "../../../src/adapters/google"; @@ -245,3 +245,70 @@ describe("Vertex publisherModels pagination", () => { expect(bytes.ok).toBe(false); }); }); + +describe("Vertex discovery credential parity with inference", () => { + const parsedRequest = { modelId: "gemini-4-hydrogen", stream: false, + context: { messages: [{ role: "user", content: "hi" }], systemPrompt: [], tools: [] }, options: {} } as unknown as OcxParsedRequest; + + /** The credential an inference request for this row actually carries, or undefined when it refuses. */ + async function inferenceCredential(provider: OcxProviderConfig): Promise { + try { + const request = await createGoogleAdapter(routedProviderConfig("parity-vertex", provider)).buildRequest(parsedRequest); + const headers = Object.fromEntries(Object.entries(request.headers).map(([name, value]) => [name.toLowerCase(), String(value)])); + return headers["x-goog-api-key"] ?? headers.authorization?.replace(/^Bearer /, ""); + } catch { + return undefined; + } + } + + test("discovery sends exactly the credential inference sends, or nothing when inference refuses", async () => { + const markerCredential = JSON.parse(readFileSync(saPath, "utf8")) as Record; + const keychain: Record = { + padded: " keychain-padded-key ", + "marker-ref": "gcp-sa:svc-account", + "svc-account": JSON.stringify({ ...markerCredential, client_email: "marker@example.test" }), + }; + setProviderKeychainEntryFactoryForTests((_service, account) => ({ + getPassword: () => keychain[account] ?? null, setPassword: () => {}, deletePassword: () => true, + })); + process.env.GOOGLE_APPLICATION_CREDENTIALS = saPath; + process.env.CLOUDSDK_CONFIG = join(tmp, "no-gcloud"); + process.env.VERTEX_PARITY_MARKER = "gcp-sa:svc-account"; + process.env.VERTEX_PARITY_PADDED = " env-reference-key "; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url; + if (url !== OAUTH_TOKEN_URL) return new Response("unexpected", { status: 404 }); + const assertion = new URLSearchParams(String(init?.body ?? "")).get("assertion") ?? ""; + const issuer = JSON.parse(Buffer.from(assertion.split(".")[1] ?? "", "base64url").toString("utf8")).iss; + return Response.json({ access_token: `token-for-${issuer}`, expires_in: 3600, token_type: "Bearer" }); + }) as typeof fetch; + + const keys: Array = [ + "literal-key", " padded-literal ", " N/A ", "N/A", "", undefined, "", + "keychain:padded", "keychain:missing", "keychain:", "keychain:marker-ref", + "$VERTEX_PARITY_MARKER", "${VERTEX_PARITY_PADDED}", "$VERTEX_PARITY_UNSET", + "gcp-sa:svc-account", "gcp-sa:", + ]; + try { + for (const ambient of ["ambient-env-key", undefined]) { + if (ambient) process.env.GOOGLE_CLOUD_API_KEY = ambient; else delete process.env.GOOGLE_CLOUD_API_KEY; + for (const apiKey of keys) { + const provider = vertex({ project: "parity-project", location: "global", ...(apiKey === undefined ? {} : { apiKey }) }); + __resetVertexTokenCache(); + const expected = await inferenceCredential(provider); + __resetVertexTokenCache(); + expect({ apiKey, ambient, credential: await resolveVertexDiscoveryToken(provider) }) + .toEqual({ apiKey, ambient, credential: expected }); + } + } + // The matrix reaches every outcome: a key, both ADC identities, and a refusal. + expect(await resolveVertexDiscoveryToken(vertex({ apiKey: "$VERTEX_PARITY_MARKER" }))).toBe("token-for-marker@example.test"); + expect(await resolveVertexDiscoveryToken(vertex())).toBe("token-for-svc@example.test"); + expect(await resolveVertexDiscoveryToken(vertex({ apiKey: " N/A " }))).toBe(" N/A "); + expect(await resolveVertexDiscoveryToken(vertex({ apiKey: "keychain:missing" }))).toBeUndefined(); + } finally { + delete process.env.VERTEX_PARITY_MARKER; + delete process.env.VERTEX_PARITY_PADDED; + } + }); +}); From df8528ebe384235c69dba77aea97fec8c33df637 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:40:56 +0900 Subject: [PATCH 08/11] fix(google-vertex): bind a discovery token to the selection that issued it Security review follow-up for the #8583 carry. Credential selection ran again after the async ADC exchange, so an API key exported mid-exchange could make discovery send the ADC bearer in x-goog-api-key. resolveVertexDiscoveryAuth now returns the token with its kind and marker and discards it if the selection changed during the exchange, and beforeSend cancels every catalog, page and probe request whose row no longer selects that credential or whose headers carry it in the other scheme's header. Regression tests cover the blocked-exchange race end to end. Also share one Gemini reasoning-ladder definition between the google and google-vertex presets. --- src/codex/catalog/provider-models.ts | 12 +++- .../google-vertex-model-discovery.ts | 68 ++++++++++++++++--- src/providers/registry/entries-core.ts | 44 +++++++----- src/server/management/provider-routes.ts | 12 ++-- structure/providers/google.md | 6 +- .../google/vertex-model-discovery.test.ts | 66 +++++++++++++++++- 6 files changed, 173 insertions(+), 35 deletions(-) diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index 8ac02f6a298..7991b2ab684 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -105,8 +105,10 @@ import { countDiscoveryResponseBytes, extractGoogleNativeModelItems, resolveObservedVertexDiscoveryToken, - resolveVertexDiscoveryToken, + resolveVertexDiscoveryAuth, + vertexDiscoveryAuthIsCurrent, vertexDiscoveryDeadline, + type VertexDiscoveryAuth, } from "../../providers/google-vertex-model-discovery"; import { applyConfiguredHeadersLast, fetchOllamaShowEnrichment, ollamaShowEnrichable } from "../../providers/ollama-show"; import upstreamModelsSnapshot from "../data/upstream-models.json"; @@ -289,6 +291,7 @@ export async function fetchProviderModelsWithAuth( } // One Vertex deadline covers ADC, the first request and every follow-up page. const vertexDeadline = effectiveGoogleMode(name, prov) === "vertex" ? vertexDiscoveryDeadline() : undefined; + let vertexAuth: VertexDiscoveryAuth | undefined; const auth: ModelsAuthResolution = prov.authMode === "oauth" && !mayResolveModelsOAuth(name, prov) ? { apiKey: undefined, observed: resolveAuth.kind === "observed" } : captured.observedAuth ?? (resolveAuth.kind === "refreshing" @@ -307,12 +310,14 @@ export async function fetchProviderModelsWithAuth( .catch(() => ({ apiKey: undefined, observed: false })) : { apiKey: effectiveGoogleMode(name, prov) === "vertex" - ? await resolveVertexDiscoveryToken(prov, vertexDeadline) + ? (vertexAuth = await resolveVertexDiscoveryAuth(prov, vertexDeadline))?.token : await resolveModelsAuthToken(name, prov), observed: false, } : resolveAuth.resolve(name, prov)); const apiKey = auth.apiKey; + // A passive gather's Vertex credential is always an API key (ADC needs an async exchange). + if (vertexDeadline && !vertexAuth && apiKey && prov.authMode !== "oauth") vertexAuth = { token: apiKey, kind: "api-key" }; if ((prov.authMode === "oauth" && !apiKey) || isProviderApiKeyPaused(prov)) { return observed(configured, "degraded"); } @@ -759,7 +764,8 @@ export async function fetchProviderModelsWithAuth( // name matches any baseUrl by design. Retargeted or renamed custom rows // fetch a different URL and keep the rejection. if (!maySendAnthropicDiscovery()) return observed(withConfiguredRetention(failedDiscoveryConfigured), "degraded"); - const outboundDependencies = { isCanonicalUrl: isRegistryModelDiscoveryUrl, beforeSend: maySendAnthropicDiscovery }; + const outboundDependencies = { isCanonicalUrl: isRegistryModelDiscoveryUrl, + beforeSend: () => maySendAnthropicDiscovery() && (!vertexAuth || vertexDiscoveryAuthIsCurrent(prov, vertexAuth, headers)) }; const antigravityObservation = cloudCodeAssist ? captureAntigravityModelObservation() : undefined; const res = request.method === "POST" ? await providerOutboundPost(name, prov, url, { diff --git a/src/providers/google-vertex-model-discovery.ts b/src/providers/google-vertex-model-discovery.ts index 9f509824b37..0897c18b9a7 100644 --- a/src/providers/google-vertex-model-discovery.ts +++ b/src/providers/google-vertex-model-discovery.ts @@ -148,26 +148,78 @@ export function resolveObservedVertexDiscoveryToken(provider: OcxProviderConfig) } /** - * Resolve the Vertex model-discovery credential (API key or GCP ADC access token). + * A resolved discovery credential bound to the selection that produced it. The token alone cannot + * say where it belongs: an ADC bearer placed in `x-goog-api-key` would leak to the wrong scheme. + */ +export interface VertexDiscoveryAuth { + token: string; + kind: "api-key" | "adc"; + marker?: string; +} + +function sameVertexSelection(left: VertexDiscoveryCredential, right: VertexDiscoveryCredential): boolean { + if (left.kind === "api-key") return right.kind === "api-key" && left.key === right.key; + return right.kind === "adc" && left.marker === right.marker; +} + +/** + * Resolve the Vertex model-discovery credential (API key or GCP ADC access token) with its kind. * * `signal` bounds the ADC exchange by the discovery time budget; without it a stalled token * endpoint would hold discovery through every token retry. Paused key pools, unreadable explicit * references and malformed `gcp-sa:` markers return `undefined` and never reach ambient credentials. + * The selection is taken again after the exchange: if it changed meanwhile (an ambient key appeared, + * a reference now resolves elsewhere) the token is discarded rather than sent under the new selection. */ -export async function resolveVertexDiscoveryToken( +export async function resolveVertexDiscoveryAuth( provider: OcxProviderConfig, signal: AbortSignal = vertexDiscoveryDeadline(), -): Promise { +): Promise { if (provider.authMode === "forward" || isVertexApiKeyPoolPaused(provider)) return undefined; - const credential = selectVertexDiscoveryCredential(provider); - if (credential.kind === "api-key") return credential.key; + const selection = selectVertexDiscoveryCredential(provider); + if (selection.kind === "api-key") return selection.key ? { token: selection.key, kind: "api-key" } : undefined; + let token: string; try { - const markerAccount = gcpCredentialMarkerAccount(credential.marker); - const { token } = await getVertexAccessTokenWithAuthority({ signal, ...(markerAccount ? { markerAccount } : {}) }); - return token; + const markerAccount = gcpCredentialMarkerAccount(selection.marker); + ({ token } = await getVertexAccessTokenWithAuthority({ signal, ...(markerAccount ? { markerAccount } : {}) })); } catch { return undefined; } + if (!sameVertexSelection(selection, selectVertexDiscoveryCredential(provider))) return undefined; + return { token, kind: "adc", ...(selection.marker ? { marker: selection.marker } : {}) }; +} + +/** The token from `resolveVertexDiscoveryAuth`, for callers that only need to know one exists. */ +export async function resolveVertexDiscoveryToken( + provider: OcxProviderConfig, + signal?: AbortSignal, +): Promise { + return (await resolveVertexDiscoveryAuth(provider, signal))?.token; +} + +/** + * Send-time check, run before every discovery request (`beforeSend`): the row still selects the + * credential this token came from, and the outgoing headers carry it in that scheme's header and + * not the other one. A selection that changed after resolution, or headers built for a different + * selection, cancel the send so a token never reaches a scheme it was not issued for. + */ +export function vertexDiscoveryAuthIsCurrent( + provider: OcxProviderConfig, + auth: VertexDiscoveryAuth, + headers: Record, +): boolean { + if (provider.authMode === "forward" || isVertexApiKeyPoolPaused(provider)) return false; + const current = selectVertexDiscoveryCredential(provider); + const issued: VertexDiscoveryCredential = auth.kind === "api-key" + ? { kind: "api-key", key: auth.token } + : { kind: "adc", ...(auth.marker ? { marker: auth.marker } : {}) }; + if (!sameVertexSelection(issued, current)) return false; + const header = (name: string): string | undefined => + Object.entries(headers).find(([key]) => key.toLowerCase() === name)?.[1]; + const bearer = `Bearer ${auth.token}`; + return auth.kind === "api-key" + ? header("x-goog-api-key") === auth.token && header("authorization") !== bearer + : header("authorization") === bearer && header("x-goog-api-key") !== auth.token; } /** diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index 3d24d085774..8a93377e6c0 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -120,6 +120,28 @@ const ANTHROPIC_FAST_MODELS: Readonly> = Object.freeze({ "claude-opus-5": true, "claude-opus-4-8": true, }); +/** + * Gemini thinking ladders shared by the Gemini API (`google`) and Vertex AI (`google-vertex`) + * presets, so the two routes cannot advertise different rungs for one model. + * 3.7 and 3.8 omit `minimal`: Google documents it as a validation error on both model pages, so + * advertising it hands the user a rung the API rejects. 3.5/3.6 keep theirs — their pages still + * list it, and this unit has no evidence to change them. Gemini 4 Argon (GA 2026-10-13): + * low/medium(default)/high; `minimal` and thinking_budget return HTTP 400 per + * ai.google.dev/gemini-api/docs/models/gemini-4-argon. Gemini 4 Hydrogen: low/medium/high/xhigh, + * default high, verified live on Vertex AI by the author of #8583 (2026-11-13). + */ +const GEMINI_MODEL_REASONING_EFFORTS: Readonly> = { + "gemini-4-hydrogen": ["low", "medium", "high", "xhigh"], + "gemini-4-carbon": ["low", "medium", "high"], + "gemini-4-argon": ["low", "medium", "high"], + "gemini-3.8-flash": ["low", "medium", "high"], + "gemini-3.7-flash": ["low", "medium", "high"], + "gemini-3.6-flash": ["minimal", "low", "medium", "high"], + "gemini-3.5-flash": ["minimal", "low", "medium", "high"], + "gemini-3.1-pro-preview": ["low", "medium", "high"], +}; +const GEMINI_MODEL_DEFAULT_REASONING_EFFORTS: Readonly> = { "gemini-4-hydrogen": "high" }; + const ANTHROPIC_FAST_TIER_DESCRIPTION = "Claude fast mode: faster output at 2x price; needs usage credits (subscription) or fast-mode access (API)"; @@ -1216,22 +1238,8 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ dashboardUrl: "https://aistudio.google.com/apikey", defaultModel: "gemini-3.5-flash", models: ["gemini-4-argon", "gemini-3.8-flash", "gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview", "gemini-3.7-flash"], modelContextWindows: { "gemini-4-hydrogen": 1_048_576, "gemini-4-carbon": 1_048_576, "gemini-4-argon": 1_048_576, "gemini-3.8-flash": 1_048_576, "gemini-3.6-flash": 1_048_576, "gemini-3.5-flash": 1_000_000, "gemini-3.5-flash-lite": 1_048_576, "gemini-3.7-flash": 1_048_576 }, modelInputModalities: { "gemini-4-hydrogen": ["text", "image"], "gemini-4-carbon": ["text", "image"], "gemini-4-argon": ["text", "image"], "gemini-3.8-flash": ["text", "image"], "gemini-3.6-flash": ["text", "image"], "gemini-3.5-flash-lite": ["text", "image"], "gemini-3.7-flash": ["text", "image"] }, - modelReasoningEfforts: { - // 3.7 and 3.8 omit `minimal`: Google documents it as a validation error on both model - // pages, so advertising it hands the user a rung the API rejects. 3.5/3.6 keep theirs — - // their pages still list it, and this unit has no evidence to change them. - // Gemini 4 Argon (GA 2026-10-13): low/medium(default)/high; `minimal` and thinking_budget - // return HTTP 400 per ai.google.dev/gemini-api/docs/models/gemini-4-argon. - "gemini-4-hydrogen": ["low", "medium", "high", "xhigh"], - "gemini-4-carbon": ["low", "medium", "high"], - "gemini-4-argon": ["low", "medium", "high"], - "gemini-3.8-flash": ["low", "medium", "high"], - "gemini-3.7-flash": ["low", "medium", "high"], - "gemini-3.6-flash": ["minimal", "low", "medium", "high"], - "gemini-3.5-flash": ["minimal", "low", "medium", "high"], - "gemini-3.1-pro-preview": ["low", "medium", "high"], - }, - modelDefaultReasoningEfforts: { "gemini-4-hydrogen": "high" }, + modelReasoningEfforts: { ...GEMINI_MODEL_REASONING_EFFORTS }, + modelDefaultReasoningEfforts: { ...GEMINI_MODEL_DEFAULT_REASONING_EFFORTS }, jawcodeBundle: "google", extraMetadataAliases: ["gemini"], }, // 2026-07-10: defaultModel is frozen pending Vertex-specific Tier-2 evidence; Gemini API @@ -1241,8 +1249,8 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ modelDiscovery: { path: "/v1beta1/publishers/google/models", query: { pageSize: "300" } }, modelContextWindows: { "gemini-4-hydrogen": 1_048_576, "gemini-4-carbon": 1_048_576, "gemini-4-argon": 1_048_576, "gemini-3.8-flash": 1_048_576, "gemini-3.6-flash": 1_048_576, "gemini-3.5-flash": 1_048_576, "gemini-3.5-flash-lite": 1_048_576 }, modelInputModalities: { "gemini-4-hydrogen": ["text", "image"], "gemini-4-carbon": ["text", "image"], "gemini-4-argon": ["text", "image"], "gemini-3.8-flash": ["text", "image"], "gemini-3.6-flash": ["text", "image"], "gemini-3.5-flash": ["text", "image"], "gemini-3.5-flash-lite": ["text", "image"] }, - modelReasoningEfforts: { "gemini-4-hydrogen": ["low", "medium", "high", "xhigh"], "gemini-4-carbon": ["low", "medium", "high"], "gemini-4-argon": ["low", "medium", "high"], "gemini-3.8-flash": ["low", "medium", "high"], "gemini-3.7-flash": ["low", "medium", "high"], "gemini-3.6-flash": ["minimal", "low", "medium", "high"], "gemini-3.5-flash": ["minimal", "low", "medium", "high"], "gemini-3.1-pro-preview": ["low", "medium", "high"] }, - modelDefaultReasoningEfforts: { "gemini-4-hydrogen": "high" }, + modelReasoningEfforts: { ...GEMINI_MODEL_REASONING_EFFORTS }, + modelDefaultReasoningEfforts: { ...GEMINI_MODEL_DEFAULT_REASONING_EFFORTS }, }, // Antigravity discovers models with a POST to the CCA `:fetchAvailableModels` RPC, which // `buildModelsRequest` already built by hand. Declaring it here changes no request URL — the diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 27e0b41117a..d646531f497 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -76,8 +76,10 @@ import { collectVertexPublisherModelPages, countDiscoveryResponseBytes, extractGoogleNativeModelItems, - resolveVertexDiscoveryToken, + resolveVertexDiscoveryAuth, + vertexDiscoveryAuthIsCurrent, vertexDiscoveryDeadline, + type VertexDiscoveryAuth, } from "../../providers/google-vertex-model-discovery"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; @@ -1656,10 +1658,11 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise undefined) : undefined; const vertexDeadline = effectiveGoogleMode(name, prov) === "vertex" ? vertexDiscoveryDeadline(req.signal) : undefined; + let vertexAuth: VertexDiscoveryAuth | undefined; const apiKey = prov.authMode === "oauth" ? snapshot?.accessToken : vertexDeadline - ? await resolveVertexDiscoveryToken(prov, vertexDeadline) + ? (vertexAuth = await resolveVertexDiscoveryAuth(prov, vertexDeadline))?.token : await resolveModelsAuthToken(name, prov); if ((prov.authMode === "oauth" || effectiveGoogleMode(name, prov) === "vertex") && !apiKey) { return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified (not logged in)" }); @@ -1726,9 +1729,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise prov.authMode !== "oauth" || (mayResolveModelsOAuth(name, config.providers[name]) + beforeSend: () => (!vertexAuth || vertexDiscoveryAuthIsCurrent(prov, vertexAuth, headers)) + && (prov.authMode !== "oauth" || (mayResolveModelsOAuth(name, config.providers[name]) && modelsOAuthTargetMatches(name, config.providers[name], authorizedTarget) - && (name !== "anthropic2" || new URL(modelsUrl).href === authorizedTarget)) }; + && (name !== "anthropic2" || new URL(modelsUrl).href === authorizedTarget))) }; const res = method === "POST" ? await providerOutboundPost(name, prov, modelsUrl, { headers, diff --git a/structure/providers/google.md b/structure/providers/google.md index c5959a55b5b..dfb85519c24 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -59,7 +59,11 @@ discovery credential the way inference does: a `gcp-sa:` marker uses that servic explicit `keychain:` or `$ENV` reference uses that key alone, and when it cannot be read discovery sends nothing and the catalog degrades, matching the routed-config rejection of the same reference, so `GOOGLE_CLOUD_API_KEY` and host ADC never stand in for it; otherwise a literal key, -then `GOOGLE_CLOUD_API_KEY`, then host ADC. One deadline (`vertexDiscoveryDeadline`, 8 s, also +then `GOOGLE_CLOUD_API_KEY`, then host ADC. The selector follows inference's steps on the same +untrimmed bytes, and a parity test compares it with the credential the adapter sends. A resolved +credential keeps its kind and marker (`VertexDiscoveryAuth`): a selection that changes during the +ADC exchange discards the token, and `beforeSend` cancels any request whose row no longer selects +that credential or whose headers place it in the other scheme's header. One deadline (`vertexDiscoveryDeadline`, 8 s, also tied to the request for the management probe) covers ADC token resolution, the first request and every follow-up page. `nextPageToken` is followed with three ceilings across all pages: five requests, the discovery row limit and the discovery byte limit, counted from the bytes actually diff --git a/tests/adapters/google/vertex-model-discovery.test.ts b/tests/adapters/google/vertex-model-discovery.test.ts index 755cde5bcb5..08373e1b5fe 100644 --- a/tests/adapters/google/vertex-model-discovery.test.ts +++ b/tests/adapters/google/vertex-model-discovery.test.ts @@ -9,13 +9,18 @@ import { collectVertexPublisherModelPages, extractGoogleNativeModelItems, resolveObservedVertexDiscoveryToken, + resolveVertexDiscoveryAuth, resolveVertexDiscoveryToken, VERTEX_DISCOVERY_MAX_PAGES, + vertexDiscoveryAuthIsCurrent, } from "../../../src/providers/google-vertex-model-discovery"; import { setProviderKeychainEntryFactoryForTests } from "../../../src/providers/key-store"; import { getProviderRegistryEntry } from "../../../src/providers/registry"; import { routedProviderConfig } from "../../../src/providers/routed-config"; -import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; +import { gatherRoutedModels } from "../../../src/codex/catalog"; +import { clearModelCache } from "../../../src/codex/model-cache"; +import { withStubbedProviderFetch } from "../../helpers/catalog-provider-fetch"; import { removeTreeWithRetry } from "../../helpers/remove-tree"; const OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; @@ -312,3 +317,62 @@ describe("Vertex discovery credential parity with inference", () => { } }); }); + +describe("Vertex discovery credential stays bound to its selection", () => { + test("a selection that changes during the ADC exchange discards the token, and send-time checks reject a mismatched header", async () => { + process.env.GOOGLE_APPLICATION_CREDENTIALS = saPath; + process.env.CLOUDSDK_CONFIG = join(tmp, "no-gcloud"); + delete process.env.GOOGLE_CLOUD_API_KEY; + let release!: () => void; + const exchangeStarted = new Promise(resolveStarted => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url; + if (url !== OAUTH_TOKEN_URL) return new Response("unexpected", { status: 404 }); + resolveStarted(); + await new Promise(resolveRelease => { release = resolveRelease; }); + return Response.json({ access_token: "host-adc-token", expires_in: 3600, token_type: "Bearer" }); + }) as typeof fetch; + }); + const pending = resolveVertexDiscoveryAuth(vertex()); + await exchangeStarted; + process.env.GOOGLE_CLOUD_API_KEY = "ambient-env-key"; + release(); + expect(await pending).toBeUndefined(); + + __resetVertexTokenCache(); + delete process.env.GOOGLE_CLOUD_API_KEY; + const adc = { token: "host-adc-token", kind: "adc" as const }; + expect(vertexDiscoveryAuthIsCurrent(vertex(), adc, { Authorization: "Bearer host-adc-token" })).toBe(true); + expect(vertexDiscoveryAuthIsCurrent(vertex(), adc, { "x-goog-api-key": "host-adc-token" })).toBe(false); + process.env.GOOGLE_CLOUD_API_KEY = "ambient-env-key"; + expect(vertexDiscoveryAuthIsCurrent(vertex(), adc, { Authorization: "Bearer host-adc-token" })).toBe(false); + const key = { token: "ambient-env-key", kind: "api-key" as const }; + expect(vertexDiscoveryAuthIsCurrent(vertex(), key, { "X-Goog-Api-Key": "ambient-env-key" })).toBe(true); + process.env.GOOGLE_CLOUD_API_KEY = "rotated-env-key"; + expect(vertexDiscoveryAuthIsCurrent(vertex(), key, { "x-goog-api-key": "ambient-env-key" })).toBe(false); + }); + + test("catalog discovery never sends an ADC token after the selection moved to an API key mid-exchange", async () => { + process.env.GOOGLE_APPLICATION_CREDENTIALS = saPath; + process.env.CLOUDSDK_CONFIG = join(tmp, "no-gcloud"); + delete process.env.GOOGLE_CLOUD_API_KEY; + const vertexRequests: Array> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url; + if (url === OAUTH_TOKEN_URL) { + // The operator exports an API key while the token exchange is in flight. + process.env.GOOGLE_CLOUD_API_KEY = "ambient-env-key"; + return Response.json({ access_token: "host-adc-token", expires_in: 3600, token_type: "Bearer" }); + } + vertexRequests.push(Object.fromEntries(new Headers(init?.headers).entries())); + return Response.json({ publisherModels: [{ name: "publishers/google/models/gemini-4-hydrogen" }] }); + }) as typeof fetch; + const config: OcxConfig = { providers: { "race-vertex": { + adapter: "google", googleMode: "vertex", baseUrl: "https://aiplatform.googleapis.com", + project: "race-project", location: "global", liveModels: true, models: ["configured-only"], + } } }; + await gatherRoutedModels(withStubbedProviderFetch(config)); + expect(JSON.stringify(vertexRequests)).not.toContain("host-adc-token"); + clearModelCache(); + }); +}); From c08f85e1203e1f7b70475db2e49d94cd60d9956a Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:43:35 +0900 Subject: [PATCH 09/11] docs(google-vertex): add the Spanish Vertex AI page the localization coverage test requires --- docs-site/astro.config.mjs | 2 +- .../content/docs/es/guides/google-vertex.md | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 docs-site/src/content/docs/es/guides/google-vertex.md diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 0eb15fe670b..3fcb94e56c4 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -100,7 +100,7 @@ export default defineConfig({ { label: "Response Inspection", translations: { es: "Inspección de respuestas", fr: "Inspection des réponses et réponses volumineuses", ko: "응답 검사와 대용량 응답", "zh-CN": "响应检查与大型响应", "zh-TW": "回應檢查與大型回應", ru: "Проверка ответов и большие ответы", ja: "レスポンスの検査と大きなレスポンス", tr: "Yanıt incelemesi ve büyük yanıtlar" }, slug: "guides/response-inspection" }, { label: "Remote Workspace", translations: { es: "Espacio de trabajo remoto", fr: "Espace de travail distant", ko: "원격 워크스페이스", "zh-CN": "远程工作区", "zh-TW": "遠端工作區", ru: "Удалённая рабочая область", ja: "リモートワークスペース", tr: "Uzak Çalışma Alanı" }, slug: "guides/remote-workspace" }, { label: "Providers", translations: { es: "Proveedores", fr: "Fournisseurs", ko: "프로바이더", "zh-CN": "提供商", "zh-TW": "供應商", ru: "Провайдеры", ja: "プロバイダー", tr: "Sağlayıcılar" }, slug: "guides/providers" }, - { label: "Google Vertex AI", slug: "guides/google-vertex" }, + { label: "Google Vertex AI", translations: { es: "Google Vertex AI" }, slug: "guides/google-vertex" }, { label: "Factory Droid Bridge", translations: { es: "Puente Factory Droid", fr: "Pont Factory Droid", ko: "Factory Droid 브리지", "zh-CN": "Factory Droid 桥接", "zh-TW": "Factory Droid 橋接", ru: "Мост Factory Droid", ja: "Factory Droid ブリッジ", tr: "Factory Droid köprüsü" }, slug: "guides/factory-droid" }, { label: "Ollama-Compatible Clients", translations: { es: "Clientes compatibles con Ollama", fr: "Clients compatibles Ollama", ko: "Ollama 호환 클라이언트", "zh-CN": "Ollama 兼容客户端", "zh-TW": "Ollama 相容用戶端", ru: "Клиенты, совместимые с Ollama", ja: "Ollama 互換クライアント", tr: "Ollama uyumlu istemciler" }, slug: "guides/ollama-clients" }, { label: "Cursor Private Inference", translations: { es: "Inferencia privada de Cursor", ko: "Cursor Private Inference", fr: "Cursor Private Inference", "zh-CN": "Cursor Private Inference", "zh-TW": "Cursor Private Inference", ru: "Cursor Private Inference", ja: "Cursor Private Inference", tr: "Cursor Private Inference" }, slug: "guides/cursor-private-inference" }, diff --git a/docs-site/src/content/docs/es/guides/google-vertex.md b/docs-site/src/content/docs/es/guides/google-vertex.md new file mode 100644 index 00000000000..1be6c8e4822 --- /dev/null +++ b/docs-site/src/content/docs/es/guides/google-vertex.md @@ -0,0 +1,39 @@ +--- +title: "Google Vertex AI" +description: Cómo un proveedor de Vertex AI descubre sus modelos, qué credencial usa el descubrimiento y cómo los modelos Gemini 4 reciben imágenes de herramientas. +--- + +Esta página describe el comportamiento propio del proveedor Google Vertex AI (`google-vertex`, o +cualquier proveedor de Google con `googleMode: "vertex"`). La configuración y las credenciales se +explican en [Proveedores](/es/guides/providers/). + +## Descubrimiento de modelos + +Un proveedor de Vertex AI con modelos en vivo lista los modelos de editor de Google y recorre todas +las páginas del listado. Muestra los modelos de chat Gemini y Gemma y omite los modelos de +embeddings, voz, Live API, Imagen y Veo, así como los modelos Gemini que generan imágenes, como +`gemini-3.1-flash-image`, cuyas fichas de modelo indican que no admiten llamadas a funciones. + +El descubrimiento usa la credencial del propio proveedor: una cuenta de servicio `gcp-sa:`, una +clave o, si no hay ninguna configurada, `GOOGLE_CLOUD_API_KEY` y después las credenciales +predeterminadas de la aplicación (ADC). Si no se puede leer una clave configurada como `keychain:` +o `$VARIABLE`, el descubrimiento se detiene en lugar de probar otra credencial, igual que las +solicitudes se niegan a empezar. Cuando el listado no se puede leer completo, OpenCodex conserva los +modelos anteriores o configurados en lugar de publicar solo una parte. + +Los niveles de razonamiento siguen el preajuste: Gemini 4 Hydrogen ofrece low, medium, high (el +predeterminado) y xhigh; Gemini 4 Carbon y Argon ofrecen low, medium y high. Un modelo al que el +preajuste no asigna niveles, como `gemini-3-pro`, no muestra ninguno, porque sus solicitudes no +llevan nivel de razonamiento. + +## Imágenes de herramientas en Gemini 4 + +En Vertex AI, `gemini-4-argon`, `gemini-4-carbon` y `gemini-4-hydrogen` reciben las imágenes +que devuelven las herramientas, como capturas de pantalla, dentro de la respuesta de función +correspondiente. En Argon esto evita el error 400 del servicio "Requests ending with a model turn +are not supported" tras una llamada a herramienta que produce imágenes; Carbon y Hydrogen usan la +misma forma documentada de respuesta de función, aunque ese error no se reprodujo en ellos. Las +imágenes de resultados duplicados o sin llamada correspondiente se conservan en un mensaje aparte +después de las respuestas de herramientas, con una marca que explica que no se pudieron emparejar. +No hace falta cambiar la configuración; este tratamiento es específico de estos modelos Gemini 4 en +Vertex. From e0acabfe3bc1bfbf47cca5d57f15868d157acc42 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:48:08 +0900 Subject: [PATCH 10/11] fix(google-vertex): bind discovery ADC tokens to the authority that minted them Security review follow-up for the #8583 carry. The ADC source and generation returned with the token were discarded, so a host ADC file or a marker credential replaced after resolution could still send the previous account's bearer, including on later pages. The token is now bound with bindVertexRequestAuthority, and beforeSend checks vertexRequestAuthorityIsCurrent, the record inference checks before dispatch. Regressions cover host-file rotation, replacement behind the same gcp-sa: marker, and rotation between pages. Docs: the Spanish and Korean provider guides no longer describe the Vertex tool-image handling as Argon-only; they link to the Vertex AI page. --- .../src/content/docs/es/guides/providers.md | 2 +- .../src/content/docs/ko/guides/providers.md | 9 +-- .../google-vertex-model-discovery.ts | 25 +++++-- structure/providers/google.md | 5 +- .../google/vertex-model-discovery.test.ts | 67 +++++++++++++++++++ 5 files changed, 94 insertions(+), 14 deletions(-) diff --git a/docs-site/src/content/docs/es/guides/providers.md b/docs-site/src/content/docs/es/guides/providers.md index 17206852ea0..a9b96438ca2 100644 --- a/docs-site/src/content/docs/es/guides/providers.md +++ b/docs-site/src/content/docs/es/guides/providers.md @@ -148,7 +148,7 @@ La pestaña Cuentas Antigravity muestra un nivel de membresía observado como un Gemini 4 Argon (`gemini-4-argon`) aparece en el preajuste `google` con niveles de pensamiento bajos, medios (el predeterminado) y altos. Google lo ofrece solo en el nivel pagado Gemini API, no en el nivel gratuito. El `google-antigravity`, Argon aparece con los mismos tres niveles, que se asignan a los modelos del protocolo `gemini-4-argon-low`, `-medium` y `-high`. Google lo limita a las cuentas AI Ultra y Enterprise elegibles. Cloud Code Assist sirve Argon solo al cliente Antigravity CLI, por lo que OpenCodex envía solicitudes de Argon y descubrimiento de modelo con el CLI User-Agent (`antigravity/cli/1.3.4` ) y mantiene el IDE User-Agent para cualquier otro modelo. -En Vertex AI, `gemini-4-argon` recibe imágenes devueltas por herramientas, como capturas de pantalla, dentro de la respuesta de función coincidente. Esto evita el error 400 aguas arriba "Las solicitudes que terminan con un giro de modelo no son compatibles" después de una llamada de herramienta de producción de imágenes mientras se conservan las imágenes. Las imágenes de resultados duplicados o resultados sin una llamada coincidente se conservan en un mensaje separado después de las respuestas de la herramienta, con un marcador que explica que no se pudieron emparejar. No se necesita ningún cambio de configuración; este manejo es específico para Vertex Argon. +En Vertex AI, `gemini-4-argon`, `gemini-4-carbon` y `gemini-4-hydrogen` reciben las imágenes devueltas por herramientas, como capturas de pantalla, dentro de la respuesta de función correspondiente. El descubrimiento de modelos de Vertex AI, sus reglas de credenciales y este tratamiento se describen en [Google Vertex AI](/es/guides/google-vertex/). Para Gemini en Antigravity, incluyendo Argon, los resúmenes de pensamiento marcados por el proveedor se mantienen separados de la respuesta: Responses usa elementos de resumen de razonamiento, Chat Completions usa `reasoning_content` y Messages usa `thinking` bloques. Los clientes deciden cómo mostrar esos campos. Un cliente Responses puede solicitar `reasoning.summary: "none"` para suprimir resúmenes; esto no desactiva el razonamiento interno del modelo. Una firma de pensamiento es metadatos de continuación y puede acompañar al texto ordinario, por lo que su presencia por sí sola no oculta ese texto. OpenCodex conserva texto de respuesta sin marcar incluso cuando su redacción suena como planificación o análisis. diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index e51b6e70f0b..837c6637027 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -124,12 +124,9 @@ ocx logout Google Antigravity 계정·제공자 할당량 확인은 모델 목록 폴백을 포함해 고정된 Google 회계 엔드포인트를 사용합니다. 해당 목적지의 투명 Fake-IP DNS를 지원하며 TLS 검증, 리다이렉트 거부, 사설 주소 검사는 유지합니다. 사용자 지정 base URL은 모델 요청에만 적용되며 할당량 목적지는 바꾸지 않습니다. `NO_PROXY`는 기존 직접 연결 정책을 유지합니다. -Vertex AI의 `gemini-4-argon`에는 스크린샷 같은 도구 결과 이미지를 해당 함수 응답 안에 담아 -전송합니다. 이미지가 있는 도구 호출 뒤에 발생하는 400 오류 -"Requests ending with a model turn are not supported"를 방지하며 이미지를 보존합니다. -중복된 결과나 일치하는 호출이 없는 결과의 이미지는 도구 응답 뒤의 별도 메시지로 보존하고, -호출과 연결할 수 없었다는 표시를 남깁니다. -별도의 설정 변경은 필요하지 않으며, 이 처리는 Vertex Argon에만 적용됩니다. +Vertex AI의 `gemini-4-argon`, `gemini-4-carbon`, `gemini-4-hydrogen`은 스크린샷 같은 도구 결과 이미지를 +해당 함수 응답 안에 담아 받습니다. Vertex AI 모델 탐색, 자격 증명 규칙과 이 처리 방식은 +[Google Vertex AI](/ko/guides/google-vertex/)에 설명되어 있습니다. ### Google 도구 스키마 손실 진단 diff --git a/src/providers/google-vertex-model-discovery.ts b/src/providers/google-vertex-model-discovery.ts index 0897c18b9a7..9680462d9d1 100644 --- a/src/providers/google-vertex-model-discovery.ts +++ b/src/providers/google-vertex-model-discovery.ts @@ -1,8 +1,10 @@ import { getModelMetadata, getModelMetadataCaseInsensitive } from "../generated/model-metadata"; import { + bindVertexRequestAuthority, GCP_CREDENTIAL_MARKER_PREFIX, gcpCredentialMarkerAccount, getVertexAccessTokenWithAuthority, + vertexRequestAuthorityIsCurrent, } from "../lib/gcp-adc"; import type { OcxProviderConfig } from "../types"; import { resolveProviderApiKey } from "./api-key-resolve"; @@ -170,6 +172,9 @@ function sameVertexSelection(left: VertexDiscoveryCredential, right: VertexDisco * references and malformed `gcp-sa:` markers return `undefined` and never reach ambient credentials. * The selection is taken again after the exchange: if it changed meanwhile (an ambient key appeared, * a reference now resolves elsewhere) the token is discarded rather than sent under the new selection. + * An ADC token is bound to the source and generation that minted it through the same + * `bindVertexRequestAuthority` record inference uses, so a replaced host ADC file or a credential + * replaced behind the same `gcp-sa:` marker invalidates it before the next send. */ export async function resolveVertexDiscoveryAuth( provider: OcxProviderConfig, @@ -178,15 +183,21 @@ export async function resolveVertexDiscoveryAuth( if (provider.authMode === "forward" || isVertexApiKeyPoolPaused(provider)) return undefined; const selection = selectVertexDiscoveryCredential(provider); if (selection.kind === "api-key") return selection.key ? { token: selection.key, kind: "api-key" } : undefined; - let token: string; + let authority: Awaited>; try { const markerAccount = gcpCredentialMarkerAccount(selection.marker); - ({ token } = await getVertexAccessTokenWithAuthority({ signal, ...(markerAccount ? { markerAccount } : {}) })); + authority = await getVertexAccessTokenWithAuthority({ signal, ...(markerAccount ? { markerAccount } : {}) }); } catch { return undefined; } if (!sameVertexSelection(selection, selectVertexDiscoveryCredential(provider))) return undefined; - return { token, kind: "adc", ...(selection.marker ? { marker: selection.marker } : {}) }; + const auth: VertexDiscoveryAuth = { token: authority.token, kind: "adc", ...(selection.marker ? { marker: selection.marker } : {}) }; + bindVertexRequestAuthority(auth, "", `Bearer ${authority.token}`, { + source: authority.source, + generation: authority.generation, + ...(authority.markerAccount ? { markerAccount: authority.markerAccount } : {}), + }); + return auth; } /** The token from `resolveVertexDiscoveryAuth`, for callers that only need to know one exists. */ @@ -199,9 +210,10 @@ export async function resolveVertexDiscoveryToken( /** * Send-time check, run before every discovery request (`beforeSend`): the row still selects the - * credential this token came from, and the outgoing headers carry it in that scheme's header and - * not the other one. A selection that changed after resolution, or headers built for a different - * selection, cancel the send so a token never reaches a scheme it was not issued for. + * credential this token came from, an ADC token's source and generation are still current (the + * check inference runs before dispatch), and the outgoing headers carry it in that scheme's header + * and not the other one. Any change cancels the send, first page and follow-up pages alike, so a + * token never reaches a scheme or an authority it was not issued for. */ export function vertexDiscoveryAuthIsCurrent( provider: OcxProviderConfig, @@ -214,6 +226,7 @@ export function vertexDiscoveryAuthIsCurrent( ? { kind: "api-key", key: auth.token } : { kind: "adc", ...(auth.marker ? { marker: auth.marker } : {}) }; if (!sameVertexSelection(issued, current)) return false; + if (auth.kind === "adc" && !vertexRequestAuthorityIsCurrent(auth)) return false; const header = (name: string): string | undefined => Object.entries(headers).find(([key]) => key.toLowerCase() === name)?.[1]; const bearer = `Bearer ${auth.token}`; diff --git a/structure/providers/google.md b/structure/providers/google.md index dfb85519c24..040861d1d99 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -63,7 +63,10 @@ then `GOOGLE_CLOUD_API_KEY`, then host ADC. The selector follows inference's ste untrimmed bytes, and a parity test compares it with the credential the adapter sends. A resolved credential keeps its kind and marker (`VertexDiscoveryAuth`): a selection that changes during the ADC exchange discards the token, and `beforeSend` cancels any request whose row no longer selects -that credential or whose headers place it in the other scheme's header. One deadline (`vertexDiscoveryDeadline`, 8 s, also +that credential, whose ADC source or generation changed since minting (the same +`bindVertexRequestAuthority` / `vertexRequestAuthorityIsCurrent` record inference checks, so a +replaced host ADC file or marker credential stops the next page), or whose headers place it in the +other scheme's header. One deadline (`vertexDiscoveryDeadline`, 8 s, also tied to the request for the management probe) covers ADC token resolution, the first request and every follow-up page. `nextPageToken` is followed with three ceilings across all pages: five requests, the discovery row limit and the discovery byte limit, counted from the bytes actually diff --git a/tests/adapters/google/vertex-model-discovery.test.ts b/tests/adapters/google/vertex-model-discovery.test.ts index 08373e1b5fe..dc972508031 100644 --- a/tests/adapters/google/vertex-model-discovery.test.ts +++ b/tests/adapters/google/vertex-model-discovery.test.ts @@ -376,3 +376,70 @@ describe("Vertex discovery credential stays bound to its selection", () => { clearModelCache(); }); }); + +describe("Vertex discovery ADC token stays bound to the authority that minted it", () => { + function tokenServer(): void { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url; + if (url !== OAUTH_TOKEN_URL) return new Response("unexpected", { status: 404 }); + const assertion = new URLSearchParams(String(init?.body ?? "")).get("assertion") ?? ""; + const issuer = JSON.parse(Buffer.from(assertion.split(".")[1] ?? "", "base64url").toString("utf8")).iss; + return Response.json({ access_token: `token-for-${issuer}`, expires_in: 3600, token_type: "Bearer" }); + }) as typeof fetch; + } + const serviceAccount = (email: string): string => + JSON.stringify({ ...JSON.parse(readFileSync(saPath, "utf8")), client_email: email }); + + test("a replaced host ADC file or a credential replaced behind the same marker invalidates the token before send", async () => { + const hostFile = join(tmp, "rotating-host.json"); + writeFileSync(hostFile, serviceAccount("host-a@example.test")); + process.env.GOOGLE_APPLICATION_CREDENTIALS = hostFile; + process.env.CLOUDSDK_CONFIG = join(tmp, "no-gcloud"); + delete process.env.GOOGLE_CLOUD_API_KEY; + tokenServer(); + const host = await resolveVertexDiscoveryAuth(vertex()); + expect(host?.token).toBe("token-for-host-a@example.test"); + const hostHeaders = { Authorization: `Bearer ${host!.token}` }; + expect(vertexDiscoveryAuthIsCurrent(vertex(), host!, hostHeaders)).toBe(true); + writeFileSync(hostFile, serviceAccount("host-b@example.test")); + expect(vertexDiscoveryAuthIsCurrent(vertex(), host!, hostHeaders)).toBe(false); + + const stored: Record = { "rotating-marker": serviceAccount("marker-a@example.test") }; + setProviderKeychainEntryFactoryForTests((_service, account) => ({ + getPassword: () => stored[account] ?? null, setPassword: () => {}, deletePassword: () => true, + })); + const markerRow = vertex({ apiKey: "gcp-sa:rotating-marker" }); + const marker = await resolveVertexDiscoveryAuth(markerRow); + expect(marker?.token).toBe("token-for-marker-a@example.test"); + const markerHeaders = { Authorization: `Bearer ${marker!.token}` }; + expect(vertexDiscoveryAuthIsCurrent(markerRow, marker!, markerHeaders)).toBe(true); + stored["rotating-marker"] = serviceAccount("marker-b@example.test"); + expect(vertexDiscoveryAuthIsCurrent(markerRow, marker!, markerHeaders)).toBe(false); + }); + + test("an ADC file replaced between pages stops the walk before the next page is sent", async () => { + const hostFile = join(tmp, "rotating-pages.json"); + writeFileSync(hostFile, serviceAccount("pages-a@example.test")); + process.env.GOOGLE_APPLICATION_CREDENTIALS = hostFile; + process.env.CLOUDSDK_CONFIG = join(tmp, "no-gcloud"); + delete process.env.GOOGLE_CLOUD_API_KEY; + const pageRequests: string[] = []; + tokenServer(); + const tokens = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url; + if (url === OAUTH_TOKEN_URL) return tokens(input, init); + pageRequests.push(new URL(url).searchParams.get("pageToken") ?? "first"); + // The host credential is replaced after page one is served. + writeFileSync(hostFile, serviceAccount("pages-b@example.test")); + return Response.json({ publisherModels: [{ name: "publishers/google/models/gemini-4-carbon" }], nextPageToken: "page-2" }); + }) as typeof fetch; + const rows = await gatherRoutedModels(withStubbedProviderFetch({ providers: { "rotating-vertex": { + adapter: "google", googleMode: "vertex", baseUrl: "https://aiplatform.googleapis.com", + project: "rotating-project", location: "global", liveModels: true, models: ["configured-only"], + } } } as OcxConfig)); + expect(pageRequests).toEqual(["first"]); + expect(rows.filter(row => row.provider === "rotating-vertex").map(row => row.id)).not.toContain("gemini-4-carbon"); + clearModelCache(); + }); +}); From dda2a544e30533f82f7723f494059e11f5a87e2d Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:54:37 +0900 Subject: [PATCH 11/11] fix(google-vertex): bind ambient-key discovery auth and follow a token-only first page Final-review follow-up for the #8583 carry: - An API key read from GOOGLE_CLOUD_API_KEY is bound with captureVertexAmbientKey and checked by vertexRequestAuthorityIsCurrent before every send (first page, later pages, passive gathers), so an A->B->A change of the variable that inference observed invalidates it, as it does for inference. The passive resolver returns the bound auth; a Vertex send without one is cancelled. - A token-only first page is an empty Vertex page (Google omits empty repeated fields); its token is now followed under the existing ceilings. --- src/codex/catalog/gather-capture.ts | 2 + src/codex/catalog/provider-models.ts | 19 +++++---- .../google-vertex-model-discovery.ts | 39 +++++++++++++++---- structure/providers/google.md | 11 +++--- .../google/vertex-model-discovery.test.ts | 39 +++++++++++++++++++ 5 files changed, 87 insertions(+), 23 deletions(-) diff --git a/src/codex/catalog/gather-capture.ts b/src/codex/catalog/gather-capture.ts index db72468f847..33b967730ad 100644 --- a/src/codex/catalog/gather-capture.ts +++ b/src/codex/catalog/gather-capture.ts @@ -130,6 +130,8 @@ export interface ModelsAuthResolution { readonly oauthGeneration?: string; readonly oauthApiBaseUrl?: string; readonly oauthProjectId?: string; + /** Vertex discovery credential bound to its authority (passive gathers). */ + readonly vertexAuth?: import("../../providers/google-vertex-model-discovery").VertexDiscoveryAuth; } export type ModelsAuthResolver = diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index 6a168fa2b1b..e2a86061b16 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -104,7 +104,7 @@ import { collectVertexPublisherModelPages, countDiscoveryResponseBytes, extractGoogleNativeModelItems, - resolveObservedVertexDiscoveryToken, + resolveObservedVertexDiscoveryAuth, resolveVertexDiscoveryAuth, vertexDiscoveryAuthIsCurrent, vertexDiscoveryDeadline, @@ -163,12 +163,9 @@ export function observedModelsAuthResolver( resolve(name, provider) { if (provider.authMode === "forward") return { apiKey: undefined, observed: true }; if (provider.authMode !== "oauth") { - return { - apiKey: effectiveGoogleMode(name, provider) === "vertex" - ? resolveObservedVertexDiscoveryToken(provider) - : resolveProviderApiKey(provider.apiKey), - observed: true, - }; + if (effectiveGoogleMode(name, provider) !== "vertex") return { apiKey: resolveProviderApiKey(provider.apiKey), observed: true }; + const vertexAuth = resolveObservedVertexDiscoveryAuth(provider); + return { apiKey: vertexAuth?.token, observed: true, ...(vertexAuth ? { vertexAuth } : {}) }; } const observation: OAuthActiveTokenObservation = mayResolveModelsOAuth(name, provider) @@ -316,8 +313,9 @@ export async function fetchProviderModelsWithAuth( } : resolveAuth.resolve(name, prov)); const apiKey = auth.apiKey; - // A passive gather's Vertex credential is always an API key (ADC needs an async exchange). - if (vertexDeadline && !vertexAuth && apiKey && prov.authMode !== "oauth") vertexAuth = { token: apiKey, kind: "api-key" }; + // A passive gather's Vertex credential (always an API key) arrives already bound to its authority. + vertexAuth ??= auth.vertexAuth; + const vertexSendGuard = vertexDeadline !== undefined && prov.authMode !== "oauth"; if ((prov.authMode === "oauth" && !apiKey) || isProviderApiKeyPaused(prov)) { return observed(configured, "degraded"); } @@ -765,7 +763,8 @@ export async function fetchProviderModelsWithAuth( // fetch a different URL and keep the rejection. if (!maySendAnthropicDiscovery()) return observed(withConfiguredRetention(failedDiscoveryConfigured), "degraded"); const outboundDependencies = { isCanonicalUrl: isRegistryModelDiscoveryUrl, - beforeSend: () => maySendAnthropicDiscovery() && (!vertexAuth || vertexDiscoveryAuthIsCurrent(prov, vertexAuth, headers)) }; + beforeSend: () => maySendAnthropicDiscovery() + && (!vertexSendGuard || (vertexAuth !== undefined && vertexDiscoveryAuthIsCurrent(prov, vertexAuth, headers))) }; const antigravityObservation = cloudCodeAssist ? captureAntigravityModelObservation() : undefined; const res = request.method === "POST" ? await providerOutboundPost(name, prov, url, { diff --git a/src/providers/google-vertex-model-discovery.ts b/src/providers/google-vertex-model-discovery.ts index 9680462d9d1..58304ab8398 100644 --- a/src/providers/google-vertex-model-discovery.ts +++ b/src/providers/google-vertex-model-discovery.ts @@ -8,6 +8,7 @@ import { } from "../lib/gcp-adc"; import type { OcxProviderConfig } from "../types"; import { resolveProviderApiKey } from "./api-key-resolve"; +import { captureVertexAmbientKey } from "./vertex-environment-authority"; import { extractGoogleAiStudioModelItems } from "./google-ai-studio-model-discovery"; import { extractModelEnvelopeRows, @@ -106,7 +107,7 @@ function isExplicitKeyReference(value: string | undefined): value is string { * inference adapter actually sends for the same rows. */ export type VertexDiscoveryCredential = - | { kind: "api-key"; key: string | undefined } + | { kind: "api-key"; key: string | undefined; ambient?: true } | { kind: "adc"; marker?: string }; export function selectVertexDiscoveryCredential(provider: OcxProviderConfig): VertexDiscoveryCredential { @@ -129,7 +130,7 @@ export function selectVertexDiscoveryCredential(provider: OcxProviderConfig): Ve const key = value && !value.startsWith("<") && value !== "N/A" ? value : undefined; if (key) return { kind: "api-key", key }; const ambientKey = process.env.GOOGLE_CLOUD_API_KEY; - return ambientKey ? { kind: "api-key", key: ambientKey } : { kind: "adc" }; + return ambientKey ? { kind: "api-key", key: ambientKey, ambient: true } : { kind: "adc" }; } /** Whether a Vertex provider row authenticates discovery with an API key rather than GCP ADC. */ @@ -144,9 +145,14 @@ export function usesVertexApiKeyForDiscovery(provider: OcxProviderConfig): boole * either reuse a fresh model cache or degrade cleanly without sending an unauthenticated GET. */ export function resolveObservedVertexDiscoveryToken(provider: OcxProviderConfig): string | undefined { + return resolveObservedVertexDiscoveryAuth(provider)?.token; +} + +/** The passive gather's credential with its authority bound, for the same send-time checks. */ +export function resolveObservedVertexDiscoveryAuth(provider: OcxProviderConfig): VertexDiscoveryAuth | undefined { if (provider.authMode === "forward" || isVertexApiKeyPoolPaused(provider)) return undefined; - const credential = selectVertexDiscoveryCredential(provider); - return credential.kind === "api-key" ? credential.key : undefined; + const selection = selectVertexDiscoveryCredential(provider); + return selection.kind === "api-key" ? apiKeyDiscoveryAuth(selection) : undefined; } /** @@ -164,6 +170,18 @@ function sameVertexSelection(left: VertexDiscoveryCredential, right: VertexDisco return right.kind === "adc" && left.marker === right.marker; } +/** + * An API-key auth. A key read from `GOOGLE_CLOUD_API_KEY` is bound to the ambient-key authority + * observed when it was read (`captureVertexAmbientKey`, the record inference binds), so an A→B→A + * change of the variable invalidates it even though the bytes match again. + */ +function apiKeyDiscoveryAuth(selection: { key: string | undefined; ambient?: true }): VertexDiscoveryAuth | undefined { + if (!selection.key) return undefined; + const auth: VertexDiscoveryAuth = { token: selection.key, kind: "api-key" }; + if (selection.ambient) bindVertexRequestAuthority(auth, "", selection.key, { ambientKey: captureVertexAmbientKey(selection.key) }); + return auth; +} + /** * Resolve the Vertex model-discovery credential (API key or GCP ADC access token) with its kind. * @@ -182,7 +200,7 @@ export async function resolveVertexDiscoveryAuth( ): Promise { if (provider.authMode === "forward" || isVertexApiKeyPoolPaused(provider)) return undefined; const selection = selectVertexDiscoveryCredential(provider); - if (selection.kind === "api-key") return selection.key ? { token: selection.key, kind: "api-key" } : undefined; + if (selection.kind === "api-key") return apiKeyDiscoveryAuth(selection); let authority: Awaited>; try { const markerAccount = gcpCredentialMarkerAccount(selection.marker); @@ -221,12 +239,14 @@ export function vertexDiscoveryAuthIsCurrent( headers: Record, ): boolean { if (provider.authMode === "forward" || isVertexApiKeyPoolPaused(provider)) return false; + // ADC source/generation and the ambient-key generation, exactly as inference checks them. Checked + // first so every send-time check also observes the current ambient key, as inference does. + if (!vertexRequestAuthorityIsCurrent(auth)) return false; const current = selectVertexDiscoveryCredential(provider); const issued: VertexDiscoveryCredential = auth.kind === "api-key" ? { kind: "api-key", key: auth.token } : { kind: "adc", ...(auth.marker ? { marker: auth.marker } : {}) }; if (!sameVertexSelection(issued, current)) return false; - if (auth.kind === "adc" && !vertexRequestAuthorityIsCurrent(auth)) return false; const header = (name: string): string | undefined => Object.entries(headers).find(([key]) => key.toLowerCase() === name)?.[1]; const bearer = `Bearer ${auth.token}`; @@ -346,13 +366,16 @@ export async function collectVertexPublisherModelPages( const first = firstValue !== null && typeof firstValue === "object" && !Array.isArray(firstValue) ? firstValue as Record : undefined; - if (!first || !Array.isArray(first.publisherModels)) return { ok: true, value: firstValue }; + // Google omits an empty repeated field, so a token-only first page is an empty Vertex page. + const tokenOnlyFirstPage = first !== undefined && first.publisherModels === undefined + && typeof first.nextPageToken === "string" && Object.keys(first).every(key => key === "nextPageToken"); + if (!first || (!Array.isArray(first.publisherModels) && !tokenOnlyFirstPage)) return { ok: true, value: firstValue }; let token = nextVertexPageToken(first); if (token === undefined) return { ok: true, value: firstValue }; const maxRows = Math.min(limits.maxModels, MODEL_DISCOVERY_MAX_MODELS); const maxBytes = Math.min(limits.maxResponseBytes, MODEL_DISCOVERY_MAX_RESPONSE_BYTES); - const rows: unknown[] = [...first.publisherModels]; + const rows: unknown[] = Array.isArray(first.publisherModels) ? [...first.publisherModels] : []; let bytes = limits.firstPageBytes ?? new TextEncoder().encode(JSON.stringify(firstValue)).byteLength; const seenTokens = new Set(); const signal = limits.signal ?? vertexDiscoveryDeadline(); diff --git a/structure/providers/google.md b/structure/providers/google.md index 040861d1d99..cc58a4af750 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -63,15 +63,16 @@ then `GOOGLE_CLOUD_API_KEY`, then host ADC. The selector follows inference's ste untrimmed bytes, and a parity test compares it with the credential the adapter sends. A resolved credential keeps its kind and marker (`VertexDiscoveryAuth`): a selection that changes during the ADC exchange discards the token, and `beforeSend` cancels any request whose row no longer selects -that credential, whose ADC source or generation changed since minting (the same -`bindVertexRequestAuthority` / `vertexRequestAuthorityIsCurrent` record inference checks, so a -replaced host ADC file or marker credential stops the next page), or whose headers place it in the +that credential, whose ADC source or generation, or ambient `GOOGLE_CLOUD_API_KEY` generation, +changed since it was read (the same `bindVertexRequestAuthority` / `vertexRequestAuthorityIsCurrent` +record inference checks, so a replaced host ADC file or marker credential, or an A→B→A change of the +variable that inference observed, stops the next page), or whose headers place it in the other scheme's header. One deadline (`vertexDiscoveryDeadline`, 8 s, also tied to the request for the management probe) covers ADC token resolution, the first request and every follow-up page. `nextPageToken` is followed with three ceilings across all pages: five requests, the discovery row limit and the discovery byte limit, counted from the bytes actually -read, page one included. A follow-up page must be a Vertex page: `publisherModels`, or an empty -`{}` / token-only page, since Google omits empty repeated fields. A walk stopped by a ceiling, a +read, page one included. Every page must be a Vertex page: `publisherModels`, or a token-only +page (an empty `{}` ends a walk), since Google omits empty repeated fields. A walk stopped by a ceiling, a failed or foreign page, a malformed or repeated token, or the deadline is reported as an incomplete listing; the catalog keeps its stale or configured rows and the probe fails, so a partial roster is never cached as authoritative. Rows keep Gemini and Gemma chat models and drop embeddings, speech, Live, diff --git a/tests/adapters/google/vertex-model-discovery.test.ts b/tests/adapters/google/vertex-model-discovery.test.ts index dc972508031..dad2d95d7b0 100644 --- a/tests/adapters/google/vertex-model-discovery.test.ts +++ b/tests/adapters/google/vertex-model-discovery.test.ts @@ -8,6 +8,7 @@ import { __resetVertexTokenCache } from "../../../src/lib/gcp-adc"; import { collectVertexPublisherModelPages, extractGoogleNativeModelItems, + resolveObservedVertexDiscoveryAuth, resolveObservedVertexDiscoveryToken, resolveVertexDiscoveryAuth, resolveVertexDiscoveryToken, @@ -443,3 +444,41 @@ describe("Vertex discovery ADC token stays bound to the authority that minted it clearModelCache(); }); }); + +describe("Vertex discovery ambient key stays bound to the generation it was read in", () => { + test("GOOGLE_CLOUD_API_KEY changing A→B→A invalidates a key read as A, on the async and passive paths", async () => { + delete process.env.GOOGLE_APPLICATION_CREDENTIALS; + process.env.GOOGLE_CLOUD_API_KEY = "ambient-a"; + const resolved = await resolveVertexDiscoveryAuth(vertex()); + const passive = resolveObservedVertexDiscoveryAuth(vertex()); + const headers = { "x-goog-api-key": "ambient-a" }; + for (const auth of [resolved, passive]) { + expect(auth).toMatchObject({ token: "ambient-a", kind: "api-key" }); + expect(vertexDiscoveryAuthIsCurrent(vertex(), auth!, headers)).toBe(true); + } + process.env.GOOGLE_CLOUD_API_KEY = "ambient-b"; + // Inference routes a request while B is set, observing B as the current ambient authority. + routedProviderConfig("google-vertex", vertex({ project: "p", location: "global" })); + process.env.GOOGLE_CLOUD_API_KEY = "ambient-a"; + for (const auth of [resolved, passive]) expect(vertexDiscoveryAuthIsCurrent(vertex(), auth!, headers)).toBe(false); + // A configured literal key is not ambient authority and is unaffected by the variable. + const literal = await resolveVertexDiscoveryAuth(vertex({ apiKey: "literal-key" })); + process.env.GOOGLE_CLOUD_API_KEY = "ambient-b"; + expect(vertexDiscoveryAuthIsCurrent(vertex({ apiKey: "literal-key" }), literal!, { "x-goog-api-key": "literal-key" })).toBe(true); + }); +}); + +test("a token-only first Vertex page is an empty page whose token is followed", async () => { + const seen: string[] = []; + const listing = await collectVertexPublisherModelPages( + "https://aiplatform.googleapis.com/v1beta1/publishers/google/models?pageSize=300", + { nextPageToken: "t1" }, + { maxModels: 2_000, maxResponseBytes: 4 * 1024 * 1024 }, + async url => { + seen.push(new URL(url).searchParams.get("pageToken") ?? ""); + return Response.json({ publisherModels: [{ name: "publishers/google/models/gemini-4-hydrogen" }] }); + }, + ); + expect(seen).toEqual(["t1"]); + expect(listing).toEqual({ ok: true, value: { publisherModels: [{ name: "publishers/google/models/gemini-4-hydrogen" }] } }); +});