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"); +});