diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 49912f94c9d..3fcb94e56c4 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", 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. diff --git a/docs-site/src/content/docs/es/guides/providers.md b/docs-site/src/content/docs/es/guides/providers.md index 0a4489451e2..5df369eedbf 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 todas las solicitudes de modelos Gemini 4 y el descubrimiento de modelos con el CLI User-Agent (`antigravity/cli/1.3.4` ) y mantiene el IDE User-Agent para cualquier otro modelo. Gemini 4 Hydrogen (low, medium, high por defecto y xhigh; verificado en Vertex AI el 2026-11-13, aún no visto en Antigravity) aparece en `google-antigravity` solo cuando la lista de modelos de tu cuenta lo incluye, y cada nivel se asigna a su modelo del protocolo `gemini-4-hydrogen-*`. -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/guides/google-vertex.md b/docs-site/src/content/docs/guides/google-vertex.md new file mode 100644 index 00000000000..45c841105e5 --- /dev/null +++ b/docs-site/src/content/docs/guides/google-vertex.md @@ -0,0 +1,36 @@ +--- +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. 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/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index f4076e9c668..39619da6067 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -277,12 +277,8 @@ OpenCodex sends every Gemini 4 model request and model discovery with the CLI Us (`antigravity/cli/1.3.4`) and keeps the IDE User-Agent for every other model. Gemini 4 Hydrogen (low, medium, high by default, and xhigh; verified on Vertex AI on 2026-11-13, not yet seen on Antigravity) appears on `google-antigravity` only when your account's model list includes it, each level mapping to its `gemini-4-hydrogen-*` wire model. -On Vertex AI, `gemini-4-argon` receives 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. +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/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/scripts/model-metadata.source.json b/scripts/model-metadata.source.json index 003a4157da7..02309daf532 100644 --- a/scripts/model-metadata.source.json +++ b/scripts/model-metadata.source.json @@ -13290,6 +13290,44 @@ "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-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/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index af94ffe7b28..adb447f29a1 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1886,6 +1886,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/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 668b66a2d7e..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", + 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/codex/catalog/gather-capture.ts b/src/codex/catalog/gather-capture.ts index fd6d5e600a6..33b967730ad 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"; @@ -129,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 = @@ -332,6 +335,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 +343,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 5f6d68bd8f9..e2a86061b16 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -100,7 +100,16 @@ import { type ProviderModelsApiItem, type ResolvedProviderModelDiscovery, } from "../../providers/model-discovery"; -import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery"; +import { + collectVertexPublisherModelPages, + countDiscoveryResponseBytes, + extractGoogleNativeModelItems, + resolveObservedVertexDiscoveryAuth, + 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"; import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission"; @@ -144,6 +153,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 +163,9 @@ 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 }; + 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) @@ -274,6 +286,9 @@ 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; + let vertexAuth: VertexDiscoveryAuth | undefined; const auth: ModelsAuthResolution = prov.authMode === "oauth" && !mayResolveModelsOAuth(name, prov) ? { apiKey: undefined, observed: resolveAuth.kind === "observed" } : captured.observedAuth ?? (resolveAuth.kind === "refreshing" @@ -290,9 +305,17 @@ 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" + ? (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 (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"); } @@ -681,6 +704,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" @@ -736,7 +762,9 @@ 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() + && (!vertexSendGuard || (vertexAuth !== undefined && vertexDiscoveryAuthIsCurrent(prov, vertexAuth, headers))) }; const antigravityObservation = cloudCodeAssist ? captureAntigravityModelObservation() : undefined; const res = request.method === "POST" ? await providerOutboundPost(name, prov, url, { @@ -746,7 +774,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) { @@ -771,7 +799,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" @@ -844,14 +873,24 @@ 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 listing = effectiveGoogleMode(name, prov) === "vertex" + ? 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) { + 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 = googleAiStudio?.ok - ? googleAiStudio - : 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/generated/model-metadata.ts b/src/generated/model-metadata.ts index 596b0859fd9..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-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 new file mode 100644 index 00000000000..58304ab8398 --- /dev/null +++ b/src/providers/google-vertex-model-discovery.ts @@ -0,0 +1,510 @@ +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"; +import { captureVertexAmbientKey } from "./vertex-environment-authority"; +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"; + +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; + +/** 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. + */ +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); +} + +/** 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 in the same order and on the same bytes + * as inference, so discovery can never publish another account's roster under this provider: + * + * 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; ambient?: true } + | { kind: "adc"; marker?: string }; + +export function selectVertexDiscoveryCredential(provider: OcxProviderConfig): VertexDiscoveryCredential { + const configured = provider.apiKey; + 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; + } + if (isExplicitKeyReference(reference) + && (!value?.trim() || value === "N/A" || value.startsWith("<") || reference === "keychain:")) { + return { kind: "api-key", key: undefined }; + } + 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, ambient: true } : { 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"; +} + +/** + * 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 { + 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 selection = selectVertexDiscoveryCredential(provider); + return selection.kind === "api-key" ? apiKeyDiscoveryAuth(selection) : undefined; +} + +/** + * 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; +} + +/** + * 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. + * + * `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. + * 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, + signal: AbortSignal = vertexDiscoveryDeadline(), +): Promise { + if (provider.authMode === "forward" || isVertexApiKeyPoolPaused(provider)) return undefined; + const selection = selectVertexDiscoveryCredential(provider); + if (selection.kind === "api-key") return apiKeyDiscoveryAuth(selection); + let authority: Awaited>; + try { + const markerAccount = gcpCredentialMarkerAccount(selection.marker); + authority = await getVertexAccessTokenWithAuthority({ signal, ...(markerAccount ? { markerAccount } : {}) }); + } catch { + return undefined; + } + if (!sameVertexSelection(selection, selectVertexDiscoveryCredential(provider))) return undefined; + 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. */ +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, 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, + auth: VertexDiscoveryAuth, + 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; + 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; +} + +/** + * 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); later pages follow nextPageToken. + return { + url: resolveUrl(`${base}/v1beta1/publishers/google/models?pageSize=300`), + headers, + }; +} + +/** + * 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); +} + +export type VertexPublisherModelListing = + | { ok: true; value: unknown } + | { ok: false; reason: "incomplete"; diagnostic: string }; + +/** 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) { + total += chunk.byteLength; + controller.enqueue(chunk); + }, + })); + 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 { + const token = page.nextPageToken; + if (token === undefined || token === "") return undefined; + return typeof token === "string" && token.length <= VERTEX_PAGE_TOKEN_MAX_LENGTH ? token : null; +} + +/** + * 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 + * (`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). + */ +export async function collectVertexPublisherModelPages( + firstUrl: string, + firstValue: unknown, + 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) + ? firstValue as Record + : undefined; + // 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[] = 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(); + 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 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"); + } + 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"); + } + // 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); + token = nextVertexPageToken(page); + } + 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; +} + +/** + * 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 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; + + 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; + if (!vertexModelSupportsFunctionCalling(id)) continue; + seen.add(id); + + const meta = getModelMetadata("google", id) ?? getModelMetadataCaseInsensitive("google", id); + const efforts = ladders && Object.hasOwn(ladders, id) ? ladders[id] : undefined; + 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?.length ? { 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( + 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, vertexReasoningLadders(providerName, provider)) + : 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..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)"; @@ -1214,26 +1236,22 @@ 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"] }, - 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-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_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: { ...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 // 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-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_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 // 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 289df1e7624..d646531f497 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -71,7 +71,16 @@ import { readBoundedDiscoveryJson, resolveProviderModelDiscovery, } from "../../providers/model-discovery"; -import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery"; +import { + buildVertexProviderModelsRequest, + collectVertexPublisherModelPages, + countDiscoveryResponseBytes, + extractGoogleNativeModelItems, + 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"; import { clearPassiveKeyLimits, listPassiveKeyLimits, passiveProviderOwnership } from "../../providers/passive-key-observation"; @@ -1648,8 +1657,14 @@ 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) { + const vertexDeadline = effectiveGoogleMode(name, prov) === "vertex" ? vertexDiscoveryDeadline(req.signal) : undefined; + let vertexAuth: VertexDiscoveryAuth | undefined; + const apiKey = prov.authMode === "oauth" + ? snapshot?.accessToken + : 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)" }); } if (prov.adapter === "zed") return (await import("./zed-provider-probe")).probeZedProvider(prov, apiKey, snapshot?.providerUserId); @@ -1705,9 +1720,8 @@ 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, @@ -1726,7 +1741,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise 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 // 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 isAiStudio = effectiveGoogleMode(name, prov) === "ai-studio"; - const googleAiStudio = !ccaModels && isAiStudio - ? extractGoogleAiStudioModelItems(bounded.value, discovery.maxModels) + const googleNative = !ccaModels + ? extractGoogleNativeModelItems(name, prov, listing.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(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/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..cc58a4af750 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,40 @@ 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. 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, 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. 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, +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, 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. + ## Vertex credential markers `src/lib/gcp-adc.ts` resolves an explicit `gcp-sa:` through the OS credential store. @@ -189,10 +223,10 @@ 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`, 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-models-listing.test.ts b/tests/adapters/google/google-models-listing.test.ts index 5b6f9b3c1b7..f7e60ff7e6e 100644 --- a/tests/adapters/google/google-models-listing.test.ts +++ b/tests/adapters/google/google-models-listing.test.ts @@ -61,7 +61,7 @@ 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"); + expect(url).toBe("https://aiplatform.googleapis.com/v1beta1/publishers/google/models?pageSize=300"); }); }); 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, 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 8136aa89296..8fbd7c32bfc 100644 --- a/tests/adapters/google/vertex-catalog.test.ts +++ b/tests/adapters/google/vertex-catalog.test.ts @@ -208,4 +208,118 @@ 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-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" }, + { 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 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", + 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); + } + }); + 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; + 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..dad2d95d7b0 --- /dev/null +++ b/tests/adapters/google/vertex-model-discovery.test.ts @@ -0,0 +1,484 @@ +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { Buffer } from "node:buffer"; +import { mkdtempSync, readFileSync, 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, + resolveObservedVertexDiscoveryAuth, + 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 { 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"; +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 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++) { + 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); + }); +}); + +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; + } + }); +}); + +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(); + }); +}); + +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(); + }); +}); + +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" }] } }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 22f86eaaba5..53a633ca19a 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",