Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion open-sse/executors/vertex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

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 as x-goog-api-key, while buildUrl() puts the key in the query string and buildHeaders() intentionally omits that header. Update the comments so future maintainers do not reintroduce the header or assume the wrong transport.

* 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(
Expand Down Expand Up @@ -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);
Expand All @@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Express API key is placed in the URL query string.

BaseExecutor logs full request URLs on timeout/retry/401 paths and returns result.url to chatCore, which passes it to logTargetRequest without URL redaction. If ?key= is used, a valid API key can be persisted in logs or pipeline payloads. Add URL redaction for this provider, avoid query-string credentials, or otherwise ensure the logging pipeline strips key before storage.

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}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 /v1/publishers/openapi/chat/completions?key=... regardless of model and stream. If Vertex Express keys do not support partner models, fail before building the URL; if they do, include the model and stream-specific operation so future callers do not accidentally send unsupported requests.

}
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";

Expand All @@ -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";
}
Expand Down
12 changes: 10 additions & 2 deletions src/lib/providers/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.

isExpressApiKey is intentionally broad, so validateProviderApiKey now returns success for values like not-json, JSON arrays, or random strings without a live call. The Settings validation UI can report a broken key as valid and users only discover the problem during chat/media requests. The same issue applies to the vertex-partner branch below.

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);
Expand All @@ -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 };
Expand Down
31 changes: 14 additions & 17 deletions tests/unit/executor-vertex-extended.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
});

Expand Down Expand Up @@ -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({
Expand Down
23 changes: 20 additions & 3 deletions tests/unit/t29-vertex-sa-json-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 102 additions & 0 deletions tests/unit/vertex-express-apikey.test.ts
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;
}
});