mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 15:52:52 +03:00
fix(vertex): support Vertex AI Express-mode API keys (#3690)
Integrated into release/v3.8.23
This commit is contained in:
@@ -21,6 +21,28 @@ export function parseSAFromApiKey(apiKey: string): ServiceAccount {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
@@ -110,7 +132,13 @@ export class VertexExecutor extends BaseExecutor {
|
||||
|
||||
async execute(input: ExecuteInput) {
|
||||
const { credentials, log } = input;
|
||||
if (credentials.apiKey && !credentials.accessToken) {
|
||||
// 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);
|
||||
@@ -123,6 +151,19 @@ export class VertexExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
@@ -146,6 +187,7 @@ export class VertexExecutor extends BaseExecutor {
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -3954,8 +3954,13 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
|
||||
},
|
||||
vertex: async ({ apiKey }: any) => {
|
||||
try {
|
||||
const { parseSAFromApiKey, getAccessToken } =
|
||||
const { parseSAFromApiKey, getAccessToken, isExpressApiKey } =
|
||||
await import("@omniroute/open-sse/executors/vertex.ts");
|
||||
// Express-mode API keys are opaque strings sent directly as the ?key= query param — there is
|
||||
// no JWT to mint, so accept any non-empty Express key (the live chat/media call validates it).
|
||||
if (isExpressApiKey(apiKey)) {
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
const sa = parseSAFromApiKey(apiKey);
|
||||
// Validates credentials by successfully successfully exchanging them for a JWT from Google Identity
|
||||
await getAccessToken(sa);
|
||||
@@ -3966,8 +3971,11 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
|
||||
},
|
||||
"vertex-partner": async ({ apiKey }: any) => {
|
||||
try {
|
||||
const { parseSAFromApiKey, getAccessToken } =
|
||||
const { parseSAFromApiKey, getAccessToken, isExpressApiKey } =
|
||||
await import("@omniroute/open-sse/executors/vertex.ts");
|
||||
if (isExpressApiKey(apiKey)) {
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
const sa = parseSAFromApiKey(apiKey);
|
||||
await getAccessToken(sa);
|
||||
return { valid: true, error: null };
|
||||
|
||||
@@ -51,17 +51,22 @@ test("VertexExecutor.buildUrl defaults to us-central1 and unknown-project when p
|
||||
apiKey: createServiceAccountJson({ includeProjectId: false }),
|
||||
providerSpecificData: {},
|
||||
});
|
||||
const invalidJson = executor.buildUrl("gemini-2.5-flash", false, 0, {
|
||||
apiKey: "not-json",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
missingProject,
|
||||
"https://aiplatform.googleapis.com/v1/projects/unknown-project/locations/us-central1/publishers/google/models/gemini-2.5-flash:generateContent"
|
||||
);
|
||||
});
|
||||
|
||||
test("VertexExecutor.buildUrl routes a non-JSON Express API key to the project-less publisher endpoint", () => {
|
||||
const executor = new VertexExecutor();
|
||||
const expressUrl = executor.buildUrl("gemini-2.5-flash", false, 0, {
|
||||
apiKey: "express-key-abc",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
invalidJson,
|
||||
"https://aiplatform.googleapis.com/v1/projects/unknown-project/locations/us-central1/publishers/google/models/gemini-2.5-flash:generateContent"
|
||||
expressUrl,
|
||||
"https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash:generateContent?key=express-key-abc"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -191,20 +196,12 @@ test("VertexExecutor.execute skips Service Account parsing when accessToken is a
|
||||
}
|
||||
});
|
||||
|
||||
test("VertexExecutor.execute rejects invalid or incomplete Service Account JSON clearly", async () => {
|
||||
test("VertexExecutor.execute rejects incomplete Service Account JSON clearly", async () => {
|
||||
const executor = new VertexExecutor();
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
executor.execute({
|
||||
model: "gemini-2.5-flash",
|
||||
body: { contents: [] },
|
||||
stream: false,
|
||||
credentials: { apiKey: "not-json" },
|
||||
}),
|
||||
/Service Account JSON/
|
||||
);
|
||||
|
||||
// A JSON object missing client_email/private_key is treated as a Service Account (not an Express
|
||||
// key) and must fail clearly when minting a JWT. A non-JSON string is an Express key (covered
|
||||
// elsewhere) and is intentionally NOT rejected here.
|
||||
await assert.rejects(
|
||||
() =>
|
||||
executor.execute({
|
||||
|
||||
@@ -55,17 +55,34 @@ test("T29: Vertex executor headers include Bearer token and SSE Accept when stre
|
||||
assert.equal(headers.Accept, "text/event-stream");
|
||||
});
|
||||
|
||||
test("T29: Vertex executor rejects invalid Service Account JSON clearly", async () => {
|
||||
test("T29: Vertex executor rejects incomplete Service Account JSON clearly", async () => {
|
||||
const executor = new VertexExecutor();
|
||||
|
||||
// A JSON object (not an opaque Express key) that is missing client_email/private_key must still
|
||||
// fail clearly when the executor tries to mint a JWT from it.
|
||||
await assert.rejects(
|
||||
() =>
|
||||
executor.execute({
|
||||
model: "gemini-2.5-flash",
|
||||
body: { contents: [] },
|
||||
stream: false,
|
||||
credentials: { apiKey: "not-json" },
|
||||
credentials: { apiKey: JSON.stringify({ project_id: "p" }) },
|
||||
}),
|
||||
/Service Account JSON/i
|
||||
/missing required fields/i
|
||||
);
|
||||
});
|
||||
|
||||
test("T29: Vertex executor routes a non-JSON Express API key to the project-less publisher endpoint", () => {
|
||||
const executor = new VertexExecutor();
|
||||
const stream = executor.buildUrl("gemini-2.5-flash", true, 0, { apiKey: "express-key-123" });
|
||||
const nonStream = executor.buildUrl("gemini-2.5-flash", false, 0, { apiKey: "express-key-123" });
|
||||
|
||||
assert.equal(
|
||||
stream,
|
||||
"https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash:streamGenerateContent?alt=sse&key=express-key-123"
|
||||
);
|
||||
assert.equal(
|
||||
nonStream,
|
||||
"https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash:generateContent?key=express-key-123"
|
||||
);
|
||||
});
|
||||
|
||||
102
tests/unit/vertex-express-apikey.test.ts
Normal file
102
tests/unit/vertex-express-apikey.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { VertexExecutor, isExpressApiKey, looksLikeServiceAccountJson } = await import(
|
||||
"../../open-sse/executors/vertex.ts"
|
||||
);
|
||||
|
||||
test("looksLikeServiceAccountJson is true only for a JSON object credential", () => {
|
||||
assert.equal(looksLikeServiceAccountJson(JSON.stringify({ project_id: "p" })), true);
|
||||
assert.equal(looksLikeServiceAccountJson("express-opaque-key"), false);
|
||||
assert.equal(looksLikeServiceAccountJson(JSON.stringify([1, 2, 3])), false);
|
||||
assert.equal(looksLikeServiceAccountJson(""), false);
|
||||
});
|
||||
|
||||
test("isExpressApiKey is true for a non-empty, non-JSON credential", () => {
|
||||
assert.equal(isExpressApiKey("AIzaSyExpressKey"), true);
|
||||
assert.equal(isExpressApiKey(" "), false);
|
||||
assert.equal(isExpressApiKey(""), false);
|
||||
assert.equal(isExpressApiKey(null), false);
|
||||
assert.equal(isExpressApiKey(undefined), false);
|
||||
assert.equal(isExpressApiKey(JSON.stringify({ project_id: "p" })), false);
|
||||
});
|
||||
|
||||
test("buildUrl Express: streaming google model uses streamGenerateContent + ?alt=sse&key=", () => {
|
||||
const executor = new VertexExecutor();
|
||||
const url = executor.buildUrl("gemini-3-flash-preview", true, 0, { apiKey: "k-express" });
|
||||
assert.equal(
|
||||
url,
|
||||
"https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-flash-preview:streamGenerateContent?alt=sse&key=k-express"
|
||||
);
|
||||
});
|
||||
|
||||
test("buildUrl Express: non-streaming google model uses generateContent?key=", () => {
|
||||
const executor = new VertexExecutor();
|
||||
const url = executor.buildUrl("gemini-3-flash-preview", false, 0, { apiKey: "k-express" });
|
||||
assert.equal(
|
||||
url,
|
||||
"https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-flash-preview:generateContent?key=k-express"
|
||||
);
|
||||
});
|
||||
|
||||
test("buildUrl Express: the API key is URL-encoded and trimmed", () => {
|
||||
const executor = new VertexExecutor();
|
||||
const url = executor.buildUrl("gemini-2.5-flash", false, 0, { apiKey: " a/b+c=d " });
|
||||
assert.equal(
|
||||
url,
|
||||
"https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash:generateContent?key=a%2Fb%2Bc%3Dd"
|
||||
);
|
||||
});
|
||||
|
||||
test("buildUrl Express: a present accessToken takes the Service Account path, not Express", () => {
|
||||
const executor = new VertexExecutor();
|
||||
const url = executor.buildUrl("gemini-2.5-flash", false, 0, {
|
||||
apiKey: "k-express",
|
||||
accessToken: "ya29.token",
|
||||
providerSpecificData: { region: "us-central1" },
|
||||
});
|
||||
assert.equal(
|
||||
url,
|
||||
"https://aiplatform.googleapis.com/v1/projects/unknown-project/locations/us-central1/publishers/google/models/gemini-2.5-flash:generateContent"
|
||||
);
|
||||
});
|
||||
|
||||
test("buildHeaders for an Express key (no accessToken) omits the Authorization header", () => {
|
||||
const executor = new VertexExecutor();
|
||||
const headers = executor.buildHeaders({ apiKey: "k-express" }, false);
|
||||
assert.equal(headers["Content-Type"], "application/json");
|
||||
assert.equal(headers.Authorization, undefined);
|
||||
});
|
||||
|
||||
test("execute with an Express key calls the publisher endpoint directly (no OAuth token exchange)", async () => {
|
||||
const executor = new VertexExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: string[] = [];
|
||||
|
||||
globalThis.fetch = async (url: any) => {
|
||||
calls.push(String(url));
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "gemini-3-flash-preview",
|
||||
body: { contents: [{ role: "user", parts: [{ text: "hi" }] }] },
|
||||
stream: false,
|
||||
credentials: { apiKey: "AIzaSyExpressKey" },
|
||||
} as any);
|
||||
|
||||
assert.equal(result.response.status, 200);
|
||||
// Exactly one call — straight to Vertex, no oauth2 token mint.
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(
|
||||
calls[0],
|
||||
"https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-flash-preview:generateContent?key=AIzaSyExpressKey"
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user