fix(ai): updated google vertex model catalog
Replaced Google Vertex project discovery with the models.dev catalog so bundled model selection includes current Vertex MaaS and Gemini entries while pruning retired fallbacks. Fixes #1456
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Google Vertex's bundled model list to use the authoritative models.dev catalog, including MaaS entries such as `deepseek-ai/deepseek-v3.2-maas` and removing retired Gemini 1.5 fallbacks. ([#1456](https://github.com/can1357/oh-my-pi/issues/1456))
|
||||
|
||||
## [15.5.7] - 2026-05-27
|
||||
### Added
|
||||
- `SimpleStreamOptions.openrouterVariant` (`"nitro"`, `"floor"`, `"online"`, `"exacto"`, …) — when set, appends `:<variant>` to OpenRouter model IDs at request time, leaving ids that already carry an explicit `:suffix` untouched. Plumbed through `openai-completions` and the pi-native gateway forwarder.
|
||||
|
||||
@@ -354,18 +354,27 @@ async function generateModels() {
|
||||
}
|
||||
}
|
||||
|
||||
// Merge previous models.json entries as fallback for any provider/model
|
||||
// not fetched dynamically. This replaces all hardcoded fallback lists —
|
||||
// static-only providers (vertex, gemini-cli), auth-gated providers when
|
||||
// credentials are unavailable, and ad-hoc model additions all persist
|
||||
// through the existing models.json seed.
|
||||
const modelsDevAuthoritativeProviders = new Set<string>();
|
||||
for (const model of modelsDevModels) {
|
||||
if (model.provider === "google-vertex") {
|
||||
modelsDevAuthoritativeProviders.add(model.provider);
|
||||
}
|
||||
}
|
||||
// Merge previous models.json entries as fallback for provider/model pairs not
|
||||
// fetched dynamically. Providers that models.dev covers authoritatively keep
|
||||
// the upstream list exactly, so retired entries from the previous snapshot do
|
||||
// not reappear during regeneration.
|
||||
// Discovery-only providers (local inference servers) — never bundle static models.
|
||||
const discoveryOnlyProviders = new Set(["ollama", "vllm"]);
|
||||
const fetchedKeys = new Set(allModels.map(model => `${model.provider}/${model.id}`));
|
||||
|
||||
for (const models of Object.values(prevModelsJson as Record<string, Record<string, Model>>)) {
|
||||
for (const model of Object.values(models)) {
|
||||
if (!fetchedKeys.has(`${model.provider}/${model.id}`) && !discoveryOnlyProviders.has(model.provider)) {
|
||||
if (
|
||||
!fetchedKeys.has(`${model.provider}/${model.id}`) &&
|
||||
!discoveryOnlyProviders.has(model.provider) &&
|
||||
!modelsDevAuthoritativeProviders.has(model.provider)
|
||||
) {
|
||||
allModels.push(model);
|
||||
}
|
||||
}
|
||||
|
||||
+880
-144
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@ import type { ModelManagerOptions } from "../model-manager";
|
||||
import type { FetchImpl } from "../types";
|
||||
import { fetchAntigravityDiscoveryModels } from "../utils/discovery/antigravity";
|
||||
import { fetchGeminiModels } from "../utils/discovery/gemini";
|
||||
import { fetchVertexOpenAIModels } from "../utils/discovery/vertex";
|
||||
|
||||
export interface GoogleModelManagerConfig {
|
||||
apiKey?: string;
|
||||
@@ -38,43 +37,8 @@ export function googleModelManagerOptions(
|
||||
};
|
||||
}
|
||||
|
||||
export function googleVertexModelManagerOptions(config?: GoogleVertexModelManagerConfig): ModelManagerOptions {
|
||||
const project = resolveVertexProject(config);
|
||||
const hasApiKey = (config?.apiKey ?? Bun.env.GOOGLE_CLOUD_API_KEY ?? "").trim().length > 0;
|
||||
const location = resolveVertexLocation(config);
|
||||
if (hasApiKey) {
|
||||
return { providerId: "google-vertex" };
|
||||
}
|
||||
if (project && location) {
|
||||
return {
|
||||
providerId: "google-vertex",
|
||||
staticModels: [],
|
||||
fetchDynamicModels: () =>
|
||||
fetchVertexOpenAIModels({
|
||||
project,
|
||||
location,
|
||||
signal: config?.signal,
|
||||
fetch: config?.fetch,
|
||||
}),
|
||||
};
|
||||
}
|
||||
// With neither ADC project+location nor API key auth configured, drop the
|
||||
// bundled static catalog so stale fallbacks (e.g. `gemini-1.5-*`) cannot leak
|
||||
// into `/models` alongside an authoritative cached Vertex project catalog on
|
||||
// the next refresh.
|
||||
return { providerId: "google-vertex", staticModels: [] };
|
||||
}
|
||||
function resolveVertexProject(config?: GoogleVertexModelManagerConfig): string | undefined {
|
||||
const project = config?.project ?? Bun.env.GOOGLE_CLOUD_PROJECT ?? Bun.env.GCP_PROJECT ?? Bun.env.GCLOUD_PROJECT;
|
||||
const trimmed = project?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function resolveVertexLocation(config?: GoogleVertexModelManagerConfig): string | undefined {
|
||||
const location =
|
||||
config?.location ?? Bun.env.GOOGLE_VERTEX_LOCATION ?? Bun.env.GOOGLE_CLOUD_LOCATION ?? Bun.env.VERTEX_LOCATION;
|
||||
const trimmed = location?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
export function googleVertexModelManagerOptions(_config?: GoogleVertexModelManagerConfig): ModelManagerOptions {
|
||||
return { providerId: "google-vertex" };
|
||||
}
|
||||
|
||||
export function googleAntigravityModelManagerOptions(
|
||||
|
||||
@@ -2360,6 +2360,17 @@ function anthropicMessagesDescriptor(
|
||||
return simpleModelsDevDescriptor(modelsDevKey, providerId, "anthropic-messages", baseUrl, options);
|
||||
}
|
||||
|
||||
const GOOGLE_VERTEX_BASE_URL = "https://{location}-aiplatform.googleapis.com";
|
||||
const GOOGLE_VERTEX_OPENAI_BASE_URL =
|
||||
"https://{location}-aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/endpoints/openapi";
|
||||
|
||||
function resolveGoogleVertexApi(modelId: string, raw: ModelsDevModel): { api: Api; baseUrl: string } {
|
||||
if (modelId.includes("/") || raw.provider?.npm === "@ai-sdk/openai-compatible") {
|
||||
return { api: "openai-completions", baseUrl: GOOGLE_VERTEX_OPENAI_BASE_URL };
|
||||
}
|
||||
return { api: "google-vertex", baseUrl: GOOGLE_VERTEX_BASE_URL };
|
||||
}
|
||||
|
||||
const MODELS_DEV_PROVIDER_DESCRIPTORS_BEDROCK: readonly ModelsDevProviderDescriptor[] = [
|
||||
// --- Amazon Bedrock ---
|
||||
{
|
||||
@@ -2515,6 +2526,13 @@ const filterActiveToolCallModels = (_id: string, m: ModelsDevModel): boolean =>
|
||||
return true;
|
||||
};
|
||||
|
||||
const MODELS_DEV_PROVIDER_DESCRIPTORS_GOOGLE_VERTEX: readonly ModelsDevProviderDescriptor[] = [
|
||||
simpleModelsDevDescriptor("google-vertex", "google-vertex", "google-vertex", GOOGLE_VERTEX_BASE_URL, {
|
||||
filterModel: filterActiveToolCallModels,
|
||||
resolveApi: resolveGoogleVertexApi,
|
||||
}),
|
||||
];
|
||||
|
||||
const MODELS_DEV_PROVIDER_DESCRIPTORS_SPECIALIZED: readonly ModelsDevProviderDescriptor[] = [
|
||||
// --- Cloudflare AI Gateway ---
|
||||
anthropicMessagesDescriptor(
|
||||
@@ -2592,6 +2610,7 @@ const MODELS_DEV_PROVIDER_DESCRIPTORS_SPECIALIZED: readonly ModelsDevProviderDes
|
||||
/** All provider descriptors for models.dev data mapping in generate-models.ts. */
|
||||
export const MODELS_DEV_PROVIDER_DESCRIPTORS: readonly ModelsDevProviderDescriptor[] = [
|
||||
...MODELS_DEV_PROVIDER_DESCRIPTORS_BEDROCK,
|
||||
...MODELS_DEV_PROVIDER_DESCRIPTORS_GOOGLE_VERTEX,
|
||||
...MODELS_DEV_PROVIDER_DESCRIPTORS_CORE,
|
||||
...MODELS_DEV_PROVIDER_DESCRIPTORS_CODING_PLANS,
|
||||
...MODELS_DEV_PROVIDER_DESCRIPTORS_SPECIALIZED,
|
||||
|
||||
@@ -48,7 +48,8 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
|
||||
const location = resolveLocation(options);
|
||||
const accessToken = await getVertexAccessToken({ signal: options?.signal, fetch: options?.fetch });
|
||||
const host = resolveEndpointHost(location);
|
||||
const url = `https://${host}/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent?alt=sse`;
|
||||
const publisher = resolvePublisher(model.id);
|
||||
const url = `https://${host}/${API_VERSION}/projects/${project}/locations/${location}/publishers/${publisher}/models/${model.id}:streamGenerateContent?alt=sse`;
|
||||
return {
|
||||
params,
|
||||
url,
|
||||
@@ -76,6 +77,9 @@ function resolveProject(options?: GoogleVertexOptions): string {
|
||||
return project;
|
||||
}
|
||||
|
||||
function resolvePublisher(modelId: string): string {
|
||||
return modelId.startsWith("claude-") ? "anthropic" : "google";
|
||||
}
|
||||
function resolveEndpointHost(location: string): string {
|
||||
return location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`;
|
||||
}
|
||||
|
||||
@@ -90,11 +90,36 @@ function createVertexOpenAIFetch(options: StreamOptions | undefined): FetchImpl
|
||||
const token = await getVertexAccessToken({ signal: options?.signal, fetch: baseFetch });
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
return baseFetch(input, { ...init, headers });
|
||||
return baseFetch(resolveVertexOpenAIRequest(input), { ...init, headers });
|
||||
};
|
||||
return Object.assign(vertexFetch, baseFetch.preconnect ? { preconnect: baseFetch.preconnect } : {});
|
||||
}
|
||||
|
||||
function resolveVertexOpenAIRequest(input: string | URL | Request): string | URL | Request {
|
||||
const project = $env.GOOGLE_CLOUD_PROJECT || $env.GCP_PROJECT || $env.GCLOUD_PROJECT;
|
||||
const location = $env.GOOGLE_VERTEX_LOCATION || $env.GOOGLE_CLOUD_LOCATION || $env.VERTEX_LOCATION;
|
||||
if (!project || !location) return input;
|
||||
|
||||
const rewriteUrl = (url: string): string => {
|
||||
if (!url.includes("{project}") && !url.includes("{location}")) return url;
|
||||
const host = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`;
|
||||
return url
|
||||
.replace("https://{location}-aiplatform.googleapis.com", `https://${host}`)
|
||||
.replaceAll("{project}", encodeURIComponent(project))
|
||||
.replaceAll("{location}", encodeURIComponent(location));
|
||||
};
|
||||
|
||||
if (input instanceof Request) {
|
||||
const rewrittenUrl = rewriteUrl(input.url);
|
||||
return rewrittenUrl === input.url ? input : new Request(rewrittenUrl, input);
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
const rewrittenUrl = rewriteUrl(input.toString());
|
||||
return rewrittenUrl === input.toString() ? input : new URL(rewrittenUrl);
|
||||
}
|
||||
return rewriteUrl(input);
|
||||
}
|
||||
|
||||
type KeyResolver = string | (() => string | undefined);
|
||||
|
||||
const serviceProviderMap: Record<string, KeyResolver> = {
|
||||
|
||||
@@ -2,4 +2,3 @@ export * from "./antigravity";
|
||||
export * from "./codex";
|
||||
export * from "./gemini";
|
||||
export * from "./openai-compatible";
|
||||
export * from "./vertex";
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "@oh-my-pi/pi-ai";
|
||||
import * as z from "zod/v4";
|
||||
import { getVertexAccessToken } from "../../providers/google-auth";
|
||||
import type { FetchImpl, Model } from "../../types";
|
||||
|
||||
const API_VERSION = "v1";
|
||||
const DEFAULT_PAGE_SIZE = 100;
|
||||
const DEFAULT_MAX_PAGES = 25;
|
||||
|
||||
const vertexOpenAIModelSchema = z.object({
|
||||
id: z.string().optional().catch(undefined),
|
||||
name: z.string().optional().catch(undefined),
|
||||
displayName: z.string().optional().catch(undefined),
|
||||
});
|
||||
|
||||
const vertexOpenAIModelsResponseSchema = z.object({
|
||||
data: z
|
||||
.array(z.unknown())
|
||||
.optional()
|
||||
.transform(items => {
|
||||
if (!items) return [];
|
||||
const parsedItems: VertexOpenAIModelItem[] = [];
|
||||
for (const item of items) {
|
||||
const parsed = vertexOpenAIModelSchema.safeParse(item);
|
||||
if (parsed.success) parsedItems.push(parsed.data);
|
||||
}
|
||||
return parsedItems;
|
||||
}),
|
||||
nextPageToken: z.string().optional().catch(undefined),
|
||||
});
|
||||
|
||||
type VertexOpenAIModelItem = z.infer<typeof vertexOpenAIModelSchema>;
|
||||
|
||||
/** Configuration for Vertex AI OpenAI-compatible model discovery. */
|
||||
export interface VertexDiscoveryOptions {
|
||||
/** Google Cloud project ID hosting the Vertex AI endpoint. */
|
||||
project: string;
|
||||
/** Vertex AI location, for example `global` or `us-central1`. */
|
||||
location: string;
|
||||
/** Optional requested page size for model listing. */
|
||||
pageSize?: number;
|
||||
/** Maximum number of pages to request before stopping pagination. */
|
||||
maxPages?: number;
|
||||
/** Optional abort signal for HTTP requests. */
|
||||
signal?: AbortSignal;
|
||||
/** Optional fetch implementation override for tests. */
|
||||
fetch?: FetchImpl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches models exposed by Vertex AI's OpenAI-compatible endpoint.
|
||||
*
|
||||
* Returns `null` on auth, transport, or protocol failures so callers can fall
|
||||
* back to cache/static models without surfacing discovery noise at startup.
|
||||
*/
|
||||
export async function fetchVertexOpenAIModels(
|
||||
options: VertexDiscoveryOptions,
|
||||
): Promise<Model<"openai-completions">[] | null> {
|
||||
const project = options.project.trim();
|
||||
const location = options.location.trim();
|
||||
if (!project || !location) return null;
|
||||
|
||||
const fetchImpl = options.fetch ?? fetch;
|
||||
const baseUrl = buildVertexOpenAIBaseUrl(project, location);
|
||||
const pageSize = normalizePositiveInt(options.pageSize, DEFAULT_PAGE_SIZE);
|
||||
const maxPages = normalizePositiveInt(options.maxPages, DEFAULT_MAX_PAGES);
|
||||
let accessToken: string;
|
||||
try {
|
||||
accessToken = await getVertexAccessToken({ signal: options.signal, fetch: fetchImpl });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modelsById = new Map<string, Model<"openai-completions">>();
|
||||
const seenTokens = new Set<string>();
|
||||
let nextPageToken: string | undefined;
|
||||
for (let page = 0; page < maxPages; page += 1) {
|
||||
const requestUrl = buildModelsUrl(baseUrl, pageSize, nextPageToken);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(requestUrl, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
signal: options.signal,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = vertexOpenAIModelsResponseSchema.safeParse(payload);
|
||||
if (!parsed.success) return null;
|
||||
|
||||
for (const item of parsed.data.data) {
|
||||
const model = normalizeModel(item, baseUrl);
|
||||
if (model) modelsById.set(model.id, model);
|
||||
}
|
||||
|
||||
const token = normalizePageToken(parsed.data.nextPageToken);
|
||||
if (!token || seenTokens.has(token)) break;
|
||||
seenTokens.add(token);
|
||||
nextPageToken = token;
|
||||
}
|
||||
|
||||
return Array.from(modelsById.values()).sort((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
/** Returns the stable Vertex AI OpenAI-compatible endpoint base URL. */
|
||||
export function buildVertexOpenAIBaseUrl(project: string, location: string): string {
|
||||
const host = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`;
|
||||
return `https://${host}/${API_VERSION}/projects/${project}/locations/${location}/endpoints/openapi`;
|
||||
}
|
||||
|
||||
function buildModelsUrl(baseUrl: string, pageSize: number, pageToken?: string): URL {
|
||||
const url = new URL(`${baseUrl}/models`);
|
||||
url.searchParams.set("pageSize", String(pageSize));
|
||||
if (pageToken) url.searchParams.set("pageToken", pageToken);
|
||||
return url;
|
||||
}
|
||||
|
||||
function normalizePositiveInt(value: number | undefined, fallback: number): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return fallback;
|
||||
const normalized = Math.floor(value);
|
||||
return normalized > 0 ? normalized : fallback;
|
||||
}
|
||||
|
||||
function normalizePageToken(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const token = value.trim();
|
||||
return token.length > 0 ? token : undefined;
|
||||
}
|
||||
|
||||
function normalizeModel(item: VertexOpenAIModelItem, baseUrl: string): Model<"openai-completions"> | null {
|
||||
const id = normalizeModelId(item.id ?? item.name);
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
name: normalizeModelName(item.displayName, id),
|
||||
api: "openai-completions",
|
||||
provider: "google-vertex",
|
||||
baseUrl,
|
||||
reasoning: inferReasoning(id),
|
||||
input: inferInput(id),
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: UNK_CONTEXT_WINDOW,
|
||||
maxTokens: UNK_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeModelId(value: string | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const marker = "/models/";
|
||||
const markerIndex = trimmed.lastIndexOf(marker);
|
||||
if (markerIndex >= 0) {
|
||||
const modelId = trimmed.slice(markerIndex + marker.length);
|
||||
const publisher = extractPublisher(trimmed.slice(0, markerIndex));
|
||||
return publisher ? `${publisher}/${modelId}` : modelId;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function extractPublisher(prefix: string): string | undefined {
|
||||
const marker = "/publishers/";
|
||||
const markerIndex = prefix.lastIndexOf(marker);
|
||||
if (markerIndex < 0) return undefined;
|
||||
const publisher = prefix.slice(markerIndex + marker.length).trim();
|
||||
return publisher.length > 0 ? publisher : undefined;
|
||||
}
|
||||
|
||||
function normalizeModelName(displayName: string | undefined, id: string): string {
|
||||
const trimmed = displayName?.trim();
|
||||
return trimmed ? trimmed : id;
|
||||
}
|
||||
|
||||
function inferReasoning(id: string): boolean {
|
||||
const normalized = id.toLowerCase();
|
||||
return (
|
||||
normalized.includes("thinking") ||
|
||||
normalized.includes("reasoning") ||
|
||||
normalized.includes("glm-4.5") ||
|
||||
normalized.includes("glm-4.6") ||
|
||||
normalized.includes("glm-4.7") ||
|
||||
normalized.includes("glm-5") ||
|
||||
normalized.includes("gemini-2.5") ||
|
||||
normalized.includes("gemini-3")
|
||||
);
|
||||
}
|
||||
|
||||
function inferInput(id: string): ("text" | "image")[] {
|
||||
const normalized = id.toLowerCase();
|
||||
if (
|
||||
normalized.includes("gemini") ||
|
||||
normalized.includes("vision") ||
|
||||
normalized.includes("image") ||
|
||||
normalized.includes("vl")
|
||||
) {
|
||||
return ["text", "image"];
|
||||
}
|
||||
return ["text"];
|
||||
}
|
||||
@@ -1,131 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { resolveProviderModels } from "../src/model-manager";
|
||||
import { googleVertexModelManagerOptions } from "../src/provider-models/google";
|
||||
import { __resetVertexTokenCache } from "../src/providers/google-auth";
|
||||
import { MODELS_DEV_PROVIDER_DESCRIPTORS, mapModelsDevToModels } from "../src/provider-models/openai-compat";
|
||||
|
||||
const OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
const METADATA_TOKEN_URL = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token";
|
||||
|
||||
describe("google-vertex model discovery", () => {
|
||||
let tempDir = "";
|
||||
let dbPath = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-vertex-models-"));
|
||||
dbPath = path.join(tempDir, "models.db");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
__resetVertexTokenCache();
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = "";
|
||||
dbPath = "";
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the Vertex OpenAI-compatible model list as the authoritative project catalog", async () => {
|
||||
const urls: string[] = [];
|
||||
const options = googleVertexModelManagerOptions({
|
||||
project: "vertex-project",
|
||||
location: "global",
|
||||
fetch: async input => {
|
||||
const url = input instanceof Request ? input.url : input.toString();
|
||||
urls.push(url);
|
||||
if (url === METADATA_TOKEN_URL || url === OAUTH_TOKEN_URL) {
|
||||
return new Response(JSON.stringify({ access_token: "vertex-token", expires_in: 3600 }));
|
||||
}
|
||||
if (
|
||||
url.startsWith(
|
||||
"https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi/models",
|
||||
)
|
||||
) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{ id: "zai-org/glm-4.7-maas", displayName: "GLM-4.7" },
|
||||
{
|
||||
name: "projects/vertex-project/locations/global/publishers/anthropic/models/claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet 4.5",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
const googleVertexModelsDevPayload = {
|
||||
"google-vertex": {
|
||||
models: {
|
||||
"gemini-3.5-flash": {
|
||||
name: "Gemini 3.5 Flash",
|
||||
tool_call: true,
|
||||
reasoning: true,
|
||||
modalities: { input: ["text", "image", "pdf"] },
|
||||
limit: { context: 1_048_576, output: 65_536 },
|
||||
cost: { input: 0.3, output: 2.5, cache_read: 0.03, cache_write: 0.75 },
|
||||
provider: { npm: "@ai-sdk/google-vertex" },
|
||||
},
|
||||
});
|
||||
"deepseek-ai/deepseek-v3.2-maas": {
|
||||
name: "DeepSeek V3.2",
|
||||
tool_call: true,
|
||||
reasoning: true,
|
||||
modalities: { input: ["text", "pdf"] },
|
||||
limit: { context: 163_840, output: 65_536 },
|
||||
provider: { npm: "@ai-sdk/openai-compatible" },
|
||||
},
|
||||
"claude-sonnet-4@20250514": {
|
||||
name: "Claude Sonnet 4",
|
||||
tool_call: true,
|
||||
reasoning: true,
|
||||
modalities: { input: ["text", "image", "pdf"] },
|
||||
limit: { context: 200_000, output: 64_000 },
|
||||
provider: { npm: "@ai-sdk/google-vertex/anthropic" },
|
||||
},
|
||||
"gemini-embedding-001": {
|
||||
name: "Gemini Embedding 001",
|
||||
tool_call: false,
|
||||
provider: { npm: "@ai-sdk/google-vertex" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, unknown>;
|
||||
|
||||
const result = await resolveProviderModels({ ...options, cacheDbPath: dbPath }, "online");
|
||||
|
||||
expect(result.stale).toBe(false);
|
||||
expect(result.models.map(model => model.id)).toEqual(["anthropic/claude-sonnet-4-5", "zai-org/glm-4.7-maas"]);
|
||||
expect(result.models.every(model => model.provider === "google-vertex")).toBe(true);
|
||||
expect(result.models.every(model => model.api === "openai-completions")).toBe(true);
|
||||
expect(
|
||||
result.models.every(
|
||||
model =>
|
||||
model.baseUrl ===
|
||||
"https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.models.some(model => model.id === "gemini-1.5-pro")).toBe(false);
|
||||
expect(urls).toContain(
|
||||
"https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi/models?pageSize=100",
|
||||
describe("google-vertex model catalog", () => {
|
||||
it("maps the models.dev Vertex catalog instead of the project discovery endpoint", () => {
|
||||
const models = mapModelsDevToModels(googleVertexModelsDevPayload, MODELS_DEV_PROVIDER_DESCRIPTORS).filter(
|
||||
model => model.provider === "google-vertex",
|
||||
);
|
||||
|
||||
expect(models.map(model => model.id)).toEqual([
|
||||
"gemini-3.5-flash",
|
||||
"deepseek-ai/deepseek-v3.2-maas",
|
||||
"claude-sonnet-4@20250514",
|
||||
]);
|
||||
|
||||
const gemini = models.find(model => model.id === "gemini-3.5-flash");
|
||||
expect(gemini?.api).toBe("google-vertex");
|
||||
expect(gemini?.baseUrl).toBe("https://{location}-aiplatform.googleapis.com");
|
||||
expect(gemini?.input).toEqual(["text", "image"]);
|
||||
expect(gemini?.contextWindow).toBe(1_048_576);
|
||||
|
||||
const deepseek = models.find(model => model.id === "deepseek-ai/deepseek-v3.2-maas");
|
||||
expect(deepseek?.api).toBe("openai-completions");
|
||||
expect(deepseek?.baseUrl).toBe(
|
||||
"https://{location}-aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/endpoints/openapi",
|
||||
);
|
||||
|
||||
const claude = models.find(model => model.id === "claude-sonnet-4@20250514");
|
||||
expect(claude?.api).toBe("google-vertex");
|
||||
expect(claude?.reasoning).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the API-key Vertex Gemini catalog when project and location are also configured", async () => {
|
||||
it("uses the bundled Vertex catalog without ADC project discovery", async () => {
|
||||
const options = googleVertexModelManagerOptions({
|
||||
apiKey: "vertex-api-key",
|
||||
project: "vertex-project",
|
||||
location: "global",
|
||||
fetch: async () => new Response("unexpected", { status: 500 }),
|
||||
});
|
||||
|
||||
const result = await resolveProviderModels({ ...options, cacheDbPath: dbPath }, "offline");
|
||||
expect(options.fetchDynamicModels).toBeUndefined();
|
||||
expect(options.staticModels).toBeUndefined();
|
||||
|
||||
expect(result.models.some(model => model.id === "gemini-2.5-pro")).toBe(true);
|
||||
expect(result.models.every(model => model.provider === "google-vertex")).toBe(true);
|
||||
expect(result.models.every(model => model.api === "google-vertex")).toBe(true);
|
||||
});
|
||||
|
||||
it("omits the bundled Vertex Gemini static fallback when neither ADC project nor API key are configured", async () => {
|
||||
const previousProject = Bun.env.GOOGLE_CLOUD_PROJECT;
|
||||
const previousGcpProject = Bun.env.GCP_PROJECT;
|
||||
const previousGcloudProject = Bun.env.GCLOUD_PROJECT;
|
||||
const previousVertexLocation = Bun.env.GOOGLE_VERTEX_LOCATION;
|
||||
const previousCloudLocation = Bun.env.GOOGLE_CLOUD_LOCATION;
|
||||
const previousLocation = Bun.env.VERTEX_LOCATION;
|
||||
const previousApiKey = Bun.env.GOOGLE_CLOUD_API_KEY;
|
||||
delete Bun.env.GOOGLE_CLOUD_PROJECT;
|
||||
delete Bun.env.GCP_PROJECT;
|
||||
delete Bun.env.GCLOUD_PROJECT;
|
||||
delete Bun.env.GOOGLE_VERTEX_LOCATION;
|
||||
delete Bun.env.GOOGLE_CLOUD_LOCATION;
|
||||
delete Bun.env.VERTEX_LOCATION;
|
||||
delete Bun.env.GOOGLE_CLOUD_API_KEY;
|
||||
try {
|
||||
const options = googleVertexModelManagerOptions();
|
||||
const result = await resolveProviderModels({ ...options, cacheDbPath: dbPath }, "offline");
|
||||
expect(result.models).toEqual([]);
|
||||
} finally {
|
||||
if (previousProject === undefined) delete Bun.env.GOOGLE_CLOUD_PROJECT;
|
||||
else Bun.env.GOOGLE_CLOUD_PROJECT = previousProject;
|
||||
if (previousGcpProject === undefined) delete Bun.env.GCP_PROJECT;
|
||||
else Bun.env.GCP_PROJECT = previousGcpProject;
|
||||
if (previousGcloudProject === undefined) delete Bun.env.GCLOUD_PROJECT;
|
||||
else Bun.env.GCLOUD_PROJECT = previousGcloudProject;
|
||||
if (previousVertexLocation === undefined) delete Bun.env.GOOGLE_VERTEX_LOCATION;
|
||||
else Bun.env.GOOGLE_VERTEX_LOCATION = previousVertexLocation;
|
||||
if (previousCloudLocation === undefined) delete Bun.env.GOOGLE_CLOUD_LOCATION;
|
||||
else Bun.env.GOOGLE_CLOUD_LOCATION = previousCloudLocation;
|
||||
if (previousLocation === undefined) delete Bun.env.VERTEX_LOCATION;
|
||||
else Bun.env.VERTEX_LOCATION = previousLocation;
|
||||
if (previousApiKey === undefined) delete Bun.env.GOOGLE_CLOUD_API_KEY;
|
||||
else Bun.env.GOOGLE_CLOUD_API_KEY = previousApiKey;
|
||||
}
|
||||
const result = await resolveProviderModels(options, "offline");
|
||||
expect(result.stale).toBe(false);
|
||||
expect(result.models.some(model => model.id === "deepseek-ai/deepseek-v3.2-maas")).toBe(true);
|
||||
expect(result.models.some(model => model.id === "gemini-3.5-flash")).toBe(true);
|
||||
expect(result.models.some(model => model.id === "gemini-1.5-pro")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user