diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index ceb99736fc..080248e8f6 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -229,9 +229,9 @@ export async function POST(request) { // Resolve proxy for the connection if credentials exist (#1904) let proxyInfo = null; - if (credentials?.id) { + if (credentials?.connectionId) { try { - proxyInfo = await resolveProxyForConnection(credentials.id); + proxyInfo = await resolveProxyForConnection(credentials.connectionId); } catch { log.debug("PROXY", `Failed to resolve proxy for image provider: ${provider}`); } @@ -248,8 +248,12 @@ export async function POST(request) { }); // Execute with proxy context when available, direct otherwise (#1904) - const result = await (credentials?.id - ? runWithProxyContext(proxyInfo?.proxy || null, generateImage) + const result = await (credentials?.connectionId + ? runWithProxyContext(proxyInfo?.proxy || null, generateImage).catch((err: any) => ({ + success: false, + status: err.statusCode || 500, + error: err.message, + })) : generateImage()); if (result.success) { diff --git a/tests/unit/codex-failover.test.ts b/tests/unit/codex-failover.test.ts index 41b1a0bce3..0546ab9980 100644 --- a/tests/unit/codex-failover.test.ts +++ b/tests/unit/codex-failover.test.ts @@ -157,15 +157,27 @@ test("codex failover: Object.assign patches credentials with new account", () => // ── Test 6: failover only triggers on 429, not other errors ────────────────── test("codex failover: only 429 triggers account rotation", () => { - const shouldTriggerFailover = (provider: string, status: number, attempt: number, maxAttempts: number) => - provider === "codex" && status === 429 && attempt < maxAttempts - 1; + const shouldTriggerFailover = ( + provider: string, + status: number, + attempt: number, + maxAttempts: number + ) => provider === "codex" && status === 429 && attempt < maxAttempts - 1; assert.equal(shouldTriggerFailover("codex", 429, 0, 3), true, "Codex 429 attempt 0 → rotate"); assert.equal(shouldTriggerFailover("codex", 429, 1, 3), true, "Codex 429 attempt 1 → rotate"); - assert.equal(shouldTriggerFailover("codex", 429, 2, 3), false, "Codex 429 last attempt → no rotate"); + assert.equal( + shouldTriggerFailover("codex", 429, 2, 3), + false, + "Codex 429 last attempt → no rotate" + ); assert.equal(shouldTriggerFailover("codex", 500, 0, 3), false, "Codex 500 → no rotate"); assert.equal(shouldTriggerFailover("codex", 200, 0, 3), false, "Codex 200 → no rotate"); - assert.equal(shouldTriggerFailover("openai", 429, 0, 1), false, "OpenAI 429 → no rotate (not codex)"); + assert.equal( + shouldTriggerFailover("openai", 429, 0, 1), + false, + "OpenAI 429 → no rotate (not codex)" + ); }); // ── Test 7: no-credentials response stops rotation ─────────────────────────── diff --git a/tests/unit/image-generation-route.test.ts b/tests/unit/image-generation-route.test.ts index 406b854ac2..e052922a2a 100644 --- a/tests/unit/image-generation-route.test.ts +++ b/tests/unit/image-generation-route.test.ts @@ -11,6 +11,7 @@ process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "image-route-test-api const core = await import("../../src/lib/db/core.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); const imageRoute = await import("../../src/app/api/v1/images/generations/route.ts"); const imageEditRoute = await import("../../src/app/api/v1/images/edits/route.ts"); @@ -138,3 +139,97 @@ test("v1 image edit POST enforces disabled API key policy", async () => { assert.equal(response.status, 403); assert.match(body.error.message, /disabled/); }); + +test("v1 image generation POST resolves proxy and executes with proxy context when credentials.connectionId exists", async () => { + // Create a connection — it gets an auto-generated id used as credentials.connectionId + const connection = await seedConnection("openai", { apiKey: "image-proxy-key" }); + + // Set a key-level proxy for this specific connection (id = connectionId) + await settingsDb.setProxyForLevel("key", (connection as any).id, { + type: "http", + host: "127.0.0.1", + port: 1, // intentionally unreachable — proves proxy path was taken + }); + + globalThis.fetch = async () => { + throw new Error("fetch should not be called — proxy fast-fail should trigger first"); + }; + + const response = await imageRoute.POST( + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "openai/gpt-image-2", + prompt: "proxy test image", + }), + }) + ); + + assert.equal(response.status, 503); + const body = (await response.json()) as any; + assert.match(body.error.message, /unreachable/i); +}); + +test("v1 image generation POST executes directly when proxy resolution fails gracefully", async () => { + const connection = await seedConnection("openai", { apiKey: "image-proxy-fail-key" }); + + const db = core.getDbInstance(); + db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('proxyConfig', 'keys', 'corrupt-json')" + ).run(); + + globalThis.fetch = async (url) => { + const stringUrl = String(url); + if (stringUrl === "https://api.openai.com/v1/images/generations") { + return new Response( + JSON.stringify({ created: 123, data: [{ url: "https://cdn.example.com/proxy-fail.png" }] }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + const response = await imageRoute.POST( + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "openai/gpt-image-2", + prompt: "proxy failover image", + }), + }) + ); + + const body = (await response.json()) as any; + assert.equal(response.status, 200); + assert.equal(body.data[0].url, "https://cdn.example.com/proxy-fail.png"); +}); + +test("v1 image generation POST executes directly when credentials.connectionId is absent (authType: none)", async () => { + globalThis.fetch = async (url) => { + const stringUrl = String(url); + if (stringUrl === "http://localhost:7860/sdapi/v1/txt2img") { + return new Response(JSON.stringify({ images: ["YmFzZTY0LWltYWdl"] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + const response = await imageRoute.POST( + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "sdwebui/stable-diffusion-v1-5", + prompt: "no credentials test", + }), + }) + ); + + const body = (await response.json()) as any; + assert.equal(response.status, 200); + assert.ok(body.data, "should have image data"); +});