diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index fd362c344a..e0543e12d7 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -167,8 +167,8 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix | `anthropicEofTolerance?` | `boolean` | Laissez une passerelle compatible Anthropic compléter un flux qui se termine avant `message_stop`, uniquement lorsque du texte visible ou une entrée complète d'outil d'objet JSON a été reçue. Désactivé par défaut. | | `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Mode Google transport/auth. Par défaut `ai-studio`. | | `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | Google uniquement. Une valeur absente ou `compatible` conserve le schéma compatible et la réparation 400 existante hors mode direct. `reject-lossy` refuse une perte initiale ou une comparaison bornée indéterminée avant l’envoi et bloque une réparation Vertex ou Cloud Code Assist qui ouvrirait des contraintes. AI Studio direct n’effectue jamais cette réparation. | -| `project?` | `string` | ID du projet Vertex ou Antigravity Cloud Code Assist. | -| `location?` | `string` | Région Vertex ; la valeur de repli de l'environnement est `GOOGLE_CLOUD_LOCATION`. | +| `project?` | `string` | ID du projet Vertex ou Antigravity Cloud Code Assist. Lorsqu'il est défini, les clés API Vertex utilisent le point de terminaison du projet ; sinon elles utilisent le mode express, même si `GOOGLE_CLOUD_PROJECT` est défini. ADC se replie sur `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT`. | +| `location?` | `string` | Région Vertex. Les points de terminaison de projet avec clé API utilisent `global` par défaut et ignorent `GOOGLE_CLOUD_LOCATION` ; ADC exige ce champ ou le repli `GOOGLE_CLOUD_LOCATION`. | | `mcpServers?` | `Record` | Cursor uniquement : serveurs MCP sur entrée-sortie standard ou HTTP diffusé en continu. | | `desktopExecutor?` | `DesktopExecutorConfig` | Cursor uniquement : commandes externes d'utilisation d'un ordinateur et d'enregistrement de l'écran. | | `unsafeAllowNativeLocalExec?` | `boolean` | Ancien booléen de Cursor, équivalent à `nativeLocalExec: "on"` uniquement lorsque le champ plus récent n'est pas défini. | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index d80b0b8238..bd1221c396 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -159,8 +159,8 @@ account を削除しても mapping は保持され、同じ id を再追加す | `anthropicEofTolerance?` | `boolean` | `message_stop` 前にストリームが終了しても、可視テキストまたは完全な JSON オブジェクトのツール入力が受信済みの場合に限り完了を許可します(Anthropic 互換ゲートウェイ向け)。デフォルトはオフ。 | | `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google トランスポート/認証モード。デフォルトは`ai-studio`です。 | | `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | Google 専用です。省略時または `compatible` は互換スキーマと既存の非直接 400 修復を維持します。`reject-lossy` は初期損失または判定不能な上限付き比較を送信前に拒否し、制約を開く Vertex/Cloud Code Assist 修復を保留します。直接 AI Studio はこの修復を行いません。 | -| `project?` | `string` | Vertex または Antigravity Cloud Code Assist プロジェクト ID。 | -| `location?` | `string` |頂点の位置。環境フォールバックは `GOOGLE_CLOUD_LOCATION` です。 | +| `project?` | `string` | Vertex または Antigravity Cloud Code Assist プロジェクト ID。Vertex の API キーは、これが設定されているとプロジェクトエンドポイントを使い、未設定なら `GOOGLE_CLOUD_PROJECT` があっても express モードを使います。ADC は `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT` にフォールバックします。 | +| `location?` | `string` | Vertex のロケーション。API キーのプロジェクトエンドポイントは既定で `global` を使い、`GOOGLE_CLOUD_LOCATION` を無視します。ADC にはこの値か `GOOGLE_CLOUD_LOCATION` フォールバックが必要です。 | | `mcpServers?` | `Record` |カーソルのみ: 標準入出力またはストリーミング可能な HTTP MCP サーバー。 | | `desktopExecutor?` | `DesktopExecutorConfig` |カーソルのみ: 外部コンピュータ使用および画面録画コマンド。 | | `unsafeAllowNativeLocalExec?` | `boolean` |カーソルのレガシー ブール値。新しいフィールドが設定されていない場合のみ、`nativeLocalExec: "on"` と同等です。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index b8eef4372b..010b1df4cc 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -159,8 +159,8 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `anthropicEofTolerance?` | `boolean` | `message_stop` 전에 스트림이 끝나도 표시 텍스트 또는 완전한 JSON 객체 툴 입력을 받은 경우에만 완료를 허용합니다(Anthropic 호환 게이트웨이용). 기본값은 꺼짐. | | `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google 전송/인증 모드입니다. 기본값은 `ai-studio`입니다. | | `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | Google 전용입니다. 생략하거나 `compatible`이면 호환 스키마와 기존 비직접 400 복구를 유지합니다. `reject-lossy`는 초기 손실 또는 판정 불가능한 상한 비교를 전송 전에 거부하고 제약을 개방하는 Vertex 또는 Cloud Code Assist 복구를 보류합니다. 직접 AI Studio는 이 복구를 수행하지 않습니다. | -| `project?` | `string` | Vertex 또는 Antigravity Cloud Code Assist 프로젝트 id입니다. | -| `location?` | `string` | Vertex 위치입니다. 환경 변수 폴백은 `GOOGLE_CLOUD_LOCATION`입니다. | +| `project?` | `string` | Vertex 또는 Antigravity Cloud Code Assist 프로젝트 id입니다. Vertex API 키는 이 값이 있으면 프로젝트 엔드포인트를 쓰고, 없으면 `GOOGLE_CLOUD_PROJECT`가 설정돼 있어도 express 모드를 씁니다. ADC는 `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT`로 폴백합니다. | +| `location?` | `string` | Vertex 위치입니다. API 키 프로젝트 엔드포인트는 기본값이 `global`이고 `GOOGLE_CLOUD_LOCATION`을 무시합니다. ADC는 이 값이나 `GOOGLE_CLOUD_LOCATION` 폴백이 필요합니다. | | `mcpServers?` | `Record` | Cursor 전용입니다. stdio 또는 Streamable HTTP MCP 서버입니다. | | `desktopExecutor?` | `DesktopExecutorConfig` | Cursor 전용입니다. 외부 computer-use 및 record-screen 명령입니다. | | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 레거시 불리언입니다. 더 새로운 필드가 설정되지 않았을 때만 `nativeLocalExec: "on"`과 같습니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index c72a3048f3..e2bf7ee5b2 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -3,6 +3,8 @@ title: Provider Configuration description: Provider entries, authentication, endpoints, model catalogs, quotas, context caps, and provider-specific options. --- + + A provider tells opencodex where a model lives, which wire adapter it speaks, and how requests are authenticated. @@ -364,11 +366,11 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google transport/auth mode. Default `ai-studio`. | | `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | Google only. Omitted/`compatible` preserves the compatible tool schema and existing non-direct 400 repair. `reject-lossy` rejects initial schema loss or an indeterminate bounded comparison before dispatch and withholds a Vertex or Cloud Code Assist repair that would open constraints. Direct AI Studio never performs that repair. | | `directGeminiWireRenames?` | `boolean` | Google only. Applies in AI Studio mode, including custom Google-compatible URLs. Omitted or `true` keeps the `-tiered` wire rename for Gemini Flash ids (`gemini-3.7-flash` -> `gemini-3.7-flash-tiered`); `false` sends the requested bare ids to the wire unchanged. Vertex and canonical keyed OpenCode Zen routes preserve the requested model ID regardless of this setting; Cloud Code Assist routing is unchanged. Set `false` when the configured upstream still serves the bare ids. | -| `project?` | `string` | Vertex or Antigravity Cloud Code Assist project id. | +| `project?` | `string` | Vertex or Antigravity Cloud Code Assist project id. Vertex API keys use this project endpoint when set; without it they use express mode, even if `GOOGLE_CLOUD_PROJECT` is set. ADC falls back to `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT`. | | — | — | Antigravity account quota probes (`retrieveUserQuota` and `retrieveUserQuotaSummary`) always go to Google's own Cloud Code host through the pinned outbound transport, regardless of a configured `baseUrl`; the account bearer is never sent to an operator-configured endpoint and a redirect aborts the probe. Only the model-list fallback still honors `baseUrl`. | | — | — | If Antigravity quota summary returns 403 for a valid OAuth account, OpenCodex retries that endpoint once with the legacy `antigravity/1.0` User-Agent and the same token and project. A 401 is not retried. Inference and model discovery retain the IDE User-Agent. | | — | — | Antigravity quota shows Gemini 4 Argon as separate `Argon` / `Argon (Weekly)` windows next to Gem and Cla. They are display-only: account ranking, quota exhaustion, reset ordering and reset notifications keep using the Gemini and Claude windows, and Argon requests are still judged by the Gemini window. | -| `location?` | `string` | Vertex location; environment fallback is `GOOGLE_CLOUD_LOCATION`. | +| `location?` | `string` | Vertex location. API-key project endpoints default to `global` and ignore `GOOGLE_CLOUD_LOCATION`; ADC requires this field or the `GOOGLE_CLOUD_LOCATION` fallback. Gemini 4 Argon supports `global`, `us`, and `eu`. | | `mcpServers?` | `Record` | Cursor only: stdio or Streamable HTTP MCP servers. | | `desktopExecutor?` | `DesktopExecutorConfig` | Cursor only: external computer-use and record-screen commands. | | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index b143a3e748..fbca4a5d27 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -172,8 +172,8 @@ cross-route credential fallback не существует. Строки API GPT- | `anthropicEofTolerance?` | `boolean` | Позволяет Anthropic-совместимому шлюзу завершить поток до `message_stop`, только если получен видимый текст или полный JSON-объект аргументов инструмента. По умолчанию выключено. | | `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Режим транспорта/аутентификации Google. По умолчанию `ai-studio`. | | `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | Только Google. Отсутствие поля или `compatible` сохраняет совместимую схему и существующее недиректное восстановление после 400. `reject-lossy` отклоняет начальную потерю или неопределённый результат ограниченного сравнения до отправки и блокирует восстановление Vertex или Cloud Code Assist, которое сняло бы ограничения. Прямой AI Studio такое восстановление не выполняет. | -| `project?` | `string` | Идентификатор проекта Vertex или Antigravity Cloud Code Assist. | -| `location?` | `string` | Локация Vertex; fallback через окружение — `GOOGLE_CLOUD_LOCATION`. | +| `project?` | `string` | Идентификатор проекта Vertex или Antigravity Cloud Code Assist. Если он задан, API-ключи Vertex используют endpoint проекта; без него — режим express, даже если задан `GOOGLE_CLOUD_PROJECT`. ADC использует fallback `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT`. | +| `location?` | `string` | Локация Vertex. Endpoint проекта для API-ключа по умолчанию использует `global` и игнорирует `GOOGLE_CLOUD_LOCATION`; ADC требует это поле или fallback `GOOGLE_CLOUD_LOCATION`. | | `mcpServers?` | `Record` | Только Cursor: MCP-серверы в режимах stdio или Streamable HTTP. | | `desktopExecutor?` | `DesktopExecutorConfig` | Только Cursor: команды внешнего computer-use и record-screen. | | `unsafeAllowNativeLocalExec?` | `boolean` | Legacy boolean Cursor, эквивалентен `nativeLocalExec: "on"` только если новое поле не задано. | diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index cd588eb2da..a2d31756d4 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -171,8 +171,8 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar. | `anthropicEofTolerance?` | `boolean` | Yalnızca görünür metin veya eksiksiz bir JSON nesnesi araç girdisi alındığında, Anthropic uyumlu bir ağ geçidinin `message_stop` öncesinde biten bir akışı tamamlamasına izin verin. Varsayılan olarak kapalıdır. | | `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google aktarım/kimlik doğrulama modu. Varsayılan `ai-studio`. | | `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | Yalnızca Google. Alanın atlanması veya `compatible`, uyumlu şemayı ve mevcut doğrudan olmayan 400 onarımını korur. `reject-lossy`, ilk kaybı veya sonucu belirsiz sınırlı bir karşılaştırmayı gönderimden önce reddeder ve kısıtları açacak Vertex ya da Cloud Code Assist onarımını engeller. Doğrudan AI Studio bu onarımı yapmaz. | -| `project?` | `string` | Vertex veya Antigravity Cloud Code Assist proje kimliği. | -| `location?` | `string` | Vertex konumu; ortam geri dönüşü `GOOGLE_CLOUD_LOCATION`'dır. | +| `project?` | `string` | Vertex veya Antigravity Cloud Code Assist proje kimliği. Ayarlandığında Vertex API anahtarları proje uç noktasını kullanır; ayarlanmadığında `GOOGLE_CLOUD_PROJECT` tanımlı olsa bile express modunu kullanır. ADC, `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT` değerine geri döner. | +| `location?` | `string` | Vertex konumu. API anahtarlı proje uç noktaları varsayılan olarak `global` kullanır ve `GOOGLE_CLOUD_LOCATION` değerini yok sayar; ADC bu alanı veya `GOOGLE_CLOUD_LOCATION` geri dönüşünü gerektirir. | | `mcpServers?` | `Record` | Yalnızca Cursor: stdio veya Akışlanabilir HTTP MCP sunucuları. | | `desktopExecutor?` | `DesktopExecutorConfig` | Yalnızca Cursor: harici bilgisayar kullanımı ve ekran kaydetme komutları. | | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor eski boolean değeri, yalnızca daha yeni alan ayarlanmadığında `nativeLocalExec: "on"` değerine eşdeğerdir. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index e3695f386d..de7d9c6187 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -161,8 +161,8 @@ selector,而不是分配一个新名称。 | `anthropicEofTolerance?` | `boolean` | 允许 Anthropic 兼容网关在 `message_stop` 前结束流,仅当已收到可见文本或完整的 JSON 对象工具输入时。默认关闭。 | | `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google 传输/身份验证模式。默认 `ai-studio`。 | | `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | 仅限 Google。省略或设为 `compatible` 时保留兼容架构和现有的非直连 400 修复。`reject-lossy` 会在发送前拒绝初始损失或结果不确定的有界比较,并阻止会放宽约束的 Vertex 或 Cloud Code Assist 修复。直连 AI Studio 从不执行该修复。 | -| `project?` | `string` | Vertex 或 Antigravity Cloud Code Assist 项目 id。 | -| `location?` | `string` | Vertex 位置;环境变量回退为 `GOOGLE_CLOUD_LOCATION`。 | +| `project?` | `string` | Vertex 或 Antigravity Cloud Code Assist 项目 id。设置后,Vertex API 密钥使用项目端点;未设置时,即使存在 `GOOGLE_CLOUD_PROJECT` 也使用 express 模式。ADC 回退到 `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT`。 | +| `location?` | `string` | Vertex 位置。API 密钥的项目端点默认使用 `global`,并忽略 `GOOGLE_CLOUD_LOCATION`;ADC 需要此字段或 `GOOGLE_CLOUD_LOCATION` 回退。 | | `mcpServers?` | `Record` | 仅 Cursor:stdio 或 Streamable HTTP MCP 服务器。 | | `desktopExecutor?` | `DesktopExecutorConfig` | 仅 Cursor:外部 computer-use 和录屏命令。 | | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 旧布尔值;仅当更新字段未设置时,等同于 `nativeLocalExec: "on"`。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 073d565357..845f5d1e85 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -131,8 +131,8 @@ ocx models provider openrouter on | `escapeBuiltinToolNames?` | `boolean` | 為 Anthropic 相容閘道轉義內建工具名稱,並在回傳的呼叫中還原它們。 | | `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google 傳輸/認證模式。預設 `ai-studio`。 | | `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | 僅限 Google。省略或設為 `compatible` 時保留相容結構描述與既有的非直接 400 修復。`reject-lossy` 會在傳送前拒絕初始損失或結果不確定的有界比較,並阻止會放寬限制的 Vertex 或 Cloud Code Assist 修復。直接 AI Studio 不會執行該修復。 | -| `project?` | `string` | Vertex 或 Antigravity Cloud Code Assist 專案 id。 | -| `location?` | `string` | Vertex 位置;環境後備為 `GOOGLE_CLOUD_LOCATION`。 | +| `project?` | `string` | Vertex 或 Antigravity Cloud Code Assist 專案 id。設定後,Vertex API 金鑰使用專案端點;未設定時,即使有 `GOOGLE_CLOUD_PROJECT` 也使用 express 模式。ADC 後備為 `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT`。 | +| `location?` | `string` | Vertex 位置。API 金鑰的專案端點預設為 `global`,並忽略 `GOOGLE_CLOUD_LOCATION`;ADC 需要此欄位或 `GOOGLE_CLOUD_LOCATION` 後備。 | | `mcpServers?` | `Record` | 僅 Cursor:stdio 或 Streamable HTTP MCP 伺服器。 | | `desktopExecutor?` | `DesktopExecutorConfig` | 僅 Cursor:外部 computer-use 與 record-screen 指令。 | | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor 舊版布林值,僅在較新欄位未設定時等同於 `nativeLocalExec: "on"`。 | diff --git a/src/adapters/google-vertex-endpoint.ts b/src/adapters/google-vertex-endpoint.ts new file mode 100644 index 0000000000..ca170022e5 --- /dev/null +++ b/src/adapters/google-vertex-endpoint.ts @@ -0,0 +1,67 @@ +import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; + +/** + * Vertex endpoint selection, shared by inference and replay identity. + * + * API keys follow explicit provider configuration only: `provider.project` selects the project + * endpoint (location defaults to `global`), otherwise the projectless express endpoint is used. + * Ambient GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT / GOOGLE_CLOUD_LOCATION belong to ADC, so an + * unrelated gcloud default in the shell cannot move an express-mode key onto another project. + */ +export type VertexEndpointSelection = + | { kind: "express" } + | { kind: "project"; project: string; location: string }; + +type VertexEndpointConfig = { project?: string; location?: string }; + +export function selectVertexEndpoint( + provider: VertexEndpointConfig, + hasApiKey: boolean, + env: NodeJS.ProcessEnv = process.env, +): VertexEndpointSelection { + // A blank or whitespace-only project is unset: it must not select a /projects/%20/ endpoint. + const configuredProject = provider.project?.trim() || undefined; + if (hasApiKey) { + if (!configuredProject) return { kind: "express" }; + return { kind: "project", project: configuredProject, location: provider.location || "global" }; + } + const project = configuredProject || env.GOOGLE_CLOUD_PROJECT || env.GCLOUD_PROJECT; + if (!project) throw new Error("Vertex AI requires a project id (provider.project or GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT)."); + const location = provider.location || env.GOOGLE_CLOUD_LOCATION; + if (!location) throw new Error("Vertex AI requires a location (provider.location or GOOGLE_CLOUD_LOCATION)."); + return { kind: "project", project, location }; +} + +/** + * Replay namespace for thought-signature continuation; mirrors the endpoint actually used. + * The endpoint kind is part of the key and the project is encoded, so no configured project + * (for example one literally named "api-key" or containing ":") can share express mode's space. + */ +export function vertexReplayModelKey(selection: VertexEndpointSelection, modelId: string): string { + return selection.kind === "express" + ? `vertex:express:${modelId}` + : `vertex:project:${encodeURIComponent(selection.project)}:${selection.location}:${modelId}`; +} + +const VERTEX_MODEL_PATH_REJECT = /[/?#\\%]/; + +/** The model id is interpolated as one URL path segment after a fixed Google authority. */ +export function vertexModelPathError(modelId: string): string | undefined { + if (!modelId || VERTEX_MODEL_PATH_REJECT.test(modelId) || modelId.includes("..")) { + return "Vertex AI model id must be a single path segment without '/', '?', '#', '\\', '%' or '..'."; + } + return undefined; +} + +/** Builds the inference URL for a selection; validates location before any authority is formed. */ +export function vertexInferenceUrl(selection: VertexEndpointSelection, modelId: string, method: string, streamParam: string): string { + const modelError = vertexModelPathError(modelId); + if (modelError) throw new Error(modelError); + if (selection.kind === "express") { + return `https://aiplatform.googleapis.com/v1/publishers/google/models/${modelId}:${method}${streamParam}`; + } + const locationError = googleVertexLocationConfigError(selection.location); + if (locationError) throw new Error(locationError); + const host = selection.location === "global" ? "aiplatform.googleapis.com" : `${selection.location}-aiplatform.googleapis.com`; + return `https://${host}/v1/projects/${encodeURIComponent(selection.project)}/locations/${selection.location}/publishers/google/models/${modelId}:${method}${streamParam}`; +} diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 5093fe0917..6bcafa0ce4 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -36,7 +36,7 @@ import { observeAntigravityReplay, } from "./google-antigravity-replay"; import { canonicalAntigravityUsageModel, resolveAntigravityEffortWireModel } from "../providers/antigravity-models"; -import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; +import { selectVertexEndpoint, vertexInferenceUrl, vertexReplayModelKey } from "./google-vertex-endpoint"; import { forgetThoughtSignatureForReplay, lookupReplayThoughtSignature } from "../responses/thought-signature-replay"; import { isTranslatorBudgetExceededError, @@ -1192,9 +1192,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const compiled = compileGoogleWireBody(body, toolSchemaProfile, toolSchemaPolicy); reportToolSchemaLoss(compiled.toolSchemaLossReport); restoreGoogleToolName = compiled.restoreToolName; - const vertexProject = provider.project || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT || "api-key"; - const vertexLocation = provider.location || process.env.GOOGLE_CLOUD_LOCATION || "global"; - vertexReplayModel = `vertex:${vertexProject}:${vertexLocation}:${parsed.modelId}`; + const apiKey = resolveVertexApiKey(provider.apiKey); + const vertexEndpoint = selectVertexEndpoint(provider, Boolean(apiKey)); + const url = vertexInferenceUrl(vertexEndpoint, parsed.modelId, method, streamParam); + vertexReplayModel = vertexReplayModelKey(vertexEndpoint, parsed.modelId); vertexReplaySession = vertexReplaySessionId(parsed); // Compile names before replay so the cache matches the exact provider-visible // functionCall identity. This is the same bounded TTL/LRU store used by CCA, with the @@ -1210,27 +1211,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte (compiled.body as { contents: unknown[] }).contents, ); } - // Vertex AI: project/location endpoint with GCP ADC, or x-goog-api-key fast path. - const apiKey = resolveVertexApiKey(provider.apiKey); - if (apiKey) { - const url = `https://aiplatform.googleapis.com/v1/publishers/google/models/${parsed.modelId}:${method}${streamParam}`; - headers["x-goog-api-key"] = apiKey; - return { url, method: "POST", headers, body: JSON.stringify(compiled.body) }; + // Endpoint, location and model path were validated above, before any credential is attached. + if (apiKey) headers["x-goog-api-key"] = apiKey; + else { + // Resolve each marker per request; never share a process-global active account. + const markerAccount = gcpCredentialMarkerAccount(provider.apiKey); + headers["Authorization"] = `Bearer ${await getVertexAccessToken({ markerAccount })}`; } - const project = provider.project || process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT; - if (!project) throw new Error("Vertex AI requires a project id (provider.project or GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT)."); - const location = provider.location || process.env.GOOGLE_CLOUD_LOCATION; - if (!location) throw new Error("Vertex AI requires a location (provider.location or GOOGLE_CLOUD_LOCATION)."); - const locationError = googleVertexLocationConfigError(location); - if (locationError) throw new Error(locationError); - const host = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`; - const url = `https://${host}/v1/projects/${project}/locations/${location}/publishers/google/models/${parsed.modelId}:${method}${streamParam}`; - // The marker account travels per-request as an argument (never process-global state): a - // module-level "active marker" written at routing time would race across concurrent - // requests for different providers and could resolve one call with another's credential. - const markerAccount = gcpCredentialMarkerAccount(provider.apiKey); - const token = await getVertexAccessToken({ markerAccount }); - headers["Authorization"] = `Bearer ${token}`; return { url, method: "POST", headers, body: JSON.stringify(compiled.body) }; } diff --git a/src/types/provider.ts b/src/types/provider.ts index d103585420..2a94ae0a52 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -1110,9 +1110,9 @@ export interface OcxProviderConfig { googleMode?: "ai-studio" | "vertex" | "cloud-code-assist"; /** Google tool-schema compatibility policy. Omitted preserves compatible report-only behavior. */ googleToolSchemaPolicy?: "compatible" | "reject-lossy"; - /** Vertex AI GCP project id (or GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT env). */ + /** Vertex AI GCP project id. API keys use it to select the project endpoint; ADC falls back to GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT env. */ project?: string; - /** Vertex AI location, e.g. "us-central1" or "global" (or GOOGLE_CLOUD_LOCATION env). */ + /** Vertex AI location, e.g. "us-central1" or "global". API-key project endpoints default to "global"; ADC falls back to GOOGLE_CLOUD_LOCATION env. */ location?: string; /** * Cursor adapter only: MCP servers opencodex starts/connects and exposes to the Cursor agent diff --git a/structure/providers/google.md b/structure/providers/google.md index ca74f84386..b4a7383e67 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -1,5 +1,24 @@ # Google Provider +Created: 2026-10-16 | Updated: 2026-10-18 | Version: 1.1 (Vertex endpoint selection) + +## Vertex API-key endpoint selection + +`src/adapters/google-vertex-endpoint.ts` selects the Vertex endpoint once per request and the +same selection feeds the thought-signature replay namespace. API keys follow explicit provider +configuration only: `provider.project` selects +`/v1/projects/{project}/locations/{location}/...` with `provider.location` or `global`, which +keeps models such as Gemini 4 Argon reachable when express mode would pick a region without +them. Without `provider.project`, API keys keep the projectless express endpoint even when +`GOOGLE_CLOUD_PROJECT`, `GCLOUD_PROJECT` or `GOOGLE_CLOUD_LOCATION` are set, so an unrelated +gcloud default cannot move an express key onto another project. Those environment fallbacks +apply to ADC only, which still requires a project and a location and resolves service-account +markers per request. Every branch validates the location and rejects a model id that is not a +single path segment before any credential is attached; keys stay in `x-goog-api-key`. +`tests/adapters/google/gcp-adc.test.ts` and +`tests/adapters/google/google-vertex-thought-signature.test.ts` cover the selection, ambient +environment isolation, ADC fallbacks, model-path rejection, and replay isolation. + ## Vertex credential markers `src/lib/gcp-adc.ts` resolves an explicit `gcp-sa:` through the OS credential store. diff --git a/tests/adapters/google/gcp-adc.test.ts b/tests/adapters/google/gcp-adc.test.ts index e7234848dc..fe0c8d894c 100644 --- a/tests/adapters/google/gcp-adc.test.ts +++ b/tests/adapters/google/gcp-adc.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, afterAll, describe, expect, test } from "bun:test"; +import { afterEach, beforeAll, beforeEach, afterAll, describe, expect, test } from "bun:test"; import { Buffer } from "node:buffer"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -58,15 +58,25 @@ afterAll(() => { afterEach(() => { __resetVertexTokenCache(); - for (const k of ["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_API_KEY", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT", "GOOGLE_CLOUD_LOCATION", "CLOUDSDK_CONFIG"]) { + for (const k of VERTEX_ENV_KEYS) { if (prevEnv[k] === undefined) delete process.env[k]; else process.env[k] = prevEnv[k]; } prevEnv = {}; oauthCalls = 0; }); +const VERTEX_ENV_KEYS = ["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_API_KEY", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT", "GOOGLE_CLOUD_LOCATION", "CLOUDSDK_CONFIG"]; + +// Start every case from a clean Vertex environment; afterEach restores the caller's values. +beforeEach(() => { + for (const k of VERTEX_ENV_KEYS) { + if (!(k in prevEnv)) prevEnv[k] = process.env[k]; + delete process.env[k]; + } +}); + function setEnv(k: string, v: string): void { - prevEnv[k] = process.env[k]; + if (!(k in prevEnv)) prevEnv[k] = process.env[k]; process.env[k] = v; } @@ -136,6 +146,90 @@ describe("google adapter vertex mode", () => { expect(oauthCalls).toBe(0); }); + test("vertex API key honors an explicit project and global location without ADC", async () => { + const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", apiKey: "fixture-key", project: "fixture-project", location: "global" } as OcxProviderConfig; + const req = await createGoogleAdapter(provider).buildRequest(parsed("gemini-4-argon")); + expect(req.url).toBe("https://aiplatform.googleapis.com/v1/projects/fixture-project/locations/global/publishers/google/models/gemini-4-argon:streamGenerateContent?alt=sse"); + expect(req.headers["x-goog-api-key"]).toBe("fixture-key"); + expect(req.headers.Authorization).toBeUndefined(); + expect(oauthCalls).toBe(0); + }); + + test("ambient project and location leave a projectless API key on express mode", async () => { + setEnv("GOOGLE_CLOUD_PROJECT", "unrelated-gcloud-default"); + setEnv("GCLOUD_PROJECT", "unrelated-gcloud-default"); + setEnv("GOOGLE_CLOUD_LOCATION", "eu"); + const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", apiKey: "fixture-key" } as OcxProviderConfig; + const req = await createGoogleAdapter(provider).buildRequest(parsed("gemini-4-argon")); + expect(req.url).toBe("https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-4-argon:streamGenerateContent?alt=sse"); + expect(req.headers["x-goog-api-key"]).toBe("fixture-key"); + expect(oauthCalls).toBe(0); + }); + + test("a blank provider project is unset for API keys and ADC", async () => { + const keyed = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", apiKey: "fixture-key", project: " " } as OcxProviderConfig; + const req = await createGoogleAdapter(keyed).buildRequest(parsed("gemini-4-argon")); + expect(req.url).toBe("https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-4-argon:streamGenerateContent?alt=sse"); + setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath); + setEnv("GOOGLE_CLOUD_PROJECT", "env-proj"); + const adc = { adapter: "google", baseUrl: "https://x", googleMode: "vertex", project: " ", location: "global" } as OcxProviderConfig; + const adcReq = await createGoogleAdapter(adc).buildRequest(parsed()); + expect(adcReq.url).toBe("https://aiplatform.googleapis.com/v1/projects/env-proj/locations/global/publishers/google/models/gemini-3-pro:streamGenerateContent?alt=sse"); + }); + + test("vertex API key project endpoint defaults to global and ignores ambient location", async () => { + setEnv("GOOGLE_CLOUD_LOCATION", "us-central1"); + const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", apiKey: "fixture-key", project: "fixture-project" } as OcxProviderConfig; + const req = await createGoogleAdapter(provider).buildRequest(parsed("gemini-4-argon")); + expect(req.url).toBe("https://aiplatform.googleapis.com/v1/projects/fixture-project/locations/global/publishers/google/models/gemini-4-argon:streamGenerateContent?alt=sse"); + }); + + test("vertex API key honors an explicit regional location and non-streaming method", async () => { + const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", apiKey: "fixture-key", project: "fixture project", location: "us-central1" } as OcxProviderConfig; + const req = await createGoogleAdapter(provider).buildRequest({ ...parsed("gemini-2.5-pro"), stream: false } as OcxParsedRequest); + expect(req.url).toBe("https://us-central1-aiplatform.googleapis.com/v1/projects/fixture%20project/locations/us-central1/publishers/google/models/gemini-2.5-pro:generateContent"); + expect(req.headers["x-goog-api-key"]).toBe("fixture-key"); + expect(req.headers.Authorization).toBeUndefined(); + }); + + test("environment API key uses the configured provider project", async () => { + setEnv("GOOGLE_CLOUD_API_KEY", "env-key"); + const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", project: "fixture-project", location: "eu" } as OcxProviderConfig; + const req = await createGoogleAdapter(provider).buildRequest(parsed("gemini-4-argon")); + expect(req.url).toBe("https://eu-aiplatform.googleapis.com/v1/projects/fixture-project/locations/eu/publishers/google/models/gemini-4-argon:streamGenerateContent?alt=sse"); + expect(req.headers["x-goog-api-key"]).toBe("env-key"); + expect(oauthCalls).toBe(0); + }); + + test("vertex accepts dotted and versioned model ids unchanged", async () => { + const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", apiKey: "fixture-key" } as OcxProviderConfig; + for (const id of ["gemini-2.5-flash-lite", "gemini-3.6-flash", "gemini-x@001"]) { + const req = await createGoogleAdapter(provider).buildRequest(parsed(id)); + expect(req.url).toBe("https://aiplatform.googleapis.com/v1/publishers/google/models/" + id + ":streamGenerateContent?alt=sse"); + } + }); + + test("vertex rejects model ids that escape the model path segment before any credential", async () => { + setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath); + const providers = [ + { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", apiKey: "fixture-key" }, + { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", apiKey: "fixture-key", project: "fixture-project" }, + { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", project: "fixture-project", location: "global" }, + ] as OcxProviderConfig[]; + for (const provider of providers) { + for (const id of ["a/b", "a?b", "a#b", "a\\b", "a%2Fb", "..", "gemini..x", ""]) { + await expect(createGoogleAdapter(provider).buildRequest(parsed(id))).rejects.toThrow("Vertex AI model id must be a single path segment"); + } + } + expect(oauthCalls).toBe(0); + }); + + test("vertex API key validates project endpoint locations before sending credentials", async () => { + const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex", apiKey: "fixture-key", project: "fixture-project", location: "attacker.example/capture" } as OcxProviderConfig; + await expect(createGoogleAdapter(provider).buildRequest(parsed())).rejects.toThrow("Vertex AI location must be a single lowercase Google Cloud location label"); + expect(oauthCalls).toBe(0); + }); + test("vertex + ADC -> regional host + Authorization Bearer", async () => { setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath); const provider = { adapter: "google", baseUrl: "https://x", googleMode: "vertex", project: "proj-1", location: "us-central1" } as OcxProviderConfig; @@ -144,6 +238,29 @@ describe("google adapter vertex mode", () => { expect(req.headers["Authorization"]).toBe("Bearer vertex-tok"); }); + test("vertex + ADC keeps the environment project aliases and location fallback", async () => { + setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath); + setEnv("GOOGLE_CLOUD_LOCATION", "europe-west4"); + const provider = { adapter: "google", baseUrl: "https://x", googleMode: "vertex" } as OcxProviderConfig; + setEnv("GOOGLE_CLOUD_PROJECT", "env-proj"); + let req = await createGoogleAdapter(provider).buildRequest(parsed()); + expect(req.url).toBe("https://europe-west4-aiplatform.googleapis.com/v1/projects/env-proj/locations/europe-west4/publishers/google/models/gemini-3-pro:streamGenerateContent?alt=sse"); + expect(req.headers["Authorization"]).toBe("Bearer vertex-tok"); + delete process.env.GOOGLE_CLOUD_PROJECT; + setEnv("GCLOUD_PROJECT", "alias-proj"); + req = await createGoogleAdapter(provider).buildRequest(parsed()); + expect(req.url).toBe("https://europe-west4-aiplatform.googleapis.com/v1/projects/alias-proj/locations/europe-west4/publishers/google/models/gemini-3-pro:streamGenerateContent?alt=sse"); + }); + + test("vertex + ADC still requires a project and a location", async () => { + setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath); + const noProject = { adapter: "google", baseUrl: "https://x", googleMode: "vertex", location: "global" } as OcxProviderConfig; + await expect(createGoogleAdapter(noProject).buildRequest(parsed())).rejects.toThrow("Vertex AI requires a project id"); + const noLocation = { adapter: "google", baseUrl: "https://x", googleMode: "vertex", project: "proj-1" } as OcxProviderConfig; + await expect(createGoogleAdapter(noLocation).buildRequest(parsed())).rejects.toThrow("Vertex AI requires a location"); + expect(oauthCalls).toBe(0); + }); + test("vertex + ADC + location global -> global aiplatform host", async () => { setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath); const provider = { adapter: "google", baseUrl: "https://x", googleMode: "vertex", project: "proj-1", location: "global" } as OcxProviderConfig; diff --git a/tests/adapters/google/google-vertex-thought-signature.test.ts b/tests/adapters/google/google-vertex-thought-signature.test.ts index 9a95896a4c..585067de08 100644 --- a/tests/adapters/google/google-vertex-thought-signature.test.ts +++ b/tests/adapters/google/google-vertex-thought-signature.test.ts @@ -147,6 +147,43 @@ describe("Vertex thought-signature continuation (#1254)", () => { expect(replayedFunctionCall(followup.body as string).thoughtSignature).toBe(SIGNATURE); }); + test("ambient Vertex environment changes do not move an express API key's replay namespace", async () => { + const saved = process.env.GOOGLE_CLOUD_PROJECT; + try { + delete process.env.GOOGLE_CLOUD_PROJECT; + const firstAdapter = createGoogleAdapter(provider); + await firstAdapter.buildRequest(firstTurn(false)); + await firstAdapter.parseResponse!(new Response(JSON.stringify(vertexResponseBody()))); + process.env.GOOGLE_CLOUD_PROJECT = "unrelated-gcloud-default"; + const followup = await createGoogleAdapter(provider).buildRequest(continuation()); + expect(followup.url).toContain("/v1/publishers/google/models/"); + expect(replayedFunctionCall(followup.body as string).thoughtSignature).toBe(SIGNATURE); + } finally { + if (saved === undefined) delete process.env.GOOGLE_CLOUD_PROJECT; else process.env.GOOGLE_CLOUD_PROJECT = saved; + } + }); + + test("distinct API-key project endpoints keep separate replay namespaces", async () => { + const firstAdapter = createGoogleAdapter({ ...provider, project: "project-a" }); + await firstAdapter.buildRequest(firstTurn(false)); + await firstAdapter.parseResponse!(new Response(JSON.stringify(vertexResponseBody()))); + + const otherProject = await createGoogleAdapter({ ...provider, project: "project-b" }).buildRequest(continuation()); + expect(replayedFunctionCall(otherProject.body as string).thoughtSignature).toBe("skip_thought_signature_validator"); + const sameProject = await createGoogleAdapter({ ...provider, project: "project-a" }).buildRequest(continuation()); + expect(replayedFunctionCall(sameProject.body as string).thoughtSignature).toBe(SIGNATURE); + }); + + test("express mode and a project literally named api-key keep separate replay namespaces", async () => { + const firstAdapter = createGoogleAdapter(provider); + await firstAdapter.buildRequest(firstTurn(false)); + await firstAdapter.parseResponse!(new Response(JSON.stringify(vertexResponseBody()))); + + const projectEndpoint = await createGoogleAdapter({ ...provider, project: "api-key", location: "global" }).buildRequest(continuation()); + expect(projectEndpoint.url).toContain("/v1/projects/api-key/locations/global/"); + expect(replayedFunctionCall(projectEndpoint.body as string).thoughtSignature).toBe("skip_thought_signature_validator"); + }); + test("Vertex signatures cannot enter the Antigravity replay namespace", async () => { const first = firstTurn(false); const adapter = createGoogleAdapter(provider);