diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md
index 59466160810..cd6ed21d0b4 100644
--- a/docs-site/src/content/docs/ko/reference/adapters.md
+++ b/docs-site/src/content/docs/ko/reference/adapters.md
@@ -138,6 +138,17 @@ 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 모드를 사용합니다.
+대시보드 프리셋은 설정한 기본 모델로 시작하는 정적 목록을 사용하며, 연결 테스트는 Vertex
+자격증명이나 권한을 검증하지 않습니다.
+기존 Vertex 항목에서 `liveModels`를 생략하면 일반 검색 엔드포인트를 통한 기존 실시간 검색이 유지됩니다.
+`liveModels: false`는 정적 목록을 사용하며 설정 화면에서 두 모드를 전환할 수 있습니다.
+
- 시스템 프롬프트 → `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..26ecb707983 100644
--- a/docs-site/src/content/docs/reference/adapters.md
+++ b/docs-site/src/content/docs/reference/adapters.md
@@ -431,6 +431,17 @@ 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.
+The dashboard preset uses a static catalog seeded by the configured default model; its connection
+test does not verify Vertex credentials or permissions.
+Existing Vertex rows without `liveModels` retain live discovery through the generic discovery endpoint.
+Use `liveModels: false` for a static catalog; Settings can switch between the two modes.
+
- **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..b337ce9da29 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,15 @@ 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 模式。
+仪表盘预设使用以配置的默认模型为起点的静态目录;连接测试不会验证 Vertex 凭据或权限。
+已有 Vertex 配置省略 `liveModels` 时会保留通过通用发现端点进行实时发现的原有行为。
+设置 `liveModels: false` 使用静态目录,也可在设置页面切换这两种模式。
+
- 系统提示词 → `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..4da3375fa26 100644
--- a/gui/src/components/add-provider-form-pane.tsx
+++ b/gui/src/components/add-provider-form-pane.tsx
@@ -183,6 +183,16 @@ export function AddProviderFormPane({
)}
>
)}
+ {preset.id === "google-vertex" && form.adapter === "google" && <>
+
{t("modal.vertexAdcHint")}
+ {t("modal.vertexStaticHint")}
+
+ 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..f2b0fc89c56 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,17 +79,20 @@ 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
? "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") : 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;
@@ -168,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"
@@ -178,7 +181,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/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/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/components/provider-workspace/ProviderSettings.tsx b/gui/src/components/provider-workspace/ProviderSettings.tsx
index c00118d6844..f82e8c6dec0 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) && (item.name === "google-vertex" || 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 && <>
+
+ {t("modal.vertexProject")}
+ setProject(e.target.value)} />
+
+
+ {t("modal.vertexLocation")}
+ setLocation(e.target.value)} placeholder="us-central1" />
+
+ >}
{adapter.trim() === "cursor" && (
{t("pws.cursorTransport")}
diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx
index ed768e2315e..2f796c34af9 100644
--- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx
+++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx
@@ -144,7 +144,7 @@ export default function ProviderWorkspaceShell({
}) {
const t = useT();
const [search, setSearch] = useState("");
- const [statusFilter, setStatusFilter] = useState({ ready: true, needsSetup: true, disabled: true });
+ const [statusFilter, setStatusFilter] = useState({ ready: true, needsSetup: true, unverified: true, disabled: true });
const [pricingFilter, setPricingFilter] = useState({ free: true, paid: true });
const [typeFilter, setTypeFilter] = useState({ cloud: true, local: true, selfHosted: true, login: true });
const [sortMode, setSortMode] = useState("az");
@@ -328,7 +328,7 @@ export default function ProviderWorkspaceShell({
}, [filterOpen]);
const allItems = useMemo(
- () => [...sections.ready, ...sections.needsSetup, ...sections.disabled],
+ () => [...sections.ready, ...sections.needsSetup, ...sections.unverified, ...sections.disabled],
[sections],
);
const freeCount = useMemo(() => allItems.filter(isFreeProvider).length, [allItems]);
@@ -355,18 +355,19 @@ export default function ProviderWorkspaceShell({
return {
ready: statusFilter.ready ? byQueryAndFacets(sections.ready) : [],
needsSetup: statusFilter.needsSetup ? byQueryAndFacets(sections.needsSetup) : [],
+ unverified: statusFilter.unverified ? byQueryAndFacets(sections.unverified) : [],
disabled: statusFilter.disabled ? byQueryAndFacets(sections.disabled) : [],
};
}, [sections, search, statusFilter, pricingFilter, typeFilter, sortMode]);
const filterActive =
- !statusFilter.ready || !statusFilter.needsSetup || !statusFilter.disabled
+ !statusFilter.ready || !statusFilter.needsSetup || !statusFilter.unverified || !statusFilter.disabled
|| !pricingFilter.free || !pricingFilter.paid
|| !typeFilter.cloud || !typeFilter.local || !typeFilter.selfHosted || !typeFilter.login
|| sortMode !== "az";
const resetFilters = () => {
- setStatusFilter({ ready: true, needsSetup: true, disabled: true });
+ setStatusFilter({ ready: true, needsSetup: true, unverified: true, disabled: true });
setPricingFilter({ free: true, paid: true });
setTypeFilter({ cloud: true, local: true, selfHosted: true, login: true });
setSortMode("az");
@@ -397,11 +398,13 @@ export default function ProviderWorkspaceShell({
const statusFilterOptions = [
{ key: "ready" as const, label: t("pws.status.ready"), count: sections.ready.length },
{ key: "needsSetup" as const, label: t("pws.status.needsSetup"), count: sections.needsSetup.length },
+ { key: "unverified" as const, label: t("pws.vertexUnverified"), count: sections.unverified.length },
{ key: "disabled" as const, label: t("prov.disabledBadge"), count: sections.disabled.length },
];
const railGroups = [
{ id: "ready", label: t("pws.status.ready"), count: filteredSections.ready.length, ariaLabel: t("pws.groupReady", { count: filteredSections.ready.length }), items: filteredSections.ready },
{ id: "needs-setup", label: t("pws.status.needsSetup"), count: filteredSections.needsSetup.length, ariaLabel: t("pws.groupNeedsSetup", { count: filteredSections.needsSetup.length }), items: filteredSections.needsSetup },
+ { id: "unverified", label: t("pws.vertexUnverified"), count: filteredSections.unverified.length, ariaLabel: `${t("pws.vertexUnverified")} (${filteredSections.unverified.length})`, items: filteredSections.unverified },
{ id: "disabled", label: t("prov.disabledBadge"), count: filteredSections.disabled.length, ariaLabel: t("pws.groupDisabled", { count: filteredSections.disabled.length }), items: filteredSections.disabled },
];
const visibleRailNames = railGroups.flatMap(group => group.items.map(item => item.name));
diff --git a/gui/src/components/provider-workspace/types.ts b/gui/src/components/provider-workspace/types.ts
index 19b85c2e7e1..fa69fc3f96d 100644
--- a/gui/src/components/provider-workspace/types.ts
+++ b/gui/src/components/provider-workspace/types.ts
@@ -10,7 +10,7 @@ import type { BrowserLaunch } from "../../oauth-browser-launch";
export type { ProviderSortMode, WorkspaceItem };
/** Rail status facets (all on by default). */
-export type StatusFilter = { ready: boolean; needsSetup: boolean; disabled: boolean };
+export type StatusFilter = { ready: boolean; needsSetup: boolean; unverified: boolean; disabled: boolean };
/** Rail pricing facets. */
export type PricingFilter = { free: boolean; paid: boolean };
@@ -129,6 +129,8 @@ export type ProviderUpdatePatch = {
adapter?: string;
baseUrl?: string;
defaultModel?: string;
+ project?: string;
+ location?: string;
apiKey?: string;
apiKeyTransport?: "x-api-key" | "bearer" | "";
authMode?: string;
diff --git a/gui/src/design/pages/accounts.css b/gui/src/design/pages/accounts.css
index 09961bb41f9..32d283f7510 100644
--- a/gui/src/design/pages/accounts.css
+++ b/gui/src/design/pages/accounts.css
@@ -562,6 +562,10 @@
background: var(--ocx-surface);
box-shadow: inset 0 0 0 1px var(--ocx-line);
}
+.pws-dashboard-summary--unverified { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+@media (max-width: 600px) {
+ .pws-dashboard-summary--unverified { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
.pws-dashboard-card {
display: flex;
flex-direction: column;
diff --git a/gui/src/hooks/useJsonConfigEditor.ts b/gui/src/hooks/useJsonConfigEditor.ts
index a72236fa56d..fecbd7ff2ea 100644
--- a/gui/src/hooks/useJsonConfigEditor.ts
+++ b/gui/src/hooks/useJsonConfigEditor.ts
@@ -9,6 +9,7 @@ export interface Config {
const PROVIDER_EDITOR_DERIVED_FIELDS = [
"hasApiKey",
"hasHeaders",
+ "vertexCredentialKind",
"xaiResponsesOptInState",
"initialModelSelection",
] as const;
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts
index 855aad80b86..d57557f260c 100644
--- a/gui/src/i18n/de.ts
+++ b/gui/src/i18n/de.ts
@@ -3313,6 +3313,13 @@ 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.vertexStaticHint": "Die konfigurierte Modellliste wird verwendet; der Verbindungstest prüft keine Vertex-Zugangsdaten.",
+ "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..98fa1ffa363 100644
--- a/gui/src/i18n/en.ts
+++ b/gui/src/i18n/en.ts
@@ -2168,6 +2168,13 @@ 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.vertexStaticHint": "Uses the configured model list; the connection test does not verify Vertex credentials.",
+ "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..96b4120a8fc 100644
--- a/gui/src/i18n/fr.ts
+++ b/gui/src/i18n/fr.ts
@@ -2141,6 +2141,13 @@ 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.vertexStaticHint": "La liste de modèles configurée est utilisée ; le test de connexion ne vérifie pas les identifiants Vertex.",
+ "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..f096bb38b7b 100644
--- a/gui/src/i18n/ja.ts
+++ b/gui/src/i18n/ja.ts
@@ -2036,6 +2036,13 @@ export const ja: Record = {
"modal.localHint": "API キーは保存されません。Cursor の静的な公開モデルカタログを Codex に追加しますが、ライブの Cursor トランスポートとネイティブのファイル/シェル実行は監査されるまで無効のままです。",
"modal.getApiKey": "{label} の API キーを取得",
"modal.apiKey": "API キー",
+ "modal.vertexProject": "プロジェクト ID(任意)",
+ "modal.vertexStaticHint": "設定したモデル一覧を使用します。接続テストでは Vertex の認証情報を検証しません。",
+ "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..bef4568e3a5 100644
--- a/gui/src/i18n/ko.ts
+++ b/gui/src/i18n/ko.ts
@@ -3360,6 +3360,13 @@ export const ko: Record = {
"modal.localHint": "API 키는 저장되지 않습니다. Cursor의 공개 모델 카탈로그만 Codex에 추가되며, live Cursor 전송과 네이티브 파일/셸 실행은 검토 전까지 비활성입니다.",
"modal.getApiKey": "{label} API 키 받기",
"modal.apiKey": "API 키",
+ "modal.vertexProject": "프로젝트 ID (선택)",
+ "modal.vertexStaticHint": "설정한 모델 목록을 사용하며 연결 테스트로 Vertex 자격증명을 검증하지 않아요.",
+ "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..786872f4f47 100644
--- a/gui/src/i18n/pt.ts
+++ b/gui/src/i18n/pt.ts
@@ -2152,6 +2152,13 @@ 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.vertexStaticHint": "Usa a lista de modelos configurada; o teste de conexão não verifica as credenciais Vertex.",
+ "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..a59de43e3bd 100644
--- a/gui/src/i18n/ru.ts
+++ b/gui/src/i18n/ru.ts
@@ -2127,6 +2127,13 @@ export const ru: Record = {
"modal.localHint": "API-ключ не сохраняется. Будет добавлен статический публичный каталог моделей Cursor для Codex, но живой транспорт Cursor и нативное выполнение файловых и shell-операций остаются отключёнными до прохождения аудита.",
"modal.getApiKey": "Получить API-ключ {label}",
"modal.apiKey": "API-ключ",
+ "modal.vertexProject": "ID проекта (необязательно)",
+ "modal.vertexStaticHint": "Используется заданный список моделей; проверка соединения не проверяет данные Vertex.",
+ "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..b9c2e89887e 100644
--- a/gui/src/i18n/tr.ts
+++ b/gui/src/i18n/tr.ts
@@ -2154,6 +2154,13 @@ 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.vertexStaticHint": "Yapılandırılan model listesi kullanılır; bağlantı testi Vertex kimlik bilgilerini doğrulamaz.",
+ "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..67516bd85af 100644
--- a/gui/src/i18n/vi.ts
+++ b/gui/src/i18n/vi.ts
@@ -2138,6 +2138,13 @@ 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.vertexStaticHint": "Dùng danh sách mô hình đã cấu hình; kiểm tra kết nối không xác minh thông tin Vertex.",
+ "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..5a63b456f2b 100644
--- a/gui/src/i18n/zh-TW.ts
+++ b/gui/src/i18n/zh-TW.ts
@@ -1811,6 +1811,13 @@ export const zhTW: Record = {
"modal.localHint": "不會儲存 API 金鑰。這會為 Codex 新增 Cursor 的公開模型目錄,但在審計完成前,即時 Cursor 傳輸與原生檔案/Shell 執行仍保持停用。",
"modal.getApiKey": "取得 {label} API 金鑰",
"modal.apiKey": "API 金鑰",
+ "modal.vertexProject": "專案 ID(選填)",
+ "modal.vertexStaticHint": "使用設定的模型清單;連線測試不會驗證 Vertex 憑證。",
+ "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..ac9b9d42767 100644
--- a/gui/src/i18n/zh.ts
+++ b/gui/src/i18n/zh.ts
@@ -3333,6 +3333,13 @@ export const zh: Record = {
"modal.localHint": "不会存储 API 密钥。这会为 Codex 添加 Cursor 的公开模型目录,但在审计完成前,实时 Cursor 传输与原生文件/Shell 执行仍保持禁用。",
"modal.getApiKey": "获取 {label} API 密钥",
"modal.apiKey": "API 密钥",
+ "modal.vertexProject": "项目 ID(可选)",
+ "modal.vertexStaticHint": "使用配置的模型列表;连接测试不会验证 Vertex 凭据。",
+ "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..4f9617e1c50 100644
--- a/gui/src/pages/providers-shared.ts
+++ b/gui/src/pages/providers-shared.ts
@@ -13,6 +13,10 @@ export interface ProvidersConfig {
reasoningWireFormat?: "gateway-object";
preserveResponsesReasoningContent?: boolean;
authMode?: string;
+ googleMode?: "ai-studio" | "vertex" | "cloud-code-assist";
+ project?: string;
+ location?: string;
+ vertexCredentialKind?: "ambient" | "marker" | "reference" | "api-key";
anthropicOAuthInstance?: "anthropic2";
keyOptional?: boolean;
disabled?: boolean;
diff --git a/gui/src/provider-payload.ts b/gui/src/provider-payload.ts
index 3e72c77d12b..557da498d9f 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,10 @@ export interface ProviderPayload {
codexAccountMode?: "pool" | "direct";
allowPrivateNetwork?: boolean;
preserveResponsesReasoningContent?: boolean;
+ googleMode?: "vertex";
+ project?: string;
+ location?: string;
+ liveModels?: boolean;
}
export function buildProviderPayload(form: ProviderPayloadForm): ProviderPayload {
@@ -93,6 +100,12 @@ 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";
+ provider.liveModels = false;
+ 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..f7018a7983b 100644
--- a/gui/src/provider-workspace/catalog.ts
+++ b/gui/src/provider-workspace/catalog.ts
@@ -46,6 +46,10 @@ 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";
+ project?: string;
+ location?: string;
+ vertexCredentialKind?: "ambient" | "marker" | "reference" | "api-key";
keyOptional?: boolean;
/** Free pricing (may still require an API key). */
freeTier?: boolean;
@@ -91,6 +95,8 @@ export interface WorkspaceSections {
ready: WorkspaceItem[];
/** Enabled providers that are missing required credentials (e.g. an API key). */
needsSetup: WorkspaceItem[];
+ /** Credentials may be supplied by the proxy host but have not been verified. */
+ unverified: WorkspaceItem[];
/** Providers explicitly disabled by the user. */
disabled: WorkspaceItem[];
}
@@ -158,6 +164,18 @@ 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 isVertexProvider(p: WorkspaceProvider | WorkspaceItem): boolean {
+ return ("name" in p && p.name === "google-vertex" && (p.googleMode === undefined || p.googleMode === "vertex"))
+ || (p.adapter === "google" && p.googleMode === "vertex");
+}
+
+export function usesUnverifiedVertexCredentials(p: WorkspaceProvider | WorkspaceItem): boolean {
+ if (p.disabled || (p.authMode !== undefined && p.authMode !== "key") || !isVertexProvider(p)) return false;
+ if (p.vertexCredentialKind) return p.vertexCredentialKind !== "api-key";
+ return !p.hasApiKey;
+}
+
/**
* True when the provider config is the canonical Codex passthrough shape.
* GUI-local mirror of `isCanonicalOpenAiForwardProvider` (src/providers/openai-tiers.ts) —
@@ -247,6 +265,7 @@ export function buildProviderWorkspace(
): WorkspaceSections {
const ready: WorkspaceItem[] = [];
const needsSetup: WorkspaceItem[] = [];
+ const unverified: WorkspaceItem[] = [];
const disabled: WorkspaceItem[] = [];
for (const [name, p] of Object.entries(providers)) {
@@ -254,14 +273,16 @@ export function buildProviderWorkspace(
disabled.push({ name, ...p });
continue;
}
- if (isConfigurationReady(p)) {
+ if (usesUnverifiedVertexCredentials({ name, ...p })) {
+ unverified.push({ name, ...p });
+ } else if (isConfigurationReady(p)) {
ready.push({ name, ...p, tier: providerTier(name, p) });
} else {
needsSetup.push({ name, ...p });
}
}
- return { ready, needsSetup, disabled };
+ return { ready, needsSetup, unverified, disabled };
}
/**
@@ -287,12 +308,13 @@ export function applyActiveAccountReauth(
return {
ready: tag(sections.ready),
needsSetup: tag(sections.needsSetup),
+ unverified: tag(sections.unverified),
disabled: sections.disabled,
};
}
/** Canonical status string for a single provider — config plus optional live-auth overlay. */
-export type ProviderStatus = "ready" | "needs-setup" | "disabled";
+export type ProviderStatus = "ready" | "needs-setup" | "unverified" | "disabled";
/**
* Returns the canonical status for a single WorkspaceProvider (or WorkspaceItem).
@@ -302,6 +324,7 @@ export type ProviderStatus = "ready" | "needs-setup" | "disabled";
export function binProviderStatus(p: WorkspaceProvider | WorkspaceItem): ProviderStatus {
if (p.disabled) return "disabled";
if ("activeNeedsReauth" in p && p.activeNeedsReauth) return "needs-setup";
+ if (usesUnverifiedVertexCredentials(p)) return "unverified";
if (isConfigurationReady(p)) return "ready";
return "needs-setup";
}
diff --git a/gui/tests/add-provider-subscription-cli.test.tsx b/gui/tests/add-provider-subscription-cli.test.tsx
index 18f63e8bfb3..b65ec93cb3f 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,47 @@ 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(location.getAttribute("aria-label")).toBe("Location (optional)");
+ 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", liveModels: false });
+ 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/overview-refresh-all-quotas.test.tsx b/gui/tests/overview-refresh-all-quotas.test.tsx
index 9408207736c..b0ab4b8e6d0 100644
--- a/gui/tests/overview-refresh-all-quotas.test.tsx
+++ b/gui/tests/overview-refresh-all-quotas.test.tsx
@@ -34,6 +34,7 @@ async function flush(): Promise {
const SECTIONS: WorkspaceSections = {
ready: [{ name: "anthropic", adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" }],
needsSetup: [],
+ unverified: [],
disabled: [],
};
diff --git a/gui/tests/provider-first-response-guidance.test.tsx b/gui/tests/provider-first-response-guidance.test.tsx
index 4fcf1867090..11182575fa2 100644
--- a/gui/tests/provider-first-response-guidance.test.tsx
+++ b/gui/tests/provider-first-response-guidance.test.tsx
@@ -127,6 +127,22 @@ 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("Credentials unverified");
+ 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/gui/tests/provider-settings-live-models-provenance.test.tsx b/gui/tests/provider-settings-live-models-provenance.test.tsx
index e1a7e4328f8..feb0cdafbe9 100644
--- a/gui/tests/provider-settings-live-models-provenance.test.tsx
+++ b/gui/tests/provider-settings-live-models-provenance.test.tsx
@@ -118,6 +118,20 @@ test("changing an explicit false to true sends an explicit liveModels choice", a
await act(async () => { root.unmount(); });
});
+test("a static Vertex provider can enable live discovery", async () => {
+ const { root, container, patches } = await mountSettings({
+ name: "google-vertex", adapter: "google", baseUrl: "https://aiplatform.googleapis.com",
+ googleMode: "vertex", authMode: "key", liveModels: false,
+ } as WorkspaceItem);
+ const toggle = container.querySelectorAll('input[type="checkbox"]')[1]!;
+ expect(toggle.disabled).toBe(false);
+ expect(toggle.checked).toBe(false);
+ await act(async () => { toggle.click(); });
+ await save(container);
+ expect(patches[0]?.liveModels).toBe(true);
+ await act(async () => { root.unmount(); });
+});
+
test("canonical ClinePass shows the static catalog as disabled even with stale liveModels true", async () => {
const { root, container } = await mountSettings({
name: "cline-pass",
diff --git a/gui/tests/provider-settings-request-pacing.test.tsx b/gui/tests/provider-settings-request-pacing.test.tsx
index e6d1b86362d..d0abd357655 100644
--- a/gui/tests/provider-settings-request-pacing.test.tsx
+++ b/gui/tests/provider-settings-request-pacing.test.tsx
@@ -56,6 +56,32 @@ async function setInput(input: HTMLInputElement, value: string): Promise {
});
}
+test("Vertex settings can update project and clear location", async () => {
+ const item = { name: "my-vertex", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authMode: "key",
+ googleMode: "vertex", project: "old-project", location: "global" } as WorkspaceItem;
+ const patches: ProviderUpdatePatch[] = [];
+ const container = await renderSettings(item, async (_name, patch) => { patches.push(patch); return { ok: true }; });
+ const inputFor = (label: string) => [...container.querySelectorAll("label")]
+ .find(element => element.textContent?.includes(label))?.querySelector("input") as HTMLInputElement;
+ const project = inputFor("Project ID");
+ const location = inputFor("Location");
+ expect(project).toBeDefined();
+ expect(location).toBeDefined();
+ await setInput(project, "new-project");
+ await setInput(location, "");
+ await act(async () => { container.querySelector(".pwi-settings-sticky-bar .btn-primary")!.click(); });
+ expect(patches[0]).toMatchObject({ project: "new-project", location: "" });
+ await act(async () => { container.unmount(); });
+});
+
+test("canonical Vertex with an old saved URL exposes project and location settings", async () => {
+ const item = { name: "google-vertex", adapter: "google", baseUrl: "https://legacy.example/v1", authMode: "key" } as WorkspaceItem;
+ const container = await renderSettings(item, async () => ({ ok: true }));
+ expect(container.textContent).toContain("Project ID");
+ expect(container.textContent).toContain("Location");
+ await act(async () => { container.unmount(); });
+});
+
test("settings saves provider pacing and a slower model override", async () => {
const item: WorkspaceItem = {
name: "nvidia",
diff --git a/gui/tests/use-json-config-editor.test.tsx b/gui/tests/use-json-config-editor.test.tsx
index 3619aed8f94..e297e614aaf 100644
--- a/gui/tests/use-json-config-editor.test.tsx
+++ b/gui/tests/use-json-config-editor.test.tsx
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, expect, test } from "bun:test";
import { Window } from "happy-dom";
-import { act } from "react";
+import { act, useEffect } from "react";
import type { Root } from "react-dom/client";
import { useJsonConfigEditor, type Config } from "../src/hooks/useJsonConfigEditor";
@@ -23,6 +23,7 @@ const config: Config = {
allowPrivateNetwork: true,
hasApiKey: true,
hasHeaders: true,
+ vertexCredentialKind: "marker",
initialModelSelection: { version: 1, registrationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", status: "pending" },
note: "derived registry note",
},
@@ -50,7 +51,7 @@ let addedProviderNames: string[];
let notifications: Array<{ message: string; ok?: boolean }>;
function Harness() {
- editor = useJsonConfigEditor({
+ const currentEditor = useJsonConfigEditor({
apiBase: "/editor",
config,
notify: (message, ok) => { notifications.push({ message, ok }); },
@@ -59,6 +60,7 @@ function Harness() {
onSaved: added => { savedCallbacks += 1; addedProviderNames = added; },
t: key => key,
});
+ useEffect(() => { editor = currentEditor; }, [currentEditor]);
return null;
}
diff --git a/src/adapters/google-http.ts b/src/adapters/google-http.ts
index f371dd2e609..f3787950d32 100644
--- a/src/adapters/google-http.ts
+++ b/src/adapters/google-http.ts
@@ -87,6 +87,9 @@ export async function fetchGoogleWithRetry(
let recovery: AttemptRecoveryKind | undefined;
for (let attempt = 0; attempt < TRANSIENT_RETRY_MAX_ATTEMPTS; attempt++) {
if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
+ // A credential rebuild mutates the original request while a repaired replay owns its body.
+ // Refresh its destination and headers without discarding the already-repaired payload.
+ if (activeRequest !== request) activeRequest = { ...activeRequest, url: request.url, method: request.method, headers: request.headers };
try {
const res = await send({ url: activeRequest.url, sendClass, recovery,
beforeDispatch: async () => {
diff --git a/src/adapters/google-vertex-key-project.ts b/src/adapters/google-vertex-key-project.ts
index a2d3325543f..3f07dde45db 100644
--- a/src/adapters/google-vertex-key-project.ts
+++ b/src/adapters/google-vertex-key-project.ts
@@ -63,6 +63,7 @@ export interface VertexKeyProjectOptions {
signal?: AbortSignal;
provider?: OcxProviderConfig;
providerName?: string;
+ beforeSend?: () => void;
/** Test seam for the bounded-body and request deadline. */
timeoutMs?: number;
}
@@ -92,8 +93,10 @@ async function probe(
fetchImpl: typeof fetch,
signal: AbortSignal,
timeoutMs: number,
+ beforeSend?: () => void,
): Promise {
const probeSignal = AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
+ beforeSend?.();
try {
const res = await fetchImpl(PROBE_URL, {
method: "POST",
@@ -137,6 +140,7 @@ function startInflight(
apiKey: string,
fetchImpl: typeof fetch,
timeoutMs: number,
+ beforeSend?: () => void,
): InflightProbe {
const controller = new AbortController();
const waiters = new Set();
@@ -145,8 +149,11 @@ function startInflight(
void (async () => {
let project: string | undefined;
+ let failure: unknown;
try {
- project = await probe(apiKey, fetchImpl, controller.signal, timeoutMs);
+ project = await probe(apiKey, fetchImpl, controller.signal, timeoutMs, beforeSend);
+ } catch (error) {
+ failure = error;
} finally {
if (inflight.get(key) === entry) inflight.delete(key);
}
@@ -154,6 +161,7 @@ function startInflight(
for (const waiter of Array.from(waiters)) {
cleanupWaiter(waiter);
if (waiter.signal?.aborted) waiter.reject(waiter.signal.reason);
+ else if (failure !== undefined) waiter.reject(failure);
else waiter.resolve(project);
}
})();
@@ -186,7 +194,7 @@ export async function resolveVertexApiKeyProject(
// provider.proxy) must reject the caller rather than being swallowed into an express fallback.
const fetchImpl = selectProbeFetch(opts);
const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS;
- const entry = inflight.get(key) ?? startInflight(key, apiKey, fetchImpl, timeoutMs);
+ const entry = inflight.get(key) ?? startInflight(key, apiKey, fetchImpl, timeoutMs, opts.beforeSend);
return new Promise((resolve, reject) => {
if (signal?.aborted) {
diff --git a/src/adapters/google.ts b/src/adapters/google.ts
index 6d75a07db42..c6ff9d971a3 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";
@@ -37,6 +37,7 @@ import {
} from "./google-antigravity-replay";
import { canonicalAntigravityUsageModel, resolveAntigravityEffortWireModel } from "../providers/antigravity-models";
import { selectVertexEndpoint, vertexInferenceUrl, vertexReplayModelKey } from "./google-vertex-endpoint";
+import { captureVertexAmbientKey, vertexAmbientKeyIsCurrent } from "../providers/vertex-environment-authority";
import { resolveVertexApiKeyProject, VERTEX_PROJECT_AUTO } from "./google-vertex-key-project";
import { forgetThoughtSignatureForReplay, lookupReplayThoughtSignature } from "../responses/thought-signature-replay";
import {
@@ -1222,10 +1223,17 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
}
if (provider.googleMode === "vertex") {
+ if (provider.authMode === "forward") throw new Error("Vertex does not support forward authentication");
const compiled = compileGoogleWireBody(body, toolSchemaProfile, toolSchemaPolicy);
reportToolSchemaLoss(compiled.toolSchemaLossReport);
restoreGoogleToolName = compiled.restoreToolName;
const apiKey = resolveVertexApiKey(provider.apiKey);
+ const ambientAuthority = apiKey && (!provider.apiKey || provider.apiKey.startsWith("<") || provider.apiKey === "N/A")
+ ? captureVertexAmbientKey(apiKey) : undefined;
+ const beforeCredentialSend = () => {
+ incoming?.beforePhysicalSend?.();
+ if (ambientAuthority && !vertexAmbientKeyIsCurrent(ambientAuthority)) throw new Error("Vertex credential authority changed before auxiliary send");
+ };
let vertexEndpoint = selectVertexEndpoint(provider, Boolean(apiKey));
if (apiKey && vertexEndpoint.kind === "project" && vertexEndpoint.project === VERTEX_PROJECT_AUTO) {
// Unresolved keys use express for this request so Google's status drives key failover.
@@ -1233,6 +1241,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
signal: incoming?.abortSignal,
provider,
providerName: incoming?.providerName,
+ beforeSend: beforeCredentialSend,
});
vertexEndpoint = project ? { ...vertexEndpoint, project } : { kind: "express" };
}
@@ -1254,13 +1263,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, beforeSend: beforeCredentialSend });
+ headers["Authorization"] = `Bearer ${adcAuthority.token}`;
+ }
+ 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 && ambientAuthority) {
+ bindVertexRequestAuthority(request, url, apiKey, { ambientKey: ambientAuthority });
}
- return { url, method: "POST", headers, body: JSON.stringify(compiled.body) };
+ 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..84fccd7d943 100644
--- a/src/lib/gcp-adc.ts
+++ b/src/lib/gcp-adc.ts
@@ -21,8 +21,10 @@
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";
+import { vertexAmbientKeyIsCurrent, type VertexAmbientKeyAuthority } from "../providers/vertex-environment-authority";
export { isGcpCredentialMaterial } from "./gcp-credential-input";
import {
captureConfigGeneration,
@@ -89,6 +91,7 @@ export function parseGcpCredentialJson(value: string): AdcFileCredentials | unde
interface CachedToken {
token: string;
expiresAtMs: number;
+ generation: number;
}
interface ServiceAccountCredentials {
@@ -114,7 +117,12 @@ interface TokenResponse {
}
const tokenCache = new Map();
-const inflight = new Map>();
+type VertexTokenAuthority = { token: string; source: string; generation: number; markerAccount?: string };
+type TokenWaiter = { beforeSend?: () => void };
+type InflightToken = { controller: AbortController; promise: Promise; waiters: Set; settled: boolean };
+const inflight = new Map();
+const sourceObservations = new Map();
+let sourceEpoch = 0;
let lastReconciledGeneration = 0;
let liveSources = new Set();
@@ -165,23 +173,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 +214,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 +235,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 {
@@ -286,10 +307,11 @@ function tokenTimeoutSignal(parent: AbortSignal | undefined): AbortSignal {
* bad grant) fail fast. The error message carries only the status code, never the response body
* (which can leak grant/account details) or the token/key.
*/
-async function postForToken(body: URLSearchParams, signal: AbortSignal | undefined, fetchImpl: FetchImpl): Promise {
+async function postForToken(body: URLSearchParams, signal: AbortSignal | undefined, fetchImpl: FetchImpl, beforeSend: () => void): Promise {
let lastError: unknown;
for (let attempt = 0; attempt < TOKEN_ATTEMPTS; attempt++) {
if (signal?.aborted) throw signal.reason ?? new Error("Google OAuth token exchange aborted");
+ beforeSend();
let response: Response;
try {
response = await fetchImpl(OAUTH_TOKEN_URL, {
@@ -316,17 +338,17 @@ async function postForToken(body: URLSearchParams, signal: AbortSignal | undefin
throw lastError instanceof Error ? lastError : new Error("Google OAuth token exchange failed");
}
-async function exchangeJwtForToken(creds: ServiceAccountCredentials, signal: AbortSignal | undefined, fetchImpl: FetchImpl): Promise {
+async function exchangeJwtForToken(creds: ServiceAccountCredentials, signal: AbortSignal | undefined, fetchImpl: FetchImpl, beforeSend: () => void): Promise {
const now = Math.floor(Date.now() / 1000);
const assertion = await signJwtRs256(
{ iss: creds.client_email, scope: CLOUD_PLATFORM_SCOPE, aud: OAUTH_TOKEN_URL, exp: now + 3600, iat: now },
creds.private_key,
creds.private_key_id,
);
- return postForToken(new URLSearchParams({ grant_type: JWT_BEARER_GRANT, assertion }), signal, fetchImpl);
+ return postForToken(new URLSearchParams({ grant_type: JWT_BEARER_GRANT, assertion }), signal, fetchImpl, beforeSend);
}
-async function exchangeRefreshToken(creds: AuthorizedUserCredentials, signal: AbortSignal | undefined, fetchImpl: FetchImpl): Promise {
+async function exchangeRefreshToken(creds: AuthorizedUserCredentials, signal: AbortSignal | undefined, fetchImpl: FetchImpl, beforeSend: () => void): Promise {
return postForToken(
new URLSearchParams({
client_id: creds.client_id,
@@ -336,6 +358,7 @@ async function exchangeRefreshToken(creds: AuthorizedUserCredentials, signal: Ab
}),
signal,
fetchImpl,
+ beforeSend,
);
}
@@ -355,14 +378,15 @@ async function fetchMetadataToken(signal: AbortSignal | undefined, fetchImpl: Fe
}
}
-async function resolveAccessTokenUncached(markerAccount: string | undefined, signal: AbortSignal | undefined, fetchImpl: FetchImpl): Promise<{ source: string; token: TokenResponse }> {
+async function resolveAccessTokenUncached(markerAccount: string | undefined, signal: AbortSignal | undefined, fetchImpl: FetchImpl, beforeSend: () => void): Promise<{ source: string; token: TokenResponse }> {
const adc = loadAdcCredentials(markerAccount);
if (adc) {
const token = adc.creds.type === "service_account"
- ? await exchangeJwtForToken(adc.creds, signal, fetchImpl)
- : await exchangeRefreshToken(adc.creds, signal, fetchImpl);
+ ? await exchangeJwtForToken(adc.creds, signal, fetchImpl, beforeSend)
+ : await exchangeRefreshToken(adc.creds, signal, fetchImpl, beforeSend);
return { source: adc.source, token };
}
+ beforeSend();
const metadata = await fetchMetadataToken(signal, fetchImpl);
if (metadata) return { source: "metadata", token: metadata };
throw new Error(
@@ -371,19 +395,21 @@ 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; beforeSend?: () => void }): 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
// adapter passes `gcpCredentialMarkerAccount(provider.apiKey)` per request instead.
const markerAccount = options?.markerAccount;
+ options?.signal?.throwIfAborted();
const fetchImpl = options?.fetch ?? globalThis.fetch.bind(globalThis);
const skew = getRefreshSkewMs();
const now = Date.now();
// 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,37 +418,130 @@ 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 existing = inflight.get(cacheKey);
- if (existing) return existing;
-
- const promise = (async () => {
+ const cacheKey = `${expectedSource}:${expected.generation}`;
+ let entry = inflight.get(cacheKey);
+ if (!entry) {
+ const controller = new AbortController();
+ entry = { controller, waiters: new Set(), settled: false, promise: undefined as unknown as Promise };
+ const owned = entry;
+ const beforeSend = () => {
+ const current = observeAdcSource(markerAccount);
+ if (current.source !== expected.source || current.generation !== expected.generation) throw new Error("Vertex ADC source changed before token exchange");
+ let failure: unknown;
+ for (const waiter of owned.waiters) {
+ try { waiter.beforeSend?.(); return; }
+ catch (error) { failure = error; }
+ }
+ throw failure ?? new Error("Vertex token exchange has no authorized waiter");
+ };
+ owned.promise = Promise.resolve().then(async () => {
try {
- const { source, token } = await resolveAccessTokenUncached(markerAccount, options?.signal, fetchImpl);
+ const { source, token } = await resolveAccessTokenUncached(markerAccount, controller.signal, fetchImpl, beforeSend);
+ 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);
+ owned.settled = true;
+ if (inflight.get(cacheKey) === owned) inflight.delete(cacheKey);
}
- })();
- inflight.set(cacheKey, promise);
- return promise;
+ });
+ inflight.set(cacheKey, owned);
+ }
+ const owned = entry;
+ const waiter: TokenWaiter = { beforeSend: options?.beforeSend };
+ owned.waiters.add(waiter);
+ return new Promise((resolve, reject) => {
+ let finished = false;
+ const finish = () => {
+ if (finished) return false;
+ finished = true;
+ options?.signal?.removeEventListener("abort", onAbort);
+ owned.waiters.delete(waiter);
+ if (owned.waiters.size === 0 && !owned.settled) {
+ if (inflight.get(cacheKey) === owned) inflight.delete(cacheKey);
+ owned.controller.abort();
+ }
+ return true;
+ };
+ const onAbort = () => { if (finish()) reject(options?.signal?.reason ?? new Error("Google OAuth token exchange aborted")); };
+ options?.signal?.addEventListener("abort", onAbort, { once: true });
+ owned.promise.then(value => {
+ if (!finish()) return;
+ try { waiter.beforeSend?.(); resolve(value); } catch (error) { reject(error); }
+ }, error => { if (finish()) reject(error); });
+ });
+}
+
+/** 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?: VertexAmbientKeyAuthority; 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: VertexAmbientKeyAuthority }): void {
+ requestAuthorities.set(request, {
+ target, headerDigest: requestDigest(header),
+ ...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 && !vertexAmbientKeyIsCurrent(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();
+ for (const entry of inflight.values()) entry.controller.abort();
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 550f5420867..e7b3ada6bcd 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";
@@ -35,7 +36,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;
}
@@ -61,10 +63,14 @@ function matchesConfiguredSelection(
const expected = routedProvider._apiKeyAttempt;
const current = currentKeyProvider(config, name, expected, allowCooling);
return current !== null && expected !== undefined
+ && vertexEnvironmentKeyIsCurrent(expected, current)
&& (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..232c583dd0e 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";
@@ -110,9 +111,35 @@ 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 reference = provider._apiKeyAttempt?.reference;
+ if (reference && (reference.startsWith("keychain:") || reference.startsWith("$"))
+ && (!provider.apiKey?.trim() || provider.apiKey === "N/A" || provider.apiKey.startsWith("<") || reference === "keychain:")) {
+ throw new Error("Vertex API key reference could not be resolved");
+ }
+ const marker = provider.apiKey?.startsWith(GCP_CREDENTIAL_MARKER_PREFIX) === true;
+ const configuredKey = provider.apiKey && !provider.apiKey.startsWith("<") && provider.apiKey !== "N/A" ? provider.apiKey : undefined;
+ captureVertexEnvironmentKey(provider._apiKeyAttempt, provider);
+ if (!marker && (configuredKey || process.env.GOOGLE_CLOUD_API_KEY)) return provider;
+ return {
+ ...provider,
+ 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);
+ if (provider.authMode === "forward" && (
+ (provider.adapter === "google" && provider.googleMode === "vertex")
+ || (registryEntry?.adapter === "google" && (provider.googleMode ?? registryEntry.googleMode) === "vertex"
+ && providerMatchesRegistryTransportWithStaticGuards(providerName, provider))
+ )) {
+ throw new Error("Vertex does not support forward authentication");
+ }
if (!registryEntry || !providerMatchesRegistryTransportWithStaticGuards(providerName, provider)) {
assertProviderDestinationAllowed(providerName, provider);
// A row whose adapter no longer matches its registry entry still reaches the Responses
@@ -124,7 +151,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 +167,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 +387,5 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
...(thinkingBudgetModels ? { thinkingBudgetModels } : {}),
};
applyDirectReasoningEffortContracts(registryEntry, resolved, provider);
- return resolved;
+ return snapshotVertexEnvironment(resolved);
}
diff --git a/src/providers/vertex-environment-authority.ts b/src/providers/vertex-environment-authority.ts
new file mode 100644
index 00000000000..1cd7ef0c06d
--- /dev/null
+++ b/src/providers/vertex-environment-authority.ts
@@ -0,0 +1,50 @@
+import { createHmac, randomBytes } from "node:crypto";
+import { GCP_CREDENTIAL_MARKER_PREFIX } from "./api-key-resolve";
+import type { ProviderApiKeySelection } from "../types/provider";
+import type { OcxProviderConfig } from "../types";
+
+// Keep the ambient key out of generic provider config and any serializable selection.
+const digestKey = randomBytes(32);
+export type VertexAmbientKeyAuthority = { digest: string; generation: number };
+const keySnapshots = new WeakMap();
+let observedKey: VertexAmbientKeyAuthority | undefined;
+let keyGeneration = 0;
+
+function ambientKeyDigest(): string {
+ return createHmac("sha256", digestKey).update(process.env.GOOGLE_CLOUD_API_KEY || "").digest("hex");
+}
+
+function observeAmbientKey(): VertexAmbientKeyAuthority {
+ const digest = ambientKeyDigest();
+ if (observedKey?.digest !== digest) observedKey = { digest, generation: ++keyGeneration };
+ return observedKey;
+}
+
+export function captureVertexAmbientKey(key: string): VertexAmbientKeyAuthority {
+ const current = observeAmbientKey();
+ const digest = createHmac("sha256", digestKey).update(key).digest("hex");
+ return digest === current.digest ? current : { digest, generation: -1 };
+}
+
+export function vertexAmbientKeyIsCurrent(expected: VertexAmbientKeyAuthority): boolean {
+ const current = observeAmbientKey();
+ return expected.digest === current.digest && expected.generation === current.generation;
+}
+
+function modelAdapterSignature(provider: OcxProviderConfig): string {
+ return JSON.stringify(Object.entries(provider.modelAdapters ?? {}).sort(([a], [b]) => a.localeCompare(b)));
+}
+
+export function captureVertexEnvironmentKey(selection: ProviderApiKeySelection | undefined, provider: OcxProviderConfig): void {
+ if (!selection || keySnapshots.has(selection)) return;
+ const configuredKey = provider.apiKey;
+ const ambient = !configuredKey?.startsWith(GCP_CREDENTIAL_MARKER_PREFIX)
+ && (!configuredKey || configuredKey.startsWith("<") || configuredKey === "N/A") ? observeAmbientKey() : undefined;
+ keySnapshots.set(selection, { adapter: provider.adapter, modelAdapters: modelAdapterSignature(provider), ambient });
+}
+
+export function vertexEnvironmentKeyIsCurrent(selection: ProviderApiKeySelection, current: OcxProviderConfig): boolean {
+ const expected = keySnapshots.get(selection);
+ return expected === undefined || (expected.adapter === current.adapter && expected.modelAdapters === modelAdapterSignature(current)
+ && (!expected.ambient || vertexAmbientKeyIsCurrent(expected.ambient)));
+}
diff --git a/src/router.ts b/src/router.ts
index b9f4b85ae99..8818803af74 100644
--- a/src/router.ts
+++ b/src/router.ts
@@ -318,6 +318,7 @@ function crossProviderRedirectTarget(config: OcxConfig, providerName: string, va
return usable ? value : undefined;
}
return authMode === "local" || effective.keyOptional === true || Boolean(effective.apiKey?.trim())
+ || (authMode === "key" && effective.adapter === "google" && effective.googleMode === "vertex")
? value : undefined;
} catch {
return undefined;
diff --git a/src/server/adapter-resolve.ts b/src/server/adapter-resolve.ts
index 18cd7e63f2a..695eb8cf53f 100644
--- a/src/server/adapter-resolve.ts
+++ b/src/server/adapter-resolve.ts
@@ -4,6 +4,13 @@ import { isWirePinnedModel, MODEL_ADAPTER_OVERRIDE_ALLOWED, pinnedWireAdapter }
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
import { type InboundWire, providerModelWireDefault } from "../providers/registry";
import type { ResolvedModelPolicy } from "../providers/resolved-model-policy";
+import { GCP_CREDENTIAL_MARKER_PREFIX } from "../providers/api-key-resolve";
+
+function selectWire(provider: OcxProviderConfig, adapter: string): OcxProviderConfig {
+ const stripMarker = adapter !== "google" && provider.apiKey?.startsWith(GCP_CREDENTIAL_MARKER_PREFIX);
+ if (adapter === provider.adapter && !stripMarker) return provider;
+ return { ...provider, adapter, ...(stripMarker ? { apiKey: undefined } : {}) };
+}
/**
* Resolve the wire a single model should use: a hard pin first, then a configured
@@ -28,13 +35,11 @@ export function resolveWireProtocolOverride(
// RouteResult policy is recaptured for the original inbound protocol whenever routing selects
// or replaces a destination, so every downstream rebuild consumes the same adapter authority.
if (staticPolicy) {
- return staticPolicy.model.adapter !== providerConfig.adapter
- ? { ...providerConfig, adapter: staticPolicy.model.adapter }
- : providerConfig;
+ return selectWire(providerConfig, staticPolicy.model.adapter);
}
const pinned = pinnedWireAdapter(providerName, modelId, providerConfig);
if (pinned && providerConfig.adapter !== pinned) {
- return { ...providerConfig, adapter: pinned };
+ return selectWire(providerConfig, pinned);
}
// Re-check the allow-list here, not just in the config validator: the file may have
// been hand-edited, or written by a build that allowed more values.
@@ -51,9 +56,9 @@ export function resolveWireProtocolOverride(
// A forward provider hands the caller's own credential upstream; the chat adapter
// only ever sends provider.apiKey, so switching wires here would drop the auth.
&& !isCanonicalOpenAiForwardProvider(providerConfig)) {
- return { ...providerConfig, adapter: requested };
+ return selectWire(providerConfig, requested);
}
- return providerConfig;
+ return selectWire(providerConfig, providerConfig.adapter);
}
/** Build the provider adapter for a resolved provider config. */
diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts
index 07d97ac6007..da6e6e32f31 100644
--- a/src/server/auth-cors.ts
+++ b/src/server/auth-cors.ts
@@ -1360,6 +1360,16 @@ export function safeConfigDTO(config: OcxConfig): unknown {
hasApiKey: !!provider.apiKey,
hasHeaders: !!provider.headers && Object.keys(provider.headers).length > 0,
};
+ const registry = getProviderRegistryEntry(name);
+ const vertex = (provider.adapter === "google" && effectiveGoogleMode(name, provider) === "vertex")
+ || (registry?.adapter === "google" && (provider.googleMode ?? registry.googleMode) === "vertex"
+ && providerMatchesRegistryTransport(name, provider));
+ if (vertex) {
+ const key = provider.apiKey;
+ dto.vertexCredentialKind = key?.startsWith("gcp-sa:") ? "marker"
+ : !key?.trim() || key === "N/A" || key.startsWith("<") ? "ambient"
+ : key.startsWith("keychain:") || /^\$\{?\w+\}?$/.test(key) ? "reference" : "api-key";
+ }
if (name === "xai") {
dto.xaiResponsesOptInState = xaiResponsesOptInState(provider);
}
diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts
index ebb44980a24..63e4089e46f 100644
--- a/src/server/management/provider-routes.ts
+++ b/src/server/management/provider-routes.ts
@@ -383,6 +383,14 @@ function applyProviderPatchFields(
else delete next.defaultModel;
touched = true;
}
+ for (const field of ["project", "location"] as const) {
+ if (!Object.hasOwn(rawBody, field)) continue;
+ const value = rawBody[field];
+ if (typeof value !== "string") return { error: `${field} must be a string` };
+ if (value.trim()) next[field] = value.trim();
+ else delete next[field];
+ touched = true;
+ }
if (Object.hasOwn(rawBody, "authMode")) {
if (typeof rawBody.authMode !== "string") return { error: "authMode must be a string" };
const mode = rawBody.authMode.trim();
diff --git a/src/server/responses/adapter-continuation.ts b/src/server/responses/adapter-continuation.ts
index 16276d30cc5..36c26e4eba6 100644
--- a/src/server/responses/adapter-continuation.ts
+++ b/src/server/responses/adapter-continuation.ts
@@ -222,6 +222,7 @@ export function createAdapterContinuations(
headers: requestState.selectedForwardHeaders,
providerName: route.providerName,
translatorBudget,
+ abortSignal: upstream.signal,
...(transportState.imageTierBias > 0 ? { imageTierBias: transportState.imageTierBias } : {}),
});
recordAdapterReasoning(logCtx, continuationRequest);
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..aa4f715d93e 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";
@@ -692,7 +693,11 @@ export async function prepareResponsesTransport(
// Persistence consent is configuration: capture its generation with the provider snapshot.
const fileConsentGeneration = captureConfigGeneration();
resolved.buildRequest = async (requestParsed, incoming) => {
- const request = await build(requestParsed, incoming);
+ const guardedIncoming = incoming ? { ...incoming, beforePhysicalSend: () => {
+ incoming.beforePhysicalSend?.();
+ if (!selectionIsCurrent(binding)) throw new Error("Credential selection changed before auxiliary send");
+ } } : incoming;
+ const request = await build(requestParsed, guardedIncoming);
// Capture at adapter creation, never from mutable serving state after an await.
if (binding) requestBindings.set(request, binding);
const allowedImageFileModels = fileProvider.modelCapabilities
@@ -789,6 +794,10 @@ export async function prepareResponsesTransport(
if (fresh) current = selectProactiveApiKeyTransport(config, route.providerName, fresh) ?? fresh;
}
if (!current) throw new Error("API key selection is unavailable before dispatch");
+ if (route.provider.googleMode === "vertex" || current.googleMode === "vertex") {
+ const freshPolicy = captureRouteStaticPolicy(route.providerName, route.modelId, current, route.staticPolicy.effectiveAlias, inboundWire);
+ if (freshPolicy.model.adapter !== route.staticPolicy.model.adapter) throw new Error("Vertex model wire changed before dispatch");
+ }
await resolveCopilotSelection(requestParsed, true, current);
}
adapter = activeAdapter = runTurnAdapter = resolveSelectionAdapter(
@@ -905,7 +914,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 +965,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 (!selectionIsCurrent(requestBindings.get(wireRequest)) || !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,
@@ -1000,6 +1012,7 @@ export async function prepareResponsesTransport(
const rebuilt = await nextAdapter.buildRequest(requestParsed, {
completedToolBlocks: requestState.completedToolBlocks,
headers: requestState.selectedForwardHeaders, providerName: route.providerName, translatorBudget,
+ abortSignal: dispatchInit.signal ?? options.abortSignal ?? req.signal,
dispatchPacingHeld: true,
...(imageTierBias > 0 ? { imageTierBias } : {}),
});
@@ -1008,11 +1021,21 @@ export async function prepareResponsesTransport(
rebuilt.releaseBodyObservation?.();
return markProxyError(formatErrorResponse(413, "outbound_body_too_large", describeOutboundBodyRefusal(bodySize)), "outbound");
}
+ // A Google schema-repair replay owns its modified body. Retain it only when the
+ // credential rebuild produced the same original payload; otherwise refuse to combine
+ // a repair for the old request with a newly constructed request.
+ let refreshedBody: BodyInit | null | undefined = rebuilt.body;
+ if (route.provider.adapter === "google" && route.provider.googleMode === "vertex"
+ && typeof dispatchInit.body === "string" && dispatchInit.body !== wireRequest.body) {
+ if (rebuilt.body !== wireRequest.body) throw new Error("Vertex request changed during schema repair");
+ refreshedBody = dispatchInit.body;
+ }
const headers = new Headers(dispatchInit.headers);
for (const name of Object.keys(wireRequest.headers)) headers.delete(name);
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);
@@ -1020,7 +1043,7 @@ export async function prepareResponsesTransport(
sameTargetParsed = requestParsed;
sameTargetToken = transportToken;
destination = rebuilt.url;
- dispatchInit = { ...dispatchInit, method: rebuilt.method, headers, body: rebuilt.body };
+ dispatchInit = { ...dispatchInit, method: rebuilt.method, headers, body: refreshedBody };
pacingChanged = route.provider.requestPacing?.enabled === true
&& (!!route.provider.requestPacing.keys || route.provider.requestPacing.keySelection === "round-robin");
// A refreshed key must reacquire admission before the next synchronous send guard.
diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md
index 1b4e8f3ecb0..8b9db2eb4a7 100644
--- a/structure/gui-and-management-api.md
+++ b/structure/gui-and-management-api.md
@@ -1,6 +1,6 @@
# GUI And Management API
The routing profiles dashboard shows its empty state only after a valid empty response; reloads hide it while pending, malformed envelopes and lists with no valid DTOs show a load error, and valid entries in mixed lists remain available.
-Provider PUT, POST and DELETE in `src/server/management/provider-routes.ts` run [GCP credential cleanup](providers/google.md#vertex-credential-markers) only after saving. `src/server/management/provider-gcp-credential-routes.ts` owns credential-input rejection responses and the locked deletion callback, which snapshots live and valid durable markers under the persistence lock. Rejected mutations and failed saves preserve secrets; other persisted references retain them. Route response shapes remain unchanged. See [LM Studio Chat instruction editor policy](providers/lm-studio-chat.md).
+Provider PUT, POST and DELETE in `src/server/management/provider-routes.ts` run [GCP credential cleanup](providers/google.md#vertex-credential-markers) only after saving. `src/server/management/provider-gcp-credential-routes.ts` owns credential-input rejection responses and the locked deletion callback, which snapshots live and valid durable markers under the persistence lock. Rejected mutations and failed saves preserve secrets; other persisted references retain them. Vertex settings PATCH project and location explicitly, and the safe config DTO classifies credential kind without exposing its value; the GUI separates unverified host credentials from missing keys. See [LM Studio Chat instruction editor policy](providers/lm-studio-chat.md).
Anthropic subscription labels and masked account display follow [subscription labels](providers/anthropic-account-pool.md#subscription-labels). `GET/PUT /api/codex-auth/preserve-signed-in` and CLI `account preserve-signed-in` expose the [signed-in quota policy](providers/openai-signed-in-preservation.md); strict boolean writes can explicitly release affected bindings/pins for the next request after persistence. The GUI retains its existing account controls. `src/server/management/combo-routes.ts` retains omitted `metered: true` metadata by trimmed provider/model identity during dashboard-shaped saves. Explicit false clears the flag; false defaults are omitted from persisted targets. Omitted combo `allowMeteredTargets` survives saves and renames; explicit false clears it and stays sparse. The CLI exposes `--allow-metered-targets on|off`, and receipts preserve the boolean. Key and loopback operator consent follow the [runtime contract](runtime.md#request-local-target-compatibility). Model-price APIs and receipt validation follow the [pricing contract](pricing.md). Anthropic OAuth account DTOs include `autoSwitchThresholdOverride` (integer or null),
`autoSwitchThreshold` (pool default) and `effectiveAutoSwitchThreshold`. The dedicated `PUT /api/oauth/accounts/auto-switch` accepts `{ provider: "anthropic", accountId, threshold }`;
explicit null restores inheritance and missing/invalid values fail. Other providers are rejected.
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..134d06b2aec 100644
--- a/structure/providers/google.md
+++ b/structure/providers/google.md
@@ -12,6 +12,15 @@ 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. The ambient API key stays out of generic provider configuration: `src/providers/vertex-environment-authority.ts` keeps a keyed process-local digest and observed generation 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 and observed generation, checks the source before and after token exchange, and binds its identity to the built request. Shared token refresh has independently cancellable waiters. `src/server/responses/request-transport.ts` checks the provider and request binding after pacing, transfers it on rebuild, and checks the final selection, target and credential after plugin rewrites. The `project: "auto"` auxiliary probe checks the same selection before sending. 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.
+
+Vertex rejects forward authentication before registry normalization, including a canonical row
+whose saved adapter differs from its registry transport. A credential rebuild during
+schema repair retains the repaired body only if the rebuilt original body is unchanged; otherwise
+it refuses the replay. New dashboard presets select a static catalog explicitly, while older rows
+with no `liveModels` value retain live discovery without adding an implicit undiscovered default.
+The Settings toggle remains editable for Vertex, including canonical rows with old saved URLs.
+
`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/structure/runtime.md b/structure/runtime.md
index 49502bec379..c641fd7de9b 100644
--- a/structure/runtime.md
+++ b/structure/runtime.md
@@ -18,7 +18,7 @@ Release packaging, Docker, installed-artifact and service smoke jobs, desktop sh
`src/router.ts` attaches one frozen `ResolvedModelPolicy` to every `RouteResult`. Fast observation, persistence and cost provenance follow the [response-tier authority contract](transports/responses.md#response-tier-observation-authority); outbound Fast policy is unchanged. Policy/combo route spreads retain that object. Every initial, fallback, and recovery route is recaptured for the request's original inbound protocol before route-dependent normalization, and all adapter rebuilds consume its recorded adapter. A translated Chat or Anthropic replay therefore cannot inherit a Responses-only default. Credential, account, quota, health, cooldown, and observed transport evidence remain late and cannot widen a captured static limit.
-Virtual models are the sole model-identity transition: the ordinary and compact paths preserve the selected public id in diagnostics, rewrite `route.modelId` to the upstream wire id, and atomically replace `route.staticPolicy` before adapter or capability decisions continue. Model aliases are resolved before the route result is built, so their policy is already keyed by the native wire id. `src/router.ts` applies bare `blockedModelRedirects` keys after this resolution; ordinary targets substitute one upstream model id on the selected provider/account, including slash-valued targets, while only targets explicitly naming a different configured provider reroute. Qualified source keys take precedence only for those cross-provider targets. A cross-provider redirect sets `RouteResult.credentialDomainRewrite`, which Responses and Chat header scoping treat like combo/policy routes, so a caller credential for the source route is never forwarded. `src/cli/alias.ts` consumes the JSON output flag before choosing its default list action. `ocx alias --json` and both flag positions around an explicit action preserve the selected read or mutation. Live selector hints obey the [credential-scoped cache contract](catalog.md): a selection change cannot reuse the previous credential's roster to choose an alias target. Passive OAuth observation neither refreshes credentials nor repairs their storage. Cross-provider redirects require an enabled destination with a resolved key, usable stored OAuth account, or local/keyless auth (never source-caller forward auth), and share one visited set and five-edge budget across alias, provider, combo and policy resolution; every policy redirect must end at an eligible declared candidate, including same-provider substitutions and candidate fallback retries, and pinned account selectors reject provider changes. Decision traces name the final provider/model while policy candidates retain selection evidence and combos retain their route kind and redirect reason. An explicit provider-qualified slug decodes against `knownModelIdsForProvider` first, then model aliases. Only when both miss on a direct request does `src/router.ts` decode once more against the provider's `modelDisplayNames` keys, which are operator-declared native ids; this keeps a live-discovery provider such as Baseten routable while its roster cache is cold. The fallback never feeds `knownModelIdsForProvider`, so display-name keys claim no aliases and never shadow a configured id; `retainModels` ids (an encoded slash-valued one included) are tried before display-name keys, and concrete policy, combo and blocked-model redirect targets skip the display-name fallback (`tests/codex-integration/slug-codec.test.ts`, `tests/routing/policy-execution.test.ts`).
+Virtual models are the sole model-identity transition: the ordinary and compact paths preserve the selected public id in diagnostics, rewrite `route.modelId` to the upstream wire id, and atomically replace `route.staticPolicy` before adapter or capability decisions continue. Model aliases are resolved before the route result is built, so their policy is already keyed by the native wire id. `src/router.ts` applies bare `blockedModelRedirects` keys after this resolution; ordinary targets substitute one upstream model id on the selected provider/account, including slash-valued targets, while only targets explicitly naming a different configured provider reroute. Qualified source keys take precedence only for those cross-provider targets. A cross-provider redirect sets `RouteResult.credentialDomainRewrite`, which Responses and Chat header scoping treat like combo/policy routes, so a caller credential for the source route is never forwarded. `src/cli/alias.ts` consumes the JSON output flag before choosing its default list action. `ocx alias --json` and both flag positions around an explicit action preserve the selected read or mutation. Live selector hints obey the [credential-scoped cache contract](catalog.md): a selection change cannot reuse the previous credential's roster to choose an alias target. Passive OAuth observation neither refreshes credentials nor repairs their storage. Cross-provider redirects require an enabled destination with a resolved key, usable stored OAuth account, or local/keyless auth (including keyless Vertex, never source-caller forward auth), and share one visited set and five-edge budget across alias, provider, combo and policy resolution; every policy redirect must end at an eligible declared candidate, including same-provider substitutions and candidate fallback retries, and pinned account selectors reject provider changes. Decision traces name the final provider/model while policy candidates retain selection evidence and combos retain their route kind and redirect reason. An explicit provider-qualified slug decodes against `knownModelIdsForProvider` first, then model aliases. Only when both miss on a direct request does `src/router.ts` decode once more against the provider's `modelDisplayNames` keys, which are operator-declared native ids; this keeps a live-discovery provider such as Baseten routable while its roster cache is cold. The fallback never feeds `knownModelIdsForProvider`, so display-name keys claim no aliases and never shadow a configured id; `retainModels` ids (an encoded slash-valued one included) are tried before display-name keys, and concrete policy, combo and blocked-model redirect targets skip the display-name fallback (`tests/codex-integration/slug-codec.test.ts`, `tests/routing/policy-execution.test.ts`).
Routed Meta Muse requests use the registry-owned [Muse effort and header contract](providers-and-adapters.md); `max` reaches the provider through the existing reasoning mapper.
diff --git a/tests/adapters/google/gcp-adc.test.ts b/tests/adapters/google/gcp-adc.test.ts
index fe0c8d894cb..76b8270d2fa 100644
--- a/tests/adapters/google/gcp-adc.test.ts
+++ b/tests/adapters/google/gcp-adc.test.ts
@@ -1,12 +1,27 @@
-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 { 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";
+import { getVertexAccessToken, getVertexAccessTokenWithAuthority, __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 { providerManagementConfigError, safeConfigDTO } from "../../../src/server/auth-cors";
+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 { 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";
@@ -81,6 +96,575 @@ function setEnv(k: string, v: string): void {
}
describe("gcp-adc resolver", () => {
+ test("ADC rotation during schema repair preserves the repaired replay", async () => {
+ const replacementPath = join(tmp, "repair-replacement.json");
+ writeFileSync(replacementPath, JSON.stringify({ type: "authorized_user", client_id: "fixture", client_secret: "fixture", refresh_token: "fixture" }));
+ 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 bodies: string[] = [];
+ const authorizations: Array = [];
+ let tokens = 0;
+ globalThis.fetch = (async (input, init) => {
+ if (String(input) === OAUTH_TOKEN_URL) return Response.json({ access_token: `fixture-${++tokens}`, expires_in: 3600 });
+ bodies.push(String(init?.body));
+ authorizations.push(new Headers(init?.headers).get("authorization"));
+ if (bodies.length === 1) setEnv("GOOGLE_APPLICATION_CREDENTIALS", replacementPath);
+ return Response.json({ error: { code: 400, status: "INVALID_ARGUMENT", message: "tools.0.custom.input_schema: JSON schema is invalid" } }, { status: 400 });
+ }) 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" }],
+ tools: [{ type: "function", function: { name: "fixture", parameters: { type: "object", properties: { ids: { type: "array", items: { type: "string" } } } } } }], stream: false,
+ }) });
+ await handleChatCompletions(request, config, { model: "", provider: "" });
+ expect(tokens).toBe(2);
+ expect(bodies).toHaveLength(2);
+ expect(authorizations).toEqual(["Bearer fixture-1", "Bearer fixture-2"]);
+ expect(bodies[1]).not.toBe(bodies[0]);
+ } finally {
+ configuredFetch.mockRestore();
+ globalThis.fetch = priorFetch;
+ releaseOwner();
+ }
+ });
+
+ test("unresolved explicit Vertex key references do not fall back to host credentials", () => {
+ setEnv("GOOGLE_CLOUD_API_KEY", "fixture-host-key");
+ setProviderKeychainEntryFactoryForTests(() => ({ getPassword: () => null, setPassword: () => {}, deletePassword: () => true }));
+ try {
+ for (const apiKey of ["keychain:missing-fixture", "${MISSING_VERTEX_TEST_KEY}"]) {
+ const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authMode: "key", googleMode: "vertex", apiKey } as OcxProviderConfig;
+ expect(() => routedProviderConfig("google-vertex", provider)).toThrow();
+ }
+ } finally {
+ setProviderKeychainEntryFactoryForTests(null);
+ }
+ });
+
+ test("Vertex forward auth cannot silently use proxy host credentials", async () => {
+ setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath);
+ const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authMode: "forward", googleMode: "vertex", project: "demo-project", location: "global" } as OcxProviderConfig;
+ await expect(createGoogleAdapter(provider).buildRequest(parsed())).rejects.toThrow();
+ expect(oauthCalls).toBe(0);
+ });
+ test("dashboard exposes only a non-secret Vertex credential kind", () => {
+ const vertex = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex" } as const;
+ const config = { port: 0, defaultProvider: "ambient", providers: {
+ ambient: { ...vertex }, marker: { ...vertex, apiKey: "gcp-sa:fixture-marker" },
+ placeholder: { ...vertex, apiKey: "N/A" }, key: { ...vertex, apiKey: "fixture-key" },
+ "google-vertex": { adapter: "openai-chat", baseUrl: "https://legacy.example/v1", apiKey: "gcp-sa:fixture-marker" },
+ } } as OcxConfig;
+ const dto = safeConfigDTO(config) as { providers: Record> };
+ expect(dto.providers.ambient?.vertexCredentialKind).toBe("ambient");
+ expect(dto.providers.marker?.vertexCredentialKind).toBe("marker");
+ expect(dto.providers.placeholder?.vertexCredentialKind).toBe("ambient");
+ expect(dto.providers.key?.vertexCredentialKind).toBe("api-key");
+ expect(dto.providers["google-vertex"]?.vertexCredentialKind).toBe("marker");
+ expect(JSON.stringify(dto)).not.toContain("fixture-marker");
+ expect(JSON.stringify(dto)).not.toContain("fixture-key");
+ });
+ 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");
+ });
+
+ test("a Vertex credential marker is never sent as a non-Google wire credential", () => {
+ const provider = { adapter: "google", baseUrl: "https://external.example/v1", authMode: "key", googleMode: "vertex", apiKey: "gcp-sa:fixture-account",
+ modelAdapters: { "example-model": "openai-chat" } } as OcxProviderConfig;
+ const wire = resolveWireProtocolOverride("custom-vertex", "example-model", routedProviderConfig("custom-vertex", provider), "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();
+ });
+
+ for (const cancelled of ["first", "second"] as const) {
+ test(`cancelling the ${cancelled} ADC waiter leaves the other refresh alive`, async () => {
+ const file = join(tmp, `adc-cancel-${cancelled}.json`);
+ writeFileSync(file, JSON.stringify({ type: "authorized_user", client_id: "fixture-client", client_secret: "fixture-secret", refresh_token: "fixture-refresh" }));
+ setEnv("GOOGLE_APPLICATION_CREDENTIALS", file);
+ const firstController = new AbortController();
+ const secondController = new AbortController();
+ let release!: (response: Response) => void;
+ let started!: () => void;
+ const requested = new Promise(resolve => { started = resolve; });
+ const fakeFetch = (async (_input, init) => {
+ started();
+ return new Promise((resolve, reject) => {
+ release = resolve;
+ init?.signal?.addEventListener("abort", () => reject(new Error("fixture exchange aborted")), { once: true });
+ });
+ }) as typeof fetch;
+ const first = getVertexAccessToken({ fetch: fakeFetch, signal: firstController.signal });
+ void first.catch(() => {});
+ await requested;
+ const second = getVertexAccessToken({ fetch: fakeFetch, signal: secondController.signal });
+ void second.catch(() => {});
+ (cancelled === "first" ? firstController : secondController).abort();
+ release(Response.json({ access_token: "fixture-shared-token", expires_in: 3600 }));
+ await expect(cancelled === "first" ? first : second).rejects.toThrow();
+ expect(await (cancelled === "first" ? second : first)).toBe("fixture-shared-token");
+ });
+ }
+
+ test("a shared ADC retry uses a remaining authorized provider", async () => {
+ const file = join(tmp, "adc-shared-authority.json");
+ writeFileSync(file, JSON.stringify({ type: "authorized_user", client_id: "fixture-client", client_secret: "fixture-secret", refresh_token: "fixture-refresh" }));
+ setEnv("GOOGLE_APPLICATION_CREDENTIALS", file);
+ let firstValid = true;
+ 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-shared-token", expires_in: 3600 });
+ }) as typeof fetch;
+ const first = getVertexAccessTokenWithAuthority({ fetch: fakeFetch, beforeSend: () => { if (!firstValid) throw new Error("first provider changed"); } });
+ void first.catch(() => {});
+ await requested;
+ const second = getVertexAccessTokenWithAuthority({ fetch: fakeFetch, beforeSend: () => {} });
+ firstValid = false;
+ release(new Response(null, { status: 503 }));
+ expect((await second).token).toBe("fixture-shared-token");
+ await expect(first).rejects.toThrow("first provider changed");
+ expect(calls).toBe(2);
+ });
+
+ test("ADC rotation during JWT signing prevents the old assertion from being sent", async () => {
+ const replacement = join(tmp, "adc-after-signing.json");
+ writeFileSync(replacement, JSON.stringify({ type: "authorized_user", client_id: "fixture-client", client_secret: "fixture-secret", refresh_token: "fixture-refresh" }));
+ setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath);
+ const originalSign = globalThis.crypto.subtle.sign.bind(globalThis.crypto.subtle);
+ const signing = spyOn(globalThis.crypto.subtle, "sign").mockImplementation(async (...args) => {
+ const signature = await originalSign(...args);
+ setEnv("GOOGLE_APPLICATION_CREDENTIALS", replacement);
+ return signature;
+ });
+ let sent = 0;
+ const fakeFetch = (async () => { sent++; return Response.json({ access_token: "fixture-old-token", expires_in: 3600 }); }) as typeof fetch;
+ try {
+ await expect(getVertexAccessToken({ fetch: fakeFetch })).rejects.toThrow();
+ expect(sent).toBe(0);
+ } finally {
+ signing.mockRestore();
+ }
+ });
+
+ 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;
+ 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 selection detects adapter and model-wire changes before dispatch", () => {
+ const provider = { adapter: "google", baseUrl: "https://external.example/v1", googleMode: "vertex", project: "demo-project", location: "global" } as OcxProviderConfig;
+ const config = { port: 0, defaultProvider: "custom-vertex", providers: { "custom-vertex": provider } } as OcxConfig;
+ const routed = routedProviderConfig("custom-vertex", provider);
+ expect(providerApiKeySelectionIsCurrent(config, "custom-vertex", routed)).toBe(true);
+ config.providers["custom-vertex"] = { ...provider, adapter: "openai-chat" };
+ expect(providerApiKeySelectionIsCurrent(config, "custom-vertex", routed)).toBe(false);
+ config.providers["custom-vertex"] = { ...provider, modelAdapters: { "example-model": "openai-chat" } };
+ expect(providerApiKeySelectionIsCurrent(config, "custom-vertex", routed)).toBe(false);
+ });
+
+ test("an observed ambient key A to B to A invalidates the original selection", () => {
+ 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_API_KEY", "fixture-key-a");
+ const first = routedProviderConfig("google-vertex", provider);
+ setEnv("GOOGLE_CLOUD_API_KEY", "fixture-key-b");
+ routedProviderConfig("google-vertex", provider);
+ setEnv("GOOGLE_CLOUD_API_KEY", "fixture-key-a");
+ routedProviderConfig("google-vertex", provider);
+ expect(providerApiKeySelectionIsCurrent(config, "google-vertex", first)).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).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("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);
+ });
+
+ 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", defaultModel: "gemini-3-pro", liveModels: false } }) });
+ 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");
+ const probe = new ManagementRequest("http://127.0.0.1/api/providers/test?name=my-vertex", { method: "POST" });
+ const probeResponse = await handleManagementAPI(probe, new URL(probe.url), config, { createManagementConvergeCodex: catalogConvergenceFactory() });
+ expect(await probeResponse!.json()).toMatchObject({ applicable: false, reason: "static_catalog" });
+ const patch = new ManagementRequest("http://127.0.0.1/api/providers?name=my-vertex", { method: "PATCH", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ project: "new-project", location: "" }) });
+ const patchResponse = await handleManagementAPI(patch, new URL(patch.url), config, { createManagementConvergeCodex: catalogConvergenceFactory() });
+ expect(patchResponse?.status).toBe(200);
+ expect(loadConfig().providers["my-vertex"]?.project).toBe("new-project");
+ expect(loadConfig().providers["my-vertex"]?.location).toBeUndefined();
+ const invalid = new ManagementRequest("http://127.0.0.1/api/providers?name=my-vertex", { method: "PATCH", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ location: "invalid.example" }) });
+ const invalidResponse = await handleManagementAPI(invalid, new URL(invalid.url), config, { createManagementConvergeCodex: catalogConvergenceFactory() });
+ expect(invalidResponse?.status).toBe(400);
+ expect(loadConfig().providers["my-vertex"]?.location).toBeUndefined();
+ } 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("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();
+ }
+ });
+
+ test("a changed model wire never rebuilds using the old Vertex policy", async () => {
+ setEnv("GOOGLE_APPLICATION_CREDENTIALS", saPath);
+ const name = "custom-vertex";
+ 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: name, providers: { [name]: provider } } as OcxConfig;
+ const releaseOwner = acquireOwnedSpendHome();
+ saveConfig(config);
+ const priorFetch = globalThis.fetch;
+ const sent: string[] = [];
+ let changed = false;
+ globalThis.fetch = (async (input, init) => {
+ if (String(input) === OAUTH_TOKEN_URL) return Response.json({ access_token: "fixture-token", expires_in: 3600 });
+ 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));
+ const unregister = registerUpstreamRewriter("fixture-model-wire-change", target => {
+ if (!changed && target.url.includes("aiplatform.googleapis.com")) {
+ changed = true;
+ config.providers[name]!.modelAdapters = { "gemini-3-pro": "openai-chat" };
+ }
+ });
+ try {
+ const request = new Request("http://localhost/v1/chat/completions", { method: "POST", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: `${name}/gemini-3-pro`, messages: [{ role: "user", content: "hello" }], stream: false }) });
+ await handleChatCompletions(request, config, { model: "", provider: "" });
+ expect(changed).toBe(true);
+ expect(sent).not.toContain("Bearer fixture-token");
+ } finally {
+ unregister();
+ configuredFetch.mockRestore();
+ globalThis.fetch = priorFetch;
+ releaseOwner();
+ }
+ });
+
+ test("client cancellation detaches a token exchange started by a Vertex rebuild", async () => {
+ const replacement = join(tmp, "adc-rebuild-cancel.json");
+ writeFileSync(replacement, 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;
+ let tokenCalls = 0;
+ let tokenSignal: AbortSignal | null | undefined;
+ let releaseToken!: (response: Response) => void;
+ let signalStarted!: () => void;
+ const started = new Promise(resolve => { signalStarted = resolve; });
+ globalThis.fetch = (async (input, init) => {
+ if (String(input) === OAUTH_TOKEN_URL) {
+ if (++tokenCalls === 1) return Response.json({ access_token: "fixture-first", expires_in: 3600 });
+ tokenSignal = init?.signal;
+ signalStarted();
+ return new Promise(resolve => { releaseToken = resolve; });
+ }
+ return Response.json({});
+ }) 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-rebuild-cancel", target => {
+ if (!rotated && target.url.includes("aiplatform.googleapis.com")) { rotated = true; setEnv("GOOGLE_APPLICATION_CREDENTIALS", replacement); }
+ });
+ const client = new AbortController();
+ let pending: Promise | undefined;
+ try {
+ const request = new Request("http://localhost/v1/chat/completions", { method: "POST", signal: client.signal, headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "google-vertex/gemini-3-pro", messages: [{ role: "user", content: "hello" }], stream: false }) });
+ pending = handleChatCompletions(request, config, { model: "", provider: "" });
+ await started;
+ client.abort();
+ await Promise.resolve();
+ expect(tokenSignal?.aborted).toBe(true);
+ } finally {
+ releaseToken?.(Response.json({ access_token: "fixture-second", expires_in: 3600 }));
+ await pending?.catch(() => undefined);
+ unregister();
+ configuredFetch.mockRestore();
+ globalThis.fetch = priorFetch;
+ releaseOwner();
+ }
+ });
+
+ for (const changed of ["ambient-key", "destination", "header", "provider-disabled"] 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");
+ if (changed === "provider-disabled") config.providers["google-vertex"]!.disabled = true;
+ });
+ 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();
diff --git a/tests/adapters/google/google-vertex-http.test.ts b/tests/adapters/google/google-vertex-http.test.ts
index fede330c371..983fa4a3f51 100644
--- a/tests/adapters/google/google-vertex-http.test.ts
+++ b/tests/adapters/google/google-vertex-http.test.ts
@@ -333,6 +333,31 @@ describe("vertex retry fetch", () => {
});
});
+ test("a repaired Vertex replay follows a later credential rebuild on transient retry", async () => {
+ const original: AdapterRequest = { ...request, headers: { authorization: "Bearer old" }, body: JSON.stringify({
+ contents: [{ role: "user", parts: [{ text: "hi" }] }],
+ tools: [{ functionDeclarations: [{ name: "fixture", parameters: { type: "object", properties: { ids: { type: "array", items: { type: "string" } } } } }] }],
+ }) };
+ const calls: Array<{ url: string; authorization: string | null; body: string }> = [];
+ const sleep = spyOn(retry, "sleepWithAbort").mockImplementation(async () => {});
+ const executor = (async (input, init) => {
+ calls.push({ url: String(input), authorization: new Headers(init?.headers).get("authorization"), body: String(init?.body) });
+ if (calls.length === 1) return new Response(vertexError(400, "INVALID_ARGUMENT", "tools.0.custom.input_schema: JSON schema is invalid"), { status: 400 });
+ if (calls.length === 2) {
+ original.url = "https://aiplatform.googleapis.com/v1/new";
+ original.headers = { authorization: "Bearer new" };
+ return new Response("retry", { status: 503 });
+ }
+ return new Response("ok");
+ }) as typeof fetch;
+ try {
+ expect((await fetchVertexWithRetry(original, { executor, timeoutMs: 5_000 })).status).toBe(200);
+ expect(calls).toHaveLength(3);
+ expect(calls[2]).toMatchObject({ url: "https://aiplatform.googleapis.com/v1/new", authorization: "Bearer new" });
+ expect(calls[2]!.body).toBe(calls[1]!.body);
+ } finally { sleep.mockRestore(); }
+ });
+
test("raw mode preserves final error body and headers without normalization", async () => {
const raw = vertexError(400, "INVALID_ARGUMENT", "provider-private-detail");
const mock = mockFetch([new Response(raw, { status: 400, headers: { "x-provider-error": "raw" } })]);
diff --git a/tests/adapters/google/google-vertex-key-project.test.ts b/tests/adapters/google/google-vertex-key-project.test.ts
index e4a5c78e881..f2b6c17b247 100644
--- a/tests/adapters/google/google-vertex-key-project.test.ts
+++ b/tests/adapters/google/google-vertex-key-project.test.ts
@@ -69,6 +69,11 @@ const vertex = (apiKey: string, extra: Partial = {}) => ({
}) as OcxProviderConfig;
describe("vertex project auto", () => {
+ test("a refused selection prevents the auxiliary project probe", async () => {
+ await expect(createGoogleAdapter(vertex("key-a")).buildRequest(parsed(), incoming({ beforePhysicalSend: () => { throw new Error("selection changed"); } })))
+ .rejects.toThrow("selection changed");
+ expect(probes).toEqual([]);
+ });
test("parses the project number from Google's projectless NOT_FOUND body", () => {
expect(parseVertexProbeProjectNumber(notFound("22932377871"))).toBe("22932377871");
expect(parseVertexProbeProjectNumber("projects/my-project/locations/global")).toBeUndefined();
diff --git a/tests/adapters/google/vertex-catalog.test.ts b/tests/adapters/google/vertex-catalog.test.ts
index 0c3a336f822..8136aa89296 100644
--- a/tests/adapters/google/vertex-catalog.test.ts
+++ b/tests/adapters/google/vertex-catalog.test.ts
@@ -26,6 +26,7 @@ function vertexProvider(name: string): OcxConfig {
googleMode: "vertex",
baseUrl: "https://aiplatform.googleapis.com",
apiKey: "test-key",
+ liveModels: true,
models: ["publisher-model-a"],
},
},
@@ -33,6 +34,37 @@ function vertexProvider(name: string): OcxConfig {
}
describe("Vertex catalog configuration", () => {
+ test("a renamed static Vertex provider exposes its configured default without a network probe", async () => {
+ let sends = 0;
+ globalThis.fetch = (() => { sends++; throw new Error("unexpected network request"); }) as typeof fetch;
+ const config: OcxConfig = { providers: { "renamed-vertex": {
+ adapter: "google", googleMode: "vertex", baseUrl: "https://aiplatform.googleapis.com",
+ defaultModel: "gemini-3-pro", liveModels: false,
+ } } };
+ const rows = await gatherRoutedModels(config);
+ expect(rows).toContainEqual(expect.objectContaining({ provider: "renamed-vertex", id: "gemini-3-pro" }));
+ expect(sends).toBe(0);
+ });
+
+ test("a legacy Vertex provider without liveModels retains live discovery", async () => {
+ let sends = 0;
+ globalThis.fetch = (() => { sends++; return Promise.resolve(Response.json({ data: [{ id: "gemini-discovered" }] })); }) as typeof fetch;
+ const config: OcxConfig = { providers: { "renamed-vertex": {
+ adapter: "google", googleMode: "vertex", baseUrl: "https://aiplatform.googleapis.com", apiKey: "test-key",
+ } } };
+ const rows = await gatherRoutedModels(config);
+ expect(rows).toContainEqual(expect.objectContaining({ provider: "renamed-vertex", id: "gemini-discovered" }));
+ expect(sends).toBeGreaterThan(0);
+ });
+ test("an implicit canonical legacy Vertex row does not add an undiscovered default to a live catalog", async () => {
+ globalThis.fetch = (() => Promise.resolve(Response.json({ data: [{ id: "upstream-available" }] }))) as typeof fetch;
+ const config: OcxConfig = { providers: { "google-vertex": {
+ adapter: "google", baseUrl: "https://aiplatform.googleapis.com", apiKey: "test-key", defaultModel: "not-in-upstream",
+ } } };
+ const rows = await gatherRoutedModels(config);
+ expect(rows).toContainEqual(expect.objectContaining({ provider: "google-vertex", id: "upstream-available" }));
+ expect(rows.some(row => row.provider === "google-vertex" && row.id === "not-in-upstream")).toBe(false);
+ });
test("defaultModel without models survives failed Vertex discovery", async () => {
globalThis.fetch = (() => { throw new TypeError("offline"); }) as typeof fetch;
const config: OcxConfig = {
diff --git a/tests/gui/provider-workspace-data.test.ts b/tests/gui/provider-workspace-data.test.ts
index 48502e5daf9..0e6d62d05e7 100644
--- a/tests/gui/provider-workspace-data.test.ts
+++ b/tests/gui/provider-workspace-data.test.ts
@@ -6,6 +6,7 @@ import {
hideRedundantChatGptForwardProviders,
isAccountProvider,
isFreeProvider,
+ providerSupportsLiveModelDiscovery,
providerTier,
sortWorkspaceItems,
type WorkspaceItem,
@@ -66,6 +67,33 @@ function forwardProv(overrides: Partial = {}): WorkspaceProvi
}
describe("applyActiveAccountReauth", () => {
+ test("Vertex discovery remains configurable independently of its saved setting", () => {
+ const provider = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", googleMode: "vertex" } as const;
+ expect(providerSupportsLiveModelDiscovery("renamed-vertex", provider)).toBe(true);
+ expect(providerSupportsLiveModelDiscovery("renamed-vertex", { ...provider, liveModels: false })).toBe(true);
+ expect(providerSupportsLiveModelDiscovery("renamed-vertex", { ...provider, liveModels: true })).toBe(true);
+ });
+ test("Vertex host and marker credentials have an unverified group", () => {
+ const vertex = { adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authMode: "key", googleMode: "vertex" } as const;
+ const sections = buildProviderWorkspace({
+ ambient: { ...vertex, hasApiKey: false, vertexCredentialKind: "ambient" },
+ marker: { ...vertex, hasApiKey: true, vertexCredentialKind: "marker" },
+ key: { ...vertex, hasApiKey: true, vertexCredentialKind: "api-key" },
+ } as never);
+ expect(sections.unverified.map(p => p.name)).toEqual(["ambient", "marker"]);
+ expect(sections.needsSetup).toEqual([]);
+ expect(sections.ready.map(p => p.name)).toEqual(["key"]);
+ });
+ test("canonical legacy Vertex with a pinned old URL is still unverified", () => {
+ const sections = buildProviderWorkspace({ "google-vertex": {
+ adapter: "google", baseUrl: "https://legacy.example/v1", authMode: "key", hasApiKey: false,
+ } } as never);
+ expect(sections.unverified.map(p => p.name)).toEqual(["google-vertex"]);
+ const mismatchedAdapter = buildProviderWorkspace({ "google-vertex": {
+ adapter: "openai-chat", baseUrl: "https://legacy.example/v1", authMode: "key", hasApiKey: true, vertexCredentialKind: "marker",
+ } } as never);
+ expect(mismatchedAdapter.unverified.map(p => p.name)).toEqual(["google-vertex"]);
+ });
test("tags ready provider when active account needs reauth without moving sections", () => {
const sections = buildProviderWorkspace({
anthropic: prov({ authMode: "oauth" }),
diff --git a/tests/routing/router-blocked-cross-provider.test.ts b/tests/routing/router-blocked-cross-provider.test.ts
index 2fd3175835d..b735f9ae6af 100644
--- a/tests/routing/router-blocked-cross-provider.test.ts
+++ b/tests/routing/router-blocked-cross-provider.test.ts
@@ -112,6 +112,25 @@ describe("blocked-model redirect compatibility and provider changes", () => {
});
});
+ test("keyless Vertex is a cross-provider redirect destination", () => {
+ const configured = keyedSourceConfig({ "source/m1": "vertex/gemini" });
+ configured.providers.vertex = {
+ adapter: "google", googleMode: "vertex", authMode: "key",
+ baseUrl: "https://aiplatform.googleapis.com", models: ["gemini"],
+ };
+ expect(routeModel(configured, "source/m1")).toMatchObject({ providerName: "vertex", modelId: "gemini", credentialDomainRewrite: true });
+ });
+
+ test("canonical Vertex forward mode is rejected before registry normalization", () => {
+ const configured = keyedSourceConfig({});
+ for (const adapter of ["google", "openai-chat"]) {
+ configured.providers["google-vertex"] = {
+ adapter, baseUrl: "https://aiplatform.googleapis.com", authMode: "forward", models: ["gemini"],
+ };
+ expect(() => routeModel(configured, "google-vertex/gemini")).toThrow(/forward/i);
+ }
+ });
+
test("policy rejects a same-provider substitute outside its eligible candidate list", () => {
const configured = config({ m1: "m2" });
configured.routingProfiles = { fast: { candidates: [{ provider: "openai", model: "m1" }] } };