From 1a17b0af6ddc215107d9bf0d704da5e8354c520f Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 03:28:54 +0900 Subject: [PATCH 1/7] fix(vertex): support dashboard ADC configuration --- .../src/content/docs/ko/reference/adapters.md | 7 ++ .../src/content/docs/reference/adapters.md | 7 ++ .../content/docs/zh-cn/reference/adapters.md | 6 + gui/src/components/AddProviderModal.tsx | 1 + gui/src/components/add-provider-form-pane.tsx | 9 ++ .../provider-workspace/ProviderOverview.tsx | 7 +- .../provider-workspace/ProviderRail.tsx | 3 + gui/src/i18n/de.ts | 6 + gui/src/i18n/en.ts | 6 + gui/src/i18n/fr.ts | 6 + gui/src/i18n/ja.ts | 6 + gui/src/i18n/ko.ts | 6 + gui/src/i18n/pt.ts | 6 + gui/src/i18n/ru.ts | 6 + gui/src/i18n/tr.ts | 6 + gui/src/i18n/vi.ts | 6 + gui/src/i18n/zh-TW.ts | 6 + gui/src/i18n/zh.ts | 6 + gui/src/pages/providers-shared.ts | 1 + gui/src/provider-payload.ts | 11 ++ gui/src/provider-workspace/catalog.ts | 8 ++ .../add-provider-subscription-cli.test.tsx | 44 +++++++ .../provider-first-response-guidance.test.tsx | 14 +++ src/providers/api-key-selection.ts | 8 +- src/providers/routed-config.ts | 21 +++- structure/providers-and-adapters.md | 2 +- structure/providers/google.md | 2 + tests/adapters/google/gcp-adc.test.ts | 109 +++++++++++++++++- 28 files changed, 315 insertions(+), 11 deletions(-) diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index 59466160810..ccdf2c2750d 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -138,6 +138,13 @@ Ollama 공급자에 `adapter: "ollama-native"`로 설정할 수도 있습니다. 사용합니다. **인증:** `googleMode`에 따라 API 키, Vertex ADC, Google Antigravity OAuth 중 하나를 선택합니다. +대시보드의 Google Vertex AI 프리셋에서 프로젝트 ID와 리전을 선택적으로 입력할 수 있습니다. +API 키를 비우고 `GOOGLE_CLOUD_API_KEY`가 설정되지 않았다면 **프록시 호스트**의 ADC를 +사용합니다. ADC에는 프로젝트와 리전이 필요하며, 폼 또는 프록시 호스트의 +`GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT` 및 `GOOGLE_CLOUD_LOCATION`으로 설정합니다. +provider를 추가했다고 자격증명이나 권한의 유효성이 검증되는 것은 아닙니다. API 키에 +프로젝트를 지정하지 않으면 Vertex express 모드를 사용합니다. + - 시스템 프롬프트 → `systemInstruction`; 메시지 → `contents[]`(assistant → `model`); 툴 → `functionDeclarations`. data URL 이미지 → `inline_data`. - Gemini가 tool-call id를 생략하면 합성합니다. Vertex와 Antigravity에서는 불투명한 diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 5eafc00c9a9..38c6fd2a876 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -431,6 +431,13 @@ MiMo model Command Code serves. `/v1beta/models/{model}:streamGenerateContent`; the other modes use their native Google endpoints. **Auth:** API key, Vertex ADC, or Google Antigravity OAuth, selected by `googleMode`. +For the Google Vertex AI dashboard preset, Project ID and Location are optional fields. With +an empty API key, the proxy uses ADC on the **proxy host**, unless `GOOGLE_CLOUD_API_KEY` is set; +that environment key takes precedence. ADC also needs a project and location, supplied in the +form or by `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION` on the proxy +host. Adding the provider does not verify that credentials or permissions are usable. A key +without an explicit project uses Vertex express mode. + - **Location denials are permission errors, not invalid requests.** Google rejects unsupported geographic or datacenter locations with HTTP 400 `FAILED_PRECONDITION: User location is not supported for the API use.` The proxy reports this as `… location not supported: …` and diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index 5af5494c331..b35a6286402 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -119,6 +119,12 @@ interface ProviderAdapter { `/v1beta/models/{model}:streamGenerateContent`,其他模式使用各自的 Google 原生 endpoint。 **认证:** 根据 `googleMode` 选择 API key、Vertex ADC 或 Google Antigravity OAuth。 +在仪表盘的 Google Vertex AI 预设中,项目 ID 和位置为可选输入。API key 留空且未设置 +`GOOGLE_CLOUD_API_KEY` 时,代理使用 **代理主机**上的 ADC。ADC 还需要项目和位置,可在 +表单中填写,或由代理主机上的 `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT` 和 +`GOOGLE_CLOUD_LOCATION` 提供。添加 provider 不代表凭据或权限已验证。API key 未指定 +项目时使用 Vertex express 模式。 + - 系统提示词 → `systemInstruction`;消息 → `contents[]`(assistant → `model`);工具 → `functionDeclarations`;data URL 图像 → `inline_data`。 - Gemini 省略 tool-call id 时会合成 id。Vertex 与 Antigravity 会保留并重放不透明 diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index 8e05222c45a..140e640c41c 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -174,6 +174,7 @@ export default function AddProviderModal({ apiKey: "", apiKeyTransport: undefined, defaultModel: p.defaultModel ?? "", + ...(p.id === "google-vertex" ? { googleMode: "vertex" as const, project: "", location: "" } : {}), allowPrivateNetwork: false, }, }); diff --git a/gui/src/components/add-provider-form-pane.tsx b/gui/src/components/add-provider-form-pane.tsx index e0e547a7bc1..bd2f143ecb7 100644 --- a/gui/src/components/add-provider-form-pane.tsx +++ b/gui/src/components/add-provider-form-pane.tsx @@ -183,6 +183,15 @@ export function AddProviderFormPane({ )} )} + {preset.id === "google-vertex" && form.adapter === "google" && <> +

{t("modal.vertexAdcHint")}

+ + onFormChange({ ...form, project: e.target.value })} /> + + + onFormChange({ ...form, location: e.target.value })} placeholder="us-central1" /> + + } {!isReservedForward && onFormChange({ ...form, defaultModel: e.target.value })} placeholder={t("modal.defaultModelPlaceholder")} /> } diff --git a/gui/src/components/provider-workspace/ProviderOverview.tsx b/gui/src/components/provider-workspace/ProviderOverview.tsx index 4912b2ed86a..6775645394e 100644 --- a/gui/src/components/provider-workspace/ProviderOverview.tsx +++ b/gui/src/components/provider-workspace/ProviderOverview.tsx @@ -7,7 +7,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { readJsonOrThrow } from "../../fetch-json"; import { useT, useI18n } from "../../i18n/shared"; import { IconAlert, IconCheck } from "../../icons"; -import { binProviderStatus, type WorkspaceItem } from "../../provider-workspace/catalog"; +import { binProviderStatus, usesUnverifiedVertexCredentials, type WorkspaceItem } from "../../provider-workspace/catalog"; import { formatRequestCount, formatTokenCount } from "../../provider-workspace/usage"; import { modelVisible } from "../../model-visibility"; import { discoveryFailureLabel, type ModelRow } from "../../pages/models-shared"; @@ -79,6 +79,7 @@ export default function ProviderOverview({ const t = useT(); const { locale } = useI18n(); const status = binProviderStatus(item); + const vertexUnverified = usesUnverifiedVertexCredentials(item); const needsAttention = Boolean(item.activeNeedsReauth) || oauth?.needsReauth === true; const needsOAuthLogin = item.authMode === "oauth" && oauth?.loggedIn === false; const effectiveStatus = item.disabled === true @@ -89,7 +90,7 @@ export default function ProviderOverview({ const statusText = effectiveStatus === "ready" ? t("pws.status.ready") : effectiveStatus === "needs-setup" - ? (needsAttention ? t("pws.status.needsAttention") : t("pws.status.needsSetup")) + ? (needsAttention ? t("pws.status.needsAttention") : vertexUnverified ? t("pws.vertexUnverified") : t("pws.status.needsSetup")) : t("prov.disabledBadge"); const requests = usageTotals?.requests; const tokens = usageTotals?.totalTokens; @@ -178,7 +179,7 @@ export default function ProviderOverview({ : providerStepState === "needs-login" ? t("pws.firstResponse.providerNeedsLogin") : providerStepState === "needs-setup" - ? t("pws.firstResponse.providerNeedsSetup") + ? t(vertexUnverified ? "pws.vertexSavedUnverified" : "pws.firstResponse.providerNeedsSetup") : t("pws.firstResponse.providerSaved"); const providerRows = (modelRows ?? []).filter(row => row.provider === item.name); diff --git a/gui/src/components/provider-workspace/ProviderRail.tsx b/gui/src/components/provider-workspace/ProviderRail.tsx index b9a8d4d2c7b..91d5e00ea08 100644 --- a/gui/src/components/provider-workspace/ProviderRail.tsx +++ b/gui/src/components/provider-workspace/ProviderRail.tsx @@ -9,6 +9,7 @@ import { IconStar } from "../../icons"; import { binProviderStatus, isFreeProvider, + usesUnverifiedVertexCredentials, type WorkspaceItem, type WorkspaceProvider, } from "../../provider-workspace/catalog"; @@ -20,6 +21,7 @@ export function statusLabel(p: WorkspaceProvider, t: TFn): string { const s = binProviderStatus(p); if (s === "disabled") return t("prov.disabledBadge"); if (s === "ready") return t("pws.status.ready"); + if (usesUnverifiedVertexCredentials(p)) return t("pws.vertexUnverified"); if ("activeNeedsReauth" in p && (p as WorkspaceItem).activeNeedsReauth) { return t("pws.status.needsAttention"); } @@ -27,6 +29,7 @@ export function statusLabel(p: WorkspaceProvider, t: TFn): string { } export function authModeLabel(item: WorkspaceItem, t: TFn): string { + if (usesUnverifiedVertexCredentials(item)) return t("pws.vertexHostAuth"); // Configured with authMode "key" (the registry's authKind), but no key is ever used. if (isSubscriptionCliProvider(item)) return t("modal.badge.subscriptionCli"); switch (item.authMode) { diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 855aad80b86..83c141fc8e1 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3313,6 +3313,12 @@ export const de: Record = { "modal.localHint": "Es wird kein API-Schlüssel gespeichert. Damit wird Cursors öffentlicher Modellkatalog für Codex hinzugefügt; live Cursor-Transport und native Datei-/Shell-Ausführung bleiben deaktiviert, bis sie geprüft sind.", "modal.getApiKey": "{label}-API-Schlüssel holen", "modal.apiKey": "API-Schlüssel", + "modal.vertexProject": "Projekt-ID (optional)", + "modal.vertexLocation": "Standort (optional)", + "pws.vertexUnverified": "Zugangsdaten ungeprüft", + "pws.vertexSavedUnverified": "Gespeichert — Proxy-Zugangsdaten ungeprüft", + "pws.vertexHostAuth": "Host-Zugangsdaten oder API-Schlüssel", + "modal.vertexAdcHint": "Ohne API-Schlüssel werden die Application Default Credentials auf dem Proxy-Host verwendet, sofern GOOGLE_CLOUD_API_KEY nicht gesetzt ist. Projekt und Standort können auch aus der Proxy-Umgebung stammen.", "modal.apiKeyTransport": "API-Schlüssel-Header", "modal.apiKeyTransportNative": "x-api-key (Anthropic-Standard)", "modal.apiKeyTransportBearer": "Authorization: Bearer", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 93c33a27e9d..3174c9607e6 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2168,6 +2168,12 @@ export const en = { "modal.localHint": "No API key is stored. This adds Cursor's static public model catalog for Codex, but live Cursor transport and native file/shell execution remain disabled until audited.", "modal.getApiKey": "Get your {label} API key", "modal.apiKey": "API key", + "modal.vertexProject": "Project ID (optional)", + "modal.vertexLocation": "Location (optional)", + "pws.vertexUnverified": "Credentials unverified", + "pws.vertexSavedUnverified": "Saved — proxy credentials unverified", + "pws.vertexHostAuth": "Host credentials or API key", + "modal.vertexAdcHint": "Leave the API key empty to use Application Default Credentials on the proxy host when GOOGLE_CLOUD_API_KEY is unset. Project and location can also come from the proxy environment.", "modal.apiKeyTransport": "API key header", "modal.apiKeyTransportNative": "x-api-key (Anthropic native)", "modal.apiKeyTransportBearer": "Authorization: Bearer", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4bac4fb5bdb..057e07d4a4f 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2141,6 +2141,12 @@ export const fr: Record = { "modal.localHint": "Aucune clé API n’est stockée. Cette option ajoute le catalogue public statique de modèles Cursor pour Codex, mais le transport Cursor en direct et l’exécution native de fichiers/commandes restent désactivés jusqu’à leur audit.", "modal.getApiKey": "Obtenir votre clé API {label}", "modal.apiKey": "Clé API", + "modal.vertexProject": "ID du projet (facultatif)", + "modal.vertexLocation": "Emplacement (facultatif)", + "pws.vertexUnverified": "Identifiants non vérifiés", + "pws.vertexSavedUnverified": "Enregistré — identifiants du proxy non vérifiés", + "pws.vertexHostAuth": "Identifiants de l’hôte ou clé API", + "modal.vertexAdcHint": "Laissez la clé API vide pour utiliser les Application Default Credentials sur l’hôte du proxy si GOOGLE_CLOUD_API_KEY n’est pas défini. Le projet et l’emplacement peuvent aussi provenir de l’environnement du proxy.", "modal.apiKeyTransport": "En-tête de clé API", "modal.apiKeyTransportNative": "x-api-key (natif Anthropic)", "modal.apiKeyTransportBearer": "Authorization: Bearer", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e17c94b7f89..7c8b825eaa7 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2036,6 +2036,12 @@ export const ja: Record = { "modal.localHint": "API キーは保存されません。Cursor の静的な公開モデルカタログを Codex に追加しますが、ライブの Cursor トランスポートとネイティブのファイル/シェル実行は監査されるまで無効のままです。", "modal.getApiKey": "{label} の API キーを取得", "modal.apiKey": "API キー", + "modal.vertexProject": "プロジェクト ID(任意)", + "modal.vertexLocation": "ロケーション(任意)", + "pws.vertexUnverified": "認証情報は未確認", + "pws.vertexSavedUnverified": "保存済み — プロキシの認証情報は未確認", + "pws.vertexHostAuth": "ホストの認証情報または API キー", + "modal.vertexAdcHint": "GOOGLE_CLOUD_API_KEY が未設定の場合、API キーを空にするとプロキシホストの Application Default Credentials を使います。プロジェクトとロケーションもプロキシ環境で設定できます。", "modal.apiKeyTransport": "API キーヘッダー", "modal.apiKeyTransportNative": "x-api-key (Anthropic 標準)", "modal.apiKeyTransportBearer": "Authorization: Bearer", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index bab550b094b..10796365308 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3360,6 +3360,12 @@ export const ko: Record = { "modal.localHint": "API 키는 저장되지 않습니다. Cursor의 공개 모델 카탈로그만 Codex에 추가되며, live Cursor 전송과 네이티브 파일/셸 실행은 검토 전까지 비활성입니다.", "modal.getApiKey": "{label} API 키 받기", "modal.apiKey": "API 키", + "modal.vertexProject": "프로젝트 ID (선택)", + "modal.vertexLocation": "리전 (선택)", + "pws.vertexUnverified": "자격증명 미확인", + "pws.vertexSavedUnverified": "저장됨 — 프록시 자격증명 미확인", + "pws.vertexHostAuth": "호스트 자격증명 또는 API 키", + "modal.vertexAdcHint": "GOOGLE_CLOUD_API_KEY가 설정되지 않았다면 API 키를 비워 프록시 호스트의 Application Default Credentials를 사용해요. 프로젝트와 리전도 프록시 환경에서 설정할 수 있어요.", "modal.apiKeyTransport": "API 키 헤더", "modal.apiKeyTransportNative": "x-api-key (Anthropic 기본)", "modal.apiKeyTransportBearer": "Authorization: Bearer", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 2f8c7b96c62..a1237a8ab04 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -2152,6 +2152,12 @@ export const pt: Record = { "modal.localHint": "Nenhuma chave de API é armazenada. Isto adiciona o catálogo estático público de modelos do Cursor para o Codex, mas o transporte ao vivo do Cursor e a execução nativa de arquivos/shell permanecem desativados até serem auditados.", "modal.getApiKey": "Obtenha sua chave de API de {label}", "modal.apiKey": "Chave de API", + "modal.vertexProject": "ID do projeto (opcional)", + "modal.vertexLocation": "Localização (opcional)", + "pws.vertexUnverified": "Credenciais não verificadas", + "pws.vertexSavedUnverified": "Salvo — credenciais do proxy não verificadas", + "pws.vertexHostAuth": "Credenciais do host ou chave de API", + "modal.vertexAdcHint": "Deixe a chave de API vazia para usar as Application Default Credentials no host do proxy quando GOOGLE_CLOUD_API_KEY não estiver definida. O projeto e a localização também podem vir do ambiente do proxy.", "modal.apiKeyTransport": "Cabeçalho da chave de API", "modal.apiKeyTransportNative": "x-api-key (nativo da Anthropic)", "modal.apiKeyTransportBearer": "Authorization: Bearer", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 132bf0e533e..6e9ceccd37e 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2127,6 +2127,12 @@ export const ru: Record = { "modal.localHint": "API-ключ не сохраняется. Будет добавлен статический публичный каталог моделей Cursor для Codex, но живой транспорт Cursor и нативное выполнение файловых и shell-операций остаются отключёнными до прохождения аудита.", "modal.getApiKey": "Получить API-ключ {label}", "modal.apiKey": "API-ключ", + "modal.vertexProject": "ID проекта (необязательно)", + "modal.vertexLocation": "Регион (необязательно)", + "pws.vertexUnverified": "Учётные данные не проверены", + "pws.vertexSavedUnverified": "Сохранено — данные прокси не проверены", + "pws.vertexHostAuth": "Данные хоста или API-ключ", + "modal.vertexAdcHint": "Оставьте API-ключ пустым для использования Application Default Credentials на хосте прокси, если GOOGLE_CLOUD_API_KEY не задан. Проект и регион также можно задать в окружении прокси.", "modal.apiKeyTransport": "Заголовок API-ключа", "modal.apiKeyTransportNative": "x-api-key (нативный Anthropic)", "modal.apiKeyTransportBearer": "Authorization: Bearer", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index db0256de5cf..ed720aebc43 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2154,6 +2154,12 @@ export const tr: Record = { "modal.localHint": "Yerel mod, Cursor'ın statik kamuya açık kataloğunu kaydeder. Canlı taşıma, dosya ve kabuk (shell) araçları denetlenene kadar kapalı kalır.", "modal.getApiKey": "{label} API anahtarınızı alın", "modal.apiKey": "API anahtarı", + "modal.vertexProject": "Proje kimliği (isteğe bağlı)", + "modal.vertexLocation": "Konum (isteğe bağlı)", + "pws.vertexUnverified": "Kimlik bilgileri doğrulanmadı", + "pws.vertexSavedUnverified": "Kaydedildi — proxy kimlik bilgileri doğrulanmadı", + "pws.vertexHostAuth": "Sunucu kimlik bilgileri veya API anahtarı", + "modal.vertexAdcHint": "GOOGLE_CLOUD_API_KEY ayarlı değilse proxy sunucusundaki Application Default Credentials bilgilerini kullanmak için API anahtarını boş bırakın. Proje ve konum da proxy ortamından alınabilir.", "modal.apiKeyTransport": "API anahtar başlığı", "modal.apiKeyTransportNative": "x-api-key (Anthropic yerel)", "modal.apiKeyTransportBearer": "Authorization: Bearer", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index e2efd284495..599629344c2 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2138,6 +2138,12 @@ export const vi: Record = { "modal.localHint": "Không lưu trữ API key. Thao tác này sẽ thêm danh mục model công khai tĩnh của Cursor cho Codex, nhưng transport trực tiếp của Cursor và tính năng thực thi file/shell native vẫn bị vô hiệu hoá cho đến khi được kiểm toán.", "modal.getApiKey": "Lấy API key {label} của bạn", "modal.apiKey": "API key", + "modal.vertexProject": "ID dự án (tùy chọn)", + "modal.vertexLocation": "Vị trí (tùy chọn)", + "pws.vertexUnverified": "Chưa xác minh thông tin xác thực", + "pws.vertexSavedUnverified": "Đã lưu — chưa xác minh thông tin của proxy", + "pws.vertexHostAuth": "Thông tin máy chủ hoặc API key", + "modal.vertexAdcHint": "Để trống API key để dùng Application Default Credentials trên máy chủ proxy khi chưa đặt GOOGLE_CLOUD_API_KEY. Dự án và vị trí cũng có thể lấy từ môi trường proxy.", "modal.apiKeyTransport": "API key header", "modal.apiKeyTransportNative": "x-api-key (Anthropic native)", "modal.apiKeyTransportBearer": "Authorization: Bearer", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index f6bfa5ab864..e3e094fefde 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1811,6 +1811,12 @@ export const zhTW: Record = { "modal.localHint": "不會儲存 API 金鑰。這會為 Codex 新增 Cursor 的公開模型目錄,但在審計完成前,即時 Cursor 傳輸與原生檔案/Shell 執行仍保持停用。", "modal.getApiKey": "取得 {label} API 金鑰", "modal.apiKey": "API 金鑰", + "modal.vertexProject": "專案 ID(選填)", + "modal.vertexLocation": "位置(選填)", + "pws.vertexUnverified": "憑證未驗證", + "pws.vertexSavedUnverified": "已儲存 — 代理憑證未驗證", + "pws.vertexHostAuth": "主機憑證或 API 金鑰", + "modal.vertexAdcHint": "未設定 GOOGLE_CLOUD_API_KEY 時,將 API 金鑰留空即可使用代理主機上的 Application Default Credentials。專案和位置也可由代理環境設定。", "modal.apiKeyTransport": "API 金鑰標頭", "modal.apiKeyTransportNative": "x-api-key(Anthropic 原生)", "modal.apiKeyTransportBearer": "Authorization: Bearer", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index aa3a52e8191..b72b487b6da 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3333,6 +3333,12 @@ export const zh: Record = { "modal.localHint": "不会存储 API 密钥。这会为 Codex 添加 Cursor 的公开模型目录,但在审计完成前,实时 Cursor 传输与原生文件/Shell 执行仍保持禁用。", "modal.getApiKey": "获取 {label} API 密钥", "modal.apiKey": "API 密钥", + "modal.vertexProject": "项目 ID(可选)", + "modal.vertexLocation": "位置(可选)", + "pws.vertexUnverified": "凭据未验证", + "pws.vertexSavedUnverified": "已保存 — 代理凭据未验证", + "pws.vertexHostAuth": "主机凭据或 API 密钥", + "modal.vertexAdcHint": "未设置 GOOGLE_CLOUD_API_KEY 时,留空 API 密钥可使用代理主机上的 Application Default Credentials。项目和位置也可通过代理环境设置。", "modal.apiKeyTransport": "API 密钥请求头", "modal.apiKeyTransportNative": "x-api-key(Anthropic 原生)", "modal.apiKeyTransportBearer": "Authorization: Bearer", diff --git a/gui/src/pages/providers-shared.ts b/gui/src/pages/providers-shared.ts index d3b90cf488d..45c3ba0e3d8 100644 --- a/gui/src/pages/providers-shared.ts +++ b/gui/src/pages/providers-shared.ts @@ -13,6 +13,7 @@ export interface ProvidersConfig { reasoningWireFormat?: "gateway-object"; preserveResponsesReasoningContent?: boolean; authMode?: string; + googleMode?: "ai-studio" | "vertex" | "cloud-code-assist"; anthropicOAuthInstance?: "anthropic2"; keyOptional?: boolean; disabled?: boolean; diff --git a/gui/src/provider-payload.ts b/gui/src/provider-payload.ts index 3e72c77d12b..a11785c8a51 100644 --- a/gui/src/provider-payload.ts +++ b/gui/src/provider-payload.ts @@ -11,6 +11,9 @@ export interface ProviderPayloadForm { defaultModel: string; allowPrivateNetwork?: boolean; preserveResponsesReasoningContent?: boolean; + googleMode?: "vertex"; + project?: string; + location?: string; } export interface ProviderPostPreset { @@ -82,6 +85,9 @@ export interface ProviderPayload { codexAccountMode?: "pool" | "direct"; allowPrivateNetwork?: boolean; preserveResponsesReasoningContent?: boolean; + googleMode?: "vertex"; + project?: string; + location?: string; } export function buildProviderPayload(form: ProviderPayloadForm): ProviderPayload { @@ -93,6 +99,11 @@ export function buildProviderPayload(form: ProviderPayloadForm): ProviderPayload if (form.responsesPath?.trim()) { provider.responsesPath = form.responsesPath.trim(); } + if (form.adapter.trim() === "google" && form.googleMode === "vertex") { + provider.googleMode = "vertex"; + if (form.project?.trim()) provider.project = form.project.trim(); + if (form.location?.trim()) provider.location = form.location.trim(); + } if (form.authMode === "key" || form.authMode === "forward") { provider.authMode = form.authMode; } diff --git a/gui/src/provider-workspace/catalog.ts b/gui/src/provider-workspace/catalog.ts index 762485b0090..837e4ff24d7 100644 --- a/gui/src/provider-workspace/catalog.ts +++ b/gui/src/provider-workspace/catalog.ts @@ -46,6 +46,7 @@ export interface WorkspaceProvider { /** Optional upstream HTTP version pin. Cursor defaults to HTTP/2 when omitted. */ upstreamHttpVersion?: "auto" | "http1.1" | "h1" | "http2" | "h2"; authMode?: "key" | "forward" | "oauth" | "local" | string; + googleMode?: "ai-studio" | "vertex" | "cloud-code-assist"; keyOptional?: boolean; /** Free pricing (may still require an API key). */ freeTier?: boolean; @@ -158,6 +159,13 @@ function isConfigurationReady(p: WorkspaceProvider): boolean { p.hasApiKey === true; } +/** A keyless Vertex row can use host credentials, but the dashboard has not verified them. */ +export function usesUnverifiedVertexCredentials(p: WorkspaceProvider | WorkspaceItem): boolean { + if (p.disabled || p.adapter !== "google" || p.hasApiKey || (p.authMode !== undefined && p.authMode !== "key")) return false; + return p.googleMode === "vertex" || (p.googleMode === undefined && "name" in p && p.name === "google-vertex" + && normalizedBaseUrl(p.baseUrl) === "https://aiplatform.googleapis.com"); +} + /** * True when the provider config is the canonical Codex passthrough shape. * GUI-local mirror of `isCanonicalOpenAiForwardProvider` (src/providers/openai-tiers.ts) — diff --git a/gui/tests/add-provider-subscription-cli.test.tsx b/gui/tests/add-provider-subscription-cli.test.tsx index 18f63e8bfb3..8668c385ee2 100644 --- a/gui/tests/add-provider-subscription-cli.test.tsx +++ b/gui/tests/add-provider-subscription-cli.test.tsx @@ -16,6 +16,7 @@ import { selectValue } from "./helpers/custom-select"; const PRESETS = [ { id: "claude-cli", label: "Claude Code CLI (subscription)", adapter: "claude-cli", baseUrl: "https://api.anthropic.com", auth: "key", keyOptional: true, defaultModel: "claude-sonnet-5", note: "Runs Claude subscription traffic through the official CLI." }, { id: "keyless", label: "Keyless Free", adapter: "openai-chat", baseUrl: "https://api.example.com/v1", auth: "key", keyOptional: true }, + { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", auth: "key", defaultModel: "gemini-3-pro" }, ]; const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; @@ -151,3 +152,46 @@ test("a keyless free row keeps the free-tier box and no subscription warning", a expect(warning()).toBeNull(); expect(win.document.body.textContent).toContain("Free tier"); }); + +test("Vertex accepts ADC project and location without a dummy API key", async () => { + await mount("paid"); + const row = [...win.document.querySelectorAll(".provider-catalog-row-wrap > button")] + .find(el => (el.textContent ?? "").includes("Google Vertex AI")) as unknown as HTMLButtonElement; + await act(async () => { row.click(); }); + const inputFor = (label: string) => [...win.document.querySelectorAll("label")] + .find(element => element.textContent?.includes(label))?.querySelector("input") as unknown as HTMLInputElement; + const project = inputFor("Project ID"); + const location = inputFor("Location"); + expect(project).toBeDefined(); + expect(location).toBeDefined(); + expect(win.document.body.textContent).toContain("Application Default Credentials"); + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!; + setter.call(project, "demo-project"); project.dispatchEvent(new win.Event("input", { bubbles: true })); + setter.call(location, "us-central1"); location.dispatchEvent(new win.Event("input", { bubbles: true })); + }); + const addButton = [...win.document.querySelectorAll("button.btn-primary")] + .find(el => el.textContent === "Add provider") as unknown as HTMLButtonElement; + await act(async () => { addButton.click(); await new Promise(r => setTimeout(r, 20)); }); + expect(posted).toHaveLength(1); + expect(posted[0]?.provider).toMatchObject({ googleMode: "vertex", project: "demo-project", location: "us-central1" }); + expect(posted[0]?.provider).not.toHaveProperty("apiKey"); +}); + +test("Vertex API-key express setup leaves the optional project unset", async () => { + await mount("paid"); + const row = [...win.document.querySelectorAll(".provider-catalog-row-wrap > button")] + .find(el => (el.textContent ?? "").includes("Google Vertex AI")) as unknown as HTMLButtonElement; + await act(async () => { row.click(); }); + const input = win.document.querySelector('input[type="password"]') as unknown as HTMLInputElement; + await act(async () => { + Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(input, "fixture-key"); + input.dispatchEvent(new win.Event("input", { bubbles: true })); + }); + const add = [...win.document.querySelectorAll("button.btn-primary")] + .find(el => el.textContent === "Add provider") as unknown as HTMLButtonElement; + await act(async () => { add.click(); await new Promise(r => setTimeout(r, 20)); }); + expect(posted[0]?.provider).toMatchObject({ googleMode: "vertex", apiKey: "fixture-key" }); + expect(posted[0]?.provider).not.toHaveProperty("project"); + expect(posted[0]?.provider).not.toHaveProperty("location"); +}); diff --git a/gui/tests/provider-first-response-guidance.test.tsx b/gui/tests/provider-first-response-guidance.test.tsx index 4fcf1867090..833edd07957 100644 --- a/gui/tests/provider-first-response-guidance.test.tsx +++ b/gui/tests/provider-first-response-guidance.test.tsx @@ -127,6 +127,20 @@ function runProbeButton(): HTMLButtonElement { } describe("first-response progression states on ProviderOverview", () => { + test("keyless Vertex reports host credentials as unverified rather than missing", async () => { + const item = { ...baseItem, name: "google-vertex", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", + authMode: "key", googleMode: "vertex", hasApiKey: false } as WorkspaceItem; + await render(); + expect(host.querySelector(".pws-kv dd")?.textContent).toContain("Credentials unverified"); + expect(stepText("provider")).toContain("proxy credentials unverified"); + expect(stepText("provider")).not.toContain("credentials required"); + await render(); + expect(host.querySelector(".pws-kv dd")?.textContent).toContain("Ready"); + await render(); + expect(host.querySelector(".pws-kv dd")?.textContent).toContain("Needs setup"); + await render(); + expect(host.querySelector(".pws-kv dd")?.textContent).toContain("Disabled"); + }); test("distinguishes provider saved, models ready, client routing guidance, and untested probe without claiming Connected", async () => { const rows: ModelRow[] = [{ provider: "my-provider", id: "model-a", namespaced: "my-provider/model-a", disabled: false }]; let openedConnect = 0; diff --git a/src/providers/api-key-selection.ts b/src/providers/api-key-selection.ts index 550f5420867..2280569a9e1 100644 --- a/src/providers/api-key-selection.ts +++ b/src/providers/api-key-selection.ts @@ -35,7 +35,8 @@ function currentKeyProvider(config: OcxConfig, name: string, selection?: Provide || row.apiKeyPool?.length && row.apiKeyPool.every(entry => entry.disabled)) return null; const current = routedProviderConfig(name, { ...row, _apiKeyAttempt: selection?.distributed ? selection : undefined }); if (current.authMode === "oauth" || current.authMode === "forward") return null; - if (current.authMode === "key" && !current.keyOptional && !current.apiKey?.trim()) return null; + if (current.authMode === "key" && !current.keyOptional && !current.apiKey?.trim() + && !(current.adapter === "google" && current.googleMode === "vertex")) return null; return current; } @@ -64,7 +65,10 @@ function matchesConfiguredSelection( && (expected.distributed === true || matchesSelection(config.providers[name]!, expected)) && current.apiKey === routedProvider.apiKey && current.authMode === routedProvider.authMode - && current.baseUrl === routedProvider.baseUrl; + && current.baseUrl === routedProvider.baseUrl + && (current.googleMode !== "vertex" && routedProvider.googleMode !== "vertex" + || current.googleMode === routedProvider.googleMode && current.project === routedProvider.project + && current.location === routedProvider.location); } /** Rebuild transport from the already committed choice; never allocate or publish a selection. */ diff --git a/src/providers/routed-config.ts b/src/providers/routed-config.ts index 3867ede0433..b8222882f0a 100644 --- a/src/providers/routed-config.ts +++ b/src/providers/routed-config.ts @@ -110,6 +110,21 @@ function usableResolvedApiKey(apiKey: string | undefined): string | undefined { return typeof resolved === "string" && resolved.trim().length > 0 ? resolved : undefined; } +/** Bind the environment values used by Vertex to the routed request for send-time checks. */ +function snapshotVertexEnvironment(provider: OcxProviderConfig): OcxProviderConfig { + if (provider.adapter !== "google" || provider.googleMode !== "vertex") return provider; + const marker = provider.apiKey?.startsWith(GCP_CREDENTIAL_MARKER_PREFIX) === true; + const configuredKey = provider.apiKey && !provider.apiKey.startsWith("<") && provider.apiKey !== "N/A" ? provider.apiKey : undefined; + const apiKey = marker ? provider.apiKey : configuredKey || process.env.GOOGLE_CLOUD_API_KEY || undefined; + if (apiKey && !marker) return { ...provider, apiKey }; + return { + ...provider, + apiKey, + project: provider.project?.trim() ? provider.project : process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT, + location: provider.location || process.env.GOOGLE_CLOUD_LOCATION, + }; +} + export function routedProviderConfig(providerName: string, provider: OcxProviderConfig): OcxProviderConfig { provider = { ...provider, _apiKeyAttempt: provider._apiKeyAttempt ?? captureProviderApiKeySelection(provider) }; const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName); @@ -124,7 +139,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider const destination = registryEntryForProviderDestination(provider); const inputModalities = provider.inputModalities === undefined ? registryInputModalitiesForDestination(providerName, provider) : undefined; - return { + return snapshotVertexEnvironment({ ...provider, ...(inputModalities ? { inputModalities } : {}), apiKey: usableResolvedApiKey(provider.apiKey), @@ -140,7 +155,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider ...(provider.dropResponsesReasoningItems === undefined && destination?.dropResponsesReasoningItems !== undefined ? { dropResponsesReasoningItems: destination.dropResponsesReasoningItems } : {}), - }; + }); } const resolvedApiKey = usableResolvedApiKey(provider.apiKey); const staticModelCatalog = !providerSupportsLiveModelDiscovery(providerName, provider); @@ -360,5 +375,5 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider ...(thinkingBudgetModels ? { thinkingBudgetModels } : {}), }; applyDirectReasoningEffortContracts(registryEntry, resolved, provider); - return resolved; + return snapshotVertexEnvironment(resolved); } diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index a52337188db..e7cd08bd4ca 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -4,7 +4,7 @@ Reasoning support, thinking on/off control, real reasoning strengths and the thi Public MiMo V2.6 is a registry instance with reasoning and toggle support, no real effort strengths, and thinking enabled by default. `src/providers/mimo-thinking.ts` supplies those facts; `thinkingToggleModels` separately declares its existing `thinking.type` wire protocol. Client compatibility alone projects the switch to legacy `none/high` choices. Internal metadata contains no synthetic MiMo effort ladder or high default. Public model rows expose optional `supports_reasoning`, `supports_thinking_toggle`, `supports_reasoning_effort` and `default_thinking_enabled` flags; chained provider discovery reads those flags back without inferring false from absence. Config hints rederive cached projections from their original discovered reasoning metadata. Free, token-plan and third-party MiMo routes keep their existing contracts. Canonical Mistral request fields, developer defaults, effort clamping and native typed-content projection follow the [Chat wire policy](providers/mistral-chat.md). Canonical key-auth Mistral Chat at `https://api.mistral.ai/v1` defaults to a patient bounded same-key 429 policy (six replays, ten-second fallback) in `src/providers/key-failover.ts`; explicit retry settings/opt-out win. The Mistral preset forwards supplied prompt-cache keys unless explicitly disabled. `src/providers/key-failover.ts` and transport selection use the same canonical-destination check, including custom provider names. `src/providers/mistral-transport.ts` shares explicit Retry-After deadlines across calls by actual credential digest and exact model, with bounded memory/waiters and cancellation. Without Retry-After, only a real 429 with validated exhausted minute capacity or a measured query charge above remaining but within the full token allowance activates a conservative 60-second recovery hold. This local policy does not claim the server reset instant. One probe gates concurrent recovery callers; a successful probe clears recovery unless a newer refusal intervened. Healthy traffic never predicts future query/cache cost; unknown, zero-entitlement, exhausted-monthly and above-full-allowance evidence does not invent a minute reset. Oversized Retry-After stops same-key replay rather than shortening the server delay: a same-target retry hold in `src/lib/upstream-retry.ts` is checked only by the same-key 429 loops, so key rotation and the original upstream status, body and Retry-After stay available. Either `requestPacing.enabled: false` or `retryOn429.respectRetryAfter: false` disables the shared hold. `tests/providers/mistral-recovery.test.ts` covers GLM/Large 4, destination/key/model isolation, absent headers, cache affinity, streamed/buffered replay, cancellation and send ceilings. There is no mid-stream replay or automatic compaction change. The same destination check gates `src/adapters/openai-chat/mistral-image-limit.ts`, the last outbound-copy step of `messagesToChatFormat` and of the native Chat passthrough (`src/adapters/openai-chat/passthrough.ts`): it keeps the newest `image_url` parts within `modelCapabilities..mistralMaxImages` (operator declaration over the destination registry row, else the documented 8) and replaces older ones with a text note naming the cap. -GCP JSON storage in `src/providers/api-keys.ts` and durable-row snapshot/post-commit cleanup in `src/providers/gcp-credential-cleanup.ts` follow [Vertex credential markers](providers/google.md#vertex-credential-markers); malformed blank marker accounts do not block cleanup of valid accounts. +GCP JSON storage in `src/providers/api-keys.ts` and durable-row snapshot/post-commit cleanup in `src/providers/gcp-credential-cleanup.ts` follow [Vertex credential markers](providers/google.md#vertex-credential-markers); malformed blank marker accounts do not block cleanup of valid accounts. Vertex ADC dispatch authority is described in [Vertex API-key endpoint selection](providers/google.md#vertex-api-key-endpoint-selection). Combo target `metered` metadata and configured-key opt-in follow the [runtime consent contract](runtime.md#request-local-target-compatibility). Subscription-to-Cursor-to-key regression coverage in `tests/adapters/anthropic/anthropic-subscription-chain.test.ts` checks unchanged healthy subscription wire bodies, effort, key authentication and cache controls, and structured runTurn 429 propagation. OpenCode Go's exact Gemini pin and credential destination guard follow the [Go Gemini transport contract](providers/google.md#opencode-go-gemini-transport). Configured spend ceilings bind adapter inference executors to the [seed and physical-start contract](transports/responses-spend.md#historical-pool-continuity-and-rollback). Zed completion and auth-recovery replays claim separately; token acquisition stays outside inference accounting. Claude CLI, CodeBuddy and Qoder each count one invocation toward the frozen request limit, seed before spawn and retain a producer through completion. Child-internal retries/tool turns are not separately counted; reported actual usage settles in full, including amounts above the seed estimate. Devin combines only [consecutive same-ID tool results](adapters/registry.md#devin-consecutive-tool-results), using linear accumulation while preserving intervening message slots, image parts and error markers without mutating the parsed request. Claude CLI replay, client tool capture and usage admission follow [Claude CLI replay](providers/claude-cli-replay.md). Anthropic account pause, model routes, quota labels, and the separate `anthropic2` pool (its helpers, quota, reset grants and management surfaces) follow the [Anthropic account-pool contract](providers/anthropic-account-pool.md). Devin Messages follows the [per-turn output ordering contract](clients/claude-desktop.md#devin-messages-output-ordering), preserving late signatures before text/tools without changing Responses or Chat ordering. The DeepSeek Harness account provider follows the [DSH account contract](providers/dsh-account.md). WorkBuddy AI subscription authentication, catalog, display-only credit balances and inference follow the [WorkBuddy contract](providers/workbuddy.md). diff --git a/structure/providers/google.md b/structure/providers/google.md index 5c44ad7722f..5ca0b811b08 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -12,6 +12,8 @@ EOF output, retain the [shared byte-accounting contract](../transports/byte-acco ## Vertex API-key endpoint selection +Vertex requests may use host ADC without an API key. `src/providers/routed-config.ts` captures the effective Vertex environment key and, for ADC, the project and location with the routed provider; `src/providers/api-key-selection.ts` rechecks those values before dispatch. A change forces transport rebuilding. A service-account marker continues to select its named credential instead of the ambient API key. The capture does not itself verify that ADC credentials are available. `tests/adapters/google/gcp-adc.test.ts` covers the selection boundary and a proxy Chat request using synthetic ADC credentials. + `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 diff --git a/tests/adapters/google/gcp-adc.test.ts b/tests/adapters/google/gcp-adc.test.ts index fe0c8d894cb..2a6ac225028 100644 --- a/tests/adapters/google/gcp-adc.test.ts +++ b/tests/adapters/google/gcp-adc.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, beforeEach, afterAll, describe, expect, test } from "bun:test"; +import { afterEach, beforeAll, beforeEach, afterAll, describe, expect, spyOn, test } from "bun:test"; import { Buffer } from "node:buffer"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -6,7 +6,17 @@ import { join } from "node:path"; import { getVertexAccessToken, __resetVertexTokenCache } from "../../../src/lib/gcp-adc"; import { createGoogleAdapter } from "../../../src/adapters/google"; import { providerManagementConfigError } from "../../../src/server/auth-cors"; -import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; +import { providerApiKeySelectionIsCurrent } from "../../../src/providers/api-key-selection"; +import { routedProviderConfig } from "../../../src/providers/routed-config"; +import { loadConfig, saveConfig } from "../../../src/config"; +import { handleChatCompletions } from "../../../src/server/chat-completions"; +import { handleManagementAPI } from "../../../src/server/management-api"; +import * as proxyEnv from "../../../src/lib/proxy-env"; +import * as destinationPolicy from "../../../src/lib/destination-policy"; +import { acquireOwnedSpendHome } from "../../helpers/owned-spend-home"; +import { ManagementRequest } from "../../helpers/management-auth"; +import { catalogConvergenceFactory } from "../../helpers/catalog-convergence"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; import { STATE_STORE_REGISTRATIONS } from "../../../src/lib/state-store-registrations"; import { removeTreeWithRetry } from "../../helpers/remove-tree"; @@ -81,6 +91,101 @@ function setEnv(k: string, v: string): void { } describe("gcp-adc resolver", () => { + test("keyless Vertex ADC remains valid through the physical-send selection check", () => { + const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authMode: "key", googleMode: "vertex", project: "demo-project", location: "global" } as OcxProviderConfig; + const config = { port: 0, defaultProvider: "google-vertex", providers: { "google-vertex": provider } } as OcxConfig; + const routed = routedProviderConfig("google-vertex", provider); + expect(providerApiKeySelectionIsCurrent(config, "google-vertex", routed)).toBe(true); + config.providers["google-vertex"] = { ...provider, project: "other-project" }; + expect(providerApiKeySelectionIsCurrent(config, "google-vertex", routed)).toBe(false); + }); + + test("Vertex send-time selection detects environment key, project and location changes", () => { + const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authMode: "key", googleMode: "vertex" } as OcxProviderConfig; + const config = { port: 0, defaultProvider: "google-vertex", providers: { "google-vertex": provider } } as OcxConfig; + setEnv("GOOGLE_CLOUD_PROJECT", "project-one"); + setEnv("GOOGLE_CLOUD_LOCATION", "global"); + const adc = routedProviderConfig("google-vertex", provider); + expect(providerApiKeySelectionIsCurrent(config, "google-vertex", adc)).toBe(true); + setEnv("GOOGLE_CLOUD_PROJECT", "project-two"); + expect(providerApiKeySelectionIsCurrent(config, "google-vertex", adc)).toBe(false); + const changedProject = routedProviderConfig("google-vertex", provider); + setEnv("GOOGLE_CLOUD_LOCATION", "us-central1"); + expect(providerApiKeySelectionIsCurrent(config, "google-vertex", changedProject)).toBe(false); + setEnv("GOOGLE_CLOUD_API_KEY", "fixture-env-key"); + expect(providerApiKeySelectionIsCurrent(config, "google-vertex", adc)).toBe(false); + const key = routedProviderConfig("google-vertex", provider); + expect(key.apiKey).toBe("fixture-env-key"); + expect(providerApiKeySelectionIsCurrent(config, "google-vertex", key)).toBe(true); + setEnv("GOOGLE_CLOUD_API_KEY", "replacement-env-key"); + expect(providerApiKeySelectionIsCurrent(config, "google-vertex", key)).toBe(false); + const placeholder = { ...provider, apiKey: "N/A" }; + config.providers["google-vertex"] = placeholder; + const fallback = routedProviderConfig("google-vertex", placeholder); + expect(fallback.apiKey).toBe("replacement-env-key"); + expect(providerApiKeySelectionIsCurrent(config, "google-vertex", fallback)).toBe(true); + setEnv("GOOGLE_CLOUD_API_KEY", "third-env-key"); + expect(providerApiKeySelectionIsCurrent(config, "google-vertex", fallback)).toBe(false); + }); + + test("management saves and reloads a renamed keyless Vertex provider", async () => { + const config = { port: 0, defaultProvider: "fixture", providers: { + fixture: { adapter: "openai-chat", baseUrl: "https://fixture.example/v1" }, + } } as OcxConfig; + saveConfig(config); + const destination = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + try { + const request = new ManagementRequest("http://127.0.0.1/api/providers", { method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "my-vertex", provider: { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authMode: "key", googleMode: "vertex", project: "demo-project", location: "global" } }) }); + const response = await handleManagementAPI(request, new URL(request.url), config, { createManagementConvergeCodex: catalogConvergenceFactory() }); + expect(response?.status).toBe(200); + expect(loadConfig().providers["my-vertex"]).toMatchObject({ googleMode: "vertex", project: "demo-project", location: "global" }); + expect(loadConfig().providers["my-vertex"]?.apiKey).toBeUndefined(); + const read = new ManagementRequest("http://127.0.0.1/api/config"); + const readResponse = await handleManagementAPI(read, new URL(read.url), config, { createManagementConvergeCodex: catalogConvergenceFactory() }); + const dto = await readResponse!.json() as { providers: Record> }; + expect(dto.providers["my-vertex"]).toMatchObject({ googleMode: "vertex", project: "demo-project", location: "global", hasApiKey: false }); + expect(dto.providers["my-vertex"]).not.toHaveProperty("apiKey"); + } finally { + destination.mockRestore(); + } + }); + + test("a keyless Vertex provider sends a proxy chat request with ADC", async () => { + setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath); + const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authMode: "key", googleMode: "vertex", project: "demo-project", location: "global" } as OcxProviderConfig; + const config = { port: 0, defaultProvider: "google-vertex", providers: { "google-vertex": provider } } as OcxConfig; + const releaseOwner = acquireOwnedSpendHome(); + saveConfig(config); + const priorFetch = globalThis.fetch; + const sent: Array<{ url: string; authorization: string | null; apiKey: string | null }> = []; + let tokenRequests = 0; + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url === OAUTH_TOKEN_URL) { tokenRequests++; return Response.json({ access_token: "vertex-tok", expires_in: 3600 }); } + const headers = new Headers(init?.headers); + sent.push({ url, authorization: headers.get("authorization"), apiKey: headers.get("x-goog-api-key") }); + return new Response(`data: ${JSON.stringify({ candidates: [{ content: { role: "model", parts: [{ text: "hello" }] }, finishReason: "STOP" }] })}\n\n`, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + Object.assign(provider, { fetch: globalThis.fetch }); + const configuredFetch = spyOn(proxyEnv, "configuredOutboundFetch").mockImplementation((input, init) => globalThis.fetch(input, init)); + try { + const request = new Request("http://localhost/v1/chat/completions", { method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "google-vertex/gemini-3-pro", messages: [{ role: "user", content: "hello" }], stream: false }) }); + const logContext = { model: "", provider: "" }; + const response = await handleChatCompletions(request, config, logContext); + if (response.status !== 200) throw new Error(`Proxy returned ${response.status}: ${await response.text()}`); + expect(response.status).toBe(200); + expect(sent).toHaveLength(1); + expect(tokenRequests).toBe(1); + expect(sent[0]).toMatchObject({ authorization: "Bearer vertex-tok", apiKey: null }); + expect(sent[0]!.url).toContain("/v1/projects/demo-project/locations/global/"); + } finally { + configuredFetch.mockRestore(); + globalThis.fetch = priorFetch; + releaseOwner(); + } + }); test("service_account flow signs an RS256 JWT and returns the access token", async () => { setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath); const tok = await getVertexAccessToken(); From 1a82bc86427aa76d99653b76a39a1d43e3d817ca Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 03:51:03 +0900 Subject: [PATCH 2/7] fix(vertex): fence ambient credentials at dispatch --- src/adapters/google.ts | 13 +- src/lib/gcp-adc.ts | 139 +++++++++--- src/providers/api-key-selection.ts | 2 + src/providers/routed-config.ts | 6 +- src/providers/vertex-environment-authority.ts | 23 ++ src/server/responses/fetch-helpers.ts | 5 + src/server/responses/request-transport.ts | 11 +- structure/providers/google.md | 2 +- tests/adapters/google/gcp-adc.test.ts | 206 +++++++++++++++++- 9 files changed, 362 insertions(+), 45 deletions(-) create mode 100644 src/providers/vertex-environment-authority.ts diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 6d75a07db42..22953a7e833 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -19,7 +19,7 @@ import type { import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../types"; import type { OcxTool } from "../types"; import { contentPartsToText, parseDataUrl } from "./image"; -import { GCP_CREDENTIAL_MARKER_PREFIX, gcpCredentialMarkerAccount, getVertexAccessToken } from "../lib/gcp-adc"; +import { bindVertexRequestAuthority, GCP_CREDENTIAL_MARKER_PREFIX, gcpCredentialMarkerAccount, getVertexAccessTokenWithAuthority } from "../lib/gcp-adc"; import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http"; import { safeAntigravityHttpErrorMessage, safeVertexHttpErrorMessage } from "./google-errors"; import { isVertexTruncatedTurn, vertexTruncationErrorEvent } from "./google-truncation"; @@ -1254,13 +1254,20 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte ); } // Endpoint, location and model path were validated above, before any credential is attached. + let adcAuthority: Awaited> | undefined; 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 })}`; + adcAuthority = await getVertexAccessTokenWithAuthority({ markerAccount, signal: incoming?.abortSignal }); + headers["Authorization"] = `Bearer ${adcAuthority.token}`; } - return { url, method: "POST", headers, body: JSON.stringify(compiled.body) }; + const request = { url, method: "POST", headers, body: JSON.stringify(compiled.body) }; + if (adcAuthority) bindVertexRequestAuthority(request, url, headers["Authorization"]!, { source: adcAuthority.source, generation: adcAuthority.generation, markerAccount: adcAuthority.markerAccount }); + else if (apiKey && (!provider.apiKey || provider.apiKey.startsWith("<") || provider.apiKey === "N/A")) { + bindVertexRequestAuthority(request, url, apiKey, { ambientKey: apiKey }); + } + return request; } // Go and Zen use separate guarded Gemini endpoints; AI Studio retains /v1beta. diff --git a/src/lib/gcp-adc.ts b/src/lib/gcp-adc.ts index e0080fb58b1..8bf08f96b4c 100644 --- a/src/lib/gcp-adc.ts +++ b/src/lib/gcp-adc.ts @@ -21,7 +21,8 @@ import { Buffer } from "node:buffer"; import * as os from "node:os"; import * as path from "node:path"; -import { readFileSync, existsSync, statSync } from "node:fs"; +import { readFileSync } from "node:fs"; +import { createHmac, randomBytes } from "node:crypto"; import { GCP_CREDENTIAL_MARKER_PREFIX, providerKeychainEntry } from "../providers/api-key-resolve"; export { isGcpCredentialMaterial } from "./gcp-credential-input"; import { @@ -89,6 +90,7 @@ export function parseGcpCredentialJson(value: string): AdcFileCredentials | unde interface CachedToken { token: string; expiresAtMs: number; + generation: number; } interface ServiceAccountCredentials { @@ -114,7 +116,10 @@ interface TokenResponse { } const tokenCache = new Map(); -const inflight = new Map>(); +type VertexTokenAuthority = { token: string; source: string; generation: number; markerAccount?: string }; +const inflight = new Map>(); +const sourceObservations = new Map(); +let sourceEpoch = 0; let lastReconciledGeneration = 0; let liveSources = new Set(); @@ -165,23 +170,24 @@ function userAdcPath(): string { return path.join(gcloudConfigDir(), "application_default_credentials.json"); } -/** - * A content-freshness fingerprint for a credential file, so an in-place rewrite (e.g. - * `gcloud auth application-default login`, or a rotated service-account key at the same path) - * changes the cache key and invalidates the stale token. Falls back to the bare path when stat fails. - */ -function fileSourceTag(prefix: string, filePath: string): string { - try { - const st = statSync(filePath); - return `${prefix}:${filePath}:${st.size}:${Math.floor(st.mtimeMs)}`; - } catch { - return `${prefix}:${filePath}`; - } +// Cache identity is keyed and process-local: neither credential contents nor their digest is public. +const sourceDigestKey = randomBytes(32); +function sourceTag(kind: string, identity: string, content: string): string { + const digest = createHmac("sha256", sourceDigestKey) + .update(kind).update("\0").update(identity).update("\0").update(content).digest("hex"); + return `${kind}:${digest}`; } -function readJsonFile(filePath: string): T | undefined { - if (!existsSync(filePath)) return undefined; - return JSON.parse(readFileSync(filePath, "utf8")) as T; +function readJsonFile(filePath: string): { value: T; content: string } | undefined { + let content: string; + try { + content = readFileSync(filePath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw new Error("Vertex ADC credential file could not be read"); + } + try { return { value: JSON.parse(content) as T, content }; } + catch { throw new Error("Vertex ADC credential file is invalid"); } } /** @@ -205,17 +211,17 @@ function loadAdcCredentials(markerAccount: string | undefined): { source: string if (markerAccount) { const creds = readMarkerCredential(markerAccount); if (!creds) throw new Error("GCP credential marker is set but its credential could not be read from the OS credential store"); - return { source: `marker:${markerAccount}`, creds }; + return { source: sourceTag("marker", markerAccount, JSON.stringify(creds)), creds }; } const gacPath = process.env.GOOGLE_APPLICATION_CREDENTIALS; if (gacPath) { const creds = readJsonFile(gacPath); if (!creds) throw new Error(`GOOGLE_APPLICATION_CREDENTIALS points to a missing file: ${gacPath}`); - return { source: fileSourceTag("gac", gacPath), creds }; + return { source: sourceTag("gac", gacPath, creds.content), creds: creds.value }; } const userPath = userAdcPath(); const creds = readJsonFile(userPath); - if (creds) return { source: fileSourceTag("user", userPath), creds }; + if (creds) return { source: sourceTag("user", userPath, creds.content), creds: creds.value }; return undefined; } @@ -226,12 +232,24 @@ function loadAdcCredentials(markerAccount: string | undefined): { source: string * token from a different source. Falls back to "metadata" when no marker/file/env ADC is present. */ function currentAdcSourceKey(markerAccount: string | undefined): string { - if (markerAccount) return `marker:${markerAccount}`; - const gacPath = process.env.GOOGLE_APPLICATION_CREDENTIALS; - if (gacPath) return fileSourceTag("gac", gacPath); - const userPath = userAdcPath(); - if (existsSync(userPath)) return fileSourceTag("user", userPath); - return "metadata"; + try { + return loadAdcCredentials(markerAccount)?.source ?? "metadata"; + } catch { + return "invalid"; + } +} + +/** Every observed source transition gets a fresh incarnation, including A→B→A. */ +function observeAdcSource(markerAccount: string | undefined): { source: string; generation: number } { + const scope = markerAccount ? `marker:${markerAccount}` : "host"; + const source = currentAdcSourceKey(markerAccount); + const previous = sourceObservations.get(scope); + if (previous?.source === source) return previous; + const next = { source, generation: ++sourceEpoch }; + sourceObservations.delete(scope); + sourceObservations.set(scope, next); + if (sourceObservations.size > 512) sourceObservations.delete(sourceObservations.keys().next().value!); + return next; } function base64UrlEncode(bytes: Uint8Array | string): string { @@ -371,7 +389,7 @@ async function resolveAccessTokenUncached(markerAccount: string | undefined, sig } /** Returns a Bearer access token for the `Authorization` header on Vertex AI calls (cached + refreshed). */ -export async function getVertexAccessToken(options?: { signal?: AbortSignal; fetch?: FetchImpl; markerAccount?: string }): Promise { +export async function getVertexAccessTokenWithAuthority(options?: { signal?: AbortSignal; fetch?: FetchImpl; markerAccount?: string }): Promise { // The marker travels as an argument, never as process-global state: a module-level "active // marker" written by the router would race across concurrent requests for different providers // and could resolve one provider's Vertex call with another provider's service account. The @@ -383,7 +401,8 @@ export async function getVertexAccessToken(options?: { signal?: AbortSignal; fet // Only serve a cached token that matches the source the next resolve would actually use; prune // expired or now-stale (different-source) entries so a credential-source change is honored. - const expectedSource = currentAdcSourceKey(markerAccount); + const expected = observeAdcSource(markerAccount); + const expectedSource = expected.source; // A cached bearer token is usable only while the explicitly selected store entry remains // readable. Removal or a locked store must fail closed before the cache can return it. if (markerAccount && !readMarkerCredential(markerAccount)) { @@ -392,25 +411,31 @@ export async function getVertexAccessToken(options?: { signal?: AbortSignal; fet } const writerGeneration = captureConfigGeneration(); for (const [source, cached] of tokenCache) { - if (source === expectedSource && cached.expiresAtMs - skew > now) return cached.token; + if (source === expectedSource && cached.generation === expected.generation && cached.expiresAtMs - skew > now) { + return { token: cached.token, source, generation: expected.generation, markerAccount }; + } if (cached.expiresAtMs - skew <= now) tokenCache.delete(source); } // Dedup in-flight fetches PER source, not globally: if the credential source changes while a // fetch is in flight, a new caller must not reuse the old source's promise (cross-source bleed). - const cacheKey = expectedSource; + const cacheKey = `${expectedSource}:${expected.generation}`; const existing = inflight.get(cacheKey); if (existing) return existing; const promise = (async () => { try { const { source, token } = await resolveAccessTokenUncached(markerAccount, options?.signal, fetchImpl); + const current = observeAdcSource(markerAccount); + if (source !== expectedSource || current.source !== source || current.generation !== expected.generation) { + throw new Error("Vertex ADC source changed during token exchange"); + } const expiresAtMs = Date.now() + Math.max(0, token.expires_in * 1000); if (writerGeneration >= lastReconciledGeneration || liveSources.has(source)) { - tokenCache.set(source, { token: token.access_token, expiresAtMs }); + tokenCache.set(source, { token: token.access_token, expiresAtMs, generation: expected.generation }); sweepExpiredOnWrite(Date.now()); } - return token.access_token; + return { token: token.access_token, source, generation: expected.generation, markerAccount }; } finally { inflight.delete(cacheKey); } @@ -419,10 +444,60 @@ export async function getVertexAccessToken(options?: { signal?: AbortSignal; fet return promise; } +/** Compatibility wrapper for callers that only need the access token. */ +export async function getVertexAccessToken(options?: { signal?: AbortSignal; fetch?: FetchImpl; markerAccount?: string }): Promise { + return (await getVertexAccessTokenWithAuthority(options)).token; +} + +type VertexRequestAuthority = { source?: string; generation?: number; markerAccount?: string; ambientKey?: string; target: string; headerDigest: string }; +const requestAuthorities = new WeakMap(); +const requestDigestKey = randomBytes(32); +function requestDigest(value: string): string { + return createHmac("sha256", requestDigestKey).update(value).digest("hex"); +} + +/** Bind the exact credential and target used by a built Vertex request without storing the secret. */ +export function bindVertexRequestAuthority(request: object, target: string, header: string, + authority: { source: string; generation: number; markerAccount?: string } | { ambientKey: string }): void { + requestAuthorities.set(request, { + target, headerDigest: requestDigest(header), + ...("ambientKey" in authority ? { ambientKey: requestDigest(authority.ambientKey) } : authority), + }); +} + +/** Rebuilds reuse the original request object, so transfer or clear its previous authority. */ +export function copyVertexRequestAuthority(from: object, to: object): void { + const authority = requestAuthorities.get(from); + if (authority) requestAuthorities.set(to, authority); + else requestAuthorities.delete(to); +} + +export function hasVertexRequestAuthority(request: object): boolean { + return requestAuthorities.has(request); +} + +/** Fail closed if the locally observable credential source or final request changed. */ +export function vertexRequestAuthorityIsCurrent(request: object, target?: string, headers?: Headers): boolean { + const authority = requestAuthorities.get(request); + if (!authority) return true; + if (authority.source) { + const current = observeAdcSource(authority.markerAccount); + if (current.source !== authority.source || current.generation !== authority.generation) return false; + } + if (authority.ambientKey && requestDigest(process.env.GOOGLE_CLOUD_API_KEY || "") !== authority.ambientKey) return false; + if (target !== undefined && target !== authority.target) return false; + if (headers) { + const actual = authority.source ? headers.get("authorization") : headers.get("x-goog-api-key"); + if (!actual || requestDigest(actual) !== authority.headerDigest) return false; + } + return true; +} + /** Test seam: restores complete process-local token and reconciliation state. */ export function __resetVertexTokenCache(): void { tokenCache.clear(); inflight.clear(); + sourceObservations.clear(); lastReconciledGeneration = 0; liveSources = new Set(); } diff --git a/src/providers/api-key-selection.ts b/src/providers/api-key-selection.ts index 2280569a9e1..c065567e302 100644 --- a/src/providers/api-key-selection.ts +++ b/src/providers/api-key-selection.ts @@ -9,6 +9,7 @@ import { routedProviderConfig } from "./routed-config"; import { OPENCODE_GO_SESSION_HEADER } from "./opencode-go-transport"; import { resolveProviderTransport, XAI_GROK_COMPATIBILITY, type OcxProviderTransport } from "./xai-transport"; import { captureProviderApiKeySelection } from "./api-key-selection-capture"; +import { vertexEnvironmentKeyIsCurrent } from "./vertex-environment-authority"; export { captureProviderApiKeySelection } from "./api-key-selection-capture"; @@ -62,6 +63,7 @@ function matchesConfiguredSelection( const expected = routedProvider._apiKeyAttempt; const current = currentKeyProvider(config, name, expected, allowCooling); return current !== null && expected !== undefined + && vertexEnvironmentKeyIsCurrent(expected) && (expected.distributed === true || matchesSelection(config.providers[name]!, expected)) && current.apiKey === routedProvider.apiKey && current.authMode === routedProvider.authMode diff --git a/src/providers/routed-config.ts b/src/providers/routed-config.ts index b8222882f0a..ebe24017272 100644 --- a/src/providers/routed-config.ts +++ b/src/providers/routed-config.ts @@ -10,6 +10,7 @@ import type { OcxProviderConfig } from "../types"; import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./key-store"; import { GCP_CREDENTIAL_MARKER_PREFIX } from "../lib/gcp-adc"; import { captureProviderApiKeySelection } from "./api-key-selection-capture"; +import { captureVertexEnvironmentKey } from "./vertex-environment-authority"; import { assertProviderDestinationAllowed } from "../lib/destination-policy"; import { redactSecretString, redactUrlForLog } from "../lib/redact"; import { PROVIDER_REGISTRY, registryEntryForProviderDestination } from "./registry"; @@ -115,11 +116,10 @@ function snapshotVertexEnvironment(provider: OcxProviderConfig): OcxProviderConf if (provider.adapter !== "google" || provider.googleMode !== "vertex") return provider; const marker = provider.apiKey?.startsWith(GCP_CREDENTIAL_MARKER_PREFIX) === true; const configuredKey = provider.apiKey && !provider.apiKey.startsWith("<") && provider.apiKey !== "N/A" ? provider.apiKey : undefined; - const apiKey = marker ? provider.apiKey : configuredKey || process.env.GOOGLE_CLOUD_API_KEY || undefined; - if (apiKey && !marker) return { ...provider, apiKey }; + captureVertexEnvironmentKey(provider._apiKeyAttempt, provider.apiKey); + if (!marker && (configuredKey || process.env.GOOGLE_CLOUD_API_KEY)) return provider; return { ...provider, - apiKey, project: provider.project?.trim() ? provider.project : process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT, location: provider.location || process.env.GOOGLE_CLOUD_LOCATION, }; diff --git a/src/providers/vertex-environment-authority.ts b/src/providers/vertex-environment-authority.ts new file mode 100644 index 00000000000..c4a51be4d1a --- /dev/null +++ b/src/providers/vertex-environment-authority.ts @@ -0,0 +1,23 @@ +import { createHmac, randomBytes } from "node:crypto"; +import { GCP_CREDENTIAL_MARKER_PREFIX } from "./api-key-resolve"; +import type { ProviderApiKeySelection } from "../types/provider"; + +// Keep the ambient key out of generic provider config and any serializable selection. +const digestKey = randomBytes(32); +const keySnapshots = new WeakMap(); + +function ambientKeyDigest(): string { + return createHmac("sha256", digestKey).update(process.env.GOOGLE_CLOUD_API_KEY || "").digest("hex"); +} + +export function captureVertexEnvironmentKey(selection: ProviderApiKeySelection | undefined, configuredKey: string | undefined): void { + if (!selection || configuredKey?.startsWith(GCP_CREDENTIAL_MARKER_PREFIX)) return; + if (configuredKey && !configuredKey.startsWith("<") && configuredKey !== "N/A") return; + if (keySnapshots.has(selection)) return; + keySnapshots.set(selection, ambientKeyDigest()); +} + +export function vertexEnvironmentKeyIsCurrent(selection: ProviderApiKeySelection): boolean { + const expected = keySnapshots.get(selection); + return expected === undefined || expected === ambientKeyDigest(); +} diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index 184e0043748..f1209ee8a27 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -54,6 +54,7 @@ const PHYSICAL_DISPATCH = Symbol.for("opencodex.physical-dispatch"); // Final destination and headers after plugin rewrites and queued rebuilds, for observers that // must attribute a response to the credential that actually reached the wire. const PHYSICAL_SEND_OBSERVER = Symbol.for("opencodex.physical-send-observer"); +const PHYSICAL_SEND_GUARD = Symbol.for("opencodex.physical-send-guard"); export type AccountAutoPauseObserver = ((response: Response) => Promise) & { physical?: (sameDestination: boolean, headers: Headers) => void; }; @@ -66,6 +67,9 @@ export function physicalSendObserverInit(observer: AccountAutoPauseObserver | un physical?.(destination === target, headers); } } : {}; } +export function physicalSendGuardInit(guard: (destination: string, headers: Headers) => void): Record { + return { [PHYSICAL_SEND_GUARD]: guard }; +} // One WS attempt and its HTTP fallback are one logical physical-send receipt. const WS_FALLBACK_RECEIPT = Symbol.for("opencodex.ws-fallback-receipt"); @@ -234,6 +238,7 @@ export function sendWithConnectionPolicy( const largeCodexBody = !compressed && typeof body === "string" && isCanonicalCodexResponsesUrl(target) && Buffer.byteLength(body, "utf8") >= 1024 * 1024; + (init as Record void) | undefined> | undefined)?.[PHYSICAL_SEND_GUARD]?.(target, headers); (init as Record void) | undefined> | undefined)?.[PHYSICAL_DISPATCH]?.(); (init as Record void) | undefined> | undefined)?.[PHYSICAL_SEND_OBSERVER]?.(target, headers); // Final headers, after plugin rewrites: an upstream can only echo what it received here, so the diff --git a/src/server/responses/request-transport.ts b/src/server/responses/request-transport.ts index 12954712e1c..7e957d14bb2 100644 --- a/src/server/responses/request-transport.ts +++ b/src/server/responses/request-transport.ts @@ -27,6 +27,7 @@ import { } from "../../oauth/store"; import { refreshKiroAccountModelsDetached } from "../../providers/kiro-model-catalog"; import type { ProviderAdapter, AdapterRequest } from "../../adapters/base"; +import { copyVertexRequestAuthority, hasVertexRequestAuthority, vertexRequestAuthorityIsCurrent } from "../../lib/gcp-adc"; import { releaseProviderRequestSlot, waitForProviderRequestSlot, type ProviderRequestSlot } from "../../providers/request-pacing"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../../types"; import type { AnthropicAccountSelectionReason } from "../../oauth/anthropic-routing"; @@ -84,7 +85,7 @@ import { resolveCurrentProviderApiKeyTransport, } from "../../providers/api-key-selection"; import { resolveAdapter, resolveWireProtocolOverride } from "../adapter-resolve"; -import { codexWsExecutorBinding, fetchWithHeaderTimeout, physicalSendObserverInit, providerFetch, sendWithConnectionPolicy } from "./fetch-helpers"; +import { codexWsExecutorBinding, fetchWithHeaderTimeout, physicalSendGuardInit, physicalSendObserverInit, providerFetch, sendWithConnectionPolicy } from "./fetch-helpers"; import { resolveConnectTimeoutMs } from "../../stall-timeout"; import { isLocalUpstream } from "../../lib/local-upstream"; import { deepSeekImageFileBody, needsDeepSeekImageFiles } from "./deepseek-image-files"; @@ -905,7 +906,7 @@ export async function prepareResponsesTransport( await reacquirePacing(route.provider, route.modelId); pacingChanged = false; } - if (selectionIsCurrent(requestBindings.get(wireRequest)) && copilotSessionExpiresAt > Date.now() + 1000) { + if (selectionIsCurrent(requestBindings.get(wireRequest)) && vertexRequestAuthorityIsCurrent(wireRequest) && copilotSessionExpiresAt > Date.now() + 1000) { const ws = codexWsExecutorBinding(execute); const fetchImpl = (route.provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? ws?.http ?? execute; const binding = requestBindings.get(wireRequest); @@ -956,7 +957,10 @@ export async function prepareResponsesTransport( distributedKeyPhysicalFetch(anthropicBillingPhysicalFetch(fetchImpl, route.providerName, route.provider), route.providerName, route.provider, target), input, { ...physicalInit, redirect: "manual", - ...physicalSendObserverInit(observeAutoPause, destination instanceof Request ? destination.url : String(destination), deferredCopilotNote ? commitKeyAttemptSend : undefined) }, + ...physicalSendObserverInit(observeAutoPause, destination instanceof Request ? destination.url : String(destination), deferredCopilotNote ? commitKeyAttemptSend : undefined), + ...(hasVertexRequestAuthority(wireRequest) ? physicalSendGuardInit((finalTarget, finalHeaders) => { + if (!vertexRequestAuthorityIsCurrent(wireRequest, finalTarget, finalHeaders)) throw new Error("Vertex credential authority changed before dispatch"); + }) : {}) }, { providerName: route.providerName, provider: route.provider }, )) as typeof globalThis.fetch; response = await fetchWithHeaderTimeout(target, dispatchInit, dispatchInit.signal ?? options.abortSignal ?? req.signal, @@ -1013,6 +1017,7 @@ export async function prepareResponsesTransport( for (const [name, value] of Object.entries(rebuilt.headers)) headers.set(name, value); wireRequest.releaseBodyObservation?.(); Object.assign(wireRequest, rebuilt); + copyVertexRequestAuthority(rebuilt, wireRequest); const binding = requestBindings.get(rebuilt); if (binding) requestBindings.set(wireRequest, binding); else requestBindings.delete(wireRequest); diff --git a/structure/providers/google.md b/structure/providers/google.md index 5ca0b811b08..75cf81d0813 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -12,7 +12,7 @@ EOF output, retain the [shared byte-accounting contract](../transports/byte-acco ## Vertex API-key endpoint selection -Vertex requests may use host ADC without an API key. `src/providers/routed-config.ts` captures the effective Vertex environment key and, for ADC, the project and location with the routed provider; `src/providers/api-key-selection.ts` rechecks those values before dispatch. A change forces transport rebuilding. A service-account marker continues to select its named credential instead of the ambient API key. The capture does not itself verify that ADC credentials are available. `tests/adapters/google/gcp-adc.test.ts` covers the selection boundary and a proxy Chat request using synthetic ADC credentials. +Vertex requests may use host ADC without an API key. The ambient API key stays out of generic provider configuration: `src/providers/vertex-environment-authority.ts` keeps a keyed process-local digest with the selected route, and only the Google adapter reads the key. For ADC, routed project and location are captured and rechecked before dispatch. `src/lib/gcp-adc.ts` identifies file and marker sources by keyed content digest, checks the source after token exchange, and binds its identity to the built request. `src/server/responses/request-transport.ts` checks that binding after pacing, transfers it on rebuild, and checks the final target and credential after plugin rewrites. Unreadable credentials fail closed. A service-account marker selects its named credential instead of the ambient key. Metadata identity changes and an external A→B→A change entirely between observations cannot be detected locally. `tests/adapters/google/gcp-adc.test.ts` covers synthetic key, ADC and physical-dispatch boundaries. `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 diff --git a/tests/adapters/google/gcp-adc.test.ts b/tests/adapters/google/gcp-adc.test.ts index 2a6ac225028..4d62b44de7b 100644 --- a/tests/adapters/google/gcp-adc.test.ts +++ b/tests/adapters/google/gcp-adc.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeAll, beforeEach, afterAll, describe, expect, spyOn, test } from "bun:test"; import { Buffer } from "node:buffer"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, statSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getVertexAccessToken, __resetVertexTokenCache } from "../../../src/lib/gcp-adc"; @@ -10,12 +10,17 @@ import { providerApiKeySelectionIsCurrent } from "../../../src/providers/api-key import { routedProviderConfig } from "../../../src/providers/routed-config"; import { loadConfig, saveConfig } from "../../../src/config"; import { handleChatCompletions } from "../../../src/server/chat-completions"; +import { handleResponses } from "../../../src/server/responses"; import { handleManagementAPI } from "../../../src/server/management-api"; +import { resolveWireProtocolOverride } from "../../../src/server/adapter-resolve"; +import { openAIChatTransport } from "../../../src/adapters/openai-chat/wire"; import * as proxyEnv from "../../../src/lib/proxy-env"; import * as destinationPolicy from "../../../src/lib/destination-policy"; import { acquireOwnedSpendHome } from "../../helpers/owned-spend-home"; import { ManagementRequest } from "../../helpers/management-auth"; import { catalogConvergenceFactory } from "../../helpers/catalog-convergence"; +import { setProviderKeychainEntryFactoryForTests } from "../../../src/providers/key-store"; +import { registerUpstreamRewriter } from "../../../src/plugins/upstream-hooks"; import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; import { STATE_STORE_REGISTRATIONS } from "../../../src/lib/state-store-registrations"; import { removeTreeWithRetry } from "../../helpers/remove-tree"; @@ -91,6 +96,123 @@ function setEnv(k: string, v: string): void { } describe("gcp-adc resolver", () => { + test("ambient Vertex API key never becomes a non-Google wire credential", () => { + setEnv("GOOGLE_CLOUD_API_KEY", "fixture-ambient-key"); + const provider = { adapter: "google", baseUrl: "https://external.example/v1", authMode: "key", googleMode: "vertex", + modelAdapters: { "example-model": "openai-chat" } } as OcxProviderConfig; + const routed = routedProviderConfig("custom-vertex", provider); + const wire = resolveWireProtocolOverride("custom-vertex", "example-model", routed, "chat"); + expect(wire.adapter).toBe("openai-chat"); + expect(wire.apiKey).toBeUndefined(); + expect(() => openAIChatTransport(wire)).toThrow("requires a non-empty credential"); + }); + + for (const inbound of ["chat", "responses"] as const) { + test(`ambient Vertex key does not reach an overridden ${inbound} transport`, async () => { + setEnv("GOOGLE_CLOUD_API_KEY", "fixture-ambient-key"); + const provider = { adapter: "google", baseUrl: "https://external.example/v1", authMode: "key", googleMode: "vertex", + modelAdapters: { "example-model": "openai-chat" } } as OcxProviderConfig; + const config = { port: 0, defaultProvider: "custom-vertex", providers: { "custom-vertex": provider } } as OcxConfig; + const releaseOwner = acquireOwnedSpendHome(); + saveConfig(config); + const priorFetch = globalThis.fetch; + const sent: string[] = []; + globalThis.fetch = (async (input, init) => { + sent.push(new Headers(init?.headers).get("authorization") ?? ""); + return Response.json({}); + }) as typeof fetch; + Object.assign(provider, { fetch: globalThis.fetch }); + const configuredFetch = spyOn(proxyEnv, "configuredOutboundFetch").mockImplementation((input, init) => globalThis.fetch(input, init)); + try { + const body = inbound === "chat" + ? { model: "custom-vertex/example-model", messages: [{ role: "user", content: "hello" }], stream: false } + : { model: "custom-vertex/example-model", input: "hello", stream: false }; + const request = new Request(`http://localhost/v1/${inbound === "chat" ? "chat/completions" : "responses"}`, + { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + const response = inbound === "chat" ? await handleChatCompletions(request, config, { model: "", provider: "" }) + : await handleResponses(request, config, { model: "", provider: "" }); + expect(response.status).toBeGreaterThanOrEqual(400); + expect(sent).toEqual([]); + } finally { + configuredFetch.mockRestore(); + globalThis.fetch = priorFetch; + releaseOwner(); + } + }); + } + + test("an ADC source switch during token exchange never returns the previous bearer", async () => { + const firstPath = join(tmp, "adc-first.json"); + const secondPath = join(tmp, "adc-second.json"); + const credential = { type: "authorized_user", client_id: "fixture-client", client_secret: "fixture-secret", refresh_token: "fixture-refresh" }; + writeFileSync(firstPath, JSON.stringify(credential)); + writeFileSync(secondPath, JSON.stringify({ ...credential, refresh_token: "other-fixture-refresh" })); + setEnv("GOOGLE_APPLICATION_CREDENTIALS", firstPath); + let release!: (response: Response) => void; + let started!: () => void; + const requested = new Promise(resolve => { started = resolve; }); + const fakeFetch = (async () => { started(); return new Promise(resolve => { release = resolve; }); }) as typeof fetch; + const pending = getVertexAccessToken({ fetch: fakeFetch }); + await requested; + setEnv("GOOGLE_APPLICATION_CREDENTIALS", secondPath); + release(Response.json({ access_token: "old-fixture-token", expires_in: 3600 })); + await expect(pending).rejects.toThrow(); + }); + + test("an observed ADC A to B to A switch rejects the first A exchange", async () => { + const firstPath = join(tmp, "adc-aba-a.json"); + const secondPath = join(tmp, "adc-aba-b.json"); + const credential = { type: "authorized_user", client_id: "fixture-client", client_secret: "fixture-secret", refresh_token: "token-one" }; + writeFileSync(firstPath, JSON.stringify(credential)); + writeFileSync(secondPath, JSON.stringify({ ...credential, refresh_token: "token-two" })); + setEnv("GOOGLE_APPLICATION_CREDENTIALS", firstPath); + let release!: (response: Response) => void; + let started!: () => void; + const requested = new Promise(resolve => { started = resolve; }); + let calls = 0; + const fakeFetch = (async () => { + if (++calls === 1) { started(); return new Promise(resolve => { release = resolve; }); } + return Response.json({ access_token: "fixture-b", expires_in: 3600 }); + }) as typeof fetch; + const old = getVertexAccessToken({ fetch: fakeFetch }); + await requested; + setEnv("GOOGLE_APPLICATION_CREDENTIALS", secondPath); + expect(await getVertexAccessToken({ fetch: fakeFetch })).toBe("fixture-b"); + setEnv("GOOGLE_APPLICATION_CREDENTIALS", firstPath); + // Observing A again establishes a new generation before the old A exchange returns. + const newer = getVertexAccessToken({ fetch: fakeFetch }); + release(Response.json({ access_token: "fixture-old-a", expires_in: 3600 })); + await expect(old).rejects.toThrow(); + expect(await newer).not.toBe("fixture-old-a"); + }); + + test("same-size ADC file replacement with preserved mtime invalidates the token", async () => { + const file = join(tmp, "same-size-adc.json"); + const credential = { type: "authorized_user", client_id: "fixture-client", client_secret: "fixture-secret", refresh_token: "token-one" }; + writeFileSync(file, JSON.stringify(credential)); + const initial = statSync(file); + setEnv("GOOGLE_APPLICATION_CREDENTIALS", file); + let calls = 0; + const fakeFetch = (async () => Response.json({ access_token: `fixture-${++calls}`, expires_in: 3600 })) as typeof fetch; + expect(await getVertexAccessToken({ fetch: fakeFetch })).toBe("fixture-1"); + writeFileSync(file, JSON.stringify({ ...credential, refresh_token: "token-two" })); + utimesSync(file, initial.atime, initial.mtime); + expect(await getVertexAccessToken({ fetch: fakeFetch })).toBe("fixture-2"); + }); + + test("replacing credentials behind the same marker invalidates the token", async () => { + let credential = JSON.stringify({ type: "authorized_user", client_id: "fixture-client", client_secret: "fixture-secret", refresh_token: "token-one" }); + setProviderKeychainEntryFactoryForTests(() => ({ getPassword: () => credential, setPassword: () => {}, deletePassword: () => true })); + let calls = 0; + const fakeFetch = (async () => Response.json({ access_token: `fixture-${++calls}`, expires_in: 3600 })) as typeof fetch; + try { + expect(await getVertexAccessToken({ markerAccount: "fixture-marker", fetch: fakeFetch })).toBe("fixture-1"); + credential = credential.replace("token-one", "token-two"); + expect(await getVertexAccessToken({ markerAccount: "fixture-marker", fetch: fakeFetch })).toBe("fixture-2"); + } finally { + setProviderKeychainEntryFactoryForTests(null); + } + }); test("keyless Vertex ADC remains valid through the physical-send selection check", () => { const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authMode: "key", googleMode: "vertex", project: "demo-project", location: "global" } as OcxProviderConfig; const config = { port: 0, defaultProvider: "google-vertex", providers: { "google-vertex": provider } } as OcxConfig; @@ -115,14 +237,14 @@ describe("gcp-adc resolver", () => { setEnv("GOOGLE_CLOUD_API_KEY", "fixture-env-key"); expect(providerApiKeySelectionIsCurrent(config, "google-vertex", adc)).toBe(false); const key = routedProviderConfig("google-vertex", provider); - expect(key.apiKey).toBe("fixture-env-key"); + expect(key.apiKey).toBeUndefined(); expect(providerApiKeySelectionIsCurrent(config, "google-vertex", key)).toBe(true); setEnv("GOOGLE_CLOUD_API_KEY", "replacement-env-key"); expect(providerApiKeySelectionIsCurrent(config, "google-vertex", key)).toBe(false); const placeholder = { ...provider, apiKey: "N/A" }; config.providers["google-vertex"] = placeholder; const fallback = routedProviderConfig("google-vertex", placeholder); - expect(fallback.apiKey).toBe("replacement-env-key"); + expect(fallback.apiKey).toBe("N/A"); expect(providerApiKeySelectionIsCurrent(config, "google-vertex", fallback)).toBe(true); setEnv("GOOGLE_CLOUD_API_KEY", "third-env-key"); expect(providerApiKeySelectionIsCurrent(config, "google-vertex", fallback)).toBe(false); @@ -186,6 +308,84 @@ describe("gcp-adc resolver", () => { releaseOwner(); } }); + + test("physical Vertex dispatch refuses a bearer whose ADC source changed after build", async () => { + const replacementPath = join(tmp, "adc-after-build.json"); + writeFileSync(replacementPath, JSON.stringify({ type: "authorized_user", client_id: "fixture-client", client_secret: "fixture-secret", refresh_token: "fixture-refresh" })); + setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath); + const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authMode: "key", googleMode: "vertex", project: "demo-project", location: "global" } as OcxProviderConfig; + const config = { port: 0, defaultProvider: "google-vertex", providers: { "google-vertex": provider } } as OcxConfig; + const releaseOwner = acquireOwnedSpendHome(); + saveConfig(config); + const priorFetch = globalThis.fetch; + const sent: string[] = []; + let tokens = 0; + globalThis.fetch = (async (input, init) => { + if (String(input) === OAUTH_TOKEN_URL) return Response.json({ access_token: `fixture-token-${++tokens}`, expires_in: 3600 }); + sent.push(new Headers(init?.headers).get("authorization") ?? ""); + return new Response(`data: ${JSON.stringify({ candidates: [{ content: { role: "model", parts: [{ text: "hello" }] }, finishReason: "STOP" }] })}\n\n`, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + Object.assign(provider, { fetch: globalThis.fetch }); + const configuredFetch = spyOn(proxyEnv, "configuredOutboundFetch").mockImplementation((input, init) => globalThis.fetch(input, init)); + let rotated = false; + const unregister = registerUpstreamRewriter("fixture-adc-rotation", target => { + if (target.url.includes("aiplatform.googleapis.com")) { rotated = true; setEnv("GOOGLE_APPLICATION_CREDENTIALS", replacementPath); } + }); + try { + const request = new Request("http://localhost/v1/chat/completions", { method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "google-vertex/gemini-3-pro", messages: [{ role: "user", content: "hello" }], stream: false }) }); + await handleChatCompletions(request, config, { model: "", provider: "" }); + expect(tokens).toBeGreaterThanOrEqual(1); + expect(rotated).toBe(true); + expect(sent).not.toContain("Bearer fixture-token-1"); + } finally { + unregister(); + configuredFetch.mockRestore(); + globalThis.fetch = priorFetch; + releaseOwner(); + } + }); + + for (const changed of ["ambient-key", "destination", "header"] as const) { + test(`physical Vertex dispatch refuses a changed ${changed}`, async () => { + setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath); + if (changed === "ambient-key") setEnv("GOOGLE_CLOUD_API_KEY", "fixture-ambient-one"); + const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authMode: "key", googleMode: "vertex", project: "demo-project", location: "global" } as OcxProviderConfig; + const config = { port: 0, defaultProvider: "google-vertex", providers: { "google-vertex": provider } } as OcxConfig; + const releaseOwner = acquireOwnedSpendHome(); + saveConfig(config); + const priorFetch = globalThis.fetch; + const sent: Array<{ url: string; key: string | null }> = []; + let modified = false; + globalThis.fetch = (async (input, init) => { + if (String(input) === OAUTH_TOKEN_URL) return Response.json({ access_token: "fixture-token", expires_in: 3600 }); + sent.push({ url: String(input), key: new Headers(init?.headers).get("x-goog-api-key") }); + return Response.json({}); + }) as typeof fetch; + Object.assign(provider, { fetch: globalThis.fetch }); + const configuredFetch = spyOn(proxyEnv, "configuredOutboundFetch").mockImplementation((input, init) => globalThis.fetch(input, init)); + const unregister = registerUpstreamRewriter("fixture-vertex-authority", target => { + if (!target.url.includes("aiplatform.googleapis.com")) return; + modified = true; + if (changed === "ambient-key") setEnv("GOOGLE_CLOUD_API_KEY", "fixture-ambient-two"); + if (changed === "destination") target.url = "https://external.example/collect"; + if (changed === "header") target.headers.set("authorization", "Bearer replacement"); + }); + try { + const request = new Request("http://localhost/v1/chat/completions", { method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "google-vertex/gemini-3-pro", messages: [{ role: "user", content: "hello" }], stream: false }) }); + await handleChatCompletions(request, config, { model: "", provider: "" }); + expect(modified).toBe(true); + if (changed === "ambient-key") expect(sent.every(entry => entry.key !== "fixture-ambient-one")).toBe(true); + else expect(sent).toEqual([]); + } finally { + unregister(); + configuredFetch.mockRestore(); + globalThis.fetch = priorFetch; + releaseOwner(); + } + }); + } test("service_account flow signs an RS256 JWT and returns the access token", async () => { setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath); const tok = await getVertexAccessToken(); From b92533ac3045f3b393776d51d86a9e2b1f297694 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 04:24:01 +0900 Subject: [PATCH 3/7] fix(vertex): address credential and setup review findings --- .../src/content/docs/ko/reference/adapters.md | 2 + .../src/content/docs/reference/adapters.md | 2 + .../content/docs/zh-cn/reference/adapters.md | 1 + gui/src/components/add-provider-form-pane.tsx | 3 +- .../provider-workspace/ProviderOverview.tsx | 4 +- .../ProviderOverviewDashboard.tsx | 5 +- .../provider-workspace/ProviderSettings.tsx | 24 +++- .../ProviderWorkspaceShell.tsx | 11 +- .../components/provider-workspace/types.ts | 4 +- gui/src/design/pages/accounts.css | 4 + gui/src/hooks/useJsonConfigEditor.ts | 1 + gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/pt.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/vi.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/providers-shared.ts | 3 + gui/src/provider-payload.ts | 2 + gui/src/provider-workspace/catalog.ts | 27 +++- .../add-provider-subscription-cli.test.tsx | 2 +- .../overview-refresh-all-quotas.test.tsx | 1 + .../provider-first-response-guidance.test.tsx | 2 + .../provider-settings-request-pacing.test.tsx | 18 +++ gui/tests/use-json-config-editor.test.tsx | 1 + src/adapters/google-vertex-key-project.ts | 12 +- src/adapters/google.ts | 14 ++- src/lib/gcp-adc.ts | 78 ++++++++---- src/providers/api-key-selection.ts | 2 +- src/providers/routed-config.ts | 2 +- src/providers/vertex-environment-authority.ts | 43 +++++-- src/server/adapter-resolve.ts | 17 ++- src/server/auth-cors.ts | 6 + src/server/management/provider-routes.ts | 8 ++ src/server/responses/request-transport.ts | 8 +- structure/gui-and-management-api.md | 2 +- structure/providers/google.md | 2 +- tests/adapters/google/gcp-adc.test.ts | 116 +++++++++++++++++- .../google/google-vertex-key-project.test.ts | 5 + tests/adapters/google/vertex-catalog.test.ts | 11 ++ tests/gui/provider-workspace-data.test.ts | 11 ++ 46 files changed, 396 insertions(+), 69 deletions(-) diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index ccdf2c2750d..917bad66514 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -144,6 +144,8 @@ API 키를 비우고 `GOOGLE_CLOUD_API_KEY`가 설정되지 않았다면 **프 `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT` 및 `GOOGLE_CLOUD_LOCATION`으로 설정합니다. provider를 추가했다고 자격증명이나 권한의 유효성이 검증되는 것은 아닙니다. API 키에 프로젝트를 지정하지 않으면 Vertex express 모드를 사용합니다. +대시보드 프리셋은 설정한 기본 모델로 시작하는 정적 목록을 사용하며, 연결 테스트는 Vertex +자격증명이나 권한을 검증하지 않습니다. - 시스템 프롬프트 → `systemInstruction`; 메시지 → `contents[]`(assistant → `model`); 툴 → `functionDeclarations`. data URL 이미지 → `inline_data`. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 38c6fd2a876..81225d7ab9e 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -437,6 +437,8 @@ that environment key takes precedence. ADC also needs a project and location, su form or by `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION` on the proxy host. Adding the provider does not verify that credentials or permissions are usable. A key without an explicit project uses Vertex express mode. +The dashboard preset uses a static catalog seeded by the configured default model; its connection +test does not verify Vertex credentials or permissions. - **Location denials are permission errors, not invalid requests.** Google rejects unsupported geographic or datacenter locations with HTTP 400 `FAILED_PRECONDITION: User location is not diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index b35a6286402..892fe329a5b 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -124,6 +124,7 @@ interface ProviderAdapter { 表单中填写,或由代理主机上的 `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT` 和 `GOOGLE_CLOUD_LOCATION` 提供。添加 provider 不代表凭据或权限已验证。API key 未指定 项目时使用 Vertex express 模式。 +仪表盘预设使用以配置的默认模型为起点的静态目录;连接测试不会验证 Vertex 凭据或权限。 - 系统提示词 → `systemInstruction`;消息 → `contents[]`(assistant → `model`);工具 → `functionDeclarations`;data URL 图像 → `inline_data`。 diff --git a/gui/src/components/add-provider-form-pane.tsx b/gui/src/components/add-provider-form-pane.tsx index bd2f143ecb7..07c8fe09b5b 100644 --- a/gui/src/components/add-provider-form-pane.tsx +++ b/gui/src/components/add-provider-form-pane.tsx @@ -184,7 +184,8 @@ export function AddProviderFormPane({ )} {preset.id === "google-vertex" && form.adapter === "google" && <> -

{t("modal.vertexAdcHint")}

+

{t("modal.vertexAdcHint")}

+

{t("modal.vertexStaticHint")}

onFormChange({ ...form, project: e.target.value })} /> diff --git a/gui/src/components/provider-workspace/ProviderOverview.tsx b/gui/src/components/provider-workspace/ProviderOverview.tsx index 6775645394e..f2b0fc89c56 100644 --- a/gui/src/components/provider-workspace/ProviderOverview.tsx +++ b/gui/src/components/provider-workspace/ProviderOverview.tsx @@ -84,11 +84,13 @@ export default function ProviderOverview({ const needsOAuthLogin = item.authMode === "oauth" && oauth?.loggedIn === false; const effectiveStatus = item.disabled === true ? "disabled" + : vertexUnverified && !needsAttention ? "unverified" : needsAttention || needsOAuthLogin || status === "needs-setup" ? "needs-setup" : "ready"; const statusText = effectiveStatus === "ready" ? t("pws.status.ready") + : effectiveStatus === "unverified" ? t("pws.vertexUnverified") : effectiveStatus === "needs-setup" ? (needsAttention ? t("pws.status.needsAttention") : vertexUnverified ? t("pws.vertexUnverified") : t("pws.status.needsSetup")) : t("prov.disabledBadge"); @@ -169,7 +171,7 @@ export default function ProviderOverview({ ? "needs-attention" : needsOAuthLogin ? "needs-login" - : effectiveStatus === "needs-setup" + : effectiveStatus === "needs-setup" || effectiveStatus === "unverified" ? "needs-setup" : "saved"; const providerStepText = providerStepState === "disabled" diff --git a/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx b/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx index 761cf54e83a..5e73899d4d9 100644 --- a/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx +++ b/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx @@ -85,7 +85,7 @@ export default function ProviderOverviewDashboard({ }; const allItems = useMemo( - () => [...sections.ready, ...sections.needsSetup, ...sections.disabled], + () => [...sections.ready, ...sections.needsSetup, ...sections.unverified, ...sections.disabled], [sections], ); const knownNames = useMemo(() => new Set(allItems.map(p => p.name)), [allItems]); @@ -163,13 +163,14 @@ export default function ProviderOverviewDashboard({ {/* A calm stat strip (one surface, hairline dividers): the numbers stay neutral, a small dot carries the tone so colour never has to shout. */} -
+
0 ? t("pws.status.needsAttention") : t("pws.status.needsSetup")} tone="warn" /> + {sections.unverified.length > 0 && }
diff --git a/gui/src/components/provider-workspace/ProviderSettings.tsx b/gui/src/components/provider-workspace/ProviderSettings.tsx index c00118d6844..8bb9389828b 100644 --- a/gui/src/components/provider-workspace/ProviderSettings.tsx +++ b/gui/src/components/provider-workspace/ProviderSettings.tsx @@ -21,7 +21,7 @@ import { isCatalogProviderId } from "../../provider-icons"; import { openAiAccountProviderState } from "../../provider-payload"; import { canConfigureResponsesReasoningReplay } from "../../responses-reasoning-replay"; import { ResponsesReasoningReplayControl } from "../responses-reasoning-replay-control"; -import { providerSupportsLiveModelDiscovery } from "../../provider-workspace/catalog"; +import { isVertexProvider, providerSupportsLiveModelDiscovery } from "../../provider-workspace/catalog"; import type { CatalogPreset } from "../provider-catalog/provider-presets"; import { authModeLabel } from "./ProviderRail"; import { ProviderProtocolPanel } from "./ProviderProtocolPanel"; @@ -85,6 +85,8 @@ export default function ProviderSettings({ const savedCursorHttpVersion = effectiveCursorHttpVersion(item.upstreamHttpVersion); const [adapter, setAdapter] = useState(item.adapter); const [baseUrl, setBaseUrl] = useState(item.baseUrl); + const [project, setProject] = useState(item.project ?? ""); + const [location, setLocation] = useState(item.location ?? ""); const [defaultModel, setDefaultModel] = useState(item.defaultModel ?? ""); const [authMode, setAuthMode] = useState(initialAuth); const [apiKeyTransport, setApiKeyTransport] = useState(item.apiKeyTransport ?? "x-api-key"); @@ -118,6 +120,8 @@ export default function ProviderSettings({ useEffect(() => { setAdapter(item.adapter); setBaseUrl(item.baseUrl); + setProject(item.project ?? ""); + setLocation(item.location ?? ""); setDefaultModel(item.defaultModel ?? ""); setAuthMode(String(item.authMode ?? (item.keyOptional ? "local" : "key"))); setApiKeyTransport(item.apiKeyTransport ?? "x-api-key"); @@ -135,7 +139,7 @@ export default function ProviderSettings({ setMsg(null); setModeMsg(null); queueMicrotask(() => setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl))); - }, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, item.preserveResponsesReasoningContent, savedLiveModels, savedCursorHttpVersion, item.requestPacing, baseUrlChoices]); + }, [item.adapter, item.baseUrl, item.project, item.location, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, item.preserveResponsesReasoningContent, savedLiveModels, savedCursorHttpVersion, item.requestPacing, baseUrlChoices]); /* eslint-enable react-hooks/set-state-in-effect */ // Account mode syncs on its own: a mode PATCH refresh must not reset an in-progress @@ -209,8 +213,10 @@ export default function ProviderSettings({ const supportsReasoningReplay = !isCatalogProviderId(item.name) && canConfigureResponsesReasoningReplay({ adapter, baseUrl, authMode }); const reasoningReplayDirty = supportsReasoningReplay && preserveReasoningReplay !== (item.preserveResponsesReasoningContent === true); + const vertexSettings = isVertexProvider(item) && adapter.trim() === "google"; const dirty = reasoningReplayDirty || adapter.trim() !== item.adapter || baseUrl.trim() !== item.baseUrl + || (vertexSettings && (project.trim() !== (item.project ?? "") || location.trim() !== (item.location ?? ""))) || defaultModel.trim() !== (item.defaultModel ?? "") || authMode !== String(item.authMode ?? (item.keyOptional ? "local" : "key")) || (adapter.trim() === "anthropic" && authMode === "key" && apiKeyTransport !== (item.apiKeyTransport ?? "x-api-key")) @@ -281,6 +287,8 @@ export default function ProviderSettings({ ...(pacingDirty ? { requestPacing: pacingDraft } : {}), }; if (!pacingOnly) { + if (vertexSettings && project.trim() !== (item.project ?? "")) patch.project = project.trim(); + if (vertexSettings && location.trim() !== (item.location ?? "")) patch.location = location.trim(); // Keep omitted legacy values omitted unless the user actually changes this toggle. // Otherwise an unrelated settings save manufactures `liveModels: true` provenance. if (liveModelDiscoverySupported && liveModels !== (item.liveModels !== false)) patch.liveModels = liveModels; @@ -342,7 +350,7 @@ export default function ProviderSettings({ }; const discard = () => { - setAdapter(item.adapter); setBaseUrl(item.baseUrl); + setAdapter(item.adapter); setBaseUrl(item.baseUrl); setProject(item.project ?? ""); setLocation(item.location ?? ""); setDefaultModel(item.defaultModel ?? ""); setAuthMode(initialAuth); setApiKeyTransport(item.apiKeyTransport ?? "x-api-key"); setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(savedLiveModels); @@ -434,6 +442,16 @@ export default function ProviderSettings({ setBaseUrl(e.target.value)} readOnly={plainBaseUrlLocked} disabled={plainBaseUrlLocked} /> )} + {vertexSettings && <> + + + } {adapter.trim() === "cursor" && (