From 29caad9f3d240ffd87ac22a7c5ea468bc64a2d79 Mon Sep 17 00:00:00 2001 From: Marcel Wysocki Date: Mon, 24 Aug 2026 05:12:37 +0800 Subject: [PATCH] feat(sse): discover Anthropic partner models on Vertex AI (#11279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated on the combined 8-PR board + the branch itself: vertex-anthropic-models 4/4 (new — pushed to your branch as a fix-in-place commit per Hard Rule #8, covering the parser's global/project-scoped resource names, malformed-input handling, and the claude-* → targetFormat heuristic on vertex/vertex-partner), 88/88 across the board's focused suites, typecheck:core clean, gates within baseline. Anthropic partner models on Vertex AI now discover dynamically via the Model Garden publisher endpoint and route through the Claude translator even for models outside the static registry. Thank you @maci0! --- open-sse/config/providerModels.ts | 3 + .../config/providers/registry/vertex/index.ts | 13 +++- .../registry/vertex/partner/index.ts | 17 +++-- src/app/api/providers/[id]/models/route.ts | 61 ++++++++++++++-- .../vertexAnthropicModelsParser.ts | 44 ++++++++++++ tests/unit/vertex-anthropic-models.test.ts | 70 +++++++++++++++++++ 6 files changed, 196 insertions(+), 12 deletions(-) create mode 100644 src/lib/providerModels/vertexAnthropicModelsParser.ts create mode 100644 tests/unit/vertex-anthropic-models.test.ts diff --git a/open-sse/config/providerModels.ts b/open-sse/config/providerModels.ts index ee0028116f..5cc9833871 100644 --- a/open-sse/config/providerModels.ts +++ b/open-sse/config/providerModels.ts @@ -186,6 +186,9 @@ export function getModelTargetFormat(aliasOrId: string, modelId: string): string // executor's /codex/i routing, 9router#102). Scoped to the openai alias so other // providers shipping *-pro ids keep their own endpoint semantics. if (alias === "openai" && /-pro$/i.test(bareModelId)) return "openai-responses"; + // ponytail: Claude models on Vertex use rawPredict with Anthropic Messages format, + // not the Gemini generateContent format. Mirrors executor isClaudeModel() check. + if ((alias === "vertex" || alias === "vp") && /^claude-/i.test(bareModelId)) return "claude"; // Model-level targetFormat is provider-scoped: a catalog entry declares how THIS // provider's endpoint serves the model — do NOT import another provider's tag. // #9994 scoped this for providers WITH a catalog; #10072 extends it to catalogless diff --git a/open-sse/config/providers/registry/vertex/index.ts b/open-sse/config/providers/registry/vertex/index.ts index fc4f2fc0cd..1eac20fc15 100644 --- a/open-sse/config/providers/registry/vertex/index.ts +++ b/open-sse/config/providers/registry/vertex/index.ts @@ -27,8 +27,17 @@ export const vertexProvider: RegistryEntry = { { id: "DeepSeek-V4-Pro", name: "DeepSeek V4 Pro (Vertex Partner)" }, { id: "Qwen3.6-35B-A3B", name: "Qwen3.6 35B A3B (Vertex Partner)" }, { id: "GLM-5.1-FP8", name: "GLM-5.1 (Vertex Partner)" }, - { id: "claude-opus-4-7", name: "Claude Opus 4.7 (Vertex)" }, - { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Vertex)" }, + { id: "claude-fable-5", name: "Claude Fable 5 (Vertex)", targetFormat: "claude" }, + { id: "claude-opus-5", name: "Claude Opus 5 (Vertex)", targetFormat: "claude" }, + { id: "claude-sonnet-5", name: "Claude Sonnet 5 (Vertex)", targetFormat: "claude" }, + { id: "claude-opus-4-8", name: "Claude Opus 4.8 (Vertex)", targetFormat: "claude" }, + { id: "claude-opus-4-7", name: "Claude Opus 4.7 (Vertex)", targetFormat: "claude" }, + { id: "claude-opus-4-6", name: "Claude Opus 4.6 (Vertex)", targetFormat: "claude" }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Vertex)", targetFormat: "claude" }, + { id: "claude-sonnet-4-5-v2", name: "Claude Sonnet 4.5 v2 (Vertex)", targetFormat: "claude" }, + { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 (Vertex)", targetFormat: "claude" }, + { id: "claude-opus-4-5", name: "Claude Opus 4.5 (Vertex)", targetFormat: "claude" }, + { id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (Vertex)", targetFormat: "claude" }, ], passthroughModels: true, }; diff --git a/open-sse/config/providers/registry/vertex/partner/index.ts b/open-sse/config/providers/registry/vertex/partner/index.ts index fe9d3984c3..4cdcd5d0b5 100644 --- a/open-sse/config/providers/registry/vertex/partner/index.ts +++ b/open-sse/config/providers/registry/vertex/partner/index.ts @@ -13,10 +13,17 @@ export const vertex_partnerProvider: RegistryEntry = { { id: "DeepSeek-V4-Pro", name: "DeepSeek V4 Pro" }, { id: "Qwen3.6-35B-A3B", name: "Qwen 3.6 35B A3B" }, { id: "GLM-5.1-FP8", name: "GLM 5.1" }, - // Sweep 2026-06-19: + Claude Opus on Vertex (Anthropic partner models). - { id: "claude-opus-4-8", name: "Claude Opus 4.8" }, - { id: "claude-opus-4-7", name: "Claude Opus 4.7" }, - { id: "claude-opus-4-6", name: "Claude Opus 4.6" }, - { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { id: "claude-fable-5", name: "Claude Fable 5", targetFormat: "claude" }, + { id: "claude-opus-5", name: "Claude Opus 5", targetFormat: "claude" }, + { id: "claude-sonnet-5", name: "Claude Sonnet 5", targetFormat: "claude" }, + { id: "claude-opus-4-8", name: "Claude Opus 4.8", targetFormat: "claude" }, + { id: "claude-opus-4-7", name: "Claude Opus 4.7", targetFormat: "claude" }, + { id: "claude-opus-4-6", name: "Claude Opus 4.6", targetFormat: "claude" }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", targetFormat: "claude" }, + { id: "claude-sonnet-4-5-v2", name: "Claude Sonnet 4.5 v2", targetFormat: "claude" }, + { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", targetFormat: "claude" }, + { id: "claude-sonnet-4", name: "Claude Sonnet 4", targetFormat: "claude" }, + { id: "claude-opus-4-5", name: "Claude Opus 4.5", targetFormat: "claude" }, + { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", targetFormat: "claude" }, ], }; diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index b1d3ece8cf..8ccbb3e311 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -85,10 +85,7 @@ import { } from "@/lib/providerModels/modelDiscovery"; import { buildProviderModelsUrl, getDiscoveryClientVersionOptions } from "./discoveryClientVersion"; import { getAdobeModels } from "./adobeFireflyDiscovery"; -import { - parseGeminiModelsList, - type GeminiDiscoveryModel, -} from "@/lib/providerModels/geminiModelsParser"; +import { parseGeminiModelsList } from "@/lib/providerModels/geminiModelsParser"; import { getSyncedAvailableModels, getCustomModels } from "@/lib/db/models"; import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation"; import { fetchCursorAgentModels } from "@/lib/providerModels/cursorAgent"; @@ -1860,7 +1857,7 @@ export async function GET( const headers: Record = { "Content-Type": "application/json" }; if (bearerToken) headers["Authorization"] = `Bearer ${bearerToken}`; - const allModels: GeminiDiscoveryModel[] = []; + const allModels: any[] = []; let pageUrl = queryKey ? `${baseUrl}&key=${encodeURIComponent(queryKey)}` : baseUrl; let pageCount = 0; const MAX_PAGES = 20; @@ -1906,6 +1903,60 @@ export async function GET( throw error; } + // ponytail: Anthropic partner models via Model Garden publisher endpoint (Bearer only) + if (bearerToken) { + const psd = asRecord(connection.providerSpecificData); + const region = + (typeof psd.region === "string" && psd.region.trim()) || "us-central1"; + + // Extract project_id from SA JSON for project-scoped listing (mirrors executor URL pattern). + // Falls back to global publisher endpoint if no project available. + let anthropicModelsUrl: string; + let projectId: string | null = null; + if (credential) { + try { + const sa = JSON.parse(credential); + if (sa?.project_id) projectId = sa.project_id; + } catch { /* not SA JSON, skip */ } + } + if (projectId) { + anthropicModelsUrl = `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/anthropic/models`; + } else { + anthropicModelsUrl = `https://aiplatform.googleapis.com/v1/publishers/anthropic/models`; + } + + try { + const anthropicResponse = await safeOutboundFetch(anthropicModelsUrl, { + ...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery, + guard: getProviderOutboundGuard(), + proxyConfig: proxy, + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${bearerToken}`, + }, + }); + if (anthropicResponse.ok) { + const anthropicData = await anthropicResponse.json(); + const { parseVertexAnthropicModels } = await import( + "@/lib/providerModels/vertexAnthropicModelsParser" + ); + allModels.push(...parseVertexAnthropicModels(anthropicData)); + } else { + console.log("[models] Vertex Anthropic partner discovery failed", { + provider, + region, + status: anthropicResponse.status, + }); + } + } catch (err) { + console.log("[models] Vertex Anthropic partner discovery error", { + provider, + error: err instanceof Error ? err.message : String(err), + }); + } + } + if (allModels.length > 0) { return buildApiDiscoveryResponse(allModels); } diff --git a/src/lib/providerModels/vertexAnthropicModelsParser.ts b/src/lib/providerModels/vertexAnthropicModelsParser.ts new file mode 100644 index 0000000000..9e08c22fa9 --- /dev/null +++ b/src/lib/providerModels/vertexAnthropicModelsParser.ts @@ -0,0 +1,44 @@ +interface VertexPublisherModel { + name?: string; + displayName?: string; + description?: string; + supportedActions?: string[]; + versionId?: string; + [key: string]: unknown; +} + +export interface VertexAnthropicDiscoveryModel { + id: string; + name: string; + supportedEndpoints: string[]; + targetFormat: string; + owned_by: string; + description?: string; + [key: string]: unknown; +} + +export function parseVertexAnthropicModels(data: unknown): VertexAnthropicDiscoveryModel[] { + if (!data || typeof data !== "object") return []; + const envelope = data as { models?: unknown[] }; + const models = Array.isArray(envelope.models) ? envelope.models : []; + + return models + .map((m: unknown) => { + const model = m as VertexPublisherModel; + const rawName = typeof model.name === "string" ? model.name : ""; + // "publishers/anthropic/models/claude-sonnet-4-6" or + // "projects/x/locations/y/publishers/anthropic/models/claude-sonnet-4-6" + const id = rawName.replace(/^(?:projects\/[^/]+\/locations\/[^/]+\/)?publishers\/anthropic\/models\//, "") || rawName; + if (!id) return null; + + return { + id, + name: (typeof model.displayName === "string" && model.displayName) || id, + supportedEndpoints: ["chat"], + targetFormat: "claude", + ...(typeof model.description === "string" ? { description: model.description } : {}), + owned_by: "anthropic", + } satisfies VertexAnthropicDiscoveryModel; + }) + .filter((m): m is VertexAnthropicDiscoveryModel => m !== null); +} diff --git a/tests/unit/vertex-anthropic-models.test.ts b/tests/unit/vertex-anthropic-models.test.ts new file mode 100644 index 0000000000..8da49c67da --- /dev/null +++ b/tests/unit/vertex-anthropic-models.test.ts @@ -0,0 +1,70 @@ +/** + * Vertex AI Anthropic partner-model discovery (#11279). + * + * Covers the two pure units the PR adds (the discovery route itself is a + * best-effort network path exercised manually per the PR's test plan): + * - parseVertexAnthropicModels: Model Garden publisher response → discovery + * models, handling global AND project-scoped resource names; + * - getModelTargetFormat: a claude-* id on vertex/vertex-partner resolves to + * the "claude" translator even when the model is NOT in the static + * registry (the future-model heuristic). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { parseVertexAnthropicModels } from "../../src/lib/providerModels/vertexAnthropicModelsParser.ts"; +import { getModelTargetFormat } from "../../open-sse/config/providerModels.ts"; + +test("parseVertexAnthropicModels: global publisher resource names", () => { + const out = parseVertexAnthropicModels({ + models: [ + { + name: "publishers/anthropic/models/claude-sonnet-4-6", + displayName: "Claude Sonnet 4.6", + description: "Latest Sonnet", + }, + { name: "publishers/anthropic/models/claude-opus-4-6", displayName: "Claude Opus 4.6" }, + ], + }); + assert.equal(out.length, 2); + assert.deepEqual(out[0], { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + supportedEndpoints: ["chat"], + targetFormat: "claude", + description: "Latest Sonnet", + owned_by: "anthropic", + }); + // displayName fallback: missing → id; description omitted when absent + assert.equal(out[1].name, "Claude Opus 4.6"); + assert.equal("description" in out[1], false); +}); + +test("parseVertexAnthropicModels: project-scoped resource names strip the prefix", () => { + const out = parseVertexAnthropicModels({ + models: [ + { + name: "projects/my-gcp-project/locations/us-east5/publishers/anthropic/models/claude-haiku-4-5", + }, + ], + }); + assert.equal(out.length, 1); + assert.equal(out[0].id, "claude-haiku-4-5"); + assert.equal(out[0].name, "claude-haiku-4-5"); +}); + +test("parseVertexAnthropicModels: malformed input yields an empty list", () => { + assert.deepEqual(parseVertexAnthropicModels(null), []); + assert.deepEqual(parseVertexAnthropicModels({}), []); + assert.deepEqual(parseVertexAnthropicModels({ models: "not-an-array" }), []); + assert.deepEqual(parseVertexAnthropicModels({ models: [{ name: "" }, {}] }), []); +}); + +test("getModelTargetFormat: claude-* on vertex resolves to the claude translator (heuristic)", () => { + // A future Claude model with no static registry entry must still route + // through the Anthropic Messages translator on both vertex ids. + assert.equal(getModelTargetFormat("vertex", "claude-future-9-9"), "claude"); + assert.equal(getModelTargetFormat("vertex-partner", "claude-future-9-9"), "claude"); + // Non-Claude ids are untouched by the heuristic. + assert.notEqual(getModelTargetFormat("vertex", "gemini-3.1-pro"), "claude"); +});