From 5f38ba3ff24f21b019edacaca6ef12cdf5668bbe Mon Sep 17 00:00:00 2001 From: Yevanchen Date: Thu, 30 Jul 2026 15:26:12 +0800 Subject: [PATCH] fix(runtime): scope OpenAI image proxy capability --- .../src/adapters/http/routes/driver-route.ts | 66 ++++++++++--- .../infrastructure/runtime-boot-token.ts | 9 +- .../runtime-vendor-proxy-env.builder.ts | 5 + apps/api/tests/driver-llm-proxy-route.test.ts | 93 +++++++++++++++++++ .../api/tests/runtime-vendor-env-vars.test.ts | 22 +++++ 5 files changed, 180 insertions(+), 15 deletions(-) diff --git a/apps/api/src/adapters/http/routes/driver-route.ts b/apps/api/src/adapters/http/routes/driver-route.ts index 0e0d271d..a289e6c5 100644 --- a/apps/api/src/adapters/http/routes/driver-route.ts +++ b/apps/api/src/adapters/http/routes/driver-route.ts @@ -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) { const grant = c.req.query("grant"); @@ -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 @@ -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; + } } } @@ -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(); @@ -678,7 +707,15 @@ export function registerDriverRoute(app: Hono) { }); } - 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 }, @@ -687,8 +724,9 @@ export function registerDriverRoute(app: Hono) { const grantedRequestBody = await readGrantedLlmProxyRequestBody( c.req.raw, - grant.modelId, + grantedModelId, grant.modelProtocol, + subPath, ); if (grantedRequestBody === null) { diff --git a/apps/api/src/modules/runtime/infrastructure/runtime-boot-token.ts b/apps/api/src/modules/runtime/infrastructure/runtime-boot-token.ts index 18b95bee..d742950d 100644 --- a/apps/api/src/modules/runtime/infrastructure/runtime-boot-token.ts +++ b/apps/api/src/modules/runtime/infrastructure/runtime-boot-token.ts @@ -53,6 +53,7 @@ export type RuntimeActionTokenPayload = action: "llm_proxy"; appId: AppId; driverGeneration: number; + imageModelId?: string; modelId: string; modelProtocol: PresetModelProtocol; resourceId: VendorCredentialId; @@ -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" || @@ -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."); @@ -214,6 +220,7 @@ function parseRuntimeActionTokenPayload(encodedPayload: string): RuntimeActionTo driverGeneration, driverInstanceId: parsedDriverInstanceId, expiresAt, + ...(imageModelId === undefined ? {} : { imageModelId }), modelId, modelProtocol, resourceId: parsePlatformId( diff --git a/apps/api/src/modules/runtime/infrastructure/runtime-sandbox-provisioning/runtime-vendor-proxy-env.builder.ts b/apps/api/src/modules/runtime/infrastructure/runtime-sandbox-provisioning/runtime-vendor-proxy-env.builder.ts index 2014403a..90b57b0f 100644 --- a/apps/api/src/modules/runtime/infrastructure/runtime-sandbox-provisioning/runtime-vendor-proxy-env.builder.ts +++ b/apps/api/src/modules/runtime/infrastructure/runtime-sandbox-provisioning/runtime-vendor-proxy-env.builder.ts @@ -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; @@ -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, }); diff --git a/apps/api/tests/driver-llm-proxy-route.test.ts b/apps/api/tests/driver-llm-proxy-route.test.ts index e3b97be9..13a14835 100644 --- a/apps/api/tests/driver-llm-proxy-route.test.ts +++ b/apps/api/tests/driver-llm-proxy-route.test.ts @@ -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(); diff --git a/apps/api/tests/runtime-vendor-env-vars.test.ts b/apps/api/tests/runtime-vendor-env-vars.test.ts index f30d7d93..6a2fd3e2 100644 --- a/apps/api/tests/runtime-vendor-env-vars.test.ts +++ b/apps/api/tests/runtime-vendor-env-vars.test.ts @@ -46,6 +46,7 @@ function vendorCredential( async function expectLlmProxyGrant( grant: string | undefined, modelBinding: { + imageModelId?: string; modelId: string; modelProtocol: | "anthropic-messages" @@ -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,