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
66 changes: 52 additions & 14 deletions apps/api/src/adapters/http/routes/driver-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const HOP_BY_HOP_HEADERS = new Set([
const LLM_PROXY_GRANT_HEADERS = new Set(["authorization", "x-api-key", "x-goog-api-key"]);
const LLM_PROXY_PATH_MARKER = "/llm/proxy/";
const LLM_PROXY_UNSAFE_PATH_ENCODING = /%(?:25|2f|5c)/iu;
const OPENAI_IMAGE_API_PATHS = new Set(["/images/edits", "/images/generations"]);

async function requireDriverActionGrant(c: Context<ApiGatewayEnvironment>) {
const grant = c.req.query("grant");
Expand Down Expand Up @@ -225,24 +226,25 @@ function extractLlmProxySubPath(pathname: string): string | null {
return subPath;
}

function isLlmProxyCapabilityAllowed(
function resolveGrantedLlmProxyModelId(
method: string,
subPath: string,
modelId: string,
modelProtocol: PresetModelProtocol,
): boolean {
imageModelId?: string,
): string | null {
const decodedPath = decodeURIComponent(subPath);

if (decodedPath !== subPath) {
return false;
return null;
}

switch (modelProtocol) {
case "anthropic-messages":
return (
method === "POST" &&
return method === "POST" &&
["/messages", "/v1/messages", "/v1/messages/count_tokens"].includes(decodedPath)
);
? modelId
: null;
case "google-gemini": {
const modelPath = modelId.includes("/") ? modelId : `models/${modelId}`;
const modelPathIsCanonical = modelPath
Expand All @@ -255,18 +257,29 @@ function isLlmProxyCapabilityAllowed(
/^[A-Za-z0-9._-]+$/u.test(segment),
);

return (
method === "POST" &&
return method === "POST" &&
modelPathIsCanonical &&
[`/${modelPath}:generateContent`, `/${modelPath}:streamGenerateContent`].includes(
decodedPath,
)
);
? modelId
: null;
}
case "openai-chat-completions":
return method === "POST" && decodedPath === "/chat/completions";
case "openai-responses":
return method === "POST" && ["/responses", "/responses/compact"].includes(decodedPath);
return method === "POST" && decodedPath === "/chat/completions" ? modelId : null;
case "openai-responses": {
if (method !== "POST") {
return null;
}

if (["/responses", "/responses/compact"].includes(decodedPath)) {
return modelId;
}

return imageModelId !== undefined && OPENAI_IMAGE_API_PATHS.has(decodedPath)
? imageModelId
: null;
}
}
}

Expand All @@ -287,12 +300,28 @@ async function readGrantedLlmProxyRequestBody(
request: Request,
modelId: string,
modelProtocol: PresetModelProtocol,
subPath: string,
): Promise<{ body: BodyInit | null } | null> {
if (modelProtocol === "google-gemini") {
// Gemini binds the model in the exact admitted request path.
return { body: request.body };
}

const mediaType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();

if (
modelProtocol === "openai-responses" &&
subPath === "/images/edits" &&
mediaType === "multipart/form-data"
) {
try {
const models = (await request.clone().formData()).getAll("model");
return models.length === 1 && models[0] === modelId ? { body: request.body } : null;
} catch {
return null;
}
}

try {
const body: unknown = await request.json();

Expand Down Expand Up @@ -678,7 +707,15 @@ export function registerDriverRoute(app: Hono<ApiGatewayEnvironment>) {
});
}

if (!isLlmProxyCapabilityAllowed(c.req.method, subPath, grant.modelId, grant.modelProtocol)) {
const grantedModelId = resolveGrantedLlmProxyModelId(
c.req.method,
subPath,
grant.modelId,
grant.modelProtocol,
grant.imageModelId,
);

if (grantedModelId === null) {
return Response.json(
{ error: "LLM proxy request is outside the granted model capability." },
{ status: 403 },
Expand All @@ -687,8 +724,9 @@ export function registerDriverRoute(app: Hono<ApiGatewayEnvironment>) {

const grantedRequestBody = await readGrantedLlmProxyRequestBody(
c.req.raw,
grant.modelId,
grantedModelId,
grant.modelProtocol,
subPath,
);

if (grantedRequestBody === null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export type RuntimeActionTokenPayload =
action: "llm_proxy";
appId: AppId;
driverGeneration: number;
imageModelId?: string;
modelId: string;
modelProtocol: PresetModelProtocol;
resourceId: VendorCredentialId;
Expand Down Expand Up @@ -192,7 +193,7 @@ function parseRuntimeActionTokenPayload(encodedPayload: string): RuntimeActionTo
);

if (action === "llm_proxy") {
const { appId, driverGeneration, modelId, modelProtocol } = parsed;
const { appId, driverGeneration, imageModelId, modelId, modelProtocol } = parsed;

if (
typeof appId !== "string" ||
Expand All @@ -203,6 +204,11 @@ function parseRuntimeActionTokenPayload(encodedPayload: string): RuntimeActionTo
typeof modelId !== "string" ||
modelId === "" ||
modelId.length > RUNTIME_LLM_PROXY_MODEL_ID_MAX_LENGTH ||
(imageModelId !== undefined &&
(modelProtocol !== "openai-responses" ||
typeof imageModelId !== "string" ||
imageModelId === "" ||
imageModelId.length > RUNTIME_LLM_PROXY_MODEL_ID_MAX_LENGTH)) ||
!isPresetModelProtocol(modelProtocol)
) {
throw new Error("Runtime action token payload is invalid.");
Expand All @@ -214,6 +220,7 @@ function parseRuntimeActionTokenPayload(encodedPayload: string): RuntimeActionTo
driverGeneration,
driverInstanceId: parsedDriverInstanceId,
expiresAt,
...(imageModelId === undefined ? {} : { imageModelId }),
modelId,
modelProtocol,
resourceId: parsePlatformId<VendorCredentialId>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
import type { RuntimeActionTokenBindings } from "../runtime-boot-token";
import { OPENCODE_CONFIG_CONTENT_ENV } from "./runtime-vendor-env-policy";

const OPENAI_IMAGE_MODEL_ID = "gpt-image-2";

export interface VendorProxyEnvironmentInput {
bindings: RuntimeActionTokenBindings;
driverGeneration: number;
Expand Down Expand Up @@ -136,6 +138,9 @@ export async function buildVendorProxyEnvVars(
driverGeneration: input.driverGeneration,
driverInstanceId: input.driverInstanceId,
expiresAt: Date.now() + RUNTIME_RUN_RETENTION_MS,
...(input.profile.runtimeId === "openai-runtime" && vendor.vendorId === "openai"
? { imageModelId: OPENAI_IMAGE_MODEL_ID }
: {}),
...modelBinding,
resourceId: credential.credentialId,
});
Expand Down
93 changes: 93 additions & 0 deletions apps/api/tests/driver-llm-proxy-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,99 @@ describe("driver LLM proxy route", () => {
expect(captured[0]?.headers.get("x-api-key")).toBeNull();
});

test("forwards scoped OpenAI image generations and multipart edits", async () => {
const { bindings } = await setupFixture({ vendorId: "openai" });
const captured = captureUpstreamFetch();
const grant = await createLlmProxyGrant(bindings, {
imageModelId: "gpt-image-2",
modelId: "gpt-5.4",
modelProtocol: "openai-responses",
});

const generationResponse = await dispatch(
bindings,
llmProxyRequest("/images/generations", {
body: JSON.stringify({ model: "gpt-image-2", prompt: "Draw a pet." }),
headers: {
Authorization: `Bearer ${grant}`,
"content-type": "application/json",
},
method: "POST",
}),
);
const editBody = new FormData();
editBody.append("model", "gpt-image-2");
editBody.append("prompt", "Animate this pet.");
editBody.append("image", new Blob(["png"], { type: "image/png" }), "pet.png");
const editResponse = await dispatch(
bindings,
llmProxyRequest("/images/edits", {
body: editBody,
headers: { Authorization: `Bearer ${grant}` },
method: "POST",
}),
);

expect(generationResponse.status).toBe(200);
expect(editResponse.status).toBe(200);
expect(captured.map((request) => request.url)).toEqual([
"https://api.openai.com/v1/images/generations",
"https://api.openai.com/v1/images/edits",
]);
expect(captured[0]?.body).toBe(JSON.stringify({ model: "gpt-image-2", prompt: "Draw a pet." }));
expect(captured[1]?.headers.get("content-type")).toStartWith("multipart/form-data; boundary=");
expect(captured[1]?.body).toContain('name="model"');
expect(captured[1]?.body).toContain("gpt-image-2");
});

test("rejects OpenAI image calls outside the scoped model capability", async () => {
const { bindings } = await setupFixture({ vendorId: "openai" });
const captured = captureUpstreamFetch();
const unscopedGrant = await createLlmProxyGrant(bindings, {
modelId: "gpt-5.4",
modelProtocol: "openai-responses",
});
const scopedGrant = await createLlmProxyGrant(bindings, {
imageModelId: "gpt-image-2",
modelId: "gpt-5.4",
modelProtocol: "openai-responses",
});

const unscopedResponse = await dispatch(
bindings,
llmProxyRequest("/images/generations", {
body: JSON.stringify({ model: "gpt-image-2", prompt: "Draw a pet." }),
headers: { Authorization: `Bearer ${unscopedGrant}` },
method: "POST",
}),
);
const wrongModelResponse = await dispatch(
bindings,
llmProxyRequest("/images/generations", {
body: JSON.stringify({ model: "gpt-image-1", prompt: "Draw a pet." }),
headers: { Authorization: `Bearer ${scopedGrant}` },
method: "POST",
}),
);
const duplicateModelBody = new FormData();
duplicateModelBody.append("model", "gpt-image-2");
duplicateModelBody.append("model", "gpt-image-1");
duplicateModelBody.append("image", new Blob(["png"], { type: "image/png" }), "pet.png");
const duplicateModelResponse = await dispatch(
bindings,
llmProxyRequest("/images/edits", {
body: duplicateModelBody,
headers: { Authorization: `Bearer ${scopedGrant}` },
method: "POST",
}),
);

expect(unscopedResponse.status).toBe(403);
expect(wrongModelResponse.status).toBe(403);
expect(duplicateModelResponse.status).toBe(403);
expect(captured).toHaveLength(0);
});

test("binds native Gemini endpoints to the granted model", async () => {
const { bindings } = await setupFixture({ vendorId: "opencode" });
const captured = captureUpstreamFetch();
Expand Down
22 changes: 22 additions & 0 deletions apps/api/tests/runtime-vendor-env-vars.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ function vendorCredential(
async function expectLlmProxyGrant(
grant: string | undefined,
modelBinding: {
imageModelId?: string;
modelId: string;
modelProtocol:
| "anthropic-messages"
Expand Down Expand Up @@ -102,6 +103,27 @@ describe("runtime vendor proxy env vars", () => {
});
});

test("scopes official OpenAI runtime grants to the GPT Image model", async () => {
const envVars = await buildVendorProxyEnvVars({
bindings: BINDINGS,
driverGeneration: DRIVER_GENERATION,
driverInstanceId: DRIVER_INSTANCE_ID,
profile: {
model: "gpt-5.4",
runtimeId: "openai-runtime",
vendorCredential: vendorCredential({ vendorId: "openai" }),
},
requestUrl: REQUEST_URL,
});

expect(envVars["OPENAI_BASE_URL"]).toBe(PROXY_URL);
await expectLlmProxyGrant(envVars["OPENAI_API_KEY"], {
imageModelId: "gpt-image-2",
modelId: "gpt-5.4",
modelProtocol: "openai-responses",
});
});

test("keeps a custom Responses endpoint on the control plane for OpenAI runtime", async () => {
const envVars = await buildVendorProxyEnvVars({
bindings: BINDINGS,
Expand Down
Loading