Repository navigation
fix(vertex): support Vertex AI Express-mode API keys #3690
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: Express API key is placed in the URL query string.
|
||
| 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}`; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: Partner Express branch returns an endpoint without the requested model or stream mode. For partner models, this returns |
||
| } | ||
| 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"; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: Provider validation accepts any non-empty non-JSON string as a valid Vertex key.
|
||
| 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 }; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SUGGESTION: Comments say Express keys use
x-goog-api-key, but implementation uses?key=.The top comment and the
execute()comment describe Express auth asx-goog-api-key, whilebuildUrl()puts the key in the query string andbuildHeaders()intentionally omits that header. Update the comments so future maintainers do not reintroduce the header or assume the wrong transport.