Files
2026-07-13 13:39:12 +08:00

209 lines
7.5 KiB
TypeScript

import { SignJWT, importPKCS8 } from "jose";
import { BaseExecutor, ExecuteInput } from "./base.ts";
import { PROVIDERS } from "../config/constants.ts";
interface ServiceAccount {
type: string;
project_id: string;
private_key_id: string;
private_key: string;
client_email: string;
[key: string]: unknown;
}
const TOKEN_CACHE = new Map<string, { token: string; expiresAt: number }>();
// OAuth scopes minted into the Vertex SA access token.
// - cloud-platform authorizes Vertex AI (aiplatform.googleapis.com) for chat/image execution.
// - generative-language.retriever is ADDITIONALLY required so model discovery can list the live
// catalog from generativelanguage.googleapis.com/v1beta/models — without it that listing returns
// 403 ACCESS_TOKEN_SCOPE_INSUFFICIENT and discovery silently falls back to the static ~10-model
// registry list. The extra scope is harmless for execution (cloud-platform still present) and for
// projects where it isn't needed (the mint never validates scope availability).
export const VERTEX_OAUTH_SCOPES = [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/generative-language.retriever",
] as const;
export function parseSAFromApiKey(apiKey: string): ServiceAccount {
try {
return JSON.parse(apiKey);
} catch {
throw new Error("Vertex AI requires a valid Service Account JSON as the API key");
}
}
/**
* A Service Account credential is a JSON object (type/client_email/private_key). A Vertex AI
* Express-mode API key is an opaque non-JSON string. Distinguishing them lets the executor
* support BOTH: Service Account JSON (JWT → OAuth → project-scoped endpoint + Bearer auth) and
* Express keys (project-less publisher endpoint + x-goog-api-key auth), instead of failing every
* Express key with "requires a valid Service Account JSON".
*/
export function looksLikeServiceAccountJson(apiKey: string): boolean {
if (!apiKey || typeof apiKey !== "string") return false;
try {
const parsed = JSON.parse(apiKey);
return !!parsed && typeof parsed === "object" && !Array.isArray(parsed);
} catch {
return false;
}
}
/** True for a Vertex AI Express-mode API key (a non-empty, non-JSON, non-OAuth credential). */
export function isExpressApiKey(apiKey?: string | null): boolean {
return typeof apiKey === "string" && apiKey.trim().length > 0 && !looksLikeServiceAccountJson(apiKey);
}
export async function getAccessToken(sa: ServiceAccount): Promise<string> {
if (!sa.client_email || !sa.private_key) {
throw new Error(
"Service Account JSON is missing required fields (client_email or private_key)"
);
}
const cacheKey = sa.client_email;
const cached = TOKEN_CACHE.get(cacheKey);
// Buffer of 60 seconds
if (cached && Date.now() < cached.expiresAt - 60_000) {
return cached.token;
}
const privateKey = await importPKCS8(sa.private_key, "RS256");
const now = Math.floor(Date.now() / 1000);
const jwt = await new SignJWT({
iss: sa.client_email,
sub: sa.client_email,
aud: "https://oauth2.googleapis.com/token",
iat: now,
exp: now + 3600,
scope: VERTEX_OAUTH_SCOPES.join(" "),
})
.setProtectedHeader({ alg: "RS256", kid: sa.private_key_id })
.sign(privateKey);
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: jwt,
}),
});
if (!tokenRes.ok) {
const errorText = await tokenRes.text();
throw new Error(
`Failed to exchange JWT for Vertex access token: ${tokenRes.status} ${errorText}`
);
}
const tokenData = await tokenRes.json();
const accessToken = tokenData.access_token;
if (!accessToken) {
throw new Error("Vertex AI token exchange succeeded but no access_token found");
}
TOKEN_CACHE.set(cacheKey, {
token: accessToken,
expiresAt: (now + 3600) * 1000,
});
return accessToken;
}
const PARTNER_MODELS = new Set([
"claude-3-5-sonnet",
"claude-3-opus",
"claude-3-haiku",
"deepseek-v3",
"deepseek-v3.2",
"deepseek-v4",
"deepseek-deepseek-r1",
"qwen3-next-80b",
"qwen3.6-",
"llama-3.1",
"mistral-",
"glm-5",
"glm-5.1",
"meta/llama",
]);
function isPartnerModel(model: string) {
const normalizedModel = model.toLowerCase();
return [...PARTNER_MODELS].some((prefix) => normalizedModel.startsWith(prefix));
}
export class VertexExecutor extends BaseExecutor {
constructor() {
super("vertex", PROVIDERS.vertex);
}
async execute(input: ExecuteInput) {
const { credentials, log } = input;
// Defensive: trim stray surrounding whitespace from a pasted credential.
if (typeof credentials.apiKey === "string") {
credentials.apiKey = credentials.apiKey.trim();
}
// Service Account JSON → mint a short-lived OAuth token (Bearer). An Express-mode API key is
// sent as-is via x-goog-api-key (see buildHeaders), so no token exchange is needed for it.
if (credentials.apiKey && !credentials.accessToken && looksLikeServiceAccountJson(credentials.apiKey)) {
try {
const sa = parseSAFromApiKey(credentials.apiKey);
credentials.accessToken = await getAccessToken(sa);
} catch (err: any) {
log?.error?.("VERTEX", `Failed to generate JWT token: ${err.message}`);
throw err;
}
}
return super.execute(input);
}
buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: any = null) {
// Vertex AI Express mode: project-less v1 publisher endpoint with the API key passed as a
// ?key= query parameter (verified working contract — same as the CaptionAI GeminiClient). The
// Express key is NOT accepted as a Bearer/OAuth credential or via x-goog-api-key on this API.
if (isExpressApiKey(credentials?.apiKey) && !credentials?.accessToken) {
const expressKey = encodeURIComponent(String(credentials.apiKey).trim());
if (isPartnerModel(model)) {
// Partner (Anthropic/etc.) models are not available via Express keys; best-effort.
return `https://aiplatform.googleapis.com/v1/publishers/openapi/chat/completions?key=${expressKey}`;
}
const op = stream ? "streamGenerateContent?alt=sse&" : "generateContent?";
return `https://aiplatform.googleapis.com/v1/publishers/google/models/${model}:${op}key=${expressKey}`;
}
const region = credentials?.providerSpecificData?.region || "us-central1";
let project = "unknown-project";
if (credentials?.apiKey) {
try {
const sa = parseSAFromApiKey(credentials.apiKey);
if (sa.project_id) project = sa.project_id;
} catch {
// Ignored, handled in execute
}
}
if (isPartnerModel(model)) {
return `https://aiplatform.googleapis.com/v1/projects/${project}/locations/global/endpoints/openapi/chat/completions`;
}
return `https://aiplatform.googleapis.com/v1/projects/${project}/locations/${region}/publishers/google/models/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`;
}
buildHeaders(credentials: any, stream = true) {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
}
// Express-mode keys are carried in the ?key= query parameter (see buildUrl), not a header.
if (stream) {
headers["Accept"] = "text/event-stream";
}
return headers;
}
}