mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-24 16:12:23 +03:00
feat(sse): discover Anthropic partner models on Vertex AI (#11279)
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!
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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<string, string> = { "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);
|
||||
}
|
||||
|
||||
44
src/lib/providerModels/vertexAnthropicModelsParser.ts
Normal file
44
src/lib/providerModels/vertexAnthropicModelsParser.ts
Normal file
@@ -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);
|
||||
}
|
||||
70
tests/unit/vertex-anthropic-models.test.ts
Normal file
70
tests/unit/vertex-anthropic-models.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user