diff --git a/open-sse/executors/vertex.ts b/open-sse/executors/vertex.ts index 7412fa24b9..02994026d0 100644 --- a/open-sse/executors/vertex.ts +++ b/open-sse/executors/vertex.ts @@ -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 { 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"; } diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 61c1aa1d40..c6387ec805 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -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 }; diff --git a/tests/unit/executor-vertex-extended.test.ts b/tests/unit/executor-vertex-extended.test.ts index 02cce26a2a..6318195c40 100644 --- a/tests/unit/executor-vertex-extended.test.ts +++ b/tests/unit/executor-vertex-extended.test.ts @@ -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({ diff --git a/tests/unit/t29-vertex-sa-json-executor.test.ts b/tests/unit/t29-vertex-sa-json-executor.test.ts index e2a2207ce7..f367718084 100644 --- a/tests/unit/t29-vertex-sa-json-executor.test.ts +++ b/tests/unit/t29-vertex-sa-json-executor.test.ts @@ -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" ); }); diff --git a/tests/unit/vertex-express-apikey.test.ts b/tests/unit/vertex-express-apikey.test.ts new file mode 100644 index 0000000000..555b11c1de --- /dev/null +++ b/tests/unit/vertex-express-apikey.test.ts @@ -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; + } +});