File size: 6,915 Bytes
88c4c60 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 | import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js";
import { parseVertexSaJson, refreshVertexToken, refreshGoogleToken } from "../services/tokenRefresh.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
// Cache project IDs resolved from raw API keys { apiKey β projectId }
const projectIdCache = new Map();
/**
* Parse Google ADC user credential JSON from apiKey string.
* This is the format produced by `gcloud auth application-default login`.
*/
function parseVertexAdcJson(apiKey) {
if (typeof apiKey !== "string") return null;
try {
const parsed = JSON.parse(apiKey);
if (
parsed.type === "authorized_user" &&
parsed.client_id &&
parsed.client_secret &&
parsed.refresh_token
) {
return parsed;
}
return null;
} catch {
return null;
}
}
/**
* Resolve GCP project ID from a raw Vertex API key.
* Sends a dummy 404 request and parses "projects/{id}" from the error message.
*/
async function resolveProjectId(apiKey) {
if (projectIdCache.has(apiKey)) return projectIdCache.get(apiKey);
const res = await fetch(
`https://aiplatform.googleapis.com/v1/publishers/google/models/__probe__:generateContent?key=${apiKey}`,
{ method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }
);
const json = await res.json().catch(() => null);
const msg = json?.[0]?.error?.message || json?.error?.message || "";
const match = msg.match(/projects\/([^/]+)\//);
const projectId = match?.[1] || null;
if (projectId) projectIdCache.set(apiKey, projectId);
return projectId;
}
/**
* VertexExecutor - Google Cloud Vertex AI
*
* "vertex" β Gemini models via regional/global Vertex endpoint
* "vertex-partner" β Partner models (Llama, Mistral, GLM, DeepSeek, Qwen)
* via global OpenAI-compatible endpoint
*
* Auth: SA JSON (stored as apiKey) β JWT assertion β Bearer token (via jose)
* Token is minted/cached in tokenRefresh.js, not here.
*/
export class VertexExecutor extends BaseExecutor {
constructor(providerId = "vertex") {
super(providerId, PROVIDERS[providerId] || {});
}
buildUrl(model, stream, urlIndex = 0, credentials = null) {
const saJson = parseVertexSaJson(credentials?.apiKey);
const adcJson = parseVertexAdcJson(credentials?.apiKey);
const usesOAuth = !!saJson || !!adcJson || !!credentials?.accessToken;
const rawKey = !usesOAuth ? credentials?.apiKey : null;
const projectId =
saJson?.project_id ||
adcJson?.quota_project_id ||
credentials?.providerSpecificData?.projectId;
if (this.provider === "vertex-partner") {
// Partner models require project_id in path regardless of auth method
if (!projectId) throw new Error("Vertex partner models require a project_id. Add it in providerSpecificData or use Service Account JSON.");
const url = `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/global/endpoints/openapi/chat/completions`;
return rawKey ? `${url}?key=${rawKey}` : url;
}
// Gemini on Vertex
const action = stream ? "streamGenerateContent" : "generateContent";
if (usesOAuth) {
// SA JSON / ADC / pre-set accessToken: must use project-scoped path to avoid RESOURCE_PROJECT_INVALID
if (!projectId) {
throw new Error(
"Vertex OAuth/ADC requires a project_id. " +
"Add quota_project_id to your ADC JSON or set providerSpecificData.projectId."
);
}
const location = credentials?.providerSpecificData?.location || "us-central1";
let url = `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${location}/publishers/google/models/${model}:${action}`;
if (stream) url += "?alt=sse";
return url;
}
// Raw API key: use global publishers endpoint with ?key= param
// ?alt=sse is required for proper SSE streaming (matches every other Gemini executor)
let url = `https://aiplatform.googleapis.com/v1/publishers/google/models/${model}:${action}`;
if (stream) url += "?alt=sse";
if (rawKey) url += stream ? `&key=${rawKey}` : `?key=${rawKey}`;
return url;
}
buildHeaders(credentials, stream = true) {
const headers = { "Content-Type": "application/json" };
// Only set Bearer token if using SA JSON flow (raw key goes in URL ?key=)
if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
}
if (stream) headers["Accept"] = "text/event-stream";
return headers;
}
async refreshCredentials(credentials, log) {
const saJson = parseVertexSaJson(credentials?.apiKey);
if (!saJson) return null;
const result = await refreshVertexToken(saJson, log);
if (!result) return null;
return { accessToken: result.accessToken, expiresAt: result.expiresAt };
}
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
const saJson = parseVertexSaJson(credentials?.apiKey);
const adcJson = parseVertexAdcJson(credentials?.apiKey);
// SA JSON flow: mint Bearer token via JWT assertion (cached)
if (saJson) {
const result = await refreshVertexToken(saJson, log);
if (!result?.accessToken) throw new Error("Vertex: failed to mint access token from Service Account JSON");
credentials.accessToken = result.accessToken;
}
// ADC user credential flow: refresh Bearer token via Google OAuth2 token endpoint
if (adcJson) {
const result = await refreshGoogleToken(
adcJson.refresh_token,
adcJson.client_id,
adcJson.client_secret,
log
);
if (!result?.accessToken) throw new Error("Vertex: failed to refresh access token from ADC JSON (authorized_user)");
credentials.accessToken = result.accessToken;
}
// vertex-partner with raw key: auto-resolve project_id if not provided
if (this.provider === "vertex-partner" && !saJson && !adcJson && !credentials?.providerSpecificData?.projectId) {
const projectId = await resolveProjectId(credentials.apiKey);
if (!projectId) throw new Error("Vertex: could not resolve project_id from API key. Please add it manually in provider settings.");
log?.debug?.("VERTEX", `Resolved project_id: ${projectId}`);
credentials.providerSpecificData = { ...credentials.providerSpecificData, projectId };
}
const url = this.buildUrl(model, stream, 0, credentials);
const headers = this.buildHeaders(credentials, stream);
const transformedBody = this.transformRequest(model, body, stream, credentials);
const response = await proxyAwareFetch(url, {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal,
}, proxyOptions);
return { response, url, headers, transformedBody };
}
}
export default VertexExecutor;
|