From 19dd3cc6640f14f9911a49a554bcac962af5a481 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 03:25:15 +0330 Subject: [PATCH 01/39] fix: repair native image routing --- .github/workflows/image-repair.yml | 43 + .gitignore | 22 + CONTEXT.md | 10 + README.md | 2 +- docs/CODEX-IMAGES.md | 57 + docs/MODEL-AVAILABILITY.md | 83 + open-sse/config/codexPlan.ts | 13 + open-sse/config/imageProviderCatalog.ts | 768 +++++ open-sse/config/imageRegistry.ts | 824 +---- open-sse/config/imageRegistryTypes.ts | 30 + .../providers/registry/codex/imageModels.ts | 47 + open-sse/executors/codex/tools.ts | 14 +- open-sse/handlers/codexImages.ts | 152 + open-sse/handlers/geminiImage.ts | 267 ++ open-sse/handlers/imageGeneration.ts | 2893 +---------------- .../providers/blackForestLabs.ts | 186 ++ .../imageGeneration/providers/codex.ts | 361 ++ .../providers/falGeneration.ts | 105 + .../handlers/imageGeneration/providers/kie.ts | 320 ++ .../imageGeneration/providers/nanoBanana.ts | 362 +++ .../imageGeneration/providers/openAI.ts | 404 +++ .../imageGeneration/providers/recraft.ts | 78 + .../imageGeneration/providers/stability.ts | 245 ++ .../imageGeneration/providers/topaz.ts | 91 + open-sse/handlers/imageGeneration/shared.ts | 486 +++ open-sse/services/imageCombo.ts | 51 +- scripts/check/image-repair-ci.mjs | 289 ++ src/app/api/v1/images/edits/editDispatch.ts | 483 +++ src/app/api/v1/images/edits/editInput.ts | 129 + src/app/api/v1/images/edits/route.ts | 645 +--- src/app/api/v1/images/generations/route.ts | 32 +- src/sse/services/imageCredentialRetry.ts | 7 +- .../services/selectedCodexImageCredentials.ts | 51 + tests/_setup/isolateDataDir.ts | 15 +- ...ard-session-lease-bypass-inventory.test.ts | 4 + tests/unit/image-registry-gpt56.test.ts | 35 +- tests/unit/image-upscale-error-log.test.ts | 12 +- .../native-antigravity-image-repair.test.ts | 135 + tests/unit/native-codex-image-repair.test.ts | 84 + tests/unit/native-codex-images-http.test.ts | 210 ++ tests/unit/native-codex-images.test.ts | 57 + .../native-image-terminal-failure.test.ts | 43 + 42 files changed, 5825 insertions(+), 4320 deletions(-) create mode 100644 .github/workflows/image-repair.yml create mode 100644 CONTEXT.md create mode 100644 docs/CODEX-IMAGES.md create mode 100644 docs/MODEL-AVAILABILITY.md create mode 100644 open-sse/config/codexPlan.ts create mode 100644 open-sse/config/imageProviderCatalog.ts create mode 100644 open-sse/config/imageRegistryTypes.ts create mode 100644 open-sse/config/providers/registry/codex/imageModels.ts create mode 100644 open-sse/handlers/codexImages.ts create mode 100644 open-sse/handlers/geminiImage.ts create mode 100644 open-sse/handlers/imageGeneration/providers/blackForestLabs.ts create mode 100644 open-sse/handlers/imageGeneration/providers/codex.ts create mode 100644 open-sse/handlers/imageGeneration/providers/falGeneration.ts create mode 100644 open-sse/handlers/imageGeneration/providers/kie.ts create mode 100644 open-sse/handlers/imageGeneration/providers/nanoBanana.ts create mode 100644 open-sse/handlers/imageGeneration/providers/openAI.ts create mode 100644 open-sse/handlers/imageGeneration/providers/recraft.ts create mode 100644 open-sse/handlers/imageGeneration/providers/stability.ts create mode 100644 open-sse/handlers/imageGeneration/providers/topaz.ts create mode 100644 open-sse/handlers/imageGeneration/shared.ts create mode 100644 scripts/check/image-repair-ci.mjs create mode 100644 src/app/api/v1/images/edits/editDispatch.ts create mode 100644 src/app/api/v1/images/edits/editInput.ts create mode 100644 src/sse/services/selectedCodexImageCredentials.ts create mode 100644 tests/unit/native-antigravity-image-repair.test.ts create mode 100644 tests/unit/native-codex-image-repair.test.ts create mode 100644 tests/unit/native-codex-images-http.test.ts create mode 100644 tests/unit/native-codex-images.test.ts create mode 100644 tests/unit/native-image-terminal-failure.test.ts diff --git a/.github/workflows/image-repair.yml b/.github/workflows/image-repair.yml new file mode 100644 index 000000000000..1e84708a2abb --- /dev/null +++ b/.github/workflows/image-repair.yml @@ -0,0 +1,43 @@ +name: Native image repair + +on: + push: + branches: [fix/native-image-editing] + workflow_dispatch: + +permissions: + contents: read + +jobs: + image-repair: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + CI: "true" + NEXT_TELEMETRY_DISABLED: "1" + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" + OMNIROUTE_BUILD_BACKEND_ONLY: "1" + OMNIROUTE_USE_TURBOPACK: "0" + NEXT_DIST_DIR: .build/next + OMNIROUTE_MAX_TEST_WORKERS: "2" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24.21.0" + - name: Install, check changed sources, test image seams, build once + run: node scripts/check/image-repair-ci.mjs + - name: Upload verified standalone build only + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: native-image-backend-${{ github.sha }} + path: .build/next/standalone/ + include-hidden-files: true + if-no-files-found: error + retention-days: 3 + - name: Remove owned CI state + if: always() + run: rm -rf -- .ci-work/image-repair diff --git a/.gitignore b/.gitignore index b4fffd360bd4..c2bf75bd4686 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,27 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +# Local workspace protection +/.ai/ +/.specify/ +/specs/ +/secrets.md +/explain-AI.md +/reference.md +/CLAUDE.md +/AGENTS.md +/.agents/ +/.claude/ +/.kiro/ +/.codex/ +/.cursor/ +/.cline/ +/graphify-out/ +/.codebase-memory/ +/plans/ +.ignoreme +**/.ignoreme +/.ci-work/ + # project-specific directories /output/ .slim/deepwork/ diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000000..e602db0fa9f8 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,10 @@ +# Domain Glossary + +- **Orchestration model**: A model that chooses and calls an image-generation tool; not necessarily the engine that creates the image. +- **Image engine**: The model responsible for generating the output pixels. A configured name does not prove which engine served a request. +- **Reference image**: Original user-approved image bytes used to guide generation or editing. +- **Native output**: The image returned by the provider, without client-side resizing. +- **Requested resolution**: The pixel dimensions or resolution tier requested by the operator. +- **Actual resolution**: Pixel dimensions verified by decoding the saved native output. +- **Account availability**: Whether the selected account can use a model and operation at the time checked. +- **Isolated gateway**: A separate runtime with its own writable artifacts, listener and state, independent of the active gateway. diff --git a/README.md b/README.md index 7b69472ec08e..5f5a4256d8a1 100644 --- a/README.md +++ b/README.md @@ -583,7 +583,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute - **🧠 Memory you control** — off by default, opt-in int8 vector quantization + typed decay, per-request `x-omniroute-no-memory`. → [Memory](docs/frameworks/MEMORY.md) - **🛡️ Security** — prompt-injection guard on every LLM route (red-team suite), opt-in credential-masking guardrail (redacts leaked API keys/secrets in both directions), free DuckDuckGo last-resort web search, and an optional OIDC login gate for the dashboard (password login always stays available). → [Guardrails](docs/security/GUARDRAILS.md) - **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md) -- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Magnific, Adobe Firefly, Segmind, and speech providers such as ElevenLabs. → [API Reference](docs/reference/API_REFERENCE.md) +- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Magnific, Adobe Firefly, Segmind, and speech providers such as ElevenLabs. → [API Reference](docs/reference/API_REFERENCE.md). This fork's [native image repair contracts](docs/CODEX-IMAGES.md) document Codex engine routing, Antigravity references and verification limits. - **🌍 Deployment & ops** — reverse-proxy `basePath`, browser-language auto-detect, per-key device tracking, root-less MITM trust, zh-TW localization. → [Environment](docs/reference/ENVIRONMENT.md) - **🤝 More providers & agents** — cloud agents (Codex Cloud, Cursor, Devin, Jules), Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **352-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md) - **📡 Routing transparency** — every response carries an `X-OmniRoute-Decision` header naming the strategy/provider/latency that served it, a new `cache-optimized` combo strategy + Auto-Combo `cacheAffinity` factor route repeat requests back to the connection holding the cached prefix, and a read-only `/v1/auto-combo/{channel}/candidates` endpoint exposes an `auto/*` channel's live candidate pool. → [Auto-Combo](docs/routing/AUTO-COMBO.md) diff --git a/docs/CODEX-IMAGES.md b/docs/CODEX-IMAGES.md new file mode 100644 index 000000000000..b87b70f6be6f --- /dev/null +++ b/docs/CODEX-IMAGES.md @@ -0,0 +1,57 @@ +# Native image repair contracts + +Target: OmniRoute 3.8.51 (`c1e30b7676975feb298b49eff6ff58923c04b89e`). + +## Separate orchestration from image engines + +`cx/gpt-6.1-sol` remains the Codex hosted image-tool orchestration route. Adding its +exact image-registry entry does not rename the image engine or promise native 4K. +The dedicated `cx/gpt-image-2` and `cx/gpt-image-2.5-sunburst` routes instead submit +one JSON request to the selected Codex backend's `images/generations` or +`images/edits` endpoint. They never translate to the hosted chat tool, rotate to +another account after submission, or silently substitute another engine. + +Official Codex request and endpoint source: +- [Typed requests](https://github.com/openai/codex/blob/447eac3b81183b32c1a09f5fba9617abfacbebe3/codex-rs/codex-api/src/images.rs) +- [JSON endpoints](https://github.com/openai/codex/blob/447eac3b81183b32c1a09f5fba9617abfacbebe3/codex-rs/codex-api/src/endpoint/images.rs) +- [Tool model and five-reference limit](https://github.com/openai/codex/blob/b172810921f89847cd310ecc496f9c901760e933/codex-rs/ext/image-generation/src/tool.rs) + +The public API documents the Sunburst identifier, but this is not proof that a +particular Codex OAuth account can use it, nor proof of the engine actually served. +HTTP success and forwarding the model string do not establish either claim. + +## Dedicated request controls + +Generation accepts `prompt`, `model`, `n=1`, optional `size`, `quality`, and +`background`. Only original `b64_json` responses are supported. Edits accept at +most five original PNG/JPEG/WebP references, at most 20 MiB decoded total; JSON +inline data URLs and multipart file inputs are normalized without resizing. +Masks, remote reference URLs, file IDs and unimplemented output controls are +rejected rather than discarded. Selected account policy, proxy and cancellation +remain in effect. Refresh failure terminates before image submission. + +A numeric `size` is forwarded, not enforced by local resampling. Inspect decoded +original output dimensions to establish what was returned. In particular, a +request for `3840x2160` does not establish a 4K result. The historical Sol request +returned 1672x941; it must not be described as native 4K. + +## Antigravity edits + +The registered Flash image route forwards one validated original reference as +Gemini `inlineData` plus the prompt, selected native model, aspect ratio and +resolution tier in the existing Cloud Code envelope. Unsupported masks and URL +response format are rejected before submission. After submission, transport, +parsing, empty-image and upstream failures are terminal: no sibling model or +account request follows an ambiguous outcome. + +[Gemini inline image documentation](https://ai.google.dev/gemini-api/docs/image-generation) +uses base64 original bytes and their MIME type. This contract is distinct from +account availability; see [MODEL-AVAILABILITY.md](MODEL-AVAILABILITY.md). + +## Verification limits + +Offline fixtures establish routing, original-byte preservation, negative guards, +option forwarding and terminal outcomes. They do not establish live entitlement, +image quality or actual native 4K. Hosted CI/build results must refer to the exact +repair commit. The existing gateway, owner credentials and private state are not +included in the fork or CI artifacts. diff --git a/docs/MODEL-AVAILABILITY.md b/docs/MODEL-AVAILABILITY.md new file mode 100644 index 000000000000..c9e3aa0d2de9 --- /dev/null +++ b/docs/MODEL-AVAILABILITY.md @@ -0,0 +1,83 @@ +# Antigravity image model availability + +Checked 2026-10-04. Target: OmniRoute 3.8.51, pinned source +`c1e30b7676975feb298b49eff6ff58923c04b89e`, plus this local image repair. + +## Diagnosis of the second model + +The reported request selected `antigravity/gemini-3-pro-image` and returned upstream +HTTP 404 / `NOT_FOUND`. **The evidence does not establish a wrong model spelling or +an account-entitlement root cause.** Do not rename the request to a preview ID or +replace it with the working Flash model automatically. + +The historical failure is recorded in the repair specification, but its sanitized raw +request/response receipt was not available in the scoped workspace during this review. +The 404 is therefore a reported observation, not a newly reproduced provider result. +No live provider call, credential lookup or private account database inspection was +performed for this diagnosis. + +## What the pinned source proves + +1. The Antigravity image provider uses OAuth and the Cloud Code + `v1internal:generateContent` operation. Its image catalog contains only + `gemini-3.1-flash-image`; it does not register Gemini 3 Pro Image. See the + [pinned image registry, lines 400–407](https://github.com/diegosouzapw/OmniRoute/blob/c1e30b7676975feb298b49eff6ff58923c04b89e/open-sse/config/imageRegistry.ts#L400-L407). +2. The general Antigravity alias map translates `gemini-3-pro-image-preview` **to** + `gemini-3-pro-image`. Its reverse map is a client/quota display conversion, not + evidence that the upstream image operation needs the preview spelling. See + [pinned aliases](https://github.com/diegosouzapw/OmniRoute/blob/c1e30b7676975feb298b49eff6ff58923c04b89e/open-sse/config/antigravityModelAliases.ts#L25-L58). +3. The image parser accepts an explicit provider prefix even when its model is absent + from the static catalog. The original image generator writes the parsed model + directly into the Cloud Code envelope; it does not run the chat executor's alias + resolver. Consequently a generation request for the exact native Pro ID can reach + upstream without being an advertised image-catalog entry. See + [pinned parser](https://github.com/diegosouzapw/OmniRoute/blob/c1e30b7676975feb298b49eff6ff58923c04b89e/open-sse/config/imageRegistry.ts) and + [pinned image generator, lines 1098–1119](https://github.com/diegosouzapw/OmniRoute/blob/c1e30b7676975feb298b49eff6ff58923c04b89e/open-sse/handlers/imageGeneration.ts#L1098-L1119). +4. The local repair preserves the selected native ID and envelope. Reference edits + require exact image-catalog membership, so the unregistered Pro model is rejected + locally with 400 before submission. That guard is not a claim that Google lacks + the model; it says this checkout has not established that edit route. Relevant + local contracts are `open-sse/handlers/geminiImage.ts`, + `open-sse/config/imageProviderCatalog.ts` and + `src/app/api/v1/images/edits/editDispatch.ts`. + +## Public model existence is not OAuth availability + +Google's current [Gemini 3 Pro Image model documentation](https://ai.google.dev/gemini-api/docs/models/gemini-3-pro-image) +identifies stable model code `gemini-3-pro-image`, image/text inputs and image/text +outputs. The page was last updated 2026-09-03 UTC. This confirms the public Gemini API +model exists; it does **not** prove availability on Antigravity's distinct Cloud Code +operation, selected OAuth account, project or client profile. + +The pinned alias source also treats image model quota visibility separately from chat +callability. A visible quota bucket, a model menu, and successful generation by +`gemini-3.1-flash-image` cannot establish Pro image entitlement. + +Upstream [issue 2334](https://github.com/diegosouzapw/OmniRoute/issues/2334) describes an +older 3.8.0 `403 ACCESS_TOKEN_SCOPE_INSUFFICIENT` against the public Gemini service. +That different status and service do not explain the reported Cloud Code 404 in this +3.8.51 task. [Issue 14545](https://github.com/diegosouzapw/OmniRoute/issues/14545) describes +catalog/direct-call asymmetry on other image routes; it supports separating registry +membership from upstream success, not a Pro-account entitlement conclusion. + +## What remains unknown + +The reported 404 is consistent with a model unavailable to that particular upstream +route/account/project at that time, but the response alone cannot distinguish a +retired or unavailable upstream slot from account/project access or another missing +entity. There is no verified alternate native ID or endpoint fix in this review. + +Before enabling Pro edits, obtain authorized, sanitized evidence from the same account, +project and client profile: its authenticated available-image-model result and a +controlled request using the exact native ID. Keep authorization, selected identity, +original references and result dimensions explicit. Do not retry an ambiguous paid +submission, switch accounts/models, upload a reference elsewhere or resize locally to +manufacture success. Account availability remains **unverified** until that evidence +exists. + +## Verification scope + +This document is a read-only source and public-documentation diagnosis. Existing +fixture tests prove local reference dispatch and guards, not Pro availability or +paid-provider success. No runtime, full suite, deployment or live model check was run +for this document. No source behavior or chosen model was changed. diff --git a/open-sse/config/codexPlan.ts b/open-sse/config/codexPlan.ts new file mode 100644 index 000000000000..75a06b87c757 --- /dev/null +++ b/open-sse/config/codexPlan.ts @@ -0,0 +1,13 @@ +// Paid-plan guard shared by hosted tools and the state-independent Images transport. +export function isCodexFreePlan(providerSpecificData: unknown): boolean { + if (!providerSpecificData || typeof providerSpecificData !== "object") return false; + const data = providerSpecificData as { + workspacePlanType?: unknown; + chatgptPlanType?: unknown; + }; + const plan = + typeof data.workspacePlanType === "string" + ? data.workspacePlanType + : data.chatgptPlanType; + return typeof plan === "string" && plan.trim().toLowerCase() === "free"; +} diff --git a/open-sse/config/imageProviderCatalog.ts b/open-sse/config/imageProviderCatalog.ts new file mode 100644 index 000000000000..e157533562d0 --- /dev/null +++ b/open-sse/config/imageProviderCatalog.ts @@ -0,0 +1,768 @@ +import { LMARENA_DIRECT_IMAGE_MODELS } from "./providers/registry/lmarena/directModels.ts"; +import { SEGMIND_IMAGE_PROVIDER } from "./providers/registry/segmind/imageModels.ts"; +import { KIE_IMAGE_MODELS } from "./providers/registry/kie/imageModels.ts"; +import { MAGNIFIC_IMAGE_PROVIDER } from "./providers/registry/magnific/index.ts"; +import { STABILITY_AI_IMAGE_MODELS } from "./providers/registry/stability-ai/imageModels.ts"; +import { CHEAPERINFERENCE_IMAGE_PROVIDER } from "./providers/registry/cheaperinference/imageModels.ts"; +import { + ADOBE_FIREFLY_IMAGE_ROUTING_ALIASES, + toRegistryImageModels, +} from "../services/adobeFireflyModels.ts"; +import { AI_HORDE_IMAGE_PROVIDER } from "./providers/registry/aihorde/imageModels.ts"; + +import type { ImageProviderConfig } from "./imageRegistryTypes.ts"; +import { CODEX_IMAGE_PROVIDER } from "./providers/registry/codex/imageModels.ts"; + +export const IMAGE_PROVIDERS: Record = { + agnes: { + id: "agnes", + baseUrl: "https://apihub.agnes-ai.com/v1/images/generations", + authType: "apikey", + authHeader: "bearer", + format: "agnes-image", + models: [ + { + id: "agnes-image-2.0-flash", + name: "Agnes Image 2.0 Flash", + inputModalities: ["text", "image"], + description: "Agnes text-to-image, image-to-image, and multi-image composition model", + }, + { + id: "agnes-image-2.1-flash", + name: "Agnes Image 2.1 Flash", + inputModalities: ["text", "image"], + description: "Agnes text-to-image, image-to-image, and multi-image composition model", + }, + { + id: "agnes-image-2.5-flash", + name: "Agnes Image 2.5 Flash", + inputModalities: ["text", "image"], + description: "Agnes text-to-image, image-to-image, and multi-image composition model", + }, + ], + supportedSizes: ["1K", "2K", "3K", "4K"], + }, + + "qwen-cloud-token-plan": { + id: "qwen-cloud-token-plan", + alias: "qct", + baseUrl: + "https://token-plan.ap-southeast-1.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", + authType: "apikey", + authHeader: "bearer", + format: "qwen-token-plan-image", + models: [ + { + id: "wan2.7-image", + name: "Wan 2.7 Image", + inputModalities: ["text", "image"], + }, + { + id: "wan2.7-image-pro", + name: "Wan 2.7 Image Pro", + inputModalities: ["text", "image"], + }, + ], + // Both models share 1K/2K support. The Pro model also accepts explicit 4K + // dimensions, which callers can still pass through the permissive request schema. + supportedSizes: ["1024x1024", "2048x2048"], + }, + + openai: { + id: "openai", + baseUrl: "https://api.openai.com/v1/images/generations", + authType: "apikey", + authHeader: "bearer", + format: "openai", // native OpenAI format + models: [ + { id: "dall-e-3", name: "DALL·E 3" }, + { id: "gpt-image-2", name: "GPT Image 2" }, + { id: "gpt-image-1.5", name: "GPT Image 1.5" }, + { id: "gpt-image-1-mini", name: "GPT Image 1 Mini" }, + ], + supportedSizes: ["1024x1024", "1024x1792", "1792x1024"], + }, + + codex: CODEX_IMAGE_PROVIDER, + + // Cursor plan image generation via the Agent CLI native `generateImage` tool. + // Reuses the same OAuth/API-key connection as chat (`provider: "cursor"`). + // Requires the `agent` binary (CURSOR_AGENT_BIN) — see cursorAgentImage handler. + cursor: { + id: "cursor", + alias: "cu", + // Sentinel: execution is local Agent CLI, not an HTTP image API. + baseUrl: "agent://cursor-agent", + authType: "oauth", + authHeader: "bearer", + format: "cursor-agent-image", + models: [ + { id: "auto", name: "Cursor Auto (Image)" }, + { id: "composer-2", name: "Composer 2 (Image)" }, + { id: "composer-2.5", name: "Composer 2.5 (Image)" }, + ], + supportedSizes: ["1024x1024", "1024x1792", "1792x1024", "1024x1536", "1536x1024"], + }, + + maxai: { + id: "maxai", + alias: "mx", + baseUrl: "https://api.maxai.me/gpt/get_image_generate_response", + authType: "apikey", + authHeader: "bearer", + format: "maxai-image", + models: [ + { id: "gpt-image-1", name: "GPT Image 1 (MaxAI)" }, + { id: "dall-e-3", name: "DALL-E 3 (MaxAI)" }, + { id: "flux-1-schnell", name: "FLUX.1 [schnell] (MaxAI)" }, + { id: "flux-1-dev", name: "FLUX.1 [dev] (MaxAI)" }, + { id: "flux-1-pro", name: "FLUX.1 [pro] (MaxAI)" }, + { id: "sd3-medium", name: "Stable Diffusion 3 Medium (MaxAI)" }, + ], + // gpt-image-1/dall-e-3 are size-snapped to 1024x1024 by the handler; flux + // models pass any size through. + supportedSizes: ["1024x1024", "1024x1536", "1536x1024", "1024x1792", "1792x1024"], + }, + + xai: { + id: "xai", + baseUrl: "https://api.x.ai/v1/images/generations", + authType: "apikey", + authHeader: "bearer", + format: "openai", + models: [ + { id: "grok-imagine-image-quality", name: "Grok Imagine Image Quality" }, + { id: "grok-imagine-image", name: "Grok Imagine Image" }, + ], + supportedSizes: ["1024x1024", "2048x2048"], + }, + + "vercel-ai-gateway": { + id: "vercel-ai-gateway", + alias: "vag", + baseUrl: "https://ai-gateway.vercel.sh/v1/images/generations", + authType: "apikey", + authHeader: "bearer", + format: "openai", + models: [ + { id: "gpt-image-1", name: "GPT Image 1" }, + { id: "black-forest-labs/flux-1.1-pro", name: "FLUX 1.1 Pro" }, + ], + supportedSizes: ["1024x1024", "1024x1792", "1792x1024"], + }, + + together: { + id: "together", + baseUrl: "https://api.together.xyz/v1/images/generations", + authType: "apikey", + authHeader: "bearer", + format: "openai", + models: [ + { + id: "black-forest-labs/FLUX.2-max", + name: "FLUX.2 Max", + inputModalities: ["text", "image"], + }, + { + id: "black-forest-labs/FLUX.2-pro", + name: "FLUX.2 Pro", + inputModalities: ["text", "image"], + }, + { + id: "black-forest-labs/FLUX.2-flex", + name: "FLUX.2 Flex", + inputModalities: ["text", "image"], + }, + { + id: "black-forest-labs/FLUX.2-dev", + name: "FLUX.2 Dev", + inputModalities: ["text", "image"], + }, + { id: "openai/gpt-image-1.5", name: "GPT Image 1.5", inputModalities: ["text", "image"] }, + { id: "Wan-AI/Wan2.6-image", name: "Wan 2.6 Image", inputModalities: ["text", "image"] }, + { + id: "Qwen/Qwen-Image-2.0-Pro", + name: "Qwen Image 2.0 Pro", + inputModalities: ["text", "image"], + }, + { id: "Qwen/Qwen-Image-2.0", name: "Qwen Image 2.0", inputModalities: ["text", "image"] }, + { id: "google/flash-image-3.1", name: "NanoBanana 2", inputModalities: ["text", "image"] }, + { + id: "google/gemini-3-pro-image", + name: "NanoBanana Pro", + inputModalities: ["text", "image"], + }, + ], + supportedSizes: ["1024x1024", "512x512"], + }, + + fireworks: { + id: "fireworks", + baseUrl: "https://api.fireworks.ai/inference/v1/images/generations", + authType: "apikey", + authHeader: "bearer", + format: "openai", + models: [ + { id: "accounts/fireworks/flux-kontext-max", name: "FLUX Kontext Max" }, + { id: "accounts/fireworks/flux-kontext-pro", name: "FLUX Kontext Pro" }, + { id: "accounts/fireworks/flux-1-schnell-fp8", name: "FLUX.1 schnell" }, + { id: "accounts/fireworks/models/flux-1-dev-fp8", name: "FLUX 1 Dev FP8" }, + { id: "accounts/fireworks/models/stable-diffusion-xl-1024-v1-0", name: "SDXL 1024 v1.0" }, + ], + supportedSizes: ["1024x1024", "512x512"], + }, + + antigravity: { + id: "antigravity", + baseUrl: "https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent", + authType: "oauth", + authHeader: "bearer", + format: "gemini-image", // Special format: uses Gemini generateContent API + models: [{ id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" }], + supportedSizes: ["1024x1024"], + }, + + //Curruntly no models serving + nebius: { + id: "nebius", + baseUrl: "https://api.tokenfactory.nebius.com/v1/images/generations", + fallbackUrl: "https://api.studio.nebius.com/v1/images/generations", + authType: "apikey", + authHeader: "bearer", + format: "openai", + models: [{ id: "black-forest-labs/flux-schnell", name: "No Model yet" }], + supportedSizes: ["1024x1024"], + }, + + hyperbolic: { + id: "hyperbolic", + baseUrl: "https://api.hyperbolic.xyz/v1/image/generation", + authType: "apikey", + authHeader: "bearer", + format: "hyperbolic", // custom: uses model_name, returns base64 images + models: [{ id: "SDXL1.0-base", name: "No Model yet" }], + supportedSizes: ["1024x1024"], + }, + //Curruntly no models serving + + nanobanana: { + id: "nanobanana", + baseUrl: "https://api.nanobananaapi.ai/api/v1/nanobanana/generate", + proUrl: "https://api.nanobananaapi.ai/api/v1/nanobanana/generate-pro", + statusUrl: "https://api.nanobananaapi.ai/api/v1/nanobanana/record-info", + authType: "apikey", + authHeader: "bearer", + format: "nanobanana", // custom format (async: submit task, then poll) + models: [ + { id: "nanobanana-flash", name: "NanoBanana Flash (Gemini 2.5 Flash)" }, + { id: "nanobanana-pro", name: "NanoBanana Pro (Gemini 3 Pro)" }, + ], + supportedSizes: ["1024x1024", "1024x1280", "1024x1536", "1536x1024", "1280x1024"], + }, + + kie: { + id: "kie", + baseUrl: "https://api.kie.ai", + statusUrl: "https://api.kie.ai/api/v1/jobs/recordInfo", + authType: "apikey", + authHeader: "bearer", + format: "kie-image", + models: KIE_IMAGE_MODELS, + supportedSizes: ["1:1", "16:9", "9:16", "4:3", "3:4"], + }, + + haiper: { + id: "haiper", + baseUrl: "https://api.haiper.ai/v1/jobs/gen2/text2image", + statusUrl: "https://api.haiper.ai/v1/jobs", + authType: "apikey", + authHeader: "HAIPER_KEY", + format: "haiper-image", + models: [{ id: "gen2", name: "Gen 2 Image" }], + supportedSizes: ["16:9", "9:16", "1:1", "4:3", "3:4"], + }, + // #2482: MiniMax already has entries in musicRegistry/audioRegistry/videoRegistry, + // but was missing an image provider entirely, so MiniMax image-model requests + // fell through the format dispatch below to a 400/unmatched-format response. + minimax: { + id: "minimax", + baseUrl: "https://api.minimax.io/v1/image_generation", + authType: "apikey", + authHeader: "bearer", + format: "minimax-image", + models: [ + { id: "image-01", name: "MiniMax Image-01" }, + { id: "image-01-live", name: "MiniMax Image-01 Live" }, + ], + supportedSizes: ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "1024x1024"], + }, + leonardo: { + id: "leonardo", + baseUrl: "https://cloud.leonardo.ai/api/rest/v1/generations", + authType: "apikey", + authHeader: "bearer", + format: "leonardo-image", + models: [ + { id: "phoenix", name: "Phoenix" }, + { id: "sdxl", name: "SDXL" }, + ], + supportedSizes: ["1024x1024", "1024x576", "576x1024"], + }, + ideogram: { + id: "ideogram", + baseUrl: "https://api.ideogram.ai/generate", + authType: "apikey", + authHeader: "Api-Key", + format: "ideogram-image", + models: [ + { id: "V_3", name: "Ideogram V3" }, + { id: "V_2A", name: "Ideogram V2A" }, + ], + supportedSizes: ["1024x1024", "1024x1792", "1792x1024"], + }, + magnific: MAGNIFIC_IMAGE_PROVIDER, + sdwebui: { + id: "sdwebui", + baseUrl: "http://localhost:7860/sdapi/v1/txt2img", + authType: "none", + authHeader: "none", + format: "sdwebui", + models: [ + { id: "stable-diffusion-v1-5", name: "Stable Diffusion v1.5" }, + { id: "sdxl-base-1.0", name: "SDXL Base 1.0" }, + ], + supportedSizes: ["512x512", "768x768", "1024x1024"], + }, + + comfyui: { + id: "comfyui", + baseUrl: "http://localhost:8188", + authType: "none", + authHeader: "none", + format: "comfyui", + models: [ + { id: "flux-dev", name: "FLUX Dev" }, + { id: "sdxl", name: "SDXL" }, + ], + supportedSizes: ["512x512", "768x768", "1024x1024"], + }, + + openrouter: { + id: "openrouter", + baseUrl: "https://openrouter.ai/api/v1/images/generations", + authType: "apikey", + authHeader: "bearer", + format: "openai", + models: [ + { id: "openai/gpt-5.4-image-2", name: "GPT Image 2 (via OpenRouter)" }, + { id: "openai/gpt-5-image-mini", name: "GPT Image 1 Mini (via OpenRouter)" }, + { id: "google/gemini-3.1-flash-image-preview", name: "Nano Banana 2 (via OpenRouter)" }, + { id: "google/gemini-3-pro-image-preview", name: "Nano Banana Pro (via OpenRouter)" }, + { id: "black-forest-labs/flux.2-max", name: "FLUX.2 Max (via OpenRouter)" }, + { id: "black-forest-labs/flux.2-pro", name: "FLUX.2 Pro (via OpenRouter)" }, + { id: "black-forest-labs/flux.2-flex", name: "FLUX.2 Flex (via OpenRouter)" }, + { id: "openai/gpt-image-2.5-sunburst", name: "GPT Image 2.5 Sunburst (via OpenRouter)" }, + { id: "openai/gpt-image-2.5-flare", name: "GPT Image 2.5 Flare (via OpenRouter)" }, + { id: "microsoft/mai-image-2.6", name: "MAI Image 2.6 (via OpenRouter)" }, + { id: "microsoft/mai-image-2.6-flash", name: "MAI Image 2.6 Flash (via OpenRouter)" }, + ], + supportedSizes: ["1024x1024", "1024x1792", "1792x1024"], + }, + + pollinations: { + id: "pollinations", + alias: "pol", + baseUrl: "https://gen.pollinations.ai/v1/images/generations", + authType: "apikey", + authHeader: "bearer", + format: "openai", + models: [ + { id: "klein", name: "FLUX.2 Klein 4B" }, + { id: "flux", name: "Flux Schnell" }, + { id: "zimage", name: "Z-Image Turbo" }, + { id: "qwen-image", name: "Qwen Image Plus" }, + { id: "wan-image", name: "Wan 2.7 Image" }, + { id: "gpt-image-2", name: "GPT Image 2" }, + { id: "gptimage-large", name: "GPT Image 1.5" }, + { id: "gptimage", name: "GPT Image 1 Mini" }, + ], + supportedSizes: ["1024x1024", "512x512"], + }, + + "fal-ai": { + id: "fal-ai", + baseUrl: "https://fal.run", + authType: "apikey", + authHeader: "key", + format: "fal-ai", + models: [ + { id: "flux-2-max", name: "FLUX.2 Max" }, + { id: "flux-2-pro", name: "FLUX.2 Pro" }, + { id: "flux-2-flex", name: "FLUX.2 Flex" }, + { id: "bria/text-to-image/3.2", name: "Bria 3.2" }, + { id: "bytedance/seedream/v4.5/text-to-image", name: "SeeDream V4.5" }, + { id: "bytedance/dreamina/v3.1/text-to-image", name: "Dreamina V3.1" }, + { id: "ideogram/v3", name: "Ideogram V3" }, + // Prefix-only on purpose: adobe-firefly owns the bare nano-banana ids + // (operator decision 2026-07-31, pinned by cheaperinference-image-models + // guard). The dispatch path tolerates the fal-ai/ prefix (fal.ts). + { id: "fal-ai/nano-banana-pro", name: "Nano Banana Pro" }, + { id: "fal-ai/nano-banana-2", name: "Nano Banana 2" }, + { id: "recraft/v4/pro/text-to-image", name: "Recraft V4 Pro via Fal" }, + { id: "recraft/v4/text-to-image", name: "Recraft V4 via Fal" }, + { id: "stable-diffusion-v35-medium", name: "Stable Diffusion v3.5 Medium" }, + ], + supportedSizes: ["1024x1024", "1024x1280", "1280x1024"], + }, + + "stability-ai": { + id: "stability-ai", + baseUrl: "https://api.stability.ai", + authType: "apikey", + authHeader: "bearer", + format: "stability-ai", + models: STABILITY_AI_IMAGE_MODELS, + supportedSizes: ["1024x1024", "1024x1280", "1280x1024"], + }, + + "black-forest-labs": { + id: "black-forest-labs", + baseUrl: "https://api.bfl.ai", + authType: "apikey", + authHeader: "x-key", + format: "black-forest-labs", + models: [ + { id: "flux-2-max", name: "FLUX.2 Max" }, + { id: "flux-2-pro", name: "FLUX.2 Pro" }, + { id: "flux-2-flex", name: "FLUX.2 Flex" }, + { id: "flux-pro-1.1-ultra", name: "flux-pro-1.1-ultra" }, + { id: "flux-pro-1.1", name: "flux-pro-1.1" }, + { id: "flux-2-klein-9b", name: "flux 2 Klein 9B" }, + { id: "flux-2-klein-4b", name: "flux 2 Klein 4B" }, + { id: "flux-kontext-max", name: "flux-kontext-max", inputModalities: ["text", "image"] }, + { id: "flux-kontext-pro", name: "flux-kontext-pro", inputModalities: ["text", "image"] }, + { id: "flux-dev", name: "flux-dev" }, + { id: "flux-pro", name: "flux-pro" }, + ], + supportedSizes: ["1024x1024", "1024x1280", "1280x1024"], + }, + + recraft: { + id: "recraft", + baseUrl: "https://external.api.recraft.ai", + authType: "apikey", + authHeader: "bearer", + format: "recraft", + models: [ + { id: "recraftv4_pro", name: "Recraft V4 Pro" }, + { id: "recraftv4", name: "Recraft V4" }, + { id: "recraftv3", name: "Recraft V3" }, + { id: "recraftv2", name: "Recraft V2" }, + ], + supportedSizes: ["1024x1024", "1024x1280", "1280x1024"], + }, + + topaz: { + id: "topaz", + baseUrl: "https://api.topazlabs.com", + authType: "apikey", + authHeader: "x-api-key", + format: "topaz", + models: [{ id: "topaz-enhance", name: "topaz-enhance", inputModalities: ["image"] }], + supportedSizes: ["1024x1024"], + }, + + // Segmind (#6656): 200+ models, `POST /v1/{model}`, x-api-key, raw image bytes. + segmind: SEGMIND_IMAGE_PROVIDER, + nanogpt: { + id: "nanogpt", + baseUrl: "https://nano-gpt.com/api/v1/images/generations", + authType: "apikey", + authHeader: "bearer", + format: "openai", + models: [ + { id: "qwen-image", name: "Qwen Image", inputModalities: ["text", "image"] }, + { id: "z-image-turbo", name: "Z Image Turbo" }, + { id: "chroma", name: "Chroma" }, + { id: "hidream", name: "Hidream I1 Full" }, + ], + supportedSizes: ["1024x1024", "1024x1280", "1280x1024"], + }, + + // NVIDIA NIM image generation (FLUX models). Distinct from the NVIDIA *chat* entry + // (open-sse/config/providers/registry/nvidia/index.ts, host integrate.api.nvidia.com, + // OpenAI-compatible) — image generation lives on ai.api.nvidia.com/v1/genai/ + // with a native NIM body per model, so it gets a dedicated `nvidia-nim` format/handler + // (handleNvidiaNimImageGeneration) rather than reusing the OpenAI image path. + // Ported from upstream 9router#1195. + nvidia: { + id: "nvidia", + baseUrl: "https://ai.api.nvidia.com/v1/genai", + authType: "apikey", + authHeader: "bearer", + format: "nvidia-nim", + models: [ + { + id: "black-forest-labs/flux.1-dev", + name: "FLUX.1 Dev", + inputModalities: ["text", "image"], + }, + { id: "black-forest-labs/flux.1-schnell", name: "FLUX.1 Schnell" }, + { + id: "black-forest-labs/flux.1-kontext-dev", + name: "FLUX.1 Kontext Dev (Edit)", + inputModalities: ["text", "image"], + }, + { + id: "black-forest-labs/flux.2-klein-4b", + name: "FLUX.2 Klein 4B", + inputModalities: ["text", "image"], + }, + ], + supportedSizes: ["1024x1024", "768x1344", "512x512"], + }, + + // SenseNova (商汤日日新) Text-to-Image on the free Token Plan. OpenAI-compatible + // `/v1/images/generations`, so the generic OpenAI image handler routes it — same + // SenseNova api-key/connection as the chat provider. (9router#2233) + sensenova: { + id: "sensenova", + baseUrl: "https://api.sensenova.cn/v1/images/generations", + authType: "apikey", + authHeader: "bearer", + format: "openai", + models: [{ id: "sensenova-u1-fast", name: "SenseNova U1 Fast" }], + supportedSizes: ["1024x1024"], + }, + + // HuggingFace Hub Inference API text-to-image task. Returns raw image bytes + // (not JSON), so it uses a dedicated "huggingface-image" format handled by + // handleHuggingFaceImageGeneration. Same base URL convention as the HF + // STT/TTS entries in audioRegistry.ts. Model list is deliberately small — + // the dashboard's "suggested models" chip row (GET + // /api/v1/providers/suggested-models) surfaces additional HF Hub models + // beyond this seed list. + huggingface: { + id: "huggingface", + // HF retired api-inference.huggingface.co; text-to-image now routes through + // router.huggingface.co with the hf-inference provider pinned in the path. + baseUrl: "https://router.huggingface.co/hf-inference/models", + authType: "apikey", + authHeader: "bearer", + format: "huggingface-image", + models: [ + { id: "black-forest-labs/FLUX.1-dev", name: "FLUX.1 Dev (HF)" }, + { id: "black-forest-labs/FLUX.1-schnell", name: "FLUX.1 Schnell (HF)" }, + { id: "stabilityai/stable-diffusion-xl-base-1.0", name: "Stable Diffusion XL (HF)" }, + ], + supportedSizes: ["1024x1024"], + }, + + // Arena (formerly LMArena) Direct-chat Image category (static scrape 2026-07-09). + // Not listed in the chat registry — image catalog only. Generation path still + // uses cookie session auth via the lmarena provider connection (stable wire id). + lmarena: { + id: "lmarena", + alias: "lma", + baseUrl: "https://arena.ai/nextjs-api/stream/create-evaluation", + authType: "apikey", + authHeader: "cookie", + format: "openai", + models: LMARENA_DIRECT_IMAGE_MODELS, + supportedSizes: ["1024x1024", "1024x1792", "1792x1024"], + }, + + // Adobe Firefly (unofficial) — IMS access_token (clio-playground-web) or browser + // Cookie from firefly.adobe.com. Async 3P image generate + poll. + // Model list = static fallback from models/discovery capture; live discovery + // refreshes via resolveAdobeFireflyCatalog when credentials work. + "adobe-firefly": { + id: "adobe-firefly", + alias: "firefly", + baseUrl: "https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async", + authType: "apikey", + authHeader: "bearer", + format: "adobe-firefly-image", + models: toRegistryImageModels(), + routingAliases: ADOBE_FIREFLY_IMAGE_ROUTING_ALIASES, + supportedSizes: [], + }, + + // Cheaper Inference (OSS-sponsor gateway). Declared AFTER adobe-firefly on + // purpose: it shares the nano-banana-pro / nano-banana-2 ids, and parseImageModel + // resolves a bare id by first-match over this object's iteration order, so + // Firefly keeps the bare ids and these are prefix-only. See the module for the + // full collision note. + cheaperinference: CHEAPERINFERENCE_IMAGE_PROVIDER, + + // Keep Bailian Coding Plan after existing duplicate model owners so adding + // explicit `bailian-coding-plan/` and `bcp/` routes does not change + // historical bare-model routing. + "bailian-coding-plan": { + id: "bailian-coding-plan", + alias: "bcp", + baseUrl: + "https://coding-intl.dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", + authType: "apikey", + authHeader: "bearer", + format: "bailian-coding-plan-image", + models: [ + { + id: "wan2.7-image", + name: "Wan 2.7 Image", + inputModalities: ["text", "image"], + }, + { + id: "wan2.7-image-pro", + name: "Wan 2.7 Image Pro", + inputModalities: ["text", "image"], + }, + { + id: "qwen-image-2.0", + name: "Qwen Image 2.0", + inputModalities: ["text", "image"], + }, + { + id: "qwen-image-2.0-pro", + name: "Qwen Image 2.0 Pro", + inputModalities: ["text", "image"], + }, + ], + supportedSizes: ["1024x1024", "2048x2048"], + }, + + // Keep Alibaba after existing duplicate model owners so adding explicit + // `alibaba/` and `ali/` routes does not change historical bare-model routing. + alibaba: { + id: "alibaba", + alias: "ali", + baseUrl: + "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", + authType: "apikey", + authHeader: "bearer", + format: "alibaba-image", + models: [ + { + id: "qwen-image-3.0-pro", + name: "Qwen Image 3.0 Pro", + inputModalities: ["text", "image"], + }, + { + id: "qwen-image-2.0-pro-2026-06-22", + name: "Qwen Image 2.0 Pro (2026-06-22)", + inputModalities: ["text", "image"], + }, + { + id: "qwen-image-2.0", + name: "Qwen Image 2.0", + inputModalities: ["text", "image"], + }, + { id: "z-image-turbo", name: "Z-Image Turbo" }, + { id: "wan2.6-t2i", name: "Wan 2.6 T2I" }, + ], + supportedSizes: ["1024x1024", "1280x1280", "2048x2048"], + }, + + // Keep regular Qwen Cloud isolated from Alibaba, Bailian Coding Plan, and + // Qwen Cloud Token Plan. Explicit `qwen-cloud/` or `qwc/` routes use only + // the regular Qwen Cloud connection and its regional DashScope endpoint. + "qwen-cloud": { + id: "qwen-cloud", + alias: "qwc", + baseUrl: + "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", + authType: "apikey", + authHeader: "bearer", + format: "qwen-cloud-image", + models: [ + { + id: "wan2.7-image-pro", + name: "Wan 2.7 Image Pro", + inputModalities: ["text", "image"], + }, + { + id: "wan2.7-image", + name: "Wan 2.7 Image", + inputModalities: ["text", "image"], + }, + { + id: "qwen-image-3.0-pro", + name: "Qwen Image 3.0 Pro", + inputModalities: ["text", "image"], + }, + { + id: "qwen-image-2.0-pro-2026-06-22", + name: "Qwen Image 2.0 Pro (2026-06-22)", + inputModalities: ["text", "image"], + }, + { + id: "qwen-image-2.0-2026-03-03", + name: "Qwen Image 2.0 (2026-03-03)", + inputModalities: ["text", "image"], + }, + { id: "z-image-turbo", name: "Z-Image Turbo" }, + ], + // 1K/2K are shared by the whole catalog. Wan 2.7 Image Pro callers can + // still pass supported 4K dimensions through the permissive request schema. + supportedSizes: ["1024x1024", "2048x2048"], + }, + aihorde: AI_HORDE_IMAGE_PROVIDER, + + // Keep UC after every existing image provider because parseImageModel() resolves + // bare duplicate ids by first match. Explicit `uc/` routes remain available while + // historical owners retain bare ids such as nano-banana and z-image-turbo. + uc: { + id: "uc", + baseUrl: "https://internal.chatuncensored.ai/v2/image-gen", + authType: "apikey", + authHeader: "bearer", + format: "uc-image", + models: [ + { id: "model-dev", name: "Flux Dev (UC)" }, + { id: "model-pro", name: "Flux Pro (UC)" }, + { id: "model-1.1", name: "Flux Pro 1.1 (UC)" }, + { id: "model-1.2", name: "Wan 2.2 (UC)" }, + { id: "seedream-v4.5", name: "Seedream v4.5 (UC)" }, + { id: "seedream-v5", name: "Seedream v5 (UC)" }, + { id: "flux-2", name: "FLUX.2 (UC)" }, + { id: "flux-2-pro", name: "FLUX.2 Pro (UC)" }, + { id: "lustify-v7", name: "Lustify v7 (UC)" }, + { id: "nano-banana", name: "Nano Banana (UC)" }, + { id: "nano-banana-2", name: "Nano Banana 2 (UC)" }, + { id: "nano-banana-pro", name: "Nano Banana Pro (UC)" }, + { id: "nano-banana-ultra", name: "Nano Banana Ultra (UC)" }, + { id: "gpt-image", name: "GPT Image (UC)" }, + { id: "gpt-image-2", name: "GPT Image 2 (UC)" }, + { id: "realism", name: "Realism (UC)" }, + { id: "realism-2", name: "Realism 2 (UC)" }, + { id: "z-image-turbo", name: "Z-Image Turbo (UC)" }, + { id: "prefect-pony-xl", name: "Prefect Pony XL (UC)" }, + { id: "wan-2.6", name: "Wan 2.6 (UC)" }, + { id: "wan-2.7-text-to-image", name: "Wan 2.7 Text-to-Image (UC)" }, + { id: "wan-2.7-text-to-image-pro", name: "Wan 2.7 Text-to-Image Pro (UC)" }, + ], + // Persona web derives imageWidth/imageHeight from an aspect ratio; uc-direct + // passes any OpenAI-style size through. These are the aspect buckets. + supportedSizes: ["1024x1024", "1024x576", "576x1024", "1024x768", "768x1024"], + }, + + // Cloudflare Workers AI image generation (FLUX.1 Schnell). Reuses the same + // Account ID + API Token connection as the existing `cloudflare-ai` chat + // provider (apikey/enterprise-cloud.ts, open-sse/executors/cloudflare-ai.ts). + // Not OpenAI-compatible (dynamic per-account URL, base64-in-JSON response), + // so it gets its own `cloudflare-ai-image` format/handler + // (handleCloudflareAiImageGeneration) rather than the generic OpenAI path. + "cloudflare-ai": { + id: "cloudflare-ai", + alias: "cf", + // Documentation only — the real URL is built per-account in the handler: + // https://api.cloudflare.com/client/v4/accounts//ai/run/ + baseUrl: "https://api.cloudflare.com/client/v4/accounts", + authType: "apikey", + authHeader: "bearer", + format: "cloudflare-ai-image", + models: [{ id: "@cf/black-forest-labs/flux-1-schnell", name: "FLUX.1 Schnell (Workers AI)" }], + supportedSizes: ["1024x1024", "768x768", "512x512"], + }, +}; + diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index b568d7fb83da..c4f80764c8e3 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -6,47 +6,10 @@ */ import { hasUnsafeModelIdSyntax } from "../utils/modelIdSafety.ts"; -import { LMARENA_DIRECT_IMAGE_MODELS } from "./providers/registry/lmarena/directModels.ts"; -import { SEGMIND_IMAGE_PROVIDER } from "./providers/registry/segmind/imageModels.ts"; -import { KIE_IMAGE_MODELS } from "./providers/registry/kie/imageModels.ts"; -import { MAGNIFIC_IMAGE_PROVIDER } from "./providers/registry/magnific/index.ts"; -import { STABILITY_AI_IMAGE_MODELS } from "./providers/registry/stability-ai/imageModels.ts"; -import { CHEAPERINFERENCE_IMAGE_PROVIDER } from "./providers/registry/cheaperinference/imageModels.ts"; -import { - ADOBE_FIREFLY_IMAGE_ROUTING_ALIASES, - toRegistryImageModels, -} from "../services/adobeFireflyModels.ts"; -import { AI_HORDE_IMAGE_PROVIDER } from "./providers/registry/aihorde/imageModels.ts"; +import { IMAGE_PROVIDERS } from "./imageProviderCatalog.ts"; +import type { ImageProviderConfig } from "./imageRegistryTypes.ts"; -interface ImageModelEntry { - id: string; - /** Public catalog id when the callable upstream id would collide with another model surface. */ - catalogId?: string; - name: string; - inputModalities?: string[]; - // See STABILITY_AI_IMAGE_MODELS for why this exists: some models accept "text" - // but mechanically require an image regardless. - imageRequired?: boolean; - description?: string; - isMarket?: boolean; - supportedSizes?: string[]; - mediaCapabilities?: Record; -} - -interface ImageProviderConfig { - id: string; - baseUrl: string; - fallbackUrl?: string; - proUrl?: string; - statusUrl?: string; - alias?: string; - authType: string; - authHeader: string; - format: string; - models: ImageModelEntry[]; - routingAliases?: readonly string[]; - supportedSizes: string[]; -} +export { IMAGE_PROVIDERS }; interface ImageModelAliasEntry { provider: string; @@ -169,787 +132,6 @@ function resolveAliasImageRequired(alias, modelConfig) { return alias.imageRequired ?? modelConfig?.imageRequired; } -export const IMAGE_PROVIDERS: Record = { - agnes: { - id: "agnes", - baseUrl: "https://apihub.agnes-ai.com/v1/images/generations", - authType: "apikey", - authHeader: "bearer", - format: "agnes-image", - models: [ - { - id: "agnes-image-2.0-flash", - name: "Agnes Image 2.0 Flash", - inputModalities: ["text", "image"], - description: "Agnes text-to-image, image-to-image, and multi-image composition model", - }, - { - id: "agnes-image-2.1-flash", - name: "Agnes Image 2.1 Flash", - inputModalities: ["text", "image"], - description: "Agnes text-to-image, image-to-image, and multi-image composition model", - }, - { - id: "agnes-image-2.5-flash", - name: "Agnes Image 2.5 Flash", - inputModalities: ["text", "image"], - description: "Agnes text-to-image, image-to-image, and multi-image composition model", - }, - ], - supportedSizes: ["1K", "2K", "3K", "4K"], - }, - - "qwen-cloud-token-plan": { - id: "qwen-cloud-token-plan", - alias: "qct", - baseUrl: - "https://token-plan.ap-southeast-1.maas.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", - authType: "apikey", - authHeader: "bearer", - format: "qwen-token-plan-image", - models: [ - { - id: "wan2.7-image", - name: "Wan 2.7 Image", - inputModalities: ["text", "image"], - }, - { - id: "wan2.7-image-pro", - name: "Wan 2.7 Image Pro", - inputModalities: ["text", "image"], - }, - ], - // Both models share 1K/2K support. The Pro model also accepts explicit 4K - // dimensions, which callers can still pass through the permissive request schema. - supportedSizes: ["1024x1024", "2048x2048"], - }, - - openai: { - id: "openai", - baseUrl: "https://api.openai.com/v1/images/generations", - authType: "apikey", - authHeader: "bearer", - format: "openai", // native OpenAI format - models: [ - { id: "dall-e-3", name: "DALL·E 3" }, - { id: "gpt-image-2", name: "GPT Image 2" }, - { id: "gpt-image-1.5", name: "GPT Image 1.5" }, - { id: "gpt-image-1-mini", name: "GPT Image 1 Mini" }, - ], - supportedSizes: ["1024x1024", "1024x1792", "1792x1024"], - }, - - // Codex exposes image generation only as a Responses-API hosted tool under - // ChatGPT OAuth. Incoming GPT-Image-style `/v1/images/generations` requests are - // translated to /responses calls with `tools: [{ type: "image_generation" }]` - // by handleCodexImageGeneration. - codex: { - id: "codex", - alias: "cx", - baseUrl: "https://chatgpt.com/backend-api/codex/responses", - authType: "oauth", - authHeader: "bearer", - format: "codex-responses", - models: [ - { - id: "gpt-5.6-sol", - catalogId: "gpt-5.6-sol-image", - name: "GPT 5.6 Sol (Codex Image)", - }, - { - id: "gpt-5.6-terra", - catalogId: "gpt-5.6-terra-image", - name: "GPT 5.6 Terra (Codex Image)", - }, - { - id: "gpt-5.6-luna", - catalogId: "gpt-5.6-luna-image", - name: "GPT 5.6 Luna (Codex Image)", - }, - ], - supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], - }, - - // Cursor plan image generation via the Agent CLI native `generateImage` tool. - // Reuses the same OAuth/API-key connection as chat (`provider: "cursor"`). - // Requires the `agent` binary (CURSOR_AGENT_BIN) — see cursorAgentImage handler. - cursor: { - id: "cursor", - alias: "cu", - // Sentinel: execution is local Agent CLI, not an HTTP image API. - baseUrl: "agent://cursor-agent", - authType: "oauth", - authHeader: "bearer", - format: "cursor-agent-image", - models: [ - { id: "auto", name: "Cursor Auto (Image)" }, - { id: "composer-2", name: "Composer 2 (Image)" }, - { id: "composer-2.5", name: "Composer 2.5 (Image)" }, - ], - supportedSizes: ["1024x1024", "1024x1792", "1792x1024", "1024x1536", "1536x1024"], - }, - - maxai: { - id: "maxai", - alias: "mx", - baseUrl: "https://api.maxai.me/gpt/get_image_generate_response", - authType: "apikey", - authHeader: "bearer", - format: "maxai-image", - models: [ - { id: "gpt-image-1", name: "GPT Image 1 (MaxAI)" }, - { id: "dall-e-3", name: "DALL-E 3 (MaxAI)" }, - { id: "flux-1-schnell", name: "FLUX.1 [schnell] (MaxAI)" }, - { id: "flux-1-dev", name: "FLUX.1 [dev] (MaxAI)" }, - { id: "flux-1-pro", name: "FLUX.1 [pro] (MaxAI)" }, - { id: "sd3-medium", name: "Stable Diffusion 3 Medium (MaxAI)" }, - ], - // gpt-image-1/dall-e-3 are size-snapped to 1024x1024 by the handler; flux - // models pass any size through. - supportedSizes: ["1024x1024", "1024x1536", "1536x1024", "1024x1792", "1792x1024"], - }, - - xai: { - id: "xai", - baseUrl: "https://api.x.ai/v1/images/generations", - authType: "apikey", - authHeader: "bearer", - format: "openai", - models: [ - { id: "grok-imagine-image-quality", name: "Grok Imagine Image Quality" }, - { id: "grok-imagine-image", name: "Grok Imagine Image" }, - ], - supportedSizes: ["1024x1024", "2048x2048"], - }, - - "vercel-ai-gateway": { - id: "vercel-ai-gateway", - alias: "vag", - baseUrl: "https://ai-gateway.vercel.sh/v1/images/generations", - authType: "apikey", - authHeader: "bearer", - format: "openai", - models: [ - { id: "gpt-image-1", name: "GPT Image 1" }, - { id: "black-forest-labs/flux-1.1-pro", name: "FLUX 1.1 Pro" }, - ], - supportedSizes: ["1024x1024", "1024x1792", "1792x1024"], - }, - - together: { - id: "together", - baseUrl: "https://api.together.xyz/v1/images/generations", - authType: "apikey", - authHeader: "bearer", - format: "openai", - models: [ - { - id: "black-forest-labs/FLUX.2-max", - name: "FLUX.2 Max", - inputModalities: ["text", "image"], - }, - { - id: "black-forest-labs/FLUX.2-pro", - name: "FLUX.2 Pro", - inputModalities: ["text", "image"], - }, - { - id: "black-forest-labs/FLUX.2-flex", - name: "FLUX.2 Flex", - inputModalities: ["text", "image"], - }, - { - id: "black-forest-labs/FLUX.2-dev", - name: "FLUX.2 Dev", - inputModalities: ["text", "image"], - }, - { id: "openai/gpt-image-1.5", name: "GPT Image 1.5", inputModalities: ["text", "image"] }, - { id: "Wan-AI/Wan2.6-image", name: "Wan 2.6 Image", inputModalities: ["text", "image"] }, - { - id: "Qwen/Qwen-Image-2.0-Pro", - name: "Qwen Image 2.0 Pro", - inputModalities: ["text", "image"], - }, - { id: "Qwen/Qwen-Image-2.0", name: "Qwen Image 2.0", inputModalities: ["text", "image"] }, - { id: "google/flash-image-3.1", name: "NanoBanana 2", inputModalities: ["text", "image"] }, - { - id: "google/gemini-3-pro-image", - name: "NanoBanana Pro", - inputModalities: ["text", "image"], - }, - ], - supportedSizes: ["1024x1024", "512x512"], - }, - - fireworks: { - id: "fireworks", - baseUrl: "https://api.fireworks.ai/inference/v1/images/generations", - authType: "apikey", - authHeader: "bearer", - format: "openai", - models: [ - { id: "accounts/fireworks/flux-kontext-max", name: "FLUX Kontext Max" }, - { id: "accounts/fireworks/flux-kontext-pro", name: "FLUX Kontext Pro" }, - { id: "accounts/fireworks/flux-1-schnell-fp8", name: "FLUX.1 schnell" }, - { id: "accounts/fireworks/models/flux-1-dev-fp8", name: "FLUX 1 Dev FP8" }, - { id: "accounts/fireworks/models/stable-diffusion-xl-1024-v1-0", name: "SDXL 1024 v1.0" }, - ], - supportedSizes: ["1024x1024", "512x512"], - }, - - antigravity: { - id: "antigravity", - baseUrl: "https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent", - authType: "oauth", - authHeader: "bearer", - format: "gemini-image", // Special format: uses Gemini generateContent API - models: [{ id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" }], - supportedSizes: ["1024x1024"], - }, - - //Curruntly no models serving - nebius: { - id: "nebius", - baseUrl: "https://api.tokenfactory.nebius.com/v1/images/generations", - fallbackUrl: "https://api.studio.nebius.com/v1/images/generations", - authType: "apikey", - authHeader: "bearer", - format: "openai", - models: [{ id: "black-forest-labs/flux-schnell", name: "No Model yet" }], - supportedSizes: ["1024x1024"], - }, - - hyperbolic: { - id: "hyperbolic", - baseUrl: "https://api.hyperbolic.xyz/v1/image/generation", - authType: "apikey", - authHeader: "bearer", - format: "hyperbolic", // custom: uses model_name, returns base64 images - models: [{ id: "SDXL1.0-base", name: "No Model yet" }], - supportedSizes: ["1024x1024"], - }, - //Curruntly no models serving - - nanobanana: { - id: "nanobanana", - baseUrl: "https://api.nanobananaapi.ai/api/v1/nanobanana/generate", - proUrl: "https://api.nanobananaapi.ai/api/v1/nanobanana/generate-pro", - statusUrl: "https://api.nanobananaapi.ai/api/v1/nanobanana/record-info", - authType: "apikey", - authHeader: "bearer", - format: "nanobanana", // custom format (async: submit task, then poll) - models: [ - { id: "nanobanana-flash", name: "NanoBanana Flash (Gemini 2.5 Flash)" }, - { id: "nanobanana-pro", name: "NanoBanana Pro (Gemini 3 Pro)" }, - ], - supportedSizes: ["1024x1024", "1024x1280", "1024x1536", "1536x1024", "1280x1024"], - }, - - kie: { - id: "kie", - baseUrl: "https://api.kie.ai", - statusUrl: "https://api.kie.ai/api/v1/jobs/recordInfo", - authType: "apikey", - authHeader: "bearer", - format: "kie-image", - models: KIE_IMAGE_MODELS, - supportedSizes: ["1:1", "16:9", "9:16", "4:3", "3:4"], - }, - - haiper: { - id: "haiper", - baseUrl: "https://api.haiper.ai/v1/jobs/gen2/text2image", - statusUrl: "https://api.haiper.ai/v1/jobs", - authType: "apikey", - authHeader: "HAIPER_KEY", - format: "haiper-image", - models: [{ id: "gen2", name: "Gen 2 Image" }], - supportedSizes: ["16:9", "9:16", "1:1", "4:3", "3:4"], - }, - // #2482: MiniMax already has entries in musicRegistry/audioRegistry/videoRegistry, - // but was missing an image provider entirely, so MiniMax image-model requests - // fell through the format dispatch below to a 400/unmatched-format response. - minimax: { - id: "minimax", - baseUrl: "https://api.minimax.io/v1/image_generation", - authType: "apikey", - authHeader: "bearer", - format: "minimax-image", - models: [ - { id: "image-01", name: "MiniMax Image-01" }, - { id: "image-01-live", name: "MiniMax Image-01 Live" }, - ], - supportedSizes: ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "1024x1024"], - }, - leonardo: { - id: "leonardo", - baseUrl: "https://cloud.leonardo.ai/api/rest/v1/generations", - authType: "apikey", - authHeader: "bearer", - format: "leonardo-image", - models: [ - { id: "phoenix", name: "Phoenix" }, - { id: "sdxl", name: "SDXL" }, - ], - supportedSizes: ["1024x1024", "1024x576", "576x1024"], - }, - ideogram: { - id: "ideogram", - baseUrl: "https://api.ideogram.ai/generate", - authType: "apikey", - authHeader: "Api-Key", - format: "ideogram-image", - models: [ - { id: "V_3", name: "Ideogram V3" }, - { id: "V_2A", name: "Ideogram V2A" }, - ], - supportedSizes: ["1024x1024", "1024x1792", "1792x1024"], - }, - magnific: MAGNIFIC_IMAGE_PROVIDER, - sdwebui: { - id: "sdwebui", - baseUrl: "http://localhost:7860/sdapi/v1/txt2img", - authType: "none", - authHeader: "none", - format: "sdwebui", - models: [ - { id: "stable-diffusion-v1-5", name: "Stable Diffusion v1.5" }, - { id: "sdxl-base-1.0", name: "SDXL Base 1.0" }, - ], - supportedSizes: ["512x512", "768x768", "1024x1024"], - }, - - comfyui: { - id: "comfyui", - baseUrl: "http://localhost:8188", - authType: "none", - authHeader: "none", - format: "comfyui", - models: [ - { id: "flux-dev", name: "FLUX Dev" }, - { id: "sdxl", name: "SDXL" }, - ], - supportedSizes: ["512x512", "768x768", "1024x1024"], - }, - - openrouter: { - id: "openrouter", - baseUrl: "https://openrouter.ai/api/v1/images/generations", - authType: "apikey", - authHeader: "bearer", - format: "openai", - models: [ - { id: "openai/gpt-5.4-image-2", name: "GPT Image 2 (via OpenRouter)" }, - { id: "openai/gpt-5-image-mini", name: "GPT Image 1 Mini (via OpenRouter)" }, - { id: "google/gemini-3.1-flash-image-preview", name: "Nano Banana 2 (via OpenRouter)" }, - { id: "google/gemini-3-pro-image-preview", name: "Nano Banana Pro (via OpenRouter)" }, - { id: "black-forest-labs/flux.2-max", name: "FLUX.2 Max (via OpenRouter)" }, - { id: "black-forest-labs/flux.2-pro", name: "FLUX.2 Pro (via OpenRouter)" }, - { id: "black-forest-labs/flux.2-flex", name: "FLUX.2 Flex (via OpenRouter)" }, - { id: "openai/gpt-image-2.5-sunburst", name: "GPT Image 2.5 Sunburst (via OpenRouter)" }, - { id: "openai/gpt-image-2.5-flare", name: "GPT Image 2.5 Flare (via OpenRouter)" }, - { id: "microsoft/mai-image-2.6", name: "MAI Image 2.6 (via OpenRouter)" }, - { id: "microsoft/mai-image-2.6-flash", name: "MAI Image 2.6 Flash (via OpenRouter)" }, - ], - supportedSizes: ["1024x1024", "1024x1792", "1792x1024"], - }, - - pollinations: { - id: "pollinations", - alias: "pol", - baseUrl: "https://gen.pollinations.ai/v1/images/generations", - authType: "apikey", - authHeader: "bearer", - format: "openai", - models: [ - { id: "klein", name: "FLUX.2 Klein 4B" }, - { id: "flux", name: "Flux Schnell" }, - { id: "zimage", name: "Z-Image Turbo" }, - { id: "qwen-image", name: "Qwen Image Plus" }, - { id: "wan-image", name: "Wan 2.7 Image" }, - { id: "gpt-image-2", name: "GPT Image 2" }, - { id: "gptimage-large", name: "GPT Image 1.5" }, - { id: "gptimage", name: "GPT Image 1 Mini" }, - ], - supportedSizes: ["1024x1024", "512x512"], - }, - - "fal-ai": { - id: "fal-ai", - baseUrl: "https://fal.run", - authType: "apikey", - authHeader: "key", - format: "fal-ai", - models: [ - { id: "flux-2-max", name: "FLUX.2 Max" }, - { id: "flux-2-pro", name: "FLUX.2 Pro" }, - { id: "flux-2-flex", name: "FLUX.2 Flex" }, - { id: "bria/text-to-image/3.2", name: "Bria 3.2" }, - { id: "bytedance/seedream/v4.5/text-to-image", name: "SeeDream V4.5" }, - { id: "bytedance/dreamina/v3.1/text-to-image", name: "Dreamina V3.1" }, - { id: "ideogram/v3", name: "Ideogram V3" }, - // Prefix-only on purpose: adobe-firefly owns the bare nano-banana ids - // (operator decision 2026-07-31, pinned by cheaperinference-image-models - // guard). The dispatch path tolerates the fal-ai/ prefix (fal.ts). - { id: "fal-ai/nano-banana-pro", name: "Nano Banana Pro" }, - { id: "fal-ai/nano-banana-2", name: "Nano Banana 2" }, - { id: "recraft/v4/pro/text-to-image", name: "Recraft V4 Pro via Fal" }, - { id: "recraft/v4/text-to-image", name: "Recraft V4 via Fal" }, - { id: "stable-diffusion-v35-medium", name: "Stable Diffusion v3.5 Medium" }, - ], - supportedSizes: ["1024x1024", "1024x1280", "1280x1024"], - }, - - "stability-ai": { - id: "stability-ai", - baseUrl: "https://api.stability.ai", - authType: "apikey", - authHeader: "bearer", - format: "stability-ai", - models: STABILITY_AI_IMAGE_MODELS, - supportedSizes: ["1024x1024", "1024x1280", "1280x1024"], - }, - - "black-forest-labs": { - id: "black-forest-labs", - baseUrl: "https://api.bfl.ai", - authType: "apikey", - authHeader: "x-key", - format: "black-forest-labs", - models: [ - { id: "flux-2-max", name: "FLUX.2 Max" }, - { id: "flux-2-pro", name: "FLUX.2 Pro" }, - { id: "flux-2-flex", name: "FLUX.2 Flex" }, - { id: "flux-pro-1.1-ultra", name: "flux-pro-1.1-ultra" }, - { id: "flux-pro-1.1", name: "flux-pro-1.1" }, - { id: "flux-2-klein-9b", name: "flux 2 Klein 9B" }, - { id: "flux-2-klein-4b", name: "flux 2 Klein 4B" }, - { id: "flux-kontext-max", name: "flux-kontext-max", inputModalities: ["text", "image"] }, - { id: "flux-kontext-pro", name: "flux-kontext-pro", inputModalities: ["text", "image"] }, - { id: "flux-dev", name: "flux-dev" }, - { id: "flux-pro", name: "flux-pro" }, - ], - supportedSizes: ["1024x1024", "1024x1280", "1280x1024"], - }, - - recraft: { - id: "recraft", - baseUrl: "https://external.api.recraft.ai", - authType: "apikey", - authHeader: "bearer", - format: "recraft", - models: [ - { id: "recraftv4_pro", name: "Recraft V4 Pro" }, - { id: "recraftv4", name: "Recraft V4" }, - { id: "recraftv3", name: "Recraft V3" }, - { id: "recraftv2", name: "Recraft V2" }, - ], - supportedSizes: ["1024x1024", "1024x1280", "1280x1024"], - }, - - topaz: { - id: "topaz", - baseUrl: "https://api.topazlabs.com", - authType: "apikey", - authHeader: "x-api-key", - format: "topaz", - models: [{ id: "topaz-enhance", name: "topaz-enhance", inputModalities: ["image"] }], - supportedSizes: ["1024x1024"], - }, - - // Segmind (#6656): 200+ models, `POST /v1/{model}`, x-api-key, raw image bytes. - segmind: SEGMIND_IMAGE_PROVIDER, - nanogpt: { - id: "nanogpt", - baseUrl: "https://nano-gpt.com/api/v1/images/generations", - authType: "apikey", - authHeader: "bearer", - format: "openai", - models: [ - { id: "qwen-image", name: "Qwen Image", inputModalities: ["text", "image"] }, - { id: "z-image-turbo", name: "Z Image Turbo" }, - { id: "chroma", name: "Chroma" }, - { id: "hidream", name: "Hidream I1 Full" }, - ], - supportedSizes: ["1024x1024", "1024x1280", "1280x1024"], - }, - - // NVIDIA NIM image generation (FLUX models). Distinct from the NVIDIA *chat* entry - // (open-sse/config/providers/registry/nvidia/index.ts, host integrate.api.nvidia.com, - // OpenAI-compatible) — image generation lives on ai.api.nvidia.com/v1/genai/ - // with a native NIM body per model, so it gets a dedicated `nvidia-nim` format/handler - // (handleNvidiaNimImageGeneration) rather than reusing the OpenAI image path. - // Ported from upstream 9router#1195. - nvidia: { - id: "nvidia", - baseUrl: "https://ai.api.nvidia.com/v1/genai", - authType: "apikey", - authHeader: "bearer", - format: "nvidia-nim", - models: [ - { - id: "black-forest-labs/flux.1-dev", - name: "FLUX.1 Dev", - inputModalities: ["text", "image"], - }, - { id: "black-forest-labs/flux.1-schnell", name: "FLUX.1 Schnell" }, - { - id: "black-forest-labs/flux.1-kontext-dev", - name: "FLUX.1 Kontext Dev (Edit)", - inputModalities: ["text", "image"], - }, - { - id: "black-forest-labs/flux.2-klein-4b", - name: "FLUX.2 Klein 4B", - inputModalities: ["text", "image"], - }, - ], - supportedSizes: ["1024x1024", "768x1344", "512x512"], - }, - - // SenseNova (商汤日日新) Text-to-Image on the free Token Plan. OpenAI-compatible - // `/v1/images/generations`, so the generic OpenAI image handler routes it — same - // SenseNova api-key/connection as the chat provider. (9router#2233) - sensenova: { - id: "sensenova", - baseUrl: "https://api.sensenova.cn/v1/images/generations", - authType: "apikey", - authHeader: "bearer", - format: "openai", - models: [{ id: "sensenova-u1-fast", name: "SenseNova U1 Fast" }], - supportedSizes: ["1024x1024"], - }, - - // HuggingFace Hub Inference API text-to-image task. Returns raw image bytes - // (not JSON), so it uses a dedicated "huggingface-image" format handled by - // handleHuggingFaceImageGeneration. Same base URL convention as the HF - // STT/TTS entries in audioRegistry.ts. Model list is deliberately small — - // the dashboard's "suggested models" chip row (GET - // /api/v1/providers/suggested-models) surfaces additional HF Hub models - // beyond this seed list. - huggingface: { - id: "huggingface", - // HF retired api-inference.huggingface.co; text-to-image now routes through - // router.huggingface.co with the hf-inference provider pinned in the path. - baseUrl: "https://router.huggingface.co/hf-inference/models", - authType: "apikey", - authHeader: "bearer", - format: "huggingface-image", - models: [ - { id: "black-forest-labs/FLUX.1-dev", name: "FLUX.1 Dev (HF)" }, - { id: "black-forest-labs/FLUX.1-schnell", name: "FLUX.1 Schnell (HF)" }, - { id: "stabilityai/stable-diffusion-xl-base-1.0", name: "Stable Diffusion XL (HF)" }, - ], - supportedSizes: ["1024x1024"], - }, - - // Arena (formerly LMArena) Direct-chat Image category (static scrape 2026-07-09). - // Not listed in the chat registry — image catalog only. Generation path still - // uses cookie session auth via the lmarena provider connection (stable wire id). - lmarena: { - id: "lmarena", - alias: "lma", - baseUrl: "https://arena.ai/nextjs-api/stream/create-evaluation", - authType: "apikey", - authHeader: "cookie", - format: "openai", - models: LMARENA_DIRECT_IMAGE_MODELS, - supportedSizes: ["1024x1024", "1024x1792", "1792x1024"], - }, - - // Adobe Firefly (unofficial) — IMS access_token (clio-playground-web) or browser - // Cookie from firefly.adobe.com. Async 3P image generate + poll. - // Model list = static fallback from models/discovery capture; live discovery - // refreshes via resolveAdobeFireflyCatalog when credentials work. - "adobe-firefly": { - id: "adobe-firefly", - alias: "firefly", - baseUrl: "https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async", - authType: "apikey", - authHeader: "bearer", - format: "adobe-firefly-image", - models: toRegistryImageModels(), - routingAliases: ADOBE_FIREFLY_IMAGE_ROUTING_ALIASES, - supportedSizes: [], - }, - - // Cheaper Inference (OSS-sponsor gateway). Declared AFTER adobe-firefly on - // purpose: it shares the nano-banana-pro / nano-banana-2 ids, and parseImageModel - // resolves a bare id by first-match over this object's iteration order, so - // Firefly keeps the bare ids and these are prefix-only. See the module for the - // full collision note. - cheaperinference: CHEAPERINFERENCE_IMAGE_PROVIDER, - - // Keep Bailian Coding Plan after existing duplicate model owners so adding - // explicit `bailian-coding-plan/` and `bcp/` routes does not change - // historical bare-model routing. - "bailian-coding-plan": { - id: "bailian-coding-plan", - alias: "bcp", - baseUrl: - "https://coding-intl.dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", - authType: "apikey", - authHeader: "bearer", - format: "bailian-coding-plan-image", - models: [ - { - id: "wan2.7-image", - name: "Wan 2.7 Image", - inputModalities: ["text", "image"], - }, - { - id: "wan2.7-image-pro", - name: "Wan 2.7 Image Pro", - inputModalities: ["text", "image"], - }, - { - id: "qwen-image-2.0", - name: "Qwen Image 2.0", - inputModalities: ["text", "image"], - }, - { - id: "qwen-image-2.0-pro", - name: "Qwen Image 2.0 Pro", - inputModalities: ["text", "image"], - }, - ], - supportedSizes: ["1024x1024", "2048x2048"], - }, - - // Keep Alibaba after existing duplicate model owners so adding explicit - // `alibaba/` and `ali/` routes does not change historical bare-model routing. - alibaba: { - id: "alibaba", - alias: "ali", - baseUrl: - "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", - authType: "apikey", - authHeader: "bearer", - format: "alibaba-image", - models: [ - { - id: "qwen-image-3.0-pro", - name: "Qwen Image 3.0 Pro", - inputModalities: ["text", "image"], - }, - { - id: "qwen-image-2.0-pro-2026-06-22", - name: "Qwen Image 2.0 Pro (2026-06-22)", - inputModalities: ["text", "image"], - }, - { - id: "qwen-image-2.0", - name: "Qwen Image 2.0", - inputModalities: ["text", "image"], - }, - { id: "z-image-turbo", name: "Z-Image Turbo" }, - { id: "wan2.6-t2i", name: "Wan 2.6 T2I" }, - ], - supportedSizes: ["1024x1024", "1280x1280", "2048x2048"], - }, - - // Keep regular Qwen Cloud isolated from Alibaba, Bailian Coding Plan, and - // Qwen Cloud Token Plan. Explicit `qwen-cloud/` or `qwc/` routes use only - // the regular Qwen Cloud connection and its regional DashScope endpoint. - "qwen-cloud": { - id: "qwen-cloud", - alias: "qwc", - baseUrl: - "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", - authType: "apikey", - authHeader: "bearer", - format: "qwen-cloud-image", - models: [ - { - id: "wan2.7-image-pro", - name: "Wan 2.7 Image Pro", - inputModalities: ["text", "image"], - }, - { - id: "wan2.7-image", - name: "Wan 2.7 Image", - inputModalities: ["text", "image"], - }, - { - id: "qwen-image-3.0-pro", - name: "Qwen Image 3.0 Pro", - inputModalities: ["text", "image"], - }, - { - id: "qwen-image-2.0-pro-2026-06-22", - name: "Qwen Image 2.0 Pro (2026-06-22)", - inputModalities: ["text", "image"], - }, - { - id: "qwen-image-2.0-2026-03-03", - name: "Qwen Image 2.0 (2026-03-03)", - inputModalities: ["text", "image"], - }, - { id: "z-image-turbo", name: "Z-Image Turbo" }, - ], - // 1K/2K are shared by the whole catalog. Wan 2.7 Image Pro callers can - // still pass supported 4K dimensions through the permissive request schema. - supportedSizes: ["1024x1024", "2048x2048"], - }, - aihorde: AI_HORDE_IMAGE_PROVIDER, - - // Keep UC after every existing image provider because parseImageModel() resolves - // bare duplicate ids by first match. Explicit `uc/` routes remain available while - // historical owners retain bare ids such as nano-banana and z-image-turbo. - uc: { - id: "uc", - baseUrl: "https://internal.chatuncensored.ai/v2/image-gen", - authType: "apikey", - authHeader: "bearer", - format: "uc-image", - models: [ - { id: "model-dev", name: "Flux Dev (UC)" }, - { id: "model-pro", name: "Flux Pro (UC)" }, - { id: "model-1.1", name: "Flux Pro 1.1 (UC)" }, - { id: "model-1.2", name: "Wan 2.2 (UC)" }, - { id: "seedream-v4.5", name: "Seedream v4.5 (UC)" }, - { id: "seedream-v5", name: "Seedream v5 (UC)" }, - { id: "flux-2", name: "FLUX.2 (UC)" }, - { id: "flux-2-pro", name: "FLUX.2 Pro (UC)" }, - { id: "lustify-v7", name: "Lustify v7 (UC)" }, - { id: "nano-banana", name: "Nano Banana (UC)" }, - { id: "nano-banana-2", name: "Nano Banana 2 (UC)" }, - { id: "nano-banana-pro", name: "Nano Banana Pro (UC)" }, - { id: "nano-banana-ultra", name: "Nano Banana Ultra (UC)" }, - { id: "gpt-image", name: "GPT Image (UC)" }, - { id: "gpt-image-2", name: "GPT Image 2 (UC)" }, - { id: "realism", name: "Realism (UC)" }, - { id: "realism-2", name: "Realism 2 (UC)" }, - { id: "z-image-turbo", name: "Z-Image Turbo (UC)" }, - { id: "prefect-pony-xl", name: "Prefect Pony XL (UC)" }, - { id: "wan-2.6", name: "Wan 2.6 (UC)" }, - { id: "wan-2.7-text-to-image", name: "Wan 2.7 Text-to-Image (UC)" }, - { id: "wan-2.7-text-to-image-pro", name: "Wan 2.7 Text-to-Image Pro (UC)" }, - ], - // Persona web derives imageWidth/imageHeight from an aspect ratio; uc-direct - // passes any OpenAI-style size through. These are the aspect buckets. - supportedSizes: ["1024x1024", "1024x576", "576x1024", "1024x768", "768x1024"], - }, - - // Cloudflare Workers AI image generation (FLUX.1 Schnell). Reuses the same - // Account ID + API Token connection as the existing `cloudflare-ai` chat - // provider (apikey/enterprise-cloud.ts, open-sse/executors/cloudflare-ai.ts). - // Not OpenAI-compatible (dynamic per-account URL, base64-in-JSON response), - // so it gets its own `cloudflare-ai-image` format/handler - // (handleCloudflareAiImageGeneration) rather than the generic OpenAI path. - "cloudflare-ai": { - id: "cloudflare-ai", - alias: "cf", - // Documentation only — the real URL is built per-account in the handler: - // https://api.cloudflare.com/client/v4/accounts//ai/run/ - baseUrl: "https://api.cloudflare.com/client/v4/accounts", - authType: "apikey", - authHeader: "bearer", - format: "cloudflare-ai-image", - models: [{ id: "@cf/black-forest-labs/flux-1-schnell", name: "FLUX.1 Schnell (Workers AI)" }], - supportedSizes: ["1024x1024", "768x768", "512x512"], - }, -}; /** * Get image provider config by ID diff --git a/open-sse/config/imageRegistryTypes.ts b/open-sse/config/imageRegistryTypes.ts new file mode 100644 index 000000000000..a1c92da551e1 --- /dev/null +++ b/open-sse/config/imageRegistryTypes.ts @@ -0,0 +1,30 @@ +export interface ImageModelEntry { + id: string; + /** Public catalog id when the callable upstream id would collide with another model surface. */ + catalogId?: string; + name: string; + inputModalities?: string[]; + // See STABILITY_AI_IMAGE_MODELS for why this exists: some models accept "text" + // but mechanically require an image regardless. + imageRequired?: boolean; + description?: string; + isMarket?: boolean; + supportedSizes?: string[]; + mediaCapabilities?: Record; +} + +export interface ImageProviderConfig { + id: string; + baseUrl: string; + fallbackUrl?: string; + proUrl?: string; + statusUrl?: string; + alias?: string; + authType: string; + authHeader: string; + format: string; + models: ImageModelEntry[]; + routingAliases?: readonly string[]; + supportedSizes: string[]; +} + diff --git a/open-sse/config/providers/registry/codex/imageModels.ts b/open-sse/config/providers/registry/codex/imageModels.ts new file mode 100644 index 000000000000..c356daa78e80 --- /dev/null +++ b/open-sse/config/providers/registry/codex/imageModels.ts @@ -0,0 +1,47 @@ +import type { ImageProviderConfig } from "../../../imageRegistryTypes.ts"; + +// Hosted orchestration models and explicitly requested image engines are distinct. +// Catalog admission does not establish the served engine or account entitlement. +export const CODEX_IMAGE_PROVIDER: ImageProviderConfig = { + id: "codex", + alias: "cx", + baseUrl: "https://chatgpt.com/backend-api/codex/responses", + authType: "oauth", + authHeader: "bearer", + format: "codex-responses", + models: [ + { + id: "gpt-5.6-sol", + catalogId: "gpt-5.6-sol-image", + name: "GPT 5.6 Sol (Codex Image)", + }, + { + id: "gpt-5.6-terra", + catalogId: "gpt-5.6-terra-image", + name: "GPT 5.6 Terra (Codex Image)", + }, + { + id: "gpt-5.6-luna", + catalogId: "gpt-5.6-luna-image", + name: "GPT 5.6 Luna (Codex Image)", + }, + { + id: "gpt-6.1-sol", + catalogId: "gpt-6.1-sol-image", + name: "GPT 6.1 Sol (Codex Image)", + }, + { + id: "gpt-image-2", + name: "GPT Image 2 (Codex requested engine)", + inputModalities: ["text", "image"], + description: "Dedicated Codex Images request; served engine, account access and actual dimensions remain unverified.", + }, + { + id: "gpt-image-2.5-sunburst", + name: "GPT Image 2.5 Sunburst (Codex requested engine)", + inputModalities: ["text", "image"], + description: "Explicit Sunburst request through Codex Images; OAuth entitlement and served engine are unknown.", + }, + ], + supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], +}; diff --git a/open-sse/executors/codex/tools.ts b/open-sse/executors/codex/tools.ts index 277af81eb991..69790cb8bf18 100644 --- a/open-sse/executors/codex/tools.ts +++ b/open-sse/executors/codex/tools.ts @@ -24,18 +24,8 @@ export const CODEX_HOSTED_TOOL_TYPES: ReadonlySet = new Set([ // id_token) cannot run the server-side `image_generation` hosted tool. The Codex // CLI injects it into every Responses request regardless of plan, so it must be // dropped for free-plan accounts (mirrors CLIProxyAPI's isCodexFreePlanAuth). -export function isCodexFreePlan(providerSpecificData: unknown): boolean { - if (!providerSpecificData || typeof providerSpecificData !== "object") return false; - const data = providerSpecificData as { - workspacePlanType?: unknown; - chatgptPlanType?: unknown; - }; - const plan = - typeof data.workspacePlanType === "string" - ? data.workspacePlanType - : data.chatgptPlanType; - return typeof plan === "string" && plan.trim().toLowerCase() === "free"; -} +import { isCodexFreePlan } from "../../config/codexPlan.ts"; +export { isCodexFreePlan }; type JsonRecord = Record; diff --git a/open-sse/handlers/codexImages.ts b/open-sse/handlers/codexImages.ts new file mode 100644 index 000000000000..4448abdef8fa --- /dev/null +++ b/open-sse/handlers/codexImages.ts @@ -0,0 +1,152 @@ +import { getCodexBackendIdentityHeaders } from "../config/codexClient.ts"; +import { isCodexFreePlan } from "../config/codexPlan.ts"; +import { getConfiguredTimeout } from "@/shared/utils/fetchTimeout"; + +const IMAGE_MODELS = new Set(["gpt-image-2", "gpt-image-2.5-sunburst"]); +const BODY_FIELDS = new Set(["model", "prompt", "n", "size", "quality", "background", "response_format", "image", "images"]); +const WARNING = "Requested image model, account entitlement and output dimensions are not verified by HTTP success; inspect the original output."; +type ReferenceImage = { bytes: Buffer; mime: string }; +const MAX_REFERENCE_BYTES = 20 * 1024 * 1024; + +// The route's existing validation imports the credential DB; keep this transport state-free. +function parseInlineReference(value: unknown): ReferenceImage | null { + if (typeof value !== "string" || value.length > Math.ceil(MAX_REFERENCE_BYTES / 3) * 4 + 64) return null; + const match = /^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/]+={0,2})$/i.exec(value); + if (!match || match[2].length % 4 !== 0) return null; + const bytes = Buffer.from(match[2], "base64"); + return bytes.toString("base64") === match[2] ? { bytes, mime: match[1].toLowerCase() } : null; +} + +function validateReferences(images: ReferenceImage[]): string | null { + if (images.length > 5) return "Codex Images accepts at most 5 reference images"; + let total = 0; + for (const image of images) { + const mime = image.mime.trim().toLowerCase(); + const bytes = image.bytes; + total += bytes.length; + if (total > MAX_REFERENCE_BYTES) return "Codex Images references exceed the 20 MiB decoded limit"; + const valid = mime === "image/png" ? bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) + : mime === "image/jpeg" ? bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff + : mime === "image/webp" && bytes.length >= 12 && bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP"; + if (!valid) return "Codex Images reference content must match PNG, JPEG or WebP MIME"; + } + return null; +} +type Metadata = { warning: string; imagegenRequestId?: string; requestId?: string }; +type Result = + | { success: true; data: Record; metadata: Metadata } + | { success: false; status: number; error: string; retryable: false; metadata: Metadata }; + +/** Explicit image engines only; hosted chat orchestration remains a separate transport. */ +export function isCodexImagesModel(model: string): boolean { + return IMAGE_MODELS.has(model); +} + +/** Single JSON POST through the selected fetch/proxy context, never upload, retry or fallback. */ +export async function handleCodexImages({ + model, + provider, + providerConfig, + body, + credentials, + log, + referenceImages, + operation, + signal = null, +}: { + model: string; + provider: string; + providerConfig: { baseUrl: string }; + body: Record; + credentials?: { accessToken?: string; apiKey?: string; providerSpecificData?: Record } | null; + log?: { info: (tag: string, message: string) => void; warn: (tag: string, message: string) => void; error: (tag: string, message: string) => void } | null; + referenceImages?: ReferenceImage[]; + operation?: "generations" | "edits"; + signal?: AbortSignal | null; +}): Promise { + const metadata: Metadata = { warning: WARNING }; + const fail = (status: number, error: string): Result => { + log?.error("IMAGE", `Codex Images request failed (${status}); details and inputs withheld`); + return { success: false, status, error, retryable: false, metadata }; + }; + if (provider !== "codex" || !isCodexImagesModel(model)) return fail(400, "Unsupported dedicated Codex image model"); + if (!body || typeof body !== "object" || Array.isArray(body)) return fail(400, "Invalid Codex Images request body"); + if (Object.keys(body).some((key) => !BODY_FIELDS.has(key))) return fail(400, "Unsupported Codex Images parameter; masks and output-format controls are not supported"); + if (typeof body.prompt !== "string" || !body.prompt.trim()) return fail(400, "Prompt is required for Codex Images"); + if (body.n !== undefined && body.n !== 1) return fail(400, "Codex Images supports n=1 only"); + if (body.response_format !== undefined && body.response_format !== "b64_json") return fail(400, "Codex Images returns original b64_json only"); + if (body.size !== undefined && (typeof body.size !== "string" || !/^(?:auto|[1-9]\d{0,4}x[1-9]\d{0,4})$/.test(body.size))) return fail(400, "Invalid Codex Images size; use auto or WIDTHxHEIGHT"); + if (body.quality !== undefined && !["low", "medium", "high", "auto"].includes(body.quality as string)) return fail(400, "Unsupported Codex Images quality"); + if (body.background !== undefined && !["transparent", "opaque", "auto"].includes(body.background as string)) return fail(400, "Unsupported Codex Images background"); + const hasImage = Object.hasOwn(body, "image"); + const hasImages = Object.hasOwn(body, "images"); + if ((hasImage && hasImages) || (referenceImages !== undefined && (hasImage || hasImages))) return fail(400, "Ambiguous Codex image references; supply one reference set"); + let images: Array; + if (referenceImages !== undefined) images = referenceImages; + else { + if (hasImages && !Array.isArray(body.images)) return fail(400, "Codex Images images must be an array"); + const candidates = hasImages ? body.images as unknown[] : hasImage ? [body.image] : []; + images = candidates.map((value) => { + if (value && typeof value === "object" && !Array.isArray(value)) { + const ref = value as Record; + if (Object.keys(ref).length !== 1 || !Object.hasOwn(ref, "image_url")) return null; + return parseInlineReference(ref.image_url); + } + return parseInlineReference(value); + }); + } + if (!Array.isArray(images) || images.some((image) => !image || !Buffer.isBuffer(image.bytes) || typeof image.mime !== "string")) return fail(400, "Invalid Codex reference; use inline PNG, JPEG or WebP bytes, not remote URLs or file IDs"); + const references = images as ReferenceImage[]; + const referenceError = validateReferences(references); + if (referenceError) return fail(400, referenceError); + const endpoint = operation ?? (references.length ? "edits" : "generations"); + if (!["generations", "edits"].includes(endpoint) || (endpoint === "edits" && !references.length) || (endpoint === "generations" && references.length) || ((hasImage || hasImages) && !references.length)) return fail(400, "Reference images do not match the Codex Images operation"); + const token = credentials?.accessToken || credentials?.apiKey; + if (!token) return fail(401, "Codex credentials missing accessToken — reconnect the Codex provider"); + if (isCodexFreePlan(credentials?.providerSpecificData)) return fail(403, "Codex Images requires a paid ChatGPT/Codex plan"); + let url: URL; + try { + url = new URL(providerConfig.baseUrl); + if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.hash || !/\/codex\/responses\/?$/.test(url.pathname)) return fail(500, "Codex Images requires a configured Codex Responses endpoint"); + url.pathname = url.pathname.replace(/\/responses\/?$/, `/images/${endpoint}`); + } catch { + return fail(500, "Invalid Codex Images provider endpoint"); + } + const upstreamBody: Record = { model, prompt: body.prompt, n: 1 }; + for (const key of ["size", "quality", "background"]) if (body[key] !== undefined) upstreamBody[key] = body[key]; + if (references.length) upstreamBody.images = references.map((image) => ({ image_url: `data:${image.mime.trim().toLowerCase()};base64,${image.bytes.toString("base64")}` })); + const headers: Record = { ...getCodexBackendIdentityHeaders(), "Content-Type": "application/json", Accept: "application/json", Authorization: `Bearer ${token}` }; + const workspaceId = credentials?.providerSpecificData?.workspaceId; + if (typeof workspaceId === "string" && workspaceId) { + headers["chatgpt-account-id"] = workspaceId; + headers.session_id = workspaceId; + } + if (signal?.aborted) return fail(499, "Codex Images request cancelled before submission"); + const timeoutMs = getConfiguredTimeout(); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 0xffffffff) return fail(500, "Invalid Codex Images timeout configuration"); + const timeout = AbortSignal.timeout(timeoutMs); + const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout; + log?.info("IMAGE", "Submitting one dedicated Codex Images request; inputs and account withheld"); + try { + const response = await fetch(url.toString(), { method: "POST", headers, body: JSON.stringify(upstreamBody), signal: requestSignal }); + for (const [header, key] of [["x-codex-imagegen-request-id", "imagegenRequestId"], ["x-request-id", "requestId"]] as const) { + const id = response.headers.get(header); + if (id && /^[A-Za-z0-9._:-]{1,200}$/.test(id)) metadata[key] = id; + } + if (!response.ok) { + await response.body?.cancel(); + // Upstream error bodies may echo prompts, images or credentials; never expose them. + return fail(response.status, `Codex Images provider rejected the request (HTTP ${response.status}); details withheld`); + } + const data: unknown = await response.json(); + if (!data || typeof data !== "object" || Array.isArray(data)) return fail(502, "Codex Images returned invalid image JSON; do not resubmit an unknown outcome"); + const payload = data as Record; + if (!Number.isSafeInteger(payload.created) || (payload.created as number) < 0 || !Array.isArray(payload.data) || !payload.data.length || payload.data.some((item) => !item || typeof item.b64_json !== "string" || !item.b64_json.trim())) return fail(502, "Codex Images returned no usable image data; do not resubmit an unknown outcome"); + log?.warn("IMAGE", WARNING); + return { success: true, data: payload, metadata }; + } catch { + if (signal?.aborted) return fail(499, "Codex Images request cancelled; outcome may be unknown, do not resubmit"); + if (timeout.aborted) return fail(504, "Codex Images request timed out; outcome may be unknown, do not resubmit"); + return fail(502, "Codex Images request or response failed; outcome may be unknown, do not resubmit"); + } +} diff --git a/open-sse/handlers/geminiImage.ts b/open-sse/handlers/geminiImage.ts new file mode 100644 index 000000000000..0762f0a0887c --- /dev/null +++ b/open-sse/handlers/geminiImage.ts @@ -0,0 +1,267 @@ +import { randomUUID } from "crypto"; +import { saveCallLog } from "@/lib/usageDb"; +import { applyAntigravityClientProfileHeaders } from "../services/antigravityClientProfile.ts"; +import { getAntigravityEnvelopeUserAgent } from "../services/antigravityIdentity.ts"; +import { mapImageSize } from "../translator/image/sizeMapper.ts"; +import { getImageModelEntry } from "../config/imageRegistry.ts"; +import { saveImageErrorResult, sanitizeImageProviderError } from "./imageGeneration/shared.ts"; +import { parseDataUrl, validateCodexImageEditReferences } from "@/lib/images/imageRouteModel"; +import { FetchTimeoutError, fetchWithTimeout, getConfiguredTimeout } from "@/shared/utils/fetchTimeout"; + +function resolveReferences(body, referenceImages) { + if (referenceImages !== undefined) return referenceImages; + const candidates = []; + for (const key of ["image", "image_url", "images", "image_urls", "imageUrls"]) { + if (body[key] !== undefined) candidates.push(...(Array.isArray(body[key]) ? body[key] : [body[key]])); + } + return (Array.isArray(candidates) ? candidates : [candidates]).map((value) => + parseDataUrl(value && typeof value === "object" ? value.image_url ?? value.url : value) + ); +} + +export function validateAntigravityReferences(images, hasMask = false): string | null { + if (hasMask) return "Antigravity image editing does not support masks"; + if (!Array.isArray(images) || images.some((image) => !image || !Buffer.isBuffer(image.bytes) || typeof image.mime !== "string")) { + return "Invalid Antigravity reference image; provide an inline PNG, JPEG, or WebP data URL"; + } + return validateCodexImageEditReferences(images, 1)?.replace(/Codex/g, "Antigravity") ?? null; +} +const IMAGE_ASPECT_RATIO_PATTERN = /^\d+:\d+$/; +const IMAGE_SIZE_PATTERN = /^(?:1K|2K|4K)$/; +function normalizeImageAspectRatio(value: unknown, fallbackSize: unknown): string { + if (typeof value === "string") { + const trimmedValue = value.trim(); + if (IMAGE_ASPECT_RATIO_PATTERN.test(trimmedValue)) return trimmedValue; + } + return mapImageSize(typeof fallbackSize === "string" ? fallbackSize : null); +} + +/** + * Normalize the caller's `image_size` for Antigravity's `imageConfig.imageSize`. + * + * This is the output-resolution axis (`1K` | `2K` | `4K` — the values #11952 observed + * Antigravity accepting; not a documented upstream enum), distinct from the `size`/`aspect_ratio` + * axis handled by `normalizeImageAspectRatio`. Returns `value: undefined` when the caller sent + * nothing usable (absent or non-string), so the key is left out and the upstream default + * applies. A string outside that set is clamped to `1K` rather than forwarded because we have + * not confirmed what upstream does with an unrecognised value; the clamp is reported through + * `clamped: true` so the caller can warn and the call log can record the raw request next to + * what was actually sent (omni-code-review LEDGER-6 / LEDGER-48 / LEDGER-57). + */ +function normalizeImageGenerationSize(value: unknown): { + value: string | undefined; + clamped: boolean; +} { + if (typeof value !== "string") return { value: undefined, clamped: false }; + const normalized = value.trim().toUpperCase(); + if (IMAGE_SIZE_PATTERN.test(normalized)) return { value: normalized, clamped: false }; + return { value: "1K", clamped: true }; +} +/** + * Handle Gemini-format image generation (Antigravity / Nano Banana) + * Uses Gemini's generateContent API with responseModalities: ["TEXT", "IMAGE"] + */ +export async function handleGeminiImageGeneration({ model, providerConfig, body, credentials, log, referenceImages = undefined, signal = null, logPath = "/v1/images/generations" }) { + const startTime = Date.now(); + const references = resolveReferences(body, referenceImages); + const referenceError = validateAntigravityReferences(references, body.mask !== undefined || body.mask_url !== undefined); + if (referenceError) return { success: false, status: 400, error: referenceError }; + const isEdit = references.length > 0 || logPath === "/v1/images/edits"; + if (isEdit && body.response_format !== undefined && body.response_format !== "b64_json") { + return { success: false, status: 400, error: "Antigravity image editing supports only b64_json response format" }; + } + if (references.length > 0) { + const entry = getImageModelEntry(`antigravity/${model}`); + if (!entry || entry.provider !== "antigravity" || entry.model !== model) return { success: false, status: 400, error: "Unsupported Antigravity image edit model" }; + } + const url = providerConfig.baseUrl; + const provider = "antigravity"; + const credentialRecord = credentials || {}; + const token = credentialRecord.accessToken || credentialRecord.apiKey; + const providerSpecificData = credentialRecord.providerSpecificData; + const providerSpecificProjectId = + providerSpecificData && typeof providerSpecificData === "object" + ? (providerSpecificData as Record).projectId + : null; + const credentialProjectId = + typeof credentialRecord.projectId === "string" ? credentialRecord.projectId.trim() : ""; + const providerProjectId = + typeof providerSpecificProjectId === "string" ? providerSpecificProjectId.trim() : ""; + const projectId = credentialProjectId || providerProjectId || null; + const candidateCount = + typeof body.n === "number" && Number.isFinite(body.n) && body.n > 0 ? Math.floor(body.n) : 1; + const promptText = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); + const aspectRatio = normalizeImageAspectRatio(body.aspect_ratio, body.size); + const { value: imageSize, clamped: imageSizeClamped } = normalizeImageGenerationSize( + body.image_size + ); + if (imageSizeClamped && log && typeof log.warn === "function") { + log.warn( + "IMAGE", + `antigravity/${model}: unsupported image_size — clamped to 1K (accepted: 1K|2K|4K)` + ); + } + + // Summarized request for call log. Both axes are recorded so the log never hides what the + // client asked for: `image_size` is the raw caller value (null when absent) and + // `image_size_applied` is what went upstream ("default" when the key was omitted). + const logRequestBody = { + model: body.model, + prompt_chars: promptText.length, + reference_images: references.map((image) => ({ mime: image.mime, bytes: image.bytes.length })), + size: body.size || "default", + aspect_ratio: aspectRatio, + image_size: body.image_size ?? null, + image_size_applied: imageSize ?? "default", + n: candidateCount, + }; + + if (!token) return { success: false, status: 401, error: "Missing Antigravity credentials" }; + + if (!projectId || typeof projectId !== "string") { + return saveImageErrorResult({ + provider, + model, + status: 400, + startTime, + error: + "Missing Google projectId for Antigravity account. Please reconnect OAuth in Providers so OmniRoute can fetch your Cloud Code project.", + requestBody: logRequestBody, + path: logPath, + }); + } + + const antigravityBody = { + project: projectId, + requestId: `image_gen/${Date.now()}/${randomUUID()}/0`, + request: { + contents: [ + { + role: "user", + parts: [{ text: promptText }, ...references.map((image) => ({ inlineData: { mimeType: image.mime.trim().toLowerCase(), data: image.bytes.toString("base64") } }))], + }, + ], + generationConfig: { + candidateCount, + imageConfig: { + aspectRatio, + ...(imageSize ? { imageSize } : {}), + }, + }, + }, + model, + userAgent: getAntigravityEnvelopeUserAgent(credentialRecord), + requestType: "image_gen", + }; + + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }; + applyAntigravityClientProfileHeaders(headers, credentialRecord, antigravityBody); + delete headers["x-goog-user-project"]; + + if (log) { + log.info( + "IMAGE", + `antigravity/${model} (gemini) | references: ${references.length} | ${aspectRatio} ${imageSize ?? "default"}` + ); + } + + try { + const response = await fetchWithTimeout(url, { + method: "POST", + headers, + body: JSON.stringify(antigravityBody), + signal, + timeoutMs: getConfiguredTimeout(), + }); + + if (!response.ok) { + const errorText = await response.text(); + const safeError = sanitizeImageProviderError(errorText); + const safeErrorLog = + typeof safeError === "string" ? safeError : JSON.stringify(safeError ?? {}); + if (log) { + log.error("IMAGE", `antigravity error ${response.status}`); + } + + saveCallLog({ + method: "POST", + path: logPath, + status: response.status, + model: `antigravity/${model}`, + provider, + duration: Date.now() - startTime, + error: `Antigravity upstream error (${response.status})`, + requestBody: logRequestBody, + }).catch(() => {}); + + return { success: false, status: response.status, error: safeError, ...(isEdit ? { retryable: false } : {}) }; + } + + const data = await response.json(); + const responseBody = data.response || data; + + // Extract image data from Antigravity's wrapped Gemini response. + const images = []; + const candidates = responseBody.candidates || []; + for (const candidate of candidates) { + const parts = candidate.content?.parts || []; + for (const part of parts) { + if (typeof part.inlineData?.data === "string" && part.inlineData.data.length > 0) { + images.push({ + b64_json: part.inlineData.data, + revised_prompt: parts.find((p) => p.text)?.text || promptText, + }); + } + } + } + + if (images.length === 0) return { success: false, status: 502, error: "Antigravity completed without an image payload", ...(isEdit ? { retryable: false } : {}) }; + + saveCallLog({ + method: "POST", + path: logPath, + status: 200, + model: `antigravity/${model}`, + provider, + duration: Date.now() - startTime, + tokens: { prompt_tokens: 0, completion_tokens: 0 }, + requestBody: logRequestBody, + responseBody: { images_count: images.length }, + }).catch(() => {}); + + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: images, + }, + }; + } catch (err) { + const status = err instanceof FetchTimeoutError ? 504 : 502; + const message = "Antigravity image request failed"; + if (log) { + log.error("IMAGE", `antigravity fetch error (${status})`); + } + + saveCallLog({ + method: "POST", + path: logPath, + status, + model: `antigravity/${model}`, + provider, + duration: Date.now() - startTime, + error: message, + requestBody: logRequestBody, + }).catch(() => {}); + + return { + success: false, + status, + error: `Image provider error: ${message}`, + ...(isEdit ? { retryable: false } : {}), + }; + } +} diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 2a4d74495b0d..fc358e7bfd60 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "crypto"; /** Image generation handler for POST /v1/images/generations (OpenAI-compatible). */ import { @@ -13,39 +12,6 @@ import { import { getImageProvider, parseImageModel } from "../config/imageRegistry.ts"; import { HTTP_STATUS } from "../config/constants.ts"; -import { applyAntigravityClientProfileHeaders } from "../services/antigravityClientProfile.ts"; -import { getAntigravityEnvelopeUserAgent } from "../services/antigravityIdentity.ts"; -import { kieExecutor } from "../executors/kie.ts"; -import { mapImageSize } from "../translator/image/sizeMapper.ts"; -import { getCodexClientVersion, getCodexUserAgent } from "../config/codexClient.ts"; -import { isCodexFreePlan } from "../executors/codex/tools.ts"; -import { saveCallLog } from "@/lib/usageDb"; -import { sleep } from "../utils/sleep.ts"; -import { - getKieErrorMessage, - getKieErrorStatus, - getKieTaskId, - isJsonObject, - parseKieResultJson, -} from "../utils/kieTask.ts"; -import { - submitComfyWorkflow, - pollComfyResult, - fetchComfyOutput, - extractComfyOutputFiles, - resolveComfyUiBaseUrl, -} from "../utils/comfyuiClient.ts"; -import { fetchUntrustedRemoteImage } from "@/shared/network/remoteImageFetch"; -import { - FetchTimeoutError, - fetchWithTimeout, - getConfiguredTimeout, -} from "@/shared/utils/fetchTimeout"; -import { sanitizeErrorMessage, sanitizeUpstreamDetails } from "../utils/error.ts"; -// Shared with imageUpscale/shared.ts — see imageErrorLog.ts for why a bare -// String(value) is unsafe here (null-prototype sanitizeUpstreamDetails() payloads, #12506). -import { stringifyImageErrorForLog } from "./imageErrorLog.ts"; - import { handleSDWebUIImageGeneration } from "./imageGeneration/providers/sdWebUI.ts"; import { handleHyperbolicImageGeneration } from "./imageGeneration/providers/hyperbolic.ts"; import { handleHuggingFaceImageGeneration } from "./imageGeneration/providers/huggingface.ts"; @@ -65,327 +31,26 @@ import { handleMaxaiImageGeneration } from "./imageGeneration/providers/maxaiIma import { handleAdobeFireflyImageGeneration } from "./imageGeneration/providers/adobeFirefly.ts"; import { handleAlibabaImageGeneration } from "./imageGeneration/providers/alibabaImage.ts"; import { handleAiHordeImageGeneration } from "./imageGeneration/providers/aihorde.ts"; -import { - applyPollinationsAnonymousFallback, - reportPollinationsAnonOutcome, -} from "./imageGeneration/pollinationsAnonAuth.ts"; - // Re-export so /v1/images/edits can dispatch Firefly reference-image edits. export { handleAdobeFireflyImageGeneration }; - -interface KieImageOptions { - model: string; - provider: string; - providerConfig: { - baseUrl: string; - statusUrl?: string; - }; - body: Record & { - prompt?: unknown; - size?: unknown; - n?: unknown; - timeout_ms?: unknown; - poll_interval_ms?: unknown; - }; - credentials?: { - apiKey?: string; - accessToken?: string; - } | null; - log?: { - info: (scope: string, message: string) => void; - error: (scope: string, message: string) => void; - } | null; -} - -// KIE Market catalog ids are namespaced for OmniRoute's catalog -// (`/`), but the KIE Market createTask API expects -// vendor-specific upstream ids that do not follow a single consistent -// pattern. Every entry below was confirmed individually against the literal -// example request JSON published on docs.kie.ai (never inferred by pattern — -// see #11326's false "everything else already matches" claim and #11296's -// follow-up correction): -// - google-imagen: nano-banana-2 and nano-banana-pro drop the vendor -// namespace entirely; nano-banana and nano-banana-edit use a `google/` -// prefix instead of `google-imagen/` (docs.kie.ai/market/google/*). -// - gpt: gpt-image-2-* drops the `gpt/` namespace entirely -// (docs.kie.ai/market/gpt/gpt-image-2-*); gpt-image-1.5-* uses a -// `gpt-image/` namespace instead of `gpt/gpt-image-1.5-`, and keeps the -// dot in "1.5" (docs.kie.ai/market/gpt-image/1-5-*). -// - seedream: 5.0-lite-* drops the ".0" — real id is `5-lite-*` -// (docs.kie.ai/market/seedream/5-lite-text-to-image); seedream 4.5 (T2I -// and edit) already matches byte-for-byte. -// - flux: `flux/2-*` uses a `flux-2/` namespace (dash, not slash); the -// generic (non-"pro") variant is named `flex` upstream, not `2` -// (docs.kie.ai/market/flux2/pro-*, .../flex-*). -// - wan: `wan/2.7-*` keeps the dot in our catalog, but KIE's documented -// enum uses a dash — real id is `wan/2-7-*` -// (docs.kie.ai/market/wan/2-7-image[-pro]). -// - ideogram (v3-text-to-image, v3-edit, v3-remix), qwen, qwen2, and -// grok-imagine already match byte-for-byte -// (docs.kie.ai/market/{ideogram,qwen,qwen2,grok-imagine}/*). -// ideogram/v3-reframe has no dedicated docs.kie.ai page as of this sweep -// (its 3 siblings above are all direct id matches, so it is assumed -// correct by pattern, not independently confirmed). -// One catalog entry remains UNRESOLVED after this sweep and is deliberately -// left untouched pending a follow-up (see #11296 discussion): -// - z-image/4.0-text-to-image and z-image/4.5-text-to-image: the only -// documented Z-Image Market page (docs.kie.ai/market/z-image/z-image) -// shows a single fixed `model` enum value `"z-image"` with no -// version-specific id or "version" input field found — unclear whether -// both catalog ids should collapse to the same upstream call. -// flux/kontext is RESOLVED (#11296): it is catalogued with `isMarket: true` -// but has no `docs.kie.ai/market/flux2/kontext` (or similar) Market page — -// Flux Kontext is documented under the separate `/flux-kontext-api/*` docs -// tree with its own endpoint (`POST /api/v1/flux/kontext/generate`, poll -// `GET /api/v1/flux/kontext/record-info`, models `flux-kontext-pro`/ -// `flux-kontext-max`), not the Market `createTask` flow this map feeds. It is -// NOT in KIE_MARKET_UPSTREAM_MODEL_IDS below on purpose — handleKieImageGeneration -// reroutes it to the dedicated endpoint instead of rewriting its id. -export const KIE_MARKET_UPSTREAM_MODEL_IDS: ReadonlyMap = new Map([ - ["google-imagen/nano-banana", "google/nano-banana"], - ["google-imagen/nano-banana-2", "nano-banana-2"], - ["google-imagen/nano-banana-pro", "nano-banana-pro"], - ["google-imagen/nano-banana-edit", "google/nano-banana-edit"], - ["gpt/gpt-image-2-text-to-image", "gpt-image-2-text-to-image"], - ["gpt/gpt-image-2-image-to-image", "gpt-image-2-image-to-image"], - ["gpt/gpt-image-1.5-text-to-image", "gpt-image/1.5-text-to-image"], - ["gpt/gpt-image-1.5-image-to-image", "gpt-image/1.5-image-to-image"], - ["seedream/5.0-lite-text-to-image", "seedream/5-lite-text-to-image"], - ["seedream/5.0-lite-image-to-image", "seedream/5-lite-image-to-image"], - ["flux/2-pro-text-to-image", "flux-2/pro-text-to-image"], - ["flux/2-pro-image-to-image", "flux-2/pro-image-to-image"], - ["flux/2-text-to-image", "flux-2/flex-text-to-image"], - ["flux/2-image-to-image", "flux-2/flex-image-to-image"], - ["wan/2.7-image", "wan/2-7-image"], - ["wan/2.7-image-pro", "wan/2-7-image-pro"], -]); - -export function resolveKieMarketUpstreamModelId(publicModelId: string): string { - return KIE_MARKET_UPSTREAM_MODEL_IDS.get(publicModelId) ?? publicModelId; -} - -const OPENAI_IMAGE_TO_IMAGE_MODELS = new Set([ - "black-forest-labs/FLUX.2-max", - "black-forest-labs/FLUX.2-pro", - "black-forest-labs/FLUX.2-flex", - "black-forest-labs/FLUX.2-dev", - "openai/gpt-image-1.5", - "Wan-AI/Wan2.6-image", - "Qwen/Qwen-Image-2.0-Pro", - "Qwen/Qwen-Image-2.0", - "google/flash-image-3.1", - "google/gemini-3-pro-image", - "flux-kontext-max", - "flux-kontext", - "flux-kontext-pro", - "qwen-image", -]); - -const IMAGE_ASPECT_RATIO_PATTERN = /^\d+:\d+$/; -const IMAGE_SIZE_PATTERN = /^(?:1K|2K|4K)$/; - -/** - * Resolve the upstream images endpoint for a custom (OpenAI-compatible) image - * provider node (#3205). - * - * Custom provider nodes store their base URL the same way the chat path does: - * in `credentials.providerSpecificData.baseUrl` (e.g. `https://example.com/v1`), - * NOT as a top-level `credentials.baseUrl`. Older callers may still pass a - * top-level `baseUrl`, so we honor that as a secondary source. When neither is - * present we fall back to `fallback` (the built-in Gemini OpenAI endpoint). - * - * Resolution order: providerSpecificData.baseUrl → credentials.baseUrl → fallback. - * - * A node base URL like `https://example.com/v1` is normalized and the - * OpenAI-compatible `/images/generations` path appended (mirroring - * `buildOpenAICompatibleUrl` in services/provider.ts). A node URL that already - * ends in `/images/generations` is returned as-is (no double-append). The - * `fallback` value is assumed to already be a complete URL and is returned - * verbatim. - */ -export function resolveImageBaseUrl( - credentials: - { baseUrl?: unknown; providerSpecificData?: { baseUrl?: unknown } | null } | null | undefined, - fallback: string, - endpoint: "generations" | "edits" = "generations" -): string { - const psd = credentials?.providerSpecificData; - const psdBaseUrl = - psd && typeof psd === "object" && typeof psd.baseUrl === "string" && psd.baseUrl.trim() - ? psd.baseUrl.trim() - : null; - const topLevelBaseUrl = - typeof credentials?.baseUrl === "string" && credentials.baseUrl.trim() - ? credentials.baseUrl.trim() - : null; - const nodeBaseUrl = psdBaseUrl || topLevelBaseUrl; - - if (!nodeBaseUrl) return fallback; - - // A single configured node serves both image routes: honor a base URL that already - // points at the requested OpenAI image path, and rewrite one that points at the other - // image endpoint (e.g. `.../images/generations` requested for edits) (#3214/#3215). - const suffix = `/images/${endpoint}`; - // Trim trailing slashes without a backtracking-prone regex (`/\/+$/` is a - // polynomial-ReDoS pattern on long runs of "/" — CodeQL js/polynomial-redos). - let normalized = nodeBaseUrl; - while (normalized.endsWith("/")) normalized = normalized.slice(0, -1); - if (normalized.endsWith(suffix)) return normalized; - const stripped = normalized.replace(/\/images\/(?:generations|edits)$/, ""); - return `${stripped}${suffix}`; -} - -function normalizeImageAspectRatio(value: unknown, fallbackSize: unknown): string { - if (typeof value === "string") { - const trimmedValue = value.trim(); - if (IMAGE_ASPECT_RATIO_PATTERN.test(trimmedValue)) return trimmedValue; - } - return mapImageSize(typeof fallbackSize === "string" ? fallbackSize : null); -} - -/** - * Normalize the caller's `image_size` for Antigravity's `imageConfig.imageSize`. - * - * This is the output-resolution axis (`1K` | `2K` | `4K` — the values #11952 observed - * Antigravity accepting; not a documented upstream enum), distinct from the `size`/`aspect_ratio` - * axis handled by `normalizeImageAspectRatio`. Returns `value: undefined` when the caller sent - * nothing usable (absent or non-string), so the key is left out and the upstream default - * applies. A string outside that set is clamped to `1K` rather than forwarded because we have - * not confirmed what upstream does with an unrecognised value; the clamp is reported through - * `clamped: true` so the caller can warn and the call log can record the raw request next to - * what was actually sent (omni-code-review LEDGER-6 / LEDGER-48 / LEDGER-57). - */ -function normalizeImageGenerationSize(value: unknown): { - value: string | undefined; - clamped: boolean; -} { - if (typeof value !== "string") return { value: undefined, clamped: false }; - const normalized = value.trim().toUpperCase(); - if (IMAGE_SIZE_PATTERN.test(normalized)) return { value: normalized, clamped: false }; - return { value: "1K", clamped: true }; -} - -function parseJsonOrNull(value: string): unknown | null { - try { - return JSON.parse(value); - } catch { - return null; - } -} - -function sanitizeImageProviderError(errorText: string): unknown { - const parsed = parseJsonOrNull(errorText); - if (parsed !== null) { - return sanitizeUpstreamDetails(parsed) || sanitizeErrorMessage(errorText); - } - return sanitizeErrorMessage(errorText); -} - -// #8307 — some ChatGPT accounts can run Codex but lack entitlement for the specific -// requested image model. Upstream signals this as a 400 with an exact, stable message -// (not a generic "invalid request"). Classify it so the caller can mark the failure -// `retryable: true`, which routes it through the same sibling-account fallback that -// already handles 401s (executeImageWithCredentialFallback, src/sse/services/imageCredentialRetry.ts). -function isCodexChatGptModelAccessError(status: number, errorText: string, model: string): boolean { - if (status !== 400) return false; - const parsed = parseJsonOrNull(errorText); - let detail: string | null = null; - if (typeof parsed === "string") { - detail = parsed; - } else if (parsed && typeof parsed === "object") { - const obj = parsed as Record; - if (typeof obj.detail === "string") detail = obj.detail; - else if (typeof obj.message === "string") detail = obj.message; - else if (obj.error && typeof obj.error === "object") { - const nested = (obj.error as Record).message; - if (typeof nested === "string") detail = nested; - } - } - return ( - detail === `The '${model}' model is not supported when using Codex with a ChatGPT account.` - ); -} - -const BFL_MODEL_ENDPOINTS = { - "flux-2-max": "/v1/flux-2-max", - "flux-2-pro": "/v1/flux-2-pro", - "flux-2-flex": "/v1/flux-2-flex", - "flux-2-klein-9b": "/v1/flux-2-klein-9b", - "flux-2-klein-4b": "/v1/flux-2-klein-4b", - "flux-kontext-pro": "/v1/flux-kontext-pro", - "flux-kontext-max": "/v1/flux-kontext-max", - "flux-pro-1.1": "/v1/flux-pro-1.1", - "flux-pro-1.1-ultra": "/v1/flux-pro-1.1-ultra", - "flux-dev": "/v1/flux-dev", - "flux-pro": "/v1/flux-pro", -}; - -const BFL_EDIT_MODELS = new Set([ - "flux-2-max", - "flux-2-pro", - "flux-2-flex", - "flux-kontext-pro", - "flux-kontext-max", -]); - -const BFL_FAILURE_STATUSES = new Set(["Error", "Failed", "Content Moderated", "Request Moderated"]); - -const STABILITY_GENERATION_ENDPOINTS = { - "sd3.5-large": "/v2beta/stable-image/generate/sd3", - "sd3.5-large-turbo": "/v2beta/stable-image/generate/sd3", - "sd3.5-medium": "/v2beta/stable-image/generate/sd3", - "sd3.5-flash": "/v2beta/stable-image/generate/sd3", - "stable-image-ultra": "/v2beta/stable-image/generate/ultra", - "stable-image-core": "/v2beta/stable-image/generate/core", -}; - -const STABILITY_EDIT_ENDPOINTS = { - inpaint: "/v2beta/stable-image/edit/inpaint", - outpaint: "/v2beta/stable-image/edit/outpaint", - erase: "/v2beta/stable-image/edit/erase", - "search-and-replace": "/v2beta/stable-image/edit/search-and-replace", - "search-and-recolor": "/v2beta/stable-image/edit/search-and-recolor", - "remove-background": "/v2beta/stable-image/edit/remove-background", - "replace-background-and-relight": "/v2beta/stable-image/edit/replace-background-and-relight", - fast: "/v2beta/stable-image/upscale/fast", - conservative: "/v2beta/stable-image/upscale/conservative", - creative: "/v2beta/stable-image/upscale/creative", - sketch: "/v2beta/stable-image/control/sketch", - structure: "/v2beta/stable-image/control/structure", - style: "/v2beta/stable-image/control/style", - "style-transfer": "/v2beta/stable-image/control/style-transfer", -}; - -const STABILITY_CONTROL_MODELS = new Set(["sketch", "structure", "style", "style-transfer"]); - -function appendOptionalFormValue(formData, key, value) { - if (value === undefined || value === null || value === "") return; - formData.append(key, String(value)); -} - -function appendImageFormValue(formData, key, source, filename) { - formData.append( - key, - new Blob([source.buffer], { - type: source.contentType || "application/octet-stream", - }), - filename - ); -} - -const FAL_PRESET_SIZES = { - "1024x1024": "square_hd", - "512x512": "square", - "1792x1024": "landscape_16_9", - "1024x1792": "portrait_16_9", - "1024x768": "landscape_4_3", - "768x1024": "portrait_4_3", - "1536x1024": "landscape_3_2", - "1024x1536": "portrait_3_2", - "576x1024": "portrait_16_9", - "1024x576": "landscape_16_9", -}; - +import { handleGeminiImageGeneration } from "./geminiImage.ts"; +import { resolveImageBaseUrl } from "./imageGeneration/shared.ts"; +import { resolveComfyUiBaseUrl } from "../utils/comfyuiClient.ts"; +import { handleKieImageGeneration } from "./imageGeneration/providers/kie.ts"; +import { handleOpenAIImageGeneration } from "./imageGeneration/providers/openAI.ts"; +import { handleCodexImageGeneration } from "./imageGeneration/providers/codex.ts"; +import { handleCodexImages, isCodexImagesModel } from "./codexImages.ts"; +import { handleFalAIImageGeneration } from "./imageGeneration/providers/falGeneration.ts"; +import { handleStabilityAIImageGeneration } from "./imageGeneration/providers/stability.ts"; +import { handleBlackForestLabsImageGeneration } from "./imageGeneration/providers/blackForestLabs.ts"; +import { handleRecraftImageGeneration } from "./imageGeneration/providers/recraft.ts"; +import { handleTopazImageGeneration } from "./imageGeneration/providers/topaz.ts"; +import { handleNanoBananaImageGeneration } from "./imageGeneration/providers/nanoBanana.ts"; +export { KIE_MARKET_UPSTREAM_MODEL_IDS, resolveKieMarketUpstreamModelId } from "./imageGeneration/providers/kie.ts"; +export { handleOpenAIImageEdit, handleOpenRouterImageEdit } from "./imageGeneration/providers/openAI.ts"; +export { handleCodexImageEdit, extractImageGenerationCalls } from "./imageGeneration/providers/codex.ts"; +export { normalizeNanoBananaTaskResult } from "./imageGeneration/providers/nanoBanana.ts"; +export { resolveImageBaseUrl, resolveImageSource, normalizeRequestedImageFormat, mapFalImageSize, normalizeProviderImagePayload, saveImageSuccessResult, saveImageErrorResult } from "./imageGeneration/shared.ts"; /** * Handle image generation request * @param {object} options @@ -518,7 +183,7 @@ export async function handleImageGeneration({ } if (providerConfig.format === "gemini-image") { - return handleGeminiImageGeneration({ model, providerConfig, body, credentials, log }); + return handleGeminiImageGeneration({ model, providerConfig, body, credentials, log, signal }); } if (providerConfig.format === "imagen3") { @@ -705,6 +370,7 @@ export async function handleImageGeneration({ } if (providerConfig.format === "codex-responses") { + if (isCodexImagesModel(model)) return handleCodexImages({ model, provider, providerConfig, body, credentials, log, signal }); return handleCodexImageGeneration({ model, provider, @@ -811,2522 +477,3 @@ export async function handleImageGeneration({ return handleOpenAIImageGeneration({ model, provider, providerConfig, body, credentials, log }); } - -function normalizeKieImageResult(recordData: unknown): string[] { - const record = isJsonObject(recordData) ? recordData : {}; - const data = isJsonObject(record.data) ? record.data : {}; - const response = isJsonObject(data.response) ? data.response : {}; - const resultJson = parseKieResultJson(recordData); - const urls = new Set(); - - const add = (val: unknown) => { - if (typeof val === "string" && val.startsWith("http")) urls.add(val); - if (Array.isArray(val)) { - val.forEach((v) => { - if (typeof v === "string" && v.startsWith("http")) urls.add(v); - }); - } - }; - - // Check resultJson (common in Market API) - add(resultJson?.resultUrls); - add(resultJson?.imageUrls); - add(resultJson?.resultUrl); - add(resultJson?.imageUrl); - - // Check data.response (common in 4o-image API); resultImageUrl(s) is the - // flux/kontext shape normalizeNanoBananaTaskResult also reads (#14335 LEDGER-11). - add(response.resultUrls); - add(response.resultUrl); - add(response.resultImageUrl); - add(response.resultImageUrls); - - // Check direct data fields - add(data.resultImageUrls); - add(data.resultImageUrl); - add(data.url); - - return Array.from(urls); -} - -async function handleKieImageGeneration({ - model, - provider, - providerConfig, - body, - credentials, - log, -}: KieImageOptions) { - const startTime = Date.now(); - const token = credentials?.apiKey || credentials?.accessToken; - const timeoutMs = normalizePositiveNumber(body.timeout_ms, 300000); - const pollIntervalMs = normalizePositiveNumber(body.poll_interval_ms, 2500); - const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); - const size = typeof body.size === "string" ? body.size : undefined; - - if (!token) { - return saveImageErrorResult({ - provider, - model, - status: 401, - startTime, - error: "KIE API key is required", - }); - } - - // Check if model is a Market model (unified API) - const fullRegistry = getImageProvider(provider); - const modelEntry = fullRegistry?.models?.find((m) => m.id === model); - // #11296 — flux/kontext is catalogued with `isMarket: true`, but KIE does not - // expose it through the Market catalog at all: it lives under a dedicated API - // tree (POST /api/v1/flux/kontext/generate, poll .../flux/kontext/record-info) - // that rejects the Market createTask flow with "model name not supported". Route - // it there instead of treating it as a Market entry (see KIE_MARKET_UPSTREAM_MODEL_IDS - // comment above for the same finding). - const isFluxKontext = model === "flux/kontext"; - const isMarket = !isFluxKontext && (modelEntry?.isMarket || model.includes("/")); - - const { imageUrl } = extractImageInputs(body); - let baseUrl = ""; - let payload: Record = {}; - - if (isFluxKontext) { - // Dedicated Flux Kontext API endpoint (not part of the Market catalog). - baseUrl = `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/flux/kontext/generate`; - payload = { - prompt, - aspectRatio: mapImageSize(size), - model: "flux-kontext-pro", - ...(imageUrl ? { inputImage: imageUrl } : {}), - }; - } else if (isMarket) { - // Unified Market API endpoint - baseUrl = `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/jobs/createTask`; - const input: Record = { - prompt, - aspect_ratio: mapImageSize(size), - }; - if (imageUrl) { - input.image_url = imageUrl; - } - payload = { - model: resolveKieMarketUpstreamModelId(model), - input, - }; - } else { - // Legacy/Direct endpoint - const modelPath = model.replace("-t2i", "").replace("-i2i", ""); - baseUrl = providerConfig.baseUrl.includes(model) - ? providerConfig.baseUrl - : `https://api.kie.ai/api/v1/${modelPath}/generate`; - - payload = { - prompt, - size: mapImageSize(size), - nVariants: body.n || 1, - }; - } - - if (log) { - const promptPreview = String(body.prompt ?? "").slice(0, 60); - log.info( - "IMAGE", - `${provider}/${model} (${isFluxKontext ? "flux-kontext" : isMarket ? "market" : "direct"}) | prompt: "${promptPreview}..."` - ); - } - - try { - const endpoint = isFluxKontext - ? "/api/v1/flux/kontext/generate" - : isMarket - ? "/api/v1/jobs/createTask" - : new URL(baseUrl).pathname; - const createBaseUrl = - isFluxKontext || isMarket ? providerConfig.baseUrl : baseUrl.replace(endpoint, ""); - const createData = await kieExecutor.createTask({ - baseUrl: createBaseUrl, - token, - payload, - endpoint, - }); - const taskId = getKieTaskId(createData); - - if (!taskId) { - const errorMessage = - createData?.msg || - createData?.message || - createData?.error || - "KIE image generation did not return taskId"; - if (log) { - log.error("IMAGE", `KIE createTask failed: ${JSON.stringify(createData)}`); - } - return saveImageErrorResult({ - provider, - model, - status: 502, - startTime, - error: errorMessage, - requestBody: payload, - }); - } - - // Use statusUrl from providerConfig if available, fallback to dynamic derivation - const statusUrl = isFluxKontext - ? `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/flux/kontext/record-info` - : isMarket - ? `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/jobs/recordInfo` - : providerConfig.statusUrl && !providerConfig.statusUrl.includes("jobs/recordInfo") - ? providerConfig.statusUrl - : baseUrl.replace(/\/generate$/, "/record-info"); - - const { data: recordData, state } = await kieExecutor.pollTask({ - statusUrl, - taskId: String(taskId), - token, - timeoutMs, - pollIntervalMs, - }); - - const kieUrls = state === "success" ? normalizeKieImageResult(recordData) : []; - // #14335 LEDGER-13: a "success" state with zero usable urls falls through to - // the failure branch below instead of a fake HTTP-200 success with data:[]. - if (kieUrls.length > 0) { - if (log) { - log.info("IMAGE", `KIE poll success for task ${taskId}`); - } - const images = kieUrls.map((url: string) => ({ url, revised_prompt: prompt })); - - return saveImageSuccessResult({ - provider, - model, - startTime, - requestBody: payload, - responseBody: { images_count: images.length }, - images, - }); - } - - const record = isJsonObject(recordData) ? recordData : {}; - const recordDataBody = isJsonObject(record.data) ? record.data : {}; - const errorMessage = - recordDataBody.errorMessage || - recordDataBody.failMsg || - record.msg || - "KIE image task failed"; - - if (log) { - log.error("IMAGE", `KIE poll failed for task ${taskId}: ${JSON.stringify(recordData)}`); - } - - return saveImageErrorResult({ - provider, - model, - status: 502, - startTime, - error: String(errorMessage), - requestBody: payload, - }); - } catch (err: unknown) { - return saveImageErrorResult({ - provider, - model, - status: getKieErrorStatus(err, 502), - startTime, - error: `Image provider error: ${getKieErrorMessage(err, "KIE image generation failed")}`, - }); - } -} -/** - * Handle Gemini-format image generation (Antigravity / Nano Banana) - * Uses Gemini's generateContent API with responseModalities: ["TEXT", "IMAGE"] - */ -async function handleGeminiImageGeneration({ model, providerConfig, body, credentials, log }) { - const startTime = Date.now(); - const url = providerConfig.baseUrl; - const provider = "antigravity"; - const credentialRecord = credentials || {}; - const token = credentialRecord.accessToken || credentialRecord.apiKey; - const providerSpecificData = credentialRecord.providerSpecificData; - const providerSpecificProjectId = - providerSpecificData && typeof providerSpecificData === "object" - ? (providerSpecificData as Record).projectId - : null; - const credentialProjectId = - typeof credentialRecord.projectId === "string" ? credentialRecord.projectId.trim() : ""; - const providerProjectId = - typeof providerSpecificProjectId === "string" ? providerSpecificProjectId.trim() : ""; - const projectId = credentialProjectId || providerProjectId || null; - const candidateCount = - typeof body.n === "number" && Number.isFinite(body.n) && body.n > 0 ? Math.floor(body.n) : 1; - const promptText = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); - const aspectRatio = normalizeImageAspectRatio(body.aspect_ratio, body.size); - const { value: imageSize, clamped: imageSizeClamped } = normalizeImageGenerationSize( - body.image_size - ); - if (imageSizeClamped && log && typeof log.warn === "function") { - log.warn( - "IMAGE", - `antigravity/${model}: unsupported image_size ${JSON.stringify(body.image_size)} — clamped to 1K (accepted: 1K|2K|4K)` - ); - } - - // Summarized request for call log. Both axes are recorded so the log never hides what the - // client asked for: `image_size` is the raw caller value (null when absent) and - // `image_size_applied` is what went upstream ("default" when the key was omitted). - const logRequestBody = { - model: body.model, - prompt: promptText.slice(0, 200), - size: body.size || "default", - aspect_ratio: aspectRatio, - image_size: body.image_size ?? null, - image_size_applied: imageSize ?? "default", - n: candidateCount, - }; - - if (!projectId || typeof projectId !== "string") { - return saveImageErrorResult({ - provider, - model, - status: 400, - startTime, - error: - "Missing Google projectId for Antigravity account. Please reconnect OAuth in Providers so OmniRoute can fetch your Cloud Code project.", - requestBody: logRequestBody, - }); - } - - const antigravityBody = { - project: projectId, - requestId: `image_gen/${Date.now()}/${randomUUID()}/0`, - request: { - contents: [ - { - role: "user", - parts: [{ text: promptText }], - }, - ], - generationConfig: { - candidateCount, - imageConfig: { - aspectRatio, - ...(imageSize ? { imageSize } : {}), - }, - }, - }, - model, - userAgent: getAntigravityEnvelopeUserAgent(credentialRecord), - requestType: "image_gen", - }; - - const headers = { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }; - applyAntigravityClientProfileHeaders(headers, credentialRecord, antigravityBody); - delete headers["x-goog-user-project"]; - - if (log) { - const promptPreview = promptText.slice(0, 60); - log.info( - "IMAGE", - `antigravity/${model} (gemini) | prompt: "${promptPreview}..." | ${aspectRatio} ${imageSize ?? "default"}` - ); - } - - try { - const response = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify(antigravityBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - const safeError = sanitizeImageProviderError(errorText); - const safeErrorLog = - typeof safeError === "string" ? safeError : JSON.stringify(safeError ?? {}); - if (log) { - log.error("IMAGE", `antigravity error ${response.status}: ${safeErrorLog.slice(0, 200)}`); - } - - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: response.status, - model: `antigravity/${model}`, - provider, - duration: Date.now() - startTime, - error: safeErrorLog.slice(0, 500), - requestBody: logRequestBody, - }).catch(() => {}); - - return { success: false, status: response.status, error: safeError }; - } - - const data = await response.json(); - const responseBody = data.response || data; - - // Extract image data from Antigravity's wrapped Gemini response. - const images = []; - const candidates = responseBody.candidates || []; - for (const candidate of candidates) { - const parts = candidate.content?.parts || []; - for (const part of parts) { - if (part.inlineData) { - images.push({ - b64_json: part.inlineData.data, - revised_prompt: parts.find((p) => p.text)?.text || promptText, - }); - } - } - } - - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 200, - model: `antigravity/${model}`, - provider, - duration: Date.now() - startTime, - tokens: { prompt_tokens: 0, completion_tokens: 0 }, - requestBody: logRequestBody, - responseBody: { images_count: images.length }, - }).catch(() => {}); - - return { - success: true, - data: { - created: Math.floor(Date.now() / 1000), - data: images, - }, - }; - } catch (err) { - if (log) { - log.error("IMAGE", `antigravity fetch error: ${err.message}`); - } - - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 502, - model: `antigravity/${model}`, - provider, - duration: Date.now() - startTime, - error: err.message, - requestBody: logRequestBody, - }).catch(() => {}); - - return { - success: false, - status: 502, - error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, - }; - } -} - -/** - * Handle OpenAI-compatible image generation (standard providers + Nebius fallback) - */ -function buildAgnesImageRequestBody(model, body) { - const upstreamBody: Record = { - model, - prompt: body.prompt, - }; - - if (body.size !== undefined) upstreamBody.size = body.size; - if (body.ratio !== undefined) { - upstreamBody.ratio = body.ratio; - } else if (body.aspect_ratio !== undefined) { - upstreamBody.ratio = body.aspect_ratio; - } - if (body.return_base64 !== undefined) upstreamBody.return_base64 = body.return_base64; - - const explicitExtraBody = - body.extra_body && typeof body.extra_body === "object" && !Array.isArray(body.extra_body) - ? body.extra_body - : {}; - const extraBody: Record = { ...explicitExtraBody }; - const { imageUrls } = extractImageInputs(body); - if (imageUrls.length > 0) extraBody.image = imageUrls; - if (body.response_format !== undefined) extraBody.response_format = body.response_format; - if (Object.keys(extraBody).length > 0) upstreamBody.extra_body = extraBody; - - return upstreamBody; -} - -async function handleOpenAIImageGeneration({ - model, - provider, - providerConfig, - body, - credentials, - log, -}) { - const startTime = Date.now(); - - // Summarized request for call log - const logRequestBody = { - model: body.model, - prompt: - typeof body.prompt === "string" - ? body.prompt.slice(0, 200) - : String(body.prompt ?? "").slice(0, 200), - size: body.size || "default", - n: body.n || 1, - quality: body.quality || undefined, - }; - - // Build upstream request (OpenAI-compatible format) - const upstreamBody: Record = - providerConfig.format === "agnes-image" - ? buildAgnesImageRequestBody(model, body) - : { - model, - prompt: body.prompt, - }; - - if (providerConfig.format !== "agnes-image") { - // Pass optional parameters for ordinary OpenAI-compatible providers. - if (body.n !== undefined) upstreamBody.n = body.n; - if (body.size !== undefined) upstreamBody.size = body.size; - if (body.quality !== undefined) upstreamBody.quality = body.quality; - if (body.response_format !== undefined) upstreamBody.response_format = body.response_format; - if (body.style !== undefined) upstreamBody.style = body.style; - - const { imageUrl } = extractImageInputs(body); - if (imageUrl && OPENAI_IMAGE_TO_IMAGE_MODELS.has(model)) { - upstreamBody.image_url = imageUrl; - } - } - - // Build headers - let headers: Record = { - "Content-Type": "application/json", - }; - - const token = credentials.apiKey || credentials.accessToken; - if (token && providerConfig.authHeader === "bearer") { - headers["Authorization"] = `Bearer ${token}`; - } else if (token && providerConfig.authHeader === "x-api-key") { - headers["x-api-key"] = token; - } - - // #8085 — keyless Pollinations image requests (the common free case) get - // no Authorization header above. Mirror the chat executor's anonymous - // fingerprint-pool fallback (open-sse/executors/pollinations.ts) so the - // outbound request isn't sent bare and rejected by Pollinations' own 401. - let pollinationsAnonSession: Awaited< - ReturnType - >["session"] = null; - if (providerConfig.id === "pollinations") { - const anon = await applyPollinationsAnonymousFallback(providerConfig.id, token, headers); - headers = anon.headers; - pollinationsAnonSession = anon.session; - } - - if (log) { - const promptPreview = - typeof body.prompt === "string" - ? body.prompt.slice(0, 60) - : String(body.prompt ?? "").slice(0, 60); - log.info( - "IMAGE", - `${provider}/${model} | prompt: "${promptPreview}..." | size: ${body.size || "default"}` - ); - } - - const requestBody = JSON.stringify(upstreamBody); - - // Try primary URL - let result = await fetchImageEndpoint( - providerConfig.baseUrl, - headers, - requestBody, - provider, - log - ); - - // Fallback for providers with fallbackUrl (e.g., Nebius) - if ( - !result.success && - providerConfig.fallbackUrl && - [404, 410, 502, 503].includes(result.status) - ) { - if (log) { - log.info("IMAGE", `${provider}: primary URL failed (${result.status}), trying fallback...`); - } - result = await fetchImageEndpoint( - providerConfig.fallbackUrl, - headers, - requestBody, - provider, - log - ); - } - - if (pollinationsAnonSession) { - reportPollinationsAnonOutcome(pollinationsAnonSession, result.status); - } - - // Save call log after result is determined - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: result.status || (result.success ? 200 : 502), - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - tokens: { prompt_tokens: 0, completion_tokens: 0 }, - error: result.success - ? null - : typeof result.error === "string" - ? result.error.slice(0, 500) - : null, - requestBody: logRequestBody, - responseBody: result.success ? { images_count: result.data?.data?.length || 0 } : null, - }).catch(() => {}); - - return result; -} - -/** - * OpenAI-compatible image *edit* forwarder for custom providers (#3214 / #3215). - * - * Mirrors `handleOpenAIImageGeneration` but posts multipart/form-data to the node's - * `/images/edits` endpoint and returns the upstream OpenAI-compatible response. Kept - * separate from provider-specific hosted-tool flows. The fetch helper leaves Content-Type unset so - * `fetch` derives the multipart boundary from the FormData body. - */ -export async function handleOpenAIImageEdit({ - model, - provider, - credentials, - prompt, - imageBytes, - imageMime, - size, - responseFormat, - n = 1, - log, -}: { - model: string; - provider: string; - credentials: - | { - apiKey?: string; - accessToken?: string; - baseUrl?: unknown; - providerSpecificData?: { baseUrl?: unknown } | null; - } - | null - | undefined; - prompt: string; - imageBytes: Buffer; - imageMime?: string | null; - size?: string | null; - responseFormat?: string | null; - n?: number; - log?: { info: (tag: string, message: string) => void } | null; -}) { - const startTime = Date.now(); - const url = resolveImageBaseUrl( - credentials, - `https://generativelanguage.googleapis.com/v1beta/openai/images/edits`, - "edits" - ); - - // Build the multipart body as a Buffer with an explicit boundary instead of a global - // `FormData`. In production `globalThis.fetch` is patched with node_modules/undici's fetch, - // whose `FormData` class differs from `globalThis.FormData` — passing a native FormData - // makes undici serialize it as the string "[object FormData]" (text/plain), dropping every - // field (including `model`, which reaches the upstream empty). A Buffer body is accepted - // verbatim by any fetch implementation. (#3273) - const boundary = `----OmniRouteImageEdit${randomUUID().replace(/-/g, "")}`; - const CRLF = "\r\n"; - const partBuffers: Buffer[] = []; - const appendField = (name: string, value: string) => { - partBuffers.push( - Buffer.from( - `--${boundary}${CRLF}Content-Disposition: form-data; name="${name}"${CRLF}${CRLF}${value}${CRLF}` - ) - ); - }; - appendField("model", model); - appendField("prompt", prompt); - if (size) appendField("size", size); - if (responseFormat) appendField("response_format", responseFormat); - appendField("n", String(n || 1)); - partBuffers.push( - Buffer.from( - `--${boundary}${CRLF}Content-Disposition: form-data; name="image"; filename="image.png"${CRLF}` + - `Content-Type: ${imageMime || "image/png"}${CRLF}${CRLF}` - ) - ); - partBuffers.push(imageBytes); - partBuffers.push(Buffer.from(`${CRLF}--${boundary}--${CRLF}`)); - const multipartBody = Buffer.concat(partBuffers); - - const headers: Record = { - "Content-Type": `multipart/form-data; boundary=${boundary}`, - }; - const token = credentials?.apiKey || credentials?.accessToken; - if (token) headers["Authorization"] = `Bearer ${token}`; - - if (log) { - log.info( - "IMAGE", - `${provider}/${model} (edit) | prompt: "${prompt.slice(0, 60)}..." -> ${url}` - ); - } - - const result = await fetchImageEndpoint( - url, - headers, - multipartBody as unknown as BodyInit, - provider, - log - ); - - saveCallLog({ - method: "POST", - path: "/v1/images/edits", - status: result.status || (result.success ? 200 : 502), - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - tokens: { prompt_tokens: 0, completion_tokens: 0 }, - error: result.success - ? null - : typeof result.error === "string" - ? result.error.slice(0, 500) - : null, - requestBody: { model, prompt: prompt.slice(0, 200), size: size || "default", n: n || 1 }, - responseBody: result.success ? { images_count: result.data?.data?.length || 0 } : null, - }).catch(() => {}); - - return result; -} - -/** - * Handle OpenRouter's unified Image API reference-image flow. - * - * OpenRouter does not expose `/images/edits`; image-to-image requests use - * `POST /api/v1/images` with `input_references` containing data-URL images. - * Keep this separate from the generic multipart `/images/edits` forwarder, - * whose contract is used by custom OpenAI-compatible nodes (#10197). - */ -export async function handleOpenRouterImageEdit({ - model, - provider, - baseUrl, - credentials, - prompt, - imageBytes, - imageMime, - size, - n = 1, - log, -}: { - model: string; - provider: string; - baseUrl: string; - credentials: - | { - apiKey?: string; - accessToken?: string; - } - | null - | undefined; - prompt: string; - imageBytes: Buffer; - imageMime?: string | null; - size?: string | null; - n?: number; - log?: { info: (tag: string, message: string) => void } | null; -}) { - const startTime = Date.now(); - let url = baseUrl.trim(); - while (url.endsWith("/")) url = url.slice(0, -1); - if (url.endsWith("/images/generations")) { - url = url.slice(0, -"/images/generations".length) + "/images"; - } else if (!url.endsWith("/images")) { - url += "/images"; - } - - const mime = imageMime || "image/png"; - const upstreamBody: Record = { - model, - prompt, - input_references: [ - { - type: "image_url", - image_url: { - url: `data:${mime};base64,${imageBytes.toString("base64")}`, - }, - }, - ], - n: n || 1, - }; - if (size) upstreamBody.size = size; - - const headers: Record = { - "Content-Type": "application/json", - }; - const token = credentials?.apiKey || credentials?.accessToken; - if (token) headers.Authorization = `Bearer ${token}`; - - log?.info( - "IMAGE", - `${provider}/${model} (reference edit) | prompt: "${prompt.slice(0, 60)}..." -> ${url}` - ); - - const result = await fetchImageEndpoint( - url, - headers, - JSON.stringify(upstreamBody), - provider, - log - ); - - saveCallLog({ - method: "POST", - path: "/v1/images/edits", - status: result.status || (result.success ? 200 : 502), - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - tokens: { prompt_tokens: 0, completion_tokens: 0 }, - error: result.success - ? null - : typeof result.error === "string" - ? result.error.slice(0, 500) - : null, - requestBody: { model, prompt: prompt.slice(0, 200), size: size || "default", n: n || 1 }, - responseBody: result.success ? { images_count: result.data?.data?.length || 0 } : null, - }).catch(() => {}); - - return result; -} - -async function handleFalAIImageGeneration({ - model, - provider, - providerConfig, - body, - credentials, - log, -}) { - const startTime = Date.now(); - const token = credentials.apiKey || credentials.accessToken; - const falModel = model.startsWith("fal-ai/") ? model : `fal-ai/${model}`; - const { imageUrl, imageUrls } = extractImageInputs(body); - const upstreamBody: Record = { - prompt: body.prompt, - sync_mode: body.sync_mode ?? true, - }; - - if (body.n !== undefined) upstreamBody.num_images = Number(body.n) || 1; - if (body.negative_prompt) upstreamBody.negative_prompt = body.negative_prompt; - if (body.seed !== undefined) upstreamBody.seed = body.seed; - if (body.style) upstreamBody.style = normalizeRecraftStyle(body.style); - - const outputFormat = normalizeRequestedImageFormat(body, "png"); - if (outputFormat) upstreamBody.output_format = outputFormat; - - if (model.includes("flux-pro/v1.1") && !model.includes("ultra")) { - upstreamBody.image_size = mapFalImageSize(body.size, "landscape_4_3"); - } else if ( - model.includes("bytedance/") || - model.includes("stable-diffusion") || - model.includes("ideogram") || - model.includes("recraft/v3") - ) { - upstreamBody.image_size = mapFalImageSize(body.size, "square_hd"); - } else { - upstreamBody.aspect_ratio = body.aspect_ratio || mapFalAspectRatio(body.size, "1:1"); - } - - if (body.quality === "hd" && model.includes("ultra")) { - upstreamBody.raw = true; - } - - if (imageUrl && model.includes("flux-pro/v1.1-ultra")) { - upstreamBody.image_url = imageUrl; - } - - if (imageUrls.length > 0 && model.includes("ideogram")) { - upstreamBody.image_urls = imageUrls; - } - - if (log) { - const promptPreview = String(body.prompt ?? "").slice(0, 60); - log.info("IMAGE", `${provider}/${model} (fal-ai) | prompt: "${promptPreview}..."`); - } - - try { - const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}/${falModel}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Key ${token}`, - }, - body: JSON.stringify(upstreamBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - if (log) - log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); - return saveImageErrorResult({ - provider, - model, - status: response.status, - startTime, - error: errorText, - requestBody: upstreamBody, - }); - } - - const payload = await response.json(); - const images = await normalizeProviderImagePayload(payload, body, log, "b64_json"); - return saveImageSuccessResult({ - provider, - model, - startTime, - requestBody: upstreamBody, - responseBody: { images_count: images.length }, - created: payload.created, - images, - }); - } catch (err) { - if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); - return saveImageErrorResult({ - provider, - model, - status: 502, - startTime, - error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, - }); - } -} - -async function handleStabilityAIImageGeneration({ - model, - provider, - providerConfig, - body, - credentials, - log, -}) { - const startTime = Date.now(); - const token = credentials.apiKey || credentials.accessToken; - const endpoint = STABILITY_GENERATION_ENDPOINTS[model] || STABILITY_EDIT_ENDPOINTS[model]; - - if (!endpoint) { - return { - success: false, - status: 400, - error: `Unsupported Stability AI image model: ${model}`, - }; - } - - const { imageUrl, maskUrl } = extractImageInputs(body); - const upstreamBody: Record = { - output_format: - model === "remove-background" - ? normalizeRequestedImageFormat(body, "png", ["png", "webp"]) - : normalizeRequestedImageFormat(body, "png"), - }; - const formData = new FormData(); - - appendOptionalFormValue(formData, "output_format", upstreamBody.output_format); - if (body.prompt) { - upstreamBody.prompt = body.prompt; - appendOptionalFormValue(formData, "prompt", body.prompt); - } - if (body.negative_prompt) { - upstreamBody.negative_prompt = body.negative_prompt; - appendOptionalFormValue(formData, "negative_prompt", body.negative_prompt); - } - if (body.seed !== undefined) { - upstreamBody.seed = body.seed; - appendOptionalFormValue(formData, "seed", body.seed); - } - - try { - if (STABILITY_GENERATION_ENDPOINTS[model]) { - if (model.startsWith("sd3.5")) { - upstreamBody.model = model; - appendOptionalFormValue(formData, "model", model); - } - - if (imageUrl) { - const imageSource = await resolveImageSource(imageUrl); - upstreamBody.mode = "image-to-image"; - appendOptionalFormValue(formData, "mode", "image-to-image"); - upstreamBody.image = imageSource.base64; - appendImageFormValue(formData, "image", imageSource, "image"); - if (body.strength !== undefined) { - upstreamBody.strength = body.strength; - appendOptionalFormValue(formData, "strength", body.strength); - } - } else { - upstreamBody.mode = "text-to-image"; - appendOptionalFormValue(formData, "mode", "text-to-image"); - } - - if (!model.startsWith("sd3.5") || !imageUrl) { - const aspectRatio = body.aspect_ratio || mapImageSize(body.size); - upstreamBody.aspect_ratio = aspectRatio; - appendOptionalFormValue(formData, "aspect_ratio", aspectRatio); - } - - if (body.style_preset) { - upstreamBody.style_preset = body.style_preset; - appendOptionalFormValue(formData, "style_preset", body.style_preset); - } - } else { - if (imageUrl) { - const imageSource = await resolveImageSource(imageUrl); - upstreamBody.image = imageSource.base64; - appendImageFormValue(formData, "image", imageSource, "image"); - } - - if (maskUrl && shouldIncludeStabilityMask(model)) { - const maskSource = await resolveImageSource(maskUrl); - upstreamBody.mask = maskSource.base64; - appendImageFormValue(formData, "mask", maskSource, "mask"); - } - - if (body.search_prompt) { - upstreamBody.search_prompt = body.search_prompt; - appendOptionalFormValue(formData, "search_prompt", body.search_prompt); - } - if (body.grow_mask !== undefined) { - upstreamBody.grow_mask = body.grow_mask; - appendOptionalFormValue(formData, "grow_mask", body.grow_mask); - } - if (body.control_strength !== undefined) { - upstreamBody.control_strength = body.control_strength; - appendOptionalFormValue(formData, "control_strength", body.control_strength); - } - if (body.creativity !== undefined) { - upstreamBody.creativity = body.creativity; - appendOptionalFormValue(formData, "creativity", body.creativity); - } - if (body.left !== undefined) { - upstreamBody.left = body.left; - appendOptionalFormValue(formData, "left", body.left); - } - if (body.right !== undefined) { - upstreamBody.right = body.right; - appendOptionalFormValue(formData, "right", body.right); - } - if (body.up !== undefined) { - upstreamBody.up = body.up; - appendOptionalFormValue(formData, "up", body.up); - } - if (body.down !== undefined) { - upstreamBody.down = body.down; - appendOptionalFormValue(formData, "down", body.down); - } - if (body.style_preset) { - upstreamBody.style_preset = body.style_preset; - appendOptionalFormValue(formData, "style_preset", body.style_preset); - } - - if (STABILITY_CONTROL_MODELS.has(model) && !upstreamBody.prompt) { - upstreamBody.prompt = body.prompt || ""; - appendOptionalFormValue(formData, "prompt", body.prompt || ""); - } - } - - if (log) { - const promptPreview = String(body.prompt ?? "").slice(0, 60); - log.info("IMAGE", `${provider}/${model} (stability-ai) | prompt: "${promptPreview}..."`); - } - - const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}${endpoint}`, { - method: "POST", - headers: { - Accept: "application/json", - Authorization: `Bearer ${token}`, - }, - body: formData, - }); - - if (!response.ok) { - const errorText = await response.text(); - if (log) - log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); - return saveImageErrorResult({ - provider, - model, - status: response.status, - startTime, - error: errorText, - requestBody: upstreamBody, - }); - } - - const contentType = response.headers.get("content-type") || ""; - let payload; - if (contentType.includes("application/json")) { - payload = await response.json(); - } else { - const buffer = Buffer.from(await response.arrayBuffer()); - payload = { image: buffer.toString("base64") }; - } - - const images = await normalizeProviderImagePayload(payload, body, log, "b64_json"); - return saveImageSuccessResult({ - provider, - model, - startTime, - requestBody: upstreamBody, - responseBody: { images_count: images.length }, - created: payload.created, - images, - }); - } catch (err) { - if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); - return saveImageErrorResult({ - provider, - model, - status: 502, - startTime, - error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, - }); - } -} - -async function handleBlackForestLabsImageGeneration({ - model, - provider, - providerConfig, - body, - credentials, - log, -}) { - const startTime = Date.now(); - const token = credentials.apiKey || credentials.accessToken; - const endpoint = BFL_MODEL_ENDPOINTS[model]; - - if (!endpoint) { - return { - success: false, - status: 400, - error: `Unsupported Black Forest Labs image model: ${model}`, - }; - } - - const { imageUrl, maskUrl } = extractImageInputs(body); - const upstreamBody: Record = { - prompt: body.prompt, - output_format: normalizeRequestedImageFormat(body, "png"), - }; - - try { - if (BFL_EDIT_MODELS.has(model) && imageUrl) { - upstreamBody.input_image = (await resolveImageSource(imageUrl)).base64; - } else if (imageUrl && isHttpUrl(imageUrl)) { - upstreamBody.image_url = imageUrl; - } - - if (maskUrl && (model === "flux-pro-1.0-fill" || model === "flux-kontext-pro")) { - upstreamBody.mask = (await resolveImageSource(maskUrl)).base64; - } - - if (model === "flux-kontext-pro" || model === "flux-kontext-max") { - upstreamBody.aspect_ratio = body.aspect_ratio || mapImageSize(body.size); - } else if (typeof body.size === "string" && body.size.includes("x")) { - const { width, height } = parseSizeToDimensions(body.size, 1024); - upstreamBody.width = width; - upstreamBody.height = height; - } - - if (body.seed !== undefined) upstreamBody.seed = body.seed; - if (body.n !== undefined && model.includes("ultra")) - upstreamBody.num_images = Number(body.n) || 1; - if (body.quality === "hd" && model.includes("ultra")) upstreamBody.raw = true; - if (body.left !== undefined) upstreamBody.left = body.left; - if (body.right !== undefined) upstreamBody.right = body.right; - if (body.top !== undefined) upstreamBody.top = body.top; - if (body.bottom !== undefined) upstreamBody.bottom = body.bottom; - if (body.steps !== undefined) upstreamBody.steps = body.steps; - if (body.guidance !== undefined) upstreamBody.guidance = body.guidance; - if (body.grow_mask !== undefined) upstreamBody.grow_mask = body.grow_mask; - if (body.safety_tolerance !== undefined) upstreamBody.safety_tolerance = body.safety_tolerance; - - if (log) { - const promptPreview = String(body.prompt ?? "").slice(0, 60); - log.info("IMAGE", `${provider}/${model} (black-forest-labs) | prompt: "${promptPreview}..."`); - } - - const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}${endpoint}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - "x-key": token, - }, - body: JSON.stringify(upstreamBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - if (log) - log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); - return saveImageErrorResult({ - provider, - model, - status: response.status, - startTime, - error: errorText, - requestBody: upstreamBody, - }); - } - - const initialPayload = await response.json(); - const finalPayload = initialPayload.polling_url - ? await pollBlackForestLabsResult({ - pollingUrl: initialPayload.polling_url, - token, - body, - log, - }) - : initialPayload; - - const images = await normalizeProviderImagePayload(finalPayload, body, log, "url"); - return saveImageSuccessResult({ - provider, - model, - startTime, - requestBody: upstreamBody, - responseBody: { images_count: images.length }, - created: finalPayload.created, - images, - }); - } catch (err) { - if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); - return saveImageErrorResult({ - provider, - model, - status: 502, - startTime, - error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, - }); - } -} - -async function handleRecraftImageGeneration({ - model, - provider, - providerConfig, - body, - credentials, - log, -}) { - const startTime = Date.now(); - const token = credentials.apiKey || credentials.accessToken; - const upstreamBody: Record = { - model, - prompt: body.prompt, - }; - - if (body.n !== undefined) upstreamBody.n = body.n; - if (body.size !== undefined) upstreamBody.size = body.size; - if (body.response_format !== undefined) upstreamBody.response_format = body.response_format; - if (body.style !== undefined) upstreamBody.style = body.style; - - if (log) { - const promptPreview = String(body.prompt ?? "").slice(0, 60); - log.info("IMAGE", `${provider}/${model} (recraft) | prompt: "${promptPreview}..."`); - } - - try { - const response = await fetch( - `${providerConfig.baseUrl.replace(/\/$/, "")}/v1/images/generations`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(upstreamBody), - } - ); - - if (!response.ok) { - const errorText = await response.text(); - if (log) - log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); - return saveImageErrorResult({ - provider, - model, - status: response.status, - startTime, - error: errorText, - requestBody: upstreamBody, - }); - } - - const payload = await response.json(); - const images = await normalizeProviderImagePayload(payload, body, log, "url"); - return saveImageSuccessResult({ - provider, - model, - startTime, - requestBody: upstreamBody, - responseBody: { images_count: images.length }, - created: payload.created, - images, - }); - } catch (err) { - if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); - return saveImageErrorResult({ - provider, - model, - status: 502, - startTime, - error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, - }); - } -} - -async function handleTopazImageGeneration({ - model, - provider, - providerConfig, - body, - credentials, - log, -}) { - const startTime = Date.now(); - const token = credentials.apiKey || credentials.accessToken; - const { imageUrl } = extractImageInputs(body); - - if (!imageUrl) { - return { - success: false, - status: 400, - error: `Topaz model ${model} requires an input image`, - }; - } - - try { - const imageSource = await resolveImageSource(imageUrl); - const formData = new FormData(); - const blob = new Blob([imageSource.buffer], { type: imageSource.contentType || "image/png" }); - formData.append("image", blob, "image.png"); - - if (typeof body.size === "string" && body.size.includes("x")) { - const { width, height } = parseSizeToDimensions(body.size, 1024); - formData.append("output_width", String(width)); - formData.append("output_height", String(height)); - } - - if (log) { - const promptPreview = String(body.prompt ?? "enhance image").slice(0, 60); - log.info("IMAGE", `${provider}/${model} (topaz) | prompt: "${promptPreview}..."`); - } - - const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}/image/v1/enhance`, { - method: "POST", - headers: { - Accept: "image/jpeg", - "X-API-Key": token, - }, - body: formData, - }); - - if (!response.ok) { - const errorText = await response.text(); - if (log) - log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); - return saveImageErrorResult({ - provider, - model, - status: response.status, - startTime, - error: errorText, - }); - } - - const contentType = response.headers.get("content-type") || "image/jpeg"; - const buffer = Buffer.from(await response.arrayBuffer()); - const base64 = buffer.toString("base64"); - const wantsBase64 = body.response_format === "b64_json"; - const images = [ - wantsBase64 - ? { b64_json: base64, revised_prompt: body.prompt } - : { url: `data:${contentType};base64,${base64}`, revised_prompt: body.prompt }, - ]; - - return saveImageSuccessResult({ - provider, - model, - startTime, - responseBody: { images_count: images.length }, - images, - }); - } catch (err) { - if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); - return saveImageErrorResult({ - provider, - model, - status: 502, - startTime, - error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, - }); - } -} - -async function pollBlackForestLabsResult({ pollingUrl, token, body, log }) { - const timeoutMs = normalizePositiveNumber(body.timeout_ms, 300000); - const pollIntervalMs = normalizePositiveNumber(body.poll_interval_ms, 1500); - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - const response = await fetch(pollingUrl, { - method: "GET", - headers: { - "x-key": token, - }, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`BFL polling failed (${response.status}): ${errorText}`); - } - - const payload = await response.json(); - const status = payload?.status; - - if (status === "Ready") { - return payload; - } - - if (BFL_FAILURE_STATUSES.has(status)) { - throw new Error(`BFL image generation failed: ${status}`); - } - - if (log) { - log.info("IMAGE", `black-forest-labs polling status: ${String(status || "Pending")}`); - } - - await sleep(pollIntervalMs); - } - - throw new Error(`BFL polling timed out after ${timeoutMs}ms`); -} - -function extractImageInputs(body) { - const imageUrls = []; - const seen = new Set(); - - const pushCandidate = (candidate) => { - if (typeof candidate !== "string") return; - const trimmed = candidate.trim(); - if (!trimmed || seen.has(trimmed)) return; - seen.add(trimmed); - imageUrls.push(trimmed); - }; - - pushCandidate(body?.image_url); - pushCandidate(body?.image); - - if (Array.isArray(body?.imageUrls)) { - for (const candidate of body.imageUrls) pushCandidate(candidate); - } - - if (Array.isArray(body?.image_urls)) { - for (const candidate of body.image_urls) pushCandidate(candidate); - } - - if (Array.isArray(body?.messages)) { - for (const msg of body.messages) { - if (!Array.isArray(msg?.content)) continue; - for (const part of msg.content) { - if (part?.type === "image_url") { - pushCandidate(part?.image_url?.url); - } - } - } - } - - return { - imageUrl: imageUrls[0] || null, - imageUrls, - maskUrl: - typeof body?.mask_url === "string" - ? body.mask_url - : typeof body?.mask === "string" - ? body.mask - : null, - }; -} - -export async function resolveImageSource(source) { - if (typeof source !== "string" || source.trim().length === 0) { - throw new Error("Invalid image source"); - } - - const trimmed = source.trim(); - const dataUriMatch = /^data:([^;]+);base64,(.+)$/i.exec(trimmed); - if (dataUriMatch) { - const [, contentType, base64] = dataUriMatch; - return { - buffer: Buffer.from(base64, "base64"), - base64, - contentType, - }; - } - - if (isHttpUrl(trimmed)) { - // Caller-input URL — public-only + DNS-pinned policy lives in fetchUntrustedRemoteImage. - const remoteImage = await fetchUntrustedRemoteImage(trimmed); - return { - buffer: remoteImage.buffer, - base64: remoteImage.buffer.toString("base64"), - contentType: remoteImage.contentType, - }; - } - - return { - buffer: Buffer.from(trimmed, "base64"), - base64: trimmed, - contentType: "application/octet-stream", - }; -} - -function parseSizeToDimensions(size, fallback = 1024) { - if (typeof size !== "string" || !size.includes("x")) { - return { width: fallback, height: fallback }; - } - - const [widthRaw, heightRaw] = size.split("x"); - const width = Number(widthRaw); - const height = Number(heightRaw); - return { - width: Number.isFinite(width) && width > 0 ? width : fallback, - height: Number.isFinite(height) && height > 0 ? height : fallback, - }; -} - -export function normalizeRequestedImageFormat( - body, - fallback = "png", - allowedFormats = ["jpeg", "png", "webp"] -) { - const formatCandidate = - typeof body?.output_format === "string" - ? body.output_format.toLowerCase() - : typeof body?.response_format === "string" && - !["url", "b64_json"].includes(body.response_format.toLowerCase()) - ? body.response_format.toLowerCase() - : fallback; - - if (allowedFormats.includes(formatCandidate)) { - return formatCandidate; - } - - return fallback; -} - -export function mapFalImageSize(size, fallback = "square_hd") { - if (typeof size !== "string") return fallback; - if (FAL_PRESET_SIZES[size]) return FAL_PRESET_SIZES[size]; - if (size.includes("x")) { - const { width, height } = parseSizeToDimensions(size, 1024); - return { width, height }; - } - return fallback; -} - -function mapFalAspectRatio(size, fallback = "1:1") { - if (!size) return fallback; - return mapImageSize(size); -} - -function normalizeRecraftStyle(style) { - if (style === "vivid") return "digital_illustration"; - if (style === "natural") return "realistic_image"; - return style; -} - -function shouldIncludeStabilityMask(model) { - return new Set([ - "inpaint", - "erase", - "search-and-replace", - "search-and-recolor", - "replace-background-and-relight", - ]).has(model); -} - -export async function normalizeProviderImagePayload(payload, body, log, defaultFormat) { - const candidates = []; - - const pushCandidate = (value) => { - if (value === undefined || value === null) return; - candidates.push(value); - }; - - if (Array.isArray(payload?.data)) { - for (const item of payload.data) pushCandidate(item); - } - - if (Array.isArray(payload?.images)) { - for (const item of payload.images) pushCandidate(item); - } - - if (payload?.image) pushCandidate({ b64_json: payload.image }); - if (payload?.url) pushCandidate({ url: payload.url }); - if (payload?.sample) pushCandidate({ url: payload.sample }); - if (payload?.result?.sample) pushCandidate({ url: payload.result.sample }); - if (Array.isArray(payload?.result?.images)) { - for (const item of payload.result.images) pushCandidate(item); - } - - const normalized = []; - for (const candidate of candidates) { - const item = await normalizeProviderImageCandidate(candidate, body, defaultFormat); - if (item) normalized.push(item); - } - - if (normalized.length === 0 && log) { - log.warn( - "IMAGE", - `Provider returned no recognizable image payload: ${JSON.stringify(payload).slice(0, 240)}` - ); - } - - return normalized; -} - -async function normalizeProviderImageCandidate(candidate, body, defaultFormat) { - const wantsBase64 = body?.response_format === "b64_json" || defaultFormat === "b64_json"; - let url = null; - let b64 = null; - - if (typeof candidate === "string") { - const dataUriMatch = /^data:[^;]+;base64,(.+)$/i.exec(candidate); - if (dataUriMatch) { - b64 = dataUriMatch[1]; - } else if (isHttpUrl(candidate)) { - url = candidate; - } else { - b64 = candidate; - } - } else if (candidate && typeof candidate === "object") { - url = - firstString(candidate.url, candidate.image_url, candidate.sample, candidate.file_url) || null; - b64 = - firstString(candidate.b64_json, candidate.image, candidate.base64, candidate.data) || null; - } - - if (wantsBase64 && !b64 && url) { - b64 = (await resolveImageSource(url)).base64; - } - - if (url && !wantsBase64) { - return { url, revised_prompt: body?.prompt }; - } - - if (b64) { - return { b64_json: b64, revised_prompt: body?.prompt }; - } - - if (url) { - return { url, revised_prompt: body?.prompt }; - } - - return null; -} - -function firstString(...values) { - for (const value of values) { - if (typeof value === "string" && value.length > 0) return value; - } - return null; -} - -function isHttpUrl(value) { - return typeof value === "string" && /^https?:\/\//i.test(value); -} - -/** - * Codex image generation — translate GPT-Image-style /v1/images/generations - * request into a /v1/responses call with the `image_generation` hosted tool, - * parse the SSE stream, and return the base64 PNG in OpenAI image response shape. - * - * Requires ChatGPT OAuth credentials (Codex provider connection). The hosted - * image_generation tool is only served upstream under ChatGPT auth; API-key - * users will receive a 400 from OpenAI. - */ -export function extractImageGenerationCalls( - sseText: string -): Array<{ b64: string; revisedPrompt: string | null }> { - const results: Array<{ b64: string; revisedPrompt: string | null }> = []; - const lines = String(sseText || "").split("\n"); - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed.startsWith("data:")) continue; - const payload = trimmed.slice(5).trim(); - if (!payload || payload === "[DONE]") continue; - let evt: Record; - try { - evt = JSON.parse(payload) as Record; - } catch { - continue; - } - if (evt?.type !== "response.output_item.done") continue; - const item = evt.item as Record | undefined; - if (!item || item.type !== "image_generation_call") continue; - const result = typeof item.result === "string" ? item.result : ""; - if (!result) continue; - const revisedPrompt = typeof item.revised_prompt === "string" ? item.revised_prompt : null; - results.push({ b64: result, revisedPrompt }); - } - return results; -} - -// The image_generation hosted tool accepts { "auto" | "low" | "medium" | "high" } -// for `quality`. Legacy image clients often send "standard" / "hd". Map those values -// so OpenWebUI's quality dropdown doesn't silently get rejected upstream. -function mapLegacyImageQualityToImageTool(value: string): string { - const normalized = value.toLowerCase(); - if (normalized === "standard") return "medium"; - if (normalized === "hd") return "high"; - return normalized; -} - -async function handleCodexImageGeneration({ - model, - provider, - providerConfig, - body, - credentials, - log, - referenceImages = [], - signal = null, - logPath = "/v1/images/generations", -}) { - const startTime = Date.now(); - const prompt = typeof body.prompt === "string" ? body.prompt : ""; - if (!prompt.trim()) { - return saveImageErrorResult({ - provider, - model, - status: 400, - startTime, - error: "Prompt is required for Codex image generation", - path: logPath, - }); - } - - const requestedCount = - Number.isInteger(body.n) && (body.n as number) > 0 ? (body.n as number) : 1; - if (log && requestedCount > 1) { - log.warn( - "IMAGE", - `Codex hosted image_generation returns one image per call; requested n=${requestedCount} will fan out in parallel` - ); - } - - const token = credentials?.accessToken || credentials?.apiKey; - if (!token) { - return saveImageErrorResult({ - provider, - model, - status: 401, - startTime, - error: "Codex credentials missing accessToken — reconnect the Codex provider", - path: logPath, - }); - } - - if (isCodexFreePlan(credentials?.providerSpecificData)) { - return saveImageErrorResult({ - provider, - model, - status: 403, - startTime, - error: "Codex image_generation is unavailable on free-plan accounts", - path: logPath, - retryable: true, - }); - } - - const workspaceId = - credentials?.providerSpecificData && - typeof credentials.providerSpecificData === "object" && - !Array.isArray(credentials.providerSpecificData) - ? (credentials.providerSpecificData as Record).workspaceId - : undefined; - - // Forward size/quality from the GPT-Image-style body into the hosted tool so - // OpenWebUI's size/quality selectors actually take effect. Everything else - // (model, n, background, moderation, output_compression) is left to the - // Codex backend's defaults — today that's `gpt-image-2`. - const toolConfig: Record = { type: "image_generation", output_format: "png" }; - if (referenceImages.length > 0) toolConfig.action = "edit"; - if (typeof body.size === "string" && body.size.trim()) { - toolConfig.size = body.size.trim(); - } - if (typeof body.quality === "string" && body.quality.trim()) { - toolConfig.quality = mapLegacyImageQualityToImageTool(body.quality.trim()); - } - - const inputContent: Array> = [{ type: "input_text", text: prompt }]; - for (const image of referenceImages) { - inputContent.push({ - type: "input_image", - image_url: `data:${image.mime || "image/png"};base64,${image.bytes.toString("base64")}`, - }); - } - - const upstreamBody: Record = { - model, - instructions: - referenceImages.length > 0 - ? `You must call the image_generation tool exactly once to edit the supplied ${referenceImages.length === 1 ? "reference image" : "reference images"}. Treat all supplied images as references for the user's requested composition or style. Do not add narration.` - : "You must call the image_generation tool exactly once to fulfill the user's request. Do not add narration.", - input: [ - { - role: "user", - content: inputContent, - }, - ], - tools: [toolConfig], - stream: true, - store: false, - }; - const requestBodyForLog = - referenceImages.length > 0 - ? { - model, - prompt_chars: prompt.length, - reference_images: referenceImages.map((image) => ({ - mime: image.mime || "image/png", - bytes: image.bytes.length, - })), - tools: [toolConfig], - stream: true, - store: false, - } - : upstreamBody; - - const headers: Record = { - "Content-Type": "application/json", - Accept: "text/event-stream", - Authorization: `Bearer ${token}`, - Version: getCodexClientVersion(), - "User-Agent": getCodexUserAgent(), - originator: "codex_cli_rs", - }; - if (typeof workspaceId === "string" && workspaceId) { - headers["chatgpt-account-id"] = workspaceId; - headers["session_id"] = workspaceId; - } - - if (log) { - const promptSummary = - referenceImages.length > 0 ? `${prompt.length} chars` : `"${prompt.slice(0, 60)}..."`; - log.info("IMAGE", `${provider}/${model} (codex-responses) | prompt: ${promptSummary}`); - } - - const fetchOneImage = async () => { - let response: Response; - try { - response = await fetch(providerConfig.baseUrl, { - method: "POST", - headers, - body: JSON.stringify(upstreamBody), - signal, - }); - } catch (err) { - const message = sanitizeErrorMessage(err); - if (log) log.error("IMAGE", `${provider} fetch error: ${message}`); - return { - ok: false as const, - error: { - provider, - model, - status: 502, - startTime, - error: `Image provider error: ${message}`, - requestBody: requestBodyForLog, - path: logPath, - }, - }; - } - - if (!response.ok) { - const errorText = await response.text(); - const safeError = sanitizeImageProviderError(errorText); - const safeErrorLog = - typeof safeError === "string" ? safeError : JSON.stringify(safeError ?? {}); - if (log) log.error("IMAGE", `${provider} error ${response.status}: ${safeErrorLog}`); - const retryable = isCodexChatGptModelAccessError(response.status, errorText, model); - return { - ok: false as const, - error: { - provider, - model, - status: response.status, - startTime, - error: safeError, - requestBody: requestBodyForLog, - path: logPath, - ...(retryable ? { retryable: true } : {}), - }, - }; - } - - const rawSSE = await response.text(); - const items = extractImageGenerationCalls(rawSSE); - if (items.length === 0) { - return { - ok: false as const, - error: { - provider, - model, - status: 502, - startTime, - error: - "Codex completed without producing an image_generation_call — the model may have declined the tool", - requestBody: requestBodyForLog, - path: logPath, - }, - }; - } - - return { ok: true as const, items }; - }; - - const imageResults = await Promise.all( - Array.from({ length: requestedCount }, () => fetchOneImage()) - ); - - const collected: Array<{ b64_json: string; revised_prompt?: string }> = []; - for (const imageResult of imageResults) { - if (!imageResult.ok) return saveImageErrorResult(imageResult.error); - for (const item of imageResult.items) { - collected.push({ - b64_json: item.b64, - ...(item.revisedPrompt ? { revised_prompt: item.revisedPrompt } : {}), - }); - } - } - - // OpenAI returns b64_json for the gpt-image-* family and reserves `url` for - // fetchable HTTPS links, so clients that omit response_format (Codex CLI's - // built-in image_gen among them) expect the bytes in b64_json. Only emit the - // data: URI when the caller explicitly asks for `url` (#12268). - const wantsUrl = body.response_format === "url"; - const data = wantsUrl - ? collected.map((item) => ({ - url: `data:image/png;base64,${item.b64_json}`, - ...(item.revised_prompt ? { revised_prompt: item.revised_prompt } : {}), - })) - : collected; - - return saveImageSuccessResult({ - provider, - model, - startTime, - requestBody: requestBodyForLog, - responseBody: { images_count: data.length }, - images: data, - path: logPath, - }); -} - -type CodexImageEditResult = - | { success: true; data: { created: number; data: Array> } } - | { success: false; status: number; error: unknown }; - -/** - * Run a stateless Codex reference-image edit through the native Responses hosted tool. - * This deliberately reuses the text-to-image implementation so OAuth headers, SSE parsing, - * response formatting, and error handling cannot drift between generations and edits. - */ -export async function handleCodexImageEdit({ - model, - provider, - providerConfig, - body, - referenceImages, - credentials, - log, - signal = null, -}: { - model: string; - provider: string; - providerConfig: unknown; - body: Record; - referenceImages: Array<{ bytes: Buffer; mime: string }>; - credentials: unknown; - log: { - info: (tag: string, message: string) => void; - warn: (tag: string, message: string) => void; - error: (tag: string, message: string) => void; - } | null; - signal?: AbortSignal | null; -}): Promise { - const result = await handleCodexImageGeneration({ - model, - provider, - providerConfig, - body: { ...body, n: 1 }, - credentials, - log, - referenceImages, - signal, - logPath: "/v1/images/edits", - }); - return result as CodexImageEditResult; -} - -export function saveImageSuccessResult({ - provider, - model, - startTime, - requestBody = null, - responseBody = null, - created = null, - images, - path = "/v1/images/generations", -}) { - saveCallLog({ - method: "POST", - path, - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - requestBody, - responseBody, - }).catch(() => {}); - - return { - success: true, - data: { - created: created || Math.floor(Date.now() / 1000), - data: images, - }, - }; -} - -export function saveImageErrorResult({ - provider, - model, - status, - startTime, - error, - requestBody = null, - path = "/v1/images/generations", - // #8307: opt-in signal for executeImageWithCredentialFallback — set by a - // provider handler when the failure is account/session-specific (expired - // or blocked credentials) rather than a generic request/provider error, so - // the retry loop tries the next eligible account even when the upstream - // status isn't a plain 401. Defaults to unset (existing 401-only behavior - // for every other provider is unchanged). - retryable = undefined, -}: { - provider: string; - model: string; - status: number; - startTime: number; - error: unknown; - requestBody?: unknown; - path?: string; - retryable?: boolean; -}) { - saveCallLog({ - method: "POST", - path, - status, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: stringifyImageErrorForLog(error).slice(0, 500), - requestBody, - }).catch(() => {}); - - return { - success: false, - status, - error, - ...(retryable !== undefined ? { retryable } : {}), - }; -} - -/** - * Fetch a single image endpoint and normalize response - */ -async function fetchImageEndpoint(url, headers, body, provider, log) { - try { - let response; - try { - response = await fetchWithTimeout(url, { - method: "POST", - headers, - body, - timeoutMs: getConfiguredTimeout(), - }); - } catch (err: unknown) { - const isAbortError = - typeof err === "object" && - err !== null && - "name" in err && - (err as { name?: unknown }).name === "AbortError"; - if (err instanceof FetchTimeoutError || isAbortError) { - const message = err instanceof Error ? err.message : String(err); - if (log) { - log.error("IMAGE", `${provider} fetch error: ${message}`); - } - return { - success: false, - status: 504, - error: `Image provider error: ${sanitizeErrorMessage(message || err)}`, - }; - } - throw err; - } - - if (!response.ok) { - const errorText = await response.text(); - if (log) { - log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); - } - return { - success: false, - status: response.status, - error: errorText, - }; - } - - const data = await response.json(); - - // Normalize response to OpenAI format - const items = Array.isArray(data?.data) ? data.data : []; - - // Some providers return HTTP 2xx with an empty or malformed image - // payload (empty data array, missing/blank b64_json and url). Treating that - // as success makes image-combo strategies stop on the first leg and hand an - // image-less 200 to the client. Require at least one usable image item and - // surface an empty 2xx as a retryable 502 so combos fall back to the next - // priority leg. - const hasUsableImage = items.some( - (item: unknown) => - isJsonObject(item) && - ((typeof item.b64_json === "string" && item.b64_json.length > 0) || - (typeof item.url === "string" && item.url.length > 0)) - ); - if (!hasUsableImage) { - if (log) { - log.warn( - "IMAGE", - `${provider} returned 200 without a usable image payload; treating as retryable 502` - ); - } - return { - success: false, - status: HTTP_STATUS.BAD_GATEWAY, - error: sanitizeErrorMessage( - "Image provider returned a success status without an image payload" - ), - }; - } - - return { - success: true, - data: { - created: data.created || Math.floor(Date.now() / 1000), - data: items, - }, - }; - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - if (log) { - log.error("IMAGE", `${provider} fetch error: ${message}`); - } - return { - success: false, - status: 502, - error: `Image provider error: ${sanitizeErrorMessage(message || err)}`, - }; - } -} - -/** - * Handle Hyperbolic image generation - * Uses { model_name, prompt, height, width } and returns { images: [{ image: base64 }] } - */ -async function handleNanoBananaImageGeneration({ - model, - provider, - providerConfig, - body, - credentials, - log, -}) { - const startTime = Date.now(); - const token = credentials.apiKey || credentials.accessToken; - - // Route to pro URL for "nanobanana-pro" model - const isPro = model === "nanobanana-pro"; - const submitUrl = isPro && providerConfig.proUrl ? providerConfig.proUrl : providerConfig.baseUrl; - const statusUrl = providerConfig.statusUrl; - - const aspectRatio = - typeof body.aspectRatio === "string" - ? body.aspectRatio - : typeof body.aspect_ratio === "string" - ? body.aspect_ratio - : mapImageSize(body.size); - - let resolution = - typeof body.resolution === "string" - ? body.resolution - : inferResolutionFromSize(body.size) || "1K"; - if (body.quality === "hd" && resolution === "1K") { - resolution = "2K"; - } - - const upstreamBody = isPro - ? { - prompt: body.prompt, - resolution, - aspectRatio, - ...(Array.isArray(body.imageUrls) ? { imageUrls: body.imageUrls } : {}), - } - : { - prompt: body.prompt, - type: - Array.isArray(body.imageUrls) && body.imageUrls.length > 0 - ? "IMAGETOIAMGE" - : "TEXTTOIAMGE", - numImages: Number.isFinite(body.n) ? Math.max(1, Number(body.n)) : 1, - image_size: aspectRatio, - ...(Array.isArray(body.imageUrls) ? { imageUrls: body.imageUrls } : {}), - }; - - if (log) { - const promptPreview = String(body.prompt ?? "").slice(0, 60); - log.info( - "IMAGE", - `${provider}/${model} (nanobanana ${isPro ? "pro" : "flash"}) | prompt: "${promptPreview}..."` - ); - } - - try { - const submitResp = await fetch(submitUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(upstreamBody), - }); - - if (!submitResp.ok) { - const errorText = await submitResp.text(); - if (log) { - log.error( - "IMAGE", - `${provider} submit error ${submitResp.status}: ${errorText.slice(0, 200)}` - ); - } - - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: submitResp.status, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText.slice(0, 500), - }).catch(() => {}); - - return { success: false, status: submitResp.status, error: errorText }; - } - - const submitData = await submitResp.json(); - - // Backward compatibility: handle providers returning image payload synchronously - const hasSyncPayload = - Boolean(submitData?.image) || - Array.isArray(submitData?.images) || - Array.isArray(submitData?.data) || - Boolean(submitData?.data?.[0]?.url) || - Boolean(submitData?.data?.[0]?.b64_json); - - if (hasSyncPayload) { - const syncResult = normalizeNanoBananaSyncPayload(submitData, body.prompt); - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - responseBody: { images_count: syncResult.data?.length || 0, mode: "sync" }, - }).catch(() => {}); - return { - success: true, - data: { created: Math.floor(Date.now() / 1000), data: syncResult.data }, - }; - } - - const taskId = submitData?.data?.taskId || submitData?.taskId; - if (!taskId) { - const errorText = `NanoBanana submit did not return taskId: ${JSON.stringify(submitData).slice(0, 400)}`; - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText, - }).catch(() => {}); - return { success: false, status: 502, error: errorText }; - } - - if (!statusUrl) { - const errorText = "NanoBanana statusUrl is not configured"; - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 500, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText, - }).catch(() => {}); - return { success: false, status: 500, error: errorText }; - } - - const timeoutMs = normalizePositiveNumber( - body.timeout_ms, - normalizePositiveNumber(process.env.NANOBANANA_POLL_TIMEOUT_MS, 120000) - ); - const pollIntervalMs = normalizePositiveNumber( - body.poll_interval_ms, - normalizePositiveNumber(process.env.NANOBANANA_POLL_INTERVAL_MS, 2500) - ); - - let lastTaskData = null; - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - const pollResp = await fetch(`${statusUrl}?taskId=${encodeURIComponent(taskId)}`, { - method: "GET", - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!pollResp.ok) { - const errorText = await pollResp.text(); - if (log) { - log.error( - "IMAGE", - `${provider} poll error ${pollResp.status}: ${errorText.slice(0, 200)}` - ); - } - return { success: false, status: pollResp.status, error: errorText }; - } - - const pollData = await pollResp.json(); - const taskData = pollData?.data || pollData; - lastTaskData = taskData; - - const successFlag = Number(taskData?.successFlag); - if (successFlag === 1) { - const normalized = await normalizeNanoBananaTaskResult(taskData, body, log); - - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - responseBody: { images_count: normalized.length, mode: "async", taskId }, - }).catch(() => {}); - - return { - success: true, - data: { - created: Math.floor(Date.now() / 1000), - data: normalized, - }, - }; - } - - if (successFlag === 2 || successFlag === 3) { - const errorText = - taskData?.errorMessage || `NanoBanana task failed (successFlag=${String(successFlag)})`; - - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText.slice(0, 500), - responseBody: { taskId, successFlag, errorCode: taskData?.errorCode ?? null }, - }).catch(() => {}); - - return { success: false, status: 502, error: errorText }; - } - - await sleep(pollIntervalMs); - } - - const timeoutError = `NanoBanana task timeout after ${timeoutMs}ms (taskId=${taskId}, successFlag=${String(lastTaskData?.successFlag ?? "unknown")})`; - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 504, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: timeoutError, - responseBody: { taskId, lastSuccessFlag: lastTaskData?.successFlag ?? null }, - }).catch(() => {}); - - return { success: false, status: 504, error: timeoutError }; - } catch (err) { - if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: err.message, - }).catch(() => {}); - return { - success: false, - status: 502, - error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, - }; - } -} - -function normalizeNanoBananaSyncPayload(data, prompt) { - const images = []; - - if (data.image) { - images.push({ b64_json: data.image, revised_prompt: prompt }); - } else if (Array.isArray(data.images)) { - for (const img of data.images) { - images.push({ - b64_json: typeof img === "string" ? img : img?.image || img?.data, - revised_prompt: prompt, - }); - } - } else if (Array.isArray(data.data)) { - for (const img of data.data) { - if (!img) continue; - images.push(img); - } - } - - return { data: images.filter(Boolean) }; -} - -export async function normalizeNanoBananaTaskResult(taskData, body, log) { - const response = taskData?.response || {}; - - const urlCandidates = [ - response?.resultImageUrl, - response?.originImageUrl, - taskData?.resultImageUrl, - taskData?.originImageUrl, - ].filter((v) => typeof v === "string" && v.length > 0); - - if (Array.isArray(response?.resultImageUrls)) { - for (const u of response.resultImageUrls) { - if (typeof u === "string" && u.length > 0) urlCandidates.push(u); - } - } - - const b64Candidates = [ - response?.resultImageBase64, - response?.resultImage, - taskData?.resultImageBase64, - taskData?.resultImage, - ].filter((v) => typeof v === "string" && v.length > 0); - - if (Array.isArray(response?.resultImageBase64List)) { - for (const b64 of response.resultImageBase64List) { - if (typeof b64 === "string" && b64.length > 0) b64Candidates.push(b64); - } - } - - const wantsBase64 = body.response_format === "b64_json"; - - if (wantsBase64) { - if (b64Candidates.length > 0) { - return b64Candidates.map((b64) => ({ b64_json: b64, revised_prompt: body.prompt })); - } - - if (urlCandidates.length > 0) { - const firstUrl = urlCandidates[0]; - // Upstream-supplied result URL, not an OmniRoute-controlled host — public-only + - // DNS-pinned policy lives in fetchUntrustedRemoteImage. - const remoteImage = await fetchUntrustedRemoteImage(firstUrl); - const base64 = remoteImage.buffer.toString("base64"); - return [{ b64_json: base64, revised_prompt: body.prompt }]; - } - } - - if (urlCandidates.length > 0) { - return urlCandidates.map((url) => ({ url, revised_prompt: body.prompt })); - } - - if (b64Candidates.length > 0) { - return b64Candidates.map((b64) => ({ b64_json: b64, revised_prompt: body.prompt })); - } - - if (log) { - log.warn( - "IMAGE", - `NanoBanana task completed without image payload: ${JSON.stringify(taskData).slice(0, 240)}` - ); - } - - return []; -} - -function inferResolutionFromSize(size) { - if (typeof size !== "string") return null; - const [wRaw, hRaw] = size.split("x"); - const width = Number(wRaw); - const height = Number(hRaw); - if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null; - - const longestSide = Math.max(width, height); - if (longestSide <= 1024) return "1K"; - if (longestSide <= 2048) return "2K"; - return "4K"; -} - -function normalizePositiveNumber(value, fallback) { - const n = Number(value); - if (!Number.isFinite(n) || n <= 0) return fallback; - return Math.floor(n); -} - -/** - * Handle SD WebUI image generation (local, no auth) - * POST {baseUrl} with { prompt, negative_prompt, width, height, steps } - * Response: { images: ["base64..."] } - */ diff --git a/open-sse/handlers/imageGeneration/providers/blackForestLabs.ts b/open-sse/handlers/imageGeneration/providers/blackForestLabs.ts new file mode 100644 index 000000000000..372aaabd1ee9 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/blackForestLabs.ts @@ -0,0 +1,186 @@ +import { sleep } from "../../../utils/sleep.ts"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; +import { mapImageSize } from "../../../translator/image/sizeMapper.ts"; +import { extractImageInputs, resolveImageSource, normalizeRequestedImageFormat, normalizeProviderImagePayload, saveImageSuccessResult, saveImageErrorResult, normalizePositiveNumber, parseSizeToDimensions, isHttpUrl } from "../shared.ts"; +const BFL_MODEL_ENDPOINTS = { + "flux-2-max": "/v1/flux-2-max", + "flux-2-pro": "/v1/flux-2-pro", + "flux-2-flex": "/v1/flux-2-flex", + "flux-2-klein-9b": "/v1/flux-2-klein-9b", + "flux-2-klein-4b": "/v1/flux-2-klein-4b", + "flux-kontext-pro": "/v1/flux-kontext-pro", + "flux-kontext-max": "/v1/flux-kontext-max", + "flux-pro-1.1": "/v1/flux-pro-1.1", + "flux-pro-1.1-ultra": "/v1/flux-pro-1.1-ultra", + "flux-dev": "/v1/flux-dev", + "flux-pro": "/v1/flux-pro", +}; + +const BFL_EDIT_MODELS = new Set([ + "flux-2-max", + "flux-2-pro", + "flux-2-flex", + "flux-kontext-pro", + "flux-kontext-max", +]); + +const BFL_FAILURE_STATUSES = new Set(["Error", "Failed", "Content Moderated", "Request Moderated"]); + +export async function handleBlackForestLabsImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const startTime = Date.now(); + const token = credentials.apiKey || credentials.accessToken; + const endpoint = BFL_MODEL_ENDPOINTS[model]; + + if (!endpoint) { + return { + success: false, + status: 400, + error: `Unsupported Black Forest Labs image model: ${model}`, + }; + } + + const { imageUrl, maskUrl } = extractImageInputs(body); + const upstreamBody: Record = { + prompt: body.prompt, + output_format: normalizeRequestedImageFormat(body, "png"), + }; + + try { + if (BFL_EDIT_MODELS.has(model) && imageUrl) { + upstreamBody.input_image = (await resolveImageSource(imageUrl)).base64; + } else if (imageUrl && isHttpUrl(imageUrl)) { + upstreamBody.image_url = imageUrl; + } + + if (maskUrl && (model === "flux-pro-1.0-fill" || model === "flux-kontext-pro")) { + upstreamBody.mask = (await resolveImageSource(maskUrl)).base64; + } + + if (model === "flux-kontext-pro" || model === "flux-kontext-max") { + upstreamBody.aspect_ratio = body.aspect_ratio || mapImageSize(body.size); + } else if (typeof body.size === "string" && body.size.includes("x")) { + const { width, height } = parseSizeToDimensions(body.size, 1024); + upstreamBody.width = width; + upstreamBody.height = height; + } + + if (body.seed !== undefined) upstreamBody.seed = body.seed; + if (body.n !== undefined && model.includes("ultra")) + upstreamBody.num_images = Number(body.n) || 1; + if (body.quality === "hd" && model.includes("ultra")) upstreamBody.raw = true; + if (body.left !== undefined) upstreamBody.left = body.left; + if (body.right !== undefined) upstreamBody.right = body.right; + if (body.top !== undefined) upstreamBody.top = body.top; + if (body.bottom !== undefined) upstreamBody.bottom = body.bottom; + if (body.steps !== undefined) upstreamBody.steps = body.steps; + if (body.guidance !== undefined) upstreamBody.guidance = body.guidance; + if (body.grow_mask !== undefined) upstreamBody.grow_mask = body.grow_mask; + if (body.safety_tolerance !== undefined) upstreamBody.safety_tolerance = body.safety_tolerance; + + if (log) { + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log.info("IMAGE", `${provider}/${model} (black-forest-labs) | prompt: "${promptPreview}..."`); + } + + const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}${endpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "x-key": token, + }, + body: JSON.stringify(upstreamBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + if (log) + log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); + return saveImageErrorResult({ + provider, + model, + status: response.status, + startTime, + error: errorText, + requestBody: upstreamBody, + }); + } + + const initialPayload = await response.json(); + const finalPayload = initialPayload.polling_url + ? await pollBlackForestLabsResult({ + pollingUrl: initialPayload.polling_url, + token, + body, + log, + }) + : initialPayload; + + const images = await normalizeProviderImagePayload(finalPayload, body, log, "url"); + return saveImageSuccessResult({ + provider, + model, + startTime, + requestBody: upstreamBody, + responseBody: { images_count: images.length }, + created: finalPayload.created, + images, + }); + } catch (err) { + if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, + }); + } +} + +async function pollBlackForestLabsResult({ pollingUrl, token, body, log }) { + const timeoutMs = normalizePositiveNumber(body.timeout_ms, 300000); + const pollIntervalMs = normalizePositiveNumber(body.poll_interval_ms, 1500); + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const response = await fetch(pollingUrl, { + method: "GET", + headers: { + "x-key": token, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`BFL polling failed (${response.status}): ${errorText}`); + } + + const payload = await response.json(); + const status = payload?.status; + + if (status === "Ready") { + return payload; + } + + if (BFL_FAILURE_STATUSES.has(status)) { + throw new Error(`BFL image generation failed: ${status}`); + } + + if (log) { + log.info("IMAGE", `black-forest-labs polling status: ${String(status || "Pending")}`); + } + + await sleep(pollIntervalMs); + } + + throw new Error(`BFL polling timed out after ${timeoutMs}ms`); +} + diff --git a/open-sse/handlers/imageGeneration/providers/codex.ts b/open-sse/handlers/imageGeneration/providers/codex.ts new file mode 100644 index 000000000000..213ad8b2dc04 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/codex.ts @@ -0,0 +1,361 @@ +import { getCodexClientVersion, getCodexUserAgent } from "../../../config/codexClient.ts"; +import { isCodexFreePlan } from "../../../executors/codex/tools.ts"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; +import { parseJsonOrNull, sanitizeImageProviderError, saveImageSuccessResult, saveImageErrorResult } from "../shared.ts"; +// #8307 — some ChatGPT accounts can run Codex but lack entitlement for the specific +// requested image model. Upstream signals this as a 400 with an exact, stable message +// (not a generic "invalid request"). Classify it so the caller can mark the failure +// `retryable: true`, which routes it through the same sibling-account fallback that +// already handles 401s (executeImageWithCredentialFallback, src/sse/services/imageCredentialRetry.ts). +function isCodexChatGptModelAccessError(status: number, errorText: string, model: string): boolean { + if (status !== 400) return false; + const parsed = parseJsonOrNull(errorText); + let detail: string | null = null; + if (typeof parsed === "string") { + detail = parsed; + } else if (parsed && typeof parsed === "object") { + const obj = parsed as Record; + if (typeof obj.detail === "string") detail = obj.detail; + else if (typeof obj.message === "string") detail = obj.message; + else if (obj.error && typeof obj.error === "object") { + const nested = (obj.error as Record).message; + if (typeof nested === "string") detail = nested; + } + } + return ( + detail === `The '${model}' model is not supported when using Codex with a ChatGPT account.` + ); +} +/** + * Codex image generation — translate GPT-Image-style /v1/images/generations + * request into a /v1/responses call with the `image_generation` hosted tool, + * parse the SSE stream, and return the base64 PNG in OpenAI image response shape. + * + * Requires ChatGPT OAuth credentials (Codex provider connection). The hosted + * image_generation tool is only served upstream under ChatGPT auth; API-key + * users will receive a 400 from OpenAI. + */ +export function extractImageGenerationCalls( + sseText: string +): Array<{ b64: string; revisedPrompt: string | null }> { + const results: Array<{ b64: string; revisedPrompt: string | null }> = []; + const lines = String(sseText || "").split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) continue; + const payload = trimmed.slice(5).trim(); + if (!payload || payload === "[DONE]") continue; + let evt: Record; + try { + evt = JSON.parse(payload) as Record; + } catch { + continue; + } + if (evt?.type !== "response.output_item.done") continue; + const item = evt.item as Record | undefined; + if (!item || item.type !== "image_generation_call") continue; + const result = typeof item.result === "string" ? item.result : ""; + if (!result) continue; + const revisedPrompt = typeof item.revised_prompt === "string" ? item.revised_prompt : null; + results.push({ b64: result, revisedPrompt }); + } + return results; +} + +// The image_generation hosted tool accepts { "auto" | "low" | "medium" | "high" } +// for `quality`. Legacy image clients often send "standard" / "hd". Map those values +// so OpenWebUI's quality dropdown doesn't silently get rejected upstream. +function mapLegacyImageQualityToImageTool(value: string): string { + const normalized = value.toLowerCase(); + if (normalized === "standard") return "medium"; + if (normalized === "hd") return "high"; + return normalized; +} + +export async function handleCodexImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + referenceImages = [], + signal = null, + logPath = "/v1/images/generations", +}) { + const startTime = Date.now(); + const prompt = typeof body.prompt === "string" ? body.prompt : ""; + if (!prompt.trim()) { + return saveImageErrorResult({ + provider, + model, + status: 400, + startTime, + error: "Prompt is required for Codex image generation", + path: logPath, + }); + } + + const requestedCount = + Number.isInteger(body.n) && (body.n as number) > 0 ? (body.n as number) : 1; + if (log && requestedCount > 1) { + log.warn( + "IMAGE", + `Codex hosted image_generation returns one image per call; requested n=${requestedCount} will fan out in parallel` + ); + } + + const token = credentials?.accessToken || credentials?.apiKey; + if (!token) { + return saveImageErrorResult({ + provider, + model, + status: 401, + startTime, + error: "Codex credentials missing accessToken — reconnect the Codex provider", + path: logPath, + }); + } + + if (isCodexFreePlan(credentials?.providerSpecificData)) { + return saveImageErrorResult({ + provider, + model, + status: 403, + startTime, + error: "Codex image_generation is unavailable on free-plan accounts", + path: logPath, + retryable: true, + }); + } + + const workspaceId = + credentials?.providerSpecificData && + typeof credentials.providerSpecificData === "object" && + !Array.isArray(credentials.providerSpecificData) + ? (credentials.providerSpecificData as Record).workspaceId + : undefined; + + // Forward size/quality from the GPT-Image-style body into the hosted tool so + // OpenWebUI's size/quality selectors actually take effect. Everything else + // (model, n, background, moderation, output_compression) is left to the + // Codex backend's defaults — today that's `gpt-image-2`. + const toolConfig: Record = { type: "image_generation", output_format: "png" }; + if (referenceImages.length > 0) toolConfig.action = "edit"; + if (typeof body.size === "string" && body.size.trim()) { + toolConfig.size = body.size.trim(); + } + if (typeof body.quality === "string" && body.quality.trim()) { + toolConfig.quality = mapLegacyImageQualityToImageTool(body.quality.trim()); + } + + const inputContent: Array> = [{ type: "input_text", text: prompt }]; + for (const image of referenceImages) { + inputContent.push({ + type: "input_image", + image_url: `data:${image.mime || "image/png"};base64,${image.bytes.toString("base64")}`, + }); + } + + const upstreamBody: Record = { + model, + instructions: + referenceImages.length > 0 + ? `You must call the image_generation tool exactly once to edit the supplied ${referenceImages.length === 1 ? "reference image" : "reference images"}. Treat all supplied images as references for the user's requested composition or style. Do not add narration.` + : "You must call the image_generation tool exactly once to fulfill the user's request. Do not add narration.", + input: [ + { + role: "user", + content: inputContent, + }, + ], + tools: [toolConfig], + stream: true, + store: false, + }; + const requestBodyForLog = + referenceImages.length > 0 + ? { + model, + prompt_chars: prompt.length, + reference_images: referenceImages.map((image) => ({ + mime: image.mime || "image/png", + bytes: image.bytes.length, + })), + tools: [toolConfig], + stream: true, + store: false, + } + : upstreamBody; + + const headers: Record = { + "Content-Type": "application/json", + Accept: "text/event-stream", + Authorization: `Bearer ${token}`, + Version: getCodexClientVersion(), + "User-Agent": getCodexUserAgent(), + originator: "codex_cli_rs", + }; + if (typeof workspaceId === "string" && workspaceId) { + headers["chatgpt-account-id"] = workspaceId; + headers["session_id"] = workspaceId; + } + + if (log) { + const promptSummary = + referenceImages.length > 0 ? `${prompt.length} chars` : `"${prompt.slice(0, 60)}..."`; + log.info("IMAGE", `${provider}/${model} (codex-responses) | prompt: ${promptSummary}`); + } + + const fetchOneImage = async () => { + let response: Response; + try { + response = await fetch(providerConfig.baseUrl, { + method: "POST", + headers, + body: JSON.stringify(upstreamBody), + signal, + }); + } catch (err) { + const message = sanitizeErrorMessage(err); + if (log) log.error("IMAGE", `${provider} fetch error: ${message}`); + return { + ok: false as const, + error: { + provider, + model, + status: 502, + startTime, + error: `Image provider error: ${message}`, + requestBody: requestBodyForLog, + path: logPath, + }, + }; + } + + if (!response.ok) { + const errorText = await response.text(); + const safeError = sanitizeImageProviderError(errorText); + const safeErrorLog = + typeof safeError === "string" ? safeError : JSON.stringify(safeError ?? {}); + if (log) log.error("IMAGE", `${provider} error ${response.status}: ${safeErrorLog}`); + const retryable = isCodexChatGptModelAccessError(response.status, errorText, model); + return { + ok: false as const, + error: { + provider, + model, + status: response.status, + startTime, + error: safeError, + requestBody: requestBodyForLog, + path: logPath, + ...(retryable ? { retryable: true } : {}), + }, + }; + } + + const rawSSE = await response.text(); + const items = extractImageGenerationCalls(rawSSE); + if (items.length === 0) { + return { + ok: false as const, + error: { + provider, + model, + status: 502, + startTime, + error: + "Codex completed without producing an image_generation_call — the model may have declined the tool", + requestBody: requestBodyForLog, + path: logPath, + }, + }; + } + + return { ok: true as const, items }; + }; + + const imageResults = await Promise.all( + Array.from({ length: requestedCount }, () => fetchOneImage()) + ); + + const collected: Array<{ b64_json: string; revised_prompt?: string }> = []; + for (const imageResult of imageResults) { + if (!imageResult.ok) return saveImageErrorResult(imageResult.error); + for (const item of imageResult.items) { + collected.push({ + b64_json: item.b64, + ...(item.revisedPrompt ? { revised_prompt: item.revisedPrompt } : {}), + }); + } + } + + // OpenAI returns b64_json for the gpt-image-* family and reserves `url` for + // fetchable HTTPS links, so clients that omit response_format (Codex CLI's + // built-in image_gen among them) expect the bytes in b64_json. Only emit the + // data: URI when the caller explicitly asks for `url` (#12268). + const wantsUrl = body.response_format === "url"; + const data = wantsUrl + ? collected.map((item) => ({ + url: `data:image/png;base64,${item.b64_json}`, + ...(item.revised_prompt ? { revised_prompt: item.revised_prompt } : {}), + })) + : collected; + + return saveImageSuccessResult({ + provider, + model, + startTime, + requestBody: requestBodyForLog, + responseBody: { images_count: data.length }, + images: data, + path: logPath, + }); +} + +type CodexImageEditResult = + | { success: true; data: { created: number; data: Array> } } + | { success: false; status: number; error: unknown }; + +/** + * Run a stateless Codex reference-image edit through the native Responses hosted tool. + * This deliberately reuses the text-to-image implementation so OAuth headers, SSE parsing, + * response formatting, and error handling cannot drift between generations and edits. + */ +export async function handleCodexImageEdit({ + model, + provider, + providerConfig, + body, + referenceImages, + credentials, + log, + signal = null, +}: { + model: string; + provider: string; + providerConfig: unknown; + body: Record; + referenceImages: Array<{ bytes: Buffer; mime: string }>; + credentials: unknown; + log: { + info: (tag: string, message: string) => void; + warn: (tag: string, message: string) => void; + error: (tag: string, message: string) => void; + } | null; + signal?: AbortSignal | null; +}): Promise { + const result = await handleCodexImageGeneration({ + model, + provider, + providerConfig, + body: { ...body, n: 1 }, + credentials, + log, + referenceImages, + signal, + logPath: "/v1/images/edits", + }); + return result as CodexImageEditResult; +} diff --git a/open-sse/handlers/imageGeneration/providers/falGeneration.ts b/open-sse/handlers/imageGeneration/providers/falGeneration.ts new file mode 100644 index 000000000000..ae1ee431e5eb --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/falGeneration.ts @@ -0,0 +1,105 @@ +import { sanitizeErrorMessage } from "../../../utils/error.ts"; + +import { extractImageInputs, normalizeRequestedImageFormat, normalizeProviderImagePayload, saveImageSuccessResult, saveImageErrorResult, mapFalImageSize, mapFalAspectRatio, normalizeRecraftStyle } from "../shared.ts"; +export async function handleFalAIImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const startTime = Date.now(); + const token = credentials.apiKey || credentials.accessToken; + const falModel = model.startsWith("fal-ai/") ? model : `fal-ai/${model}`; + const { imageUrl, imageUrls } = extractImageInputs(body); + const upstreamBody: Record = { + prompt: body.prompt, + sync_mode: body.sync_mode ?? true, + }; + + if (body.n !== undefined) upstreamBody.num_images = Number(body.n) || 1; + if (body.negative_prompt) upstreamBody.negative_prompt = body.negative_prompt; + if (body.seed !== undefined) upstreamBody.seed = body.seed; + if (body.style) upstreamBody.style = normalizeRecraftStyle(body.style); + + const outputFormat = normalizeRequestedImageFormat(body, "png"); + if (outputFormat) upstreamBody.output_format = outputFormat; + + if (model.includes("flux-pro/v1.1") && !model.includes("ultra")) { + upstreamBody.image_size = mapFalImageSize(body.size, "landscape_4_3"); + } else if ( + model.includes("bytedance/") || + model.includes("stable-diffusion") || + model.includes("ideogram") || + model.includes("recraft/v3") + ) { + upstreamBody.image_size = mapFalImageSize(body.size, "square_hd"); + } else { + upstreamBody.aspect_ratio = body.aspect_ratio || mapFalAspectRatio(body.size, "1:1"); + } + + if (body.quality === "hd" && model.includes("ultra")) { + upstreamBody.raw = true; + } + + if (imageUrl && model.includes("flux-pro/v1.1-ultra")) { + upstreamBody.image_url = imageUrl; + } + + if (imageUrls.length > 0 && model.includes("ideogram")) { + upstreamBody.image_urls = imageUrls; + } + + if (log) { + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log.info("IMAGE", `${provider}/${model} (fal-ai) | prompt: "${promptPreview}..."`); + } + + try { + const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}/${falModel}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Key ${token}`, + }, + body: JSON.stringify(upstreamBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + if (log) + log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); + return saveImageErrorResult({ + provider, + model, + status: response.status, + startTime, + error: errorText, + requestBody: upstreamBody, + }); + } + + const payload = await response.json(); + const images = await normalizeProviderImagePayload(payload, body, log, "b64_json"); + return saveImageSuccessResult({ + provider, + model, + startTime, + requestBody: upstreamBody, + responseBody: { images_count: images.length }, + created: payload.created, + images, + }); + } catch (err) { + if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, + }); + } +} + diff --git a/open-sse/handlers/imageGeneration/providers/kie.ts b/open-sse/handlers/imageGeneration/providers/kie.ts new file mode 100644 index 000000000000..551cdd7a59c8 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/kie.ts @@ -0,0 +1,320 @@ +import { getImageProvider } from "../../../config/imageRegistry.ts"; +import { kieExecutor } from "../../../executors/kie.ts"; +import { getKieErrorMessage, getKieErrorStatus, getKieTaskId, isJsonObject, parseKieResultJson } from "../../../utils/kieTask.ts"; + +import { mapImageSize } from "../../../translator/image/sizeMapper.ts"; +import { extractImageInputs, saveImageSuccessResult, saveImageErrorResult, normalizePositiveNumber } from "../shared.ts"; +interface KieImageOptions { + model: string; + provider: string; + providerConfig: { + baseUrl: string; + statusUrl?: string; + }; + body: Record & { + prompt?: unknown; + size?: unknown; + n?: unknown; + timeout_ms?: unknown; + poll_interval_ms?: unknown; + }; + credentials?: { + apiKey?: string; + accessToken?: string; + } | null; + log?: { + info: (scope: string, message: string) => void; + error: (scope: string, message: string) => void; + } | null; +} + +// KIE Market catalog ids are namespaced for OmniRoute's catalog +// (`/`), but the KIE Market createTask API expects +// vendor-specific upstream ids that do not follow a single consistent +// pattern. Every entry below was confirmed individually against the literal +// example request JSON published on docs.kie.ai (never inferred by pattern — +// see #11326's false "everything else already matches" claim and #11296's +// follow-up correction): +// - google-imagen: nano-banana-2 and nano-banana-pro drop the vendor +// namespace entirely; nano-banana and nano-banana-edit use a `google/` +// prefix instead of `google-imagen/` (docs.kie.ai/market/google/*). +// - gpt: gpt-image-2-* drops the `gpt/` namespace entirely +// (docs.kie.ai/market/gpt/gpt-image-2-*); gpt-image-1.5-* uses a +// `gpt-image/` namespace instead of `gpt/gpt-image-1.5-`, and keeps the +// dot in "1.5" (docs.kie.ai/market/gpt-image/1-5-*). +// - seedream: 5.0-lite-* drops the ".0" — real id is `5-lite-*` +// (docs.kie.ai/market/seedream/5-lite-text-to-image); seedream 4.5 (T2I +// and edit) already matches byte-for-byte. +// - flux: `flux/2-*` uses a `flux-2/` namespace (dash, not slash); the +// generic (non-"pro") variant is named `flex` upstream, not `2` +// (docs.kie.ai/market/flux2/pro-*, .../flex-*). +// - wan: `wan/2.7-*` keeps the dot in our catalog, but KIE's documented +// enum uses a dash — real id is `wan/2-7-*` +// (docs.kie.ai/market/wan/2-7-image[-pro]). +// - ideogram (v3-text-to-image, v3-edit, v3-remix), qwen, qwen2, and +// grok-imagine already match byte-for-byte +// (docs.kie.ai/market/{ideogram,qwen,qwen2,grok-imagine}/*). +// ideogram/v3-reframe has no dedicated docs.kie.ai page as of this sweep +// (its 3 siblings above are all direct id matches, so it is assumed +// correct by pattern, not independently confirmed). +// One catalog entry remains UNRESOLVED after this sweep and is deliberately +// left untouched pending a follow-up (see #11296 discussion): +// - z-image/4.0-text-to-image and z-image/4.5-text-to-image: the only +// documented Z-Image Market page (docs.kie.ai/market/z-image/z-image) +// shows a single fixed `model` enum value `"z-image"` with no +// version-specific id or "version" input field found — unclear whether +// both catalog ids should collapse to the same upstream call. +// flux/kontext is RESOLVED (#11296): it is catalogued with `isMarket: true` +// but has no `docs.kie.ai/market/flux2/kontext` (or similar) Market page — +// Flux Kontext is documented under the separate `/flux-kontext-api/*` docs +// tree with its own endpoint (`POST /api/v1/flux/kontext/generate`, poll +// `GET /api/v1/flux/kontext/record-info`, models `flux-kontext-pro`/ +// `flux-kontext-max`), not the Market `createTask` flow this map feeds. It is +// NOT in KIE_MARKET_UPSTREAM_MODEL_IDS below on purpose — handleKieImageGeneration +// reroutes it to the dedicated endpoint instead of rewriting its id. +export const KIE_MARKET_UPSTREAM_MODEL_IDS: ReadonlyMap = new Map([ + ["google-imagen/nano-banana", "google/nano-banana"], + ["google-imagen/nano-banana-2", "nano-banana-2"], + ["google-imagen/nano-banana-pro", "nano-banana-pro"], + ["google-imagen/nano-banana-edit", "google/nano-banana-edit"], + ["gpt/gpt-image-2-text-to-image", "gpt-image-2-text-to-image"], + ["gpt/gpt-image-2-image-to-image", "gpt-image-2-image-to-image"], + ["gpt/gpt-image-1.5-text-to-image", "gpt-image/1.5-text-to-image"], + ["gpt/gpt-image-1.5-image-to-image", "gpt-image/1.5-image-to-image"], + ["seedream/5.0-lite-text-to-image", "seedream/5-lite-text-to-image"], + ["seedream/5.0-lite-image-to-image", "seedream/5-lite-image-to-image"], + ["flux/2-pro-text-to-image", "flux-2/pro-text-to-image"], + ["flux/2-pro-image-to-image", "flux-2/pro-image-to-image"], + ["flux/2-text-to-image", "flux-2/flex-text-to-image"], + ["flux/2-image-to-image", "flux-2/flex-image-to-image"], + ["wan/2.7-image", "wan/2-7-image"], + ["wan/2.7-image-pro", "wan/2-7-image-pro"], +]); + +export function resolveKieMarketUpstreamModelId(publicModelId: string): string { + return KIE_MARKET_UPSTREAM_MODEL_IDS.get(publicModelId) ?? publicModelId; +} +function normalizeKieImageResult(recordData: unknown): string[] { + const record = isJsonObject(recordData) ? recordData : {}; + const data = isJsonObject(record.data) ? record.data : {}; + const response = isJsonObject(data.response) ? data.response : {}; + const resultJson = parseKieResultJson(recordData); + const urls = new Set(); + + const add = (val: unknown) => { + if (typeof val === "string" && val.startsWith("http")) urls.add(val); + if (Array.isArray(val)) { + val.forEach((v) => { + if (typeof v === "string" && v.startsWith("http")) urls.add(v); + }); + } + }; + + // Check resultJson (common in Market API) + add(resultJson?.resultUrls); + add(resultJson?.imageUrls); + add(resultJson?.resultUrl); + add(resultJson?.imageUrl); + + // Check data.response (common in 4o-image API); resultImageUrl(s) is the + // flux/kontext shape normalizeNanoBananaTaskResult also reads (#14335 LEDGER-11). + add(response.resultUrls); + add(response.resultUrl); + add(response.resultImageUrl); + add(response.resultImageUrls); + + // Check direct data fields + add(data.resultImageUrls); + add(data.resultImageUrl); + add(data.url); + + return Array.from(urls); +} + +export async function handleKieImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: KieImageOptions) { + const startTime = Date.now(); + const token = credentials?.apiKey || credentials?.accessToken; + const timeoutMs = normalizePositiveNumber(body.timeout_ms, 300000); + const pollIntervalMs = normalizePositiveNumber(body.poll_interval_ms, 2500); + const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); + const size = typeof body.size === "string" ? body.size : undefined; + + if (!token) { + return saveImageErrorResult({ + provider, + model, + status: 401, + startTime, + error: "KIE API key is required", + }); + } + + // Check if model is a Market model (unified API) + const fullRegistry = getImageProvider(provider); + const modelEntry = fullRegistry?.models?.find((m) => m.id === model); + // #11296 — flux/kontext is catalogued with `isMarket: true`, but KIE does not + // expose it through the Market catalog at all: it lives under a dedicated API + // tree (POST /api/v1/flux/kontext/generate, poll .../flux/kontext/record-info) + // that rejects the Market createTask flow with "model name not supported". Route + // it there instead of treating it as a Market entry (see KIE_MARKET_UPSTREAM_MODEL_IDS + // comment above for the same finding). + const isFluxKontext = model === "flux/kontext"; + const isMarket = !isFluxKontext && (modelEntry?.isMarket || model.includes("/")); + + const { imageUrl } = extractImageInputs(body); + let baseUrl = ""; + let payload: Record = {}; + + if (isFluxKontext) { + // Dedicated Flux Kontext API endpoint (not part of the Market catalog). + baseUrl = `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/flux/kontext/generate`; + payload = { + prompt, + aspectRatio: mapImageSize(size), + model: "flux-kontext-pro", + ...(imageUrl ? { inputImage: imageUrl } : {}), + }; + } else if (isMarket) { + // Unified Market API endpoint + baseUrl = `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/jobs/createTask`; + const input: Record = { + prompt, + aspect_ratio: mapImageSize(size), + }; + if (imageUrl) { + input.image_url = imageUrl; + } + payload = { + model: resolveKieMarketUpstreamModelId(model), + input, + }; + } else { + // Legacy/Direct endpoint + const modelPath = model.replace("-t2i", "").replace("-i2i", ""); + baseUrl = providerConfig.baseUrl.includes(model) + ? providerConfig.baseUrl + : `https://api.kie.ai/api/v1/${modelPath}/generate`; + + payload = { + prompt, + size: mapImageSize(size), + nVariants: body.n || 1, + }; + } + + if (log) { + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log.info( + "IMAGE", + `${provider}/${model} (${isFluxKontext ? "flux-kontext" : isMarket ? "market" : "direct"}) | prompt: "${promptPreview}..."` + ); + } + + try { + const endpoint = isFluxKontext + ? "/api/v1/flux/kontext/generate" + : isMarket + ? "/api/v1/jobs/createTask" + : new URL(baseUrl).pathname; + const createBaseUrl = + isFluxKontext || isMarket ? providerConfig.baseUrl : baseUrl.replace(endpoint, ""); + const createData = await kieExecutor.createTask({ + baseUrl: createBaseUrl, + token, + payload, + endpoint, + }); + const taskId = getKieTaskId(createData); + + if (!taskId) { + const errorMessage = + createData?.msg || + createData?.message || + createData?.error || + "KIE image generation did not return taskId"; + if (log) { + log.error("IMAGE", `KIE createTask failed: ${JSON.stringify(createData)}`); + } + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: errorMessage, + requestBody: payload, + }); + } + + // Use statusUrl from providerConfig if available, fallback to dynamic derivation + const statusUrl = isFluxKontext + ? `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/flux/kontext/record-info` + : isMarket + ? `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/jobs/recordInfo` + : providerConfig.statusUrl && !providerConfig.statusUrl.includes("jobs/recordInfo") + ? providerConfig.statusUrl + : baseUrl.replace(/\/generate$/, "/record-info"); + + const { data: recordData, state } = await kieExecutor.pollTask({ + statusUrl, + taskId: String(taskId), + token, + timeoutMs, + pollIntervalMs, + }); + + const kieUrls = state === "success" ? normalizeKieImageResult(recordData) : []; + // #14335 LEDGER-13: a "success" state with zero usable urls falls through to + // the failure branch below instead of a fake HTTP-200 success with data:[]. + if (kieUrls.length > 0) { + if (log) { + log.info("IMAGE", `KIE poll success for task ${taskId}`); + } + const images = kieUrls.map((url: string) => ({ url, revised_prompt: prompt })); + + return saveImageSuccessResult({ + provider, + model, + startTime, + requestBody: payload, + responseBody: { images_count: images.length }, + images, + }); + } + + const record = isJsonObject(recordData) ? recordData : {}; + const recordDataBody = isJsonObject(record.data) ? record.data : {}; + const errorMessage = + recordDataBody.errorMessage || + recordDataBody.failMsg || + record.msg || + "KIE image task failed"; + + if (log) { + log.error("IMAGE", `KIE poll failed for task ${taskId}: ${JSON.stringify(recordData)}`); + } + + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: String(errorMessage), + requestBody: payload, + }); + } catch (err: unknown) { + return saveImageErrorResult({ + provider, + model, + status: getKieErrorStatus(err, 502), + startTime, + error: `Image provider error: ${getKieErrorMessage(err, "KIE image generation failed")}`, + }); + } +} diff --git a/open-sse/handlers/imageGeneration/providers/nanoBanana.ts b/open-sse/handlers/imageGeneration/providers/nanoBanana.ts new file mode 100644 index 000000000000..b1aea35b8520 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/nanoBanana.ts @@ -0,0 +1,362 @@ +import { saveCallLog } from "@/lib/usageDb"; +import { fetchUntrustedRemoteImage } from "@/shared/network/remoteImageFetch"; +import { sleep } from "../../../utils/sleep.ts"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; +import { mapImageSize } from "../../../translator/image/sizeMapper.ts"; +import { normalizePositiveNumber } from "../shared.ts"; +/** + * Handle Hyperbolic image generation + * Uses { model_name, prompt, height, width } and returns { images: [{ image: base64 }] } + */ +export async function handleNanoBananaImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const startTime = Date.now(); + const token = credentials.apiKey || credentials.accessToken; + + // Route to pro URL for "nanobanana-pro" model + const isPro = model === "nanobanana-pro"; + const submitUrl = isPro && providerConfig.proUrl ? providerConfig.proUrl : providerConfig.baseUrl; + const statusUrl = providerConfig.statusUrl; + + const aspectRatio = + typeof body.aspectRatio === "string" + ? body.aspectRatio + : typeof body.aspect_ratio === "string" + ? body.aspect_ratio + : mapImageSize(body.size); + + let resolution = + typeof body.resolution === "string" + ? body.resolution + : inferResolutionFromSize(body.size) || "1K"; + if (body.quality === "hd" && resolution === "1K") { + resolution = "2K"; + } + + const upstreamBody = isPro + ? { + prompt: body.prompt, + resolution, + aspectRatio, + ...(Array.isArray(body.imageUrls) ? { imageUrls: body.imageUrls } : {}), + } + : { + prompt: body.prompt, + type: + Array.isArray(body.imageUrls) && body.imageUrls.length > 0 + ? "IMAGETOIAMGE" + : "TEXTTOIAMGE", + numImages: Number.isFinite(body.n) ? Math.max(1, Number(body.n)) : 1, + image_size: aspectRatio, + ...(Array.isArray(body.imageUrls) ? { imageUrls: body.imageUrls } : {}), + }; + + if (log) { + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log.info( + "IMAGE", + `${provider}/${model} (nanobanana ${isPro ? "pro" : "flash"}) | prompt: "${promptPreview}..."` + ); + } + + try { + const submitResp = await fetch(submitUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(upstreamBody), + }); + + if (!submitResp.ok) { + const errorText = await submitResp.text(); + if (log) { + log.error( + "IMAGE", + `${provider} submit error ${submitResp.status}: ${errorText.slice(0, 200)}` + ); + } + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: submitResp.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + }).catch(() => {}); + + return { success: false, status: submitResp.status, error: errorText }; + } + + const submitData = await submitResp.json(); + + // Backward compatibility: handle providers returning image payload synchronously + const hasSyncPayload = + Boolean(submitData?.image) || + Array.isArray(submitData?.images) || + Array.isArray(submitData?.data) || + Boolean(submitData?.data?.[0]?.url) || + Boolean(submitData?.data?.[0]?.b64_json); + + if (hasSyncPayload) { + const syncResult = normalizeNanoBananaSyncPayload(submitData, body.prompt); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { images_count: syncResult.data?.length || 0, mode: "sync" }, + }).catch(() => {}); + return { + success: true, + data: { created: Math.floor(Date.now() / 1000), data: syncResult.data }, + }; + } + + const taskId = submitData?.data?.taskId || submitData?.taskId; + if (!taskId) { + const errorText = `NanoBanana submit did not return taskId: ${JSON.stringify(submitData).slice(0, 400)}`; + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText, + }).catch(() => {}); + return { success: false, status: 502, error: errorText }; + } + + if (!statusUrl) { + const errorText = "NanoBanana statusUrl is not configured"; + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 500, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText, + }).catch(() => {}); + return { success: false, status: 500, error: errorText }; + } + + const timeoutMs = normalizePositiveNumber( + body.timeout_ms, + normalizePositiveNumber(process.env.NANOBANANA_POLL_TIMEOUT_MS, 120000) + ); + const pollIntervalMs = normalizePositiveNumber( + body.poll_interval_ms, + normalizePositiveNumber(process.env.NANOBANANA_POLL_INTERVAL_MS, 2500) + ); + + let lastTaskData = null; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const pollResp = await fetch(`${statusUrl}?taskId=${encodeURIComponent(taskId)}`, { + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!pollResp.ok) { + const errorText = await pollResp.text(); + if (log) { + log.error( + "IMAGE", + `${provider} poll error ${pollResp.status}: ${errorText.slice(0, 200)}` + ); + } + return { success: false, status: pollResp.status, error: errorText }; + } + + const pollData = await pollResp.json(); + const taskData = pollData?.data || pollData; + lastTaskData = taskData; + + const successFlag = Number(taskData?.successFlag); + if (successFlag === 1) { + const normalized = await normalizeNanoBananaTaskResult(taskData, body, log); + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { images_count: normalized.length, mode: "async", taskId }, + }).catch(() => {}); + + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: normalized, + }, + }; + } + + if (successFlag === 2 || successFlag === 3) { + const errorText = + taskData?.errorMessage || `NanoBanana task failed (successFlag=${String(successFlag)})`; + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + responseBody: { taskId, successFlag, errorCode: taskData?.errorCode ?? null }, + }).catch(() => {}); + + return { success: false, status: 502, error: errorText }; + } + + await sleep(pollIntervalMs); + } + + const timeoutError = `NanoBanana task timeout after ${timeoutMs}ms (taskId=${taskId}, successFlag=${String(lastTaskData?.successFlag ?? "unknown")})`; + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 504, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: timeoutError, + responseBody: { taskId, lastSuccessFlag: lastTaskData?.successFlag ?? null }, + }).catch(() => {}); + + return { success: false, status: 504, error: timeoutError }; + } catch (err) { + if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: err.message, + }).catch(() => {}); + return { + success: false, + status: 502, + error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, + }; + } +} + +function normalizeNanoBananaSyncPayload(data, prompt) { + const images = []; + + if (data.image) { + images.push({ b64_json: data.image, revised_prompt: prompt }); + } else if (Array.isArray(data.images)) { + for (const img of data.images) { + images.push({ + b64_json: typeof img === "string" ? img : img?.image || img?.data, + revised_prompt: prompt, + }); + } + } else if (Array.isArray(data.data)) { + for (const img of data.data) { + if (!img) continue; + images.push(img); + } + } + + return { data: images.filter(Boolean) }; +} + +export async function normalizeNanoBananaTaskResult(taskData, body, log) { + const response = taskData?.response || {}; + + const urlCandidates = [ + response?.resultImageUrl, + response?.originImageUrl, + taskData?.resultImageUrl, + taskData?.originImageUrl, + ].filter((v) => typeof v === "string" && v.length > 0); + + if (Array.isArray(response?.resultImageUrls)) { + for (const u of response.resultImageUrls) { + if (typeof u === "string" && u.length > 0) urlCandidates.push(u); + } + } + + const b64Candidates = [ + response?.resultImageBase64, + response?.resultImage, + taskData?.resultImageBase64, + taskData?.resultImage, + ].filter((v) => typeof v === "string" && v.length > 0); + + if (Array.isArray(response?.resultImageBase64List)) { + for (const b64 of response.resultImageBase64List) { + if (typeof b64 === "string" && b64.length > 0) b64Candidates.push(b64); + } + } + + const wantsBase64 = body.response_format === "b64_json"; + + if (wantsBase64) { + if (b64Candidates.length > 0) { + return b64Candidates.map((b64) => ({ b64_json: b64, revised_prompt: body.prompt })); + } + + if (urlCandidates.length > 0) { + const firstUrl = urlCandidates[0]; + // Upstream-supplied result URL, not an OmniRoute-controlled host — public-only + + // DNS-pinned policy lives in fetchUntrustedRemoteImage. + const remoteImage = await fetchUntrustedRemoteImage(firstUrl); + const base64 = remoteImage.buffer.toString("base64"); + return [{ b64_json: base64, revised_prompt: body.prompt }]; + } + } + + if (urlCandidates.length > 0) { + return urlCandidates.map((url) => ({ url, revised_prompt: body.prompt })); + } + + if (b64Candidates.length > 0) { + return b64Candidates.map((b64) => ({ b64_json: b64, revised_prompt: body.prompt })); + } + + if (log) { + log.warn( + "IMAGE", + `NanoBanana task completed without image payload: ${JSON.stringify(taskData).slice(0, 240)}` + ); + } + + return []; +} + +function inferResolutionFromSize(size) { + if (typeof size !== "string") return null; + const [wRaw, hRaw] = size.split("x"); + const width = Number(wRaw); + const height = Number(hRaw); + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null; + + const longestSide = Math.max(width, height); + if (longestSide <= 1024) return "1K"; + if (longestSide <= 2048) return "2K"; + return "4K"; +} diff --git a/open-sse/handlers/imageGeneration/providers/openAI.ts b/open-sse/handlers/imageGeneration/providers/openAI.ts new file mode 100644 index 000000000000..aabf8c164285 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/openAI.ts @@ -0,0 +1,404 @@ +import { randomUUID } from "crypto"; +import { saveCallLog } from "@/lib/usageDb"; +import { applyPollinationsAnonymousFallback, reportPollinationsAnonOutcome } from "../pollinationsAnonAuth.ts"; +import { resolveImageBaseUrl, extractImageInputs, fetchImageEndpoint } from "../shared.ts"; +const OPENAI_IMAGE_TO_IMAGE_MODELS = new Set([ + "black-forest-labs/FLUX.2-max", + "black-forest-labs/FLUX.2-pro", + "black-forest-labs/FLUX.2-flex", + "black-forest-labs/FLUX.2-dev", + "openai/gpt-image-1.5", + "Wan-AI/Wan2.6-image", + "Qwen/Qwen-Image-2.0-Pro", + "Qwen/Qwen-Image-2.0", + "google/flash-image-3.1", + "google/gemini-3-pro-image", + "flux-kontext-max", + "flux-kontext", + "flux-kontext-pro", + "qwen-image", +]); +/** + * Handle OpenAI-compatible image generation (standard providers + Nebius fallback) + */ +function buildAgnesImageRequestBody(model, body) { + const upstreamBody: Record = { + model, + prompt: body.prompt, + }; + + if (body.size !== undefined) upstreamBody.size = body.size; + if (body.ratio !== undefined) { + upstreamBody.ratio = body.ratio; + } else if (body.aspect_ratio !== undefined) { + upstreamBody.ratio = body.aspect_ratio; + } + if (body.return_base64 !== undefined) upstreamBody.return_base64 = body.return_base64; + + const explicitExtraBody = + body.extra_body && typeof body.extra_body === "object" && !Array.isArray(body.extra_body) + ? body.extra_body + : {}; + const extraBody: Record = { ...explicitExtraBody }; + const { imageUrls } = extractImageInputs(body); + if (imageUrls.length > 0) extraBody.image = imageUrls; + if (body.response_format !== undefined) extraBody.response_format = body.response_format; + if (Object.keys(extraBody).length > 0) upstreamBody.extra_body = extraBody; + + return upstreamBody; +} + +export async function handleOpenAIImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const startTime = Date.now(); + + // Summarized request for call log + const logRequestBody = { + model: body.model, + prompt: + typeof body.prompt === "string" + ? body.prompt.slice(0, 200) + : String(body.prompt ?? "").slice(0, 200), + size: body.size || "default", + n: body.n || 1, + quality: body.quality || undefined, + }; + + // Build upstream request (OpenAI-compatible format) + const upstreamBody: Record = + providerConfig.format === "agnes-image" + ? buildAgnesImageRequestBody(model, body) + : { + model, + prompt: body.prompt, + }; + + if (providerConfig.format !== "agnes-image") { + // Pass optional parameters for ordinary OpenAI-compatible providers. + if (body.n !== undefined) upstreamBody.n = body.n; + if (body.size !== undefined) upstreamBody.size = body.size; + if (body.quality !== undefined) upstreamBody.quality = body.quality; + if (body.response_format !== undefined) upstreamBody.response_format = body.response_format; + if (body.style !== undefined) upstreamBody.style = body.style; + + const { imageUrl } = extractImageInputs(body); + if (imageUrl && OPENAI_IMAGE_TO_IMAGE_MODELS.has(model)) { + upstreamBody.image_url = imageUrl; + } + } + + // Build headers + let headers: Record = { + "Content-Type": "application/json", + }; + + const token = credentials.apiKey || credentials.accessToken; + if (token && providerConfig.authHeader === "bearer") { + headers["Authorization"] = `Bearer ${token}`; + } else if (token && providerConfig.authHeader === "x-api-key") { + headers["x-api-key"] = token; + } + + // #8085 — keyless Pollinations image requests (the common free case) get + // no Authorization header above. Mirror the chat executor's anonymous + // fingerprint-pool fallback (open-sse/executors/pollinations.ts) so the + // outbound request isn't sent bare and rejected by Pollinations' own 401. + let pollinationsAnonSession: Awaited< + ReturnType + >["session"] = null; + if (providerConfig.id === "pollinations") { + const anon = await applyPollinationsAnonymousFallback(providerConfig.id, token, headers); + headers = anon.headers; + pollinationsAnonSession = anon.session; + } + + if (log) { + const promptPreview = + typeof body.prompt === "string" + ? body.prompt.slice(0, 60) + : String(body.prompt ?? "").slice(0, 60); + log.info( + "IMAGE", + `${provider}/${model} | prompt: "${promptPreview}..." | size: ${body.size || "default"}` + ); + } + + const requestBody = JSON.stringify(upstreamBody); + + // Try primary URL + let result = await fetchImageEndpoint( + providerConfig.baseUrl, + headers, + requestBody, + provider, + log + ); + + // Fallback for providers with fallbackUrl (e.g., Nebius) + if ( + !result.success && + providerConfig.fallbackUrl && + [404, 410, 502, 503].includes(result.status) + ) { + if (log) { + log.info("IMAGE", `${provider}: primary URL failed (${result.status}), trying fallback...`); + } + result = await fetchImageEndpoint( + providerConfig.fallbackUrl, + headers, + requestBody, + provider, + log + ); + } + + if (pollinationsAnonSession) { + reportPollinationsAnonOutcome(pollinationsAnonSession, result.status); + } + + // Save call log after result is determined + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: result.status || (result.success ? 200 : 502), + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + tokens: { prompt_tokens: 0, completion_tokens: 0 }, + error: result.success + ? null + : typeof result.error === "string" + ? result.error.slice(0, 500) + : null, + requestBody: logRequestBody, + responseBody: result.success ? { images_count: result.data?.data?.length || 0 } : null, + }).catch(() => {}); + + return result; +} + +/** + * OpenAI-compatible image *edit* forwarder for custom providers (#3214 / #3215). + * + * Mirrors `handleOpenAIImageGeneration` but posts multipart/form-data to the node's + * `/images/edits` endpoint and returns the upstream OpenAI-compatible response. Kept + * separate from provider-specific hosted-tool flows. The fetch helper leaves Content-Type unset so + * `fetch` derives the multipart boundary from the FormData body. + */ +export async function handleOpenAIImageEdit({ + model, + provider, + credentials, + prompt, + imageBytes, + imageMime, + size, + responseFormat, + n = 1, + log, +}: { + model: string; + provider: string; + credentials: + | { + apiKey?: string; + accessToken?: string; + baseUrl?: unknown; + providerSpecificData?: { baseUrl?: unknown } | null; + } + | null + | undefined; + prompt: string; + imageBytes: Buffer; + imageMime?: string | null; + size?: string | null; + responseFormat?: string | null; + n?: number; + log?: { info: (tag: string, message: string) => void } | null; +}) { + const startTime = Date.now(); + const url = resolveImageBaseUrl( + credentials, + `https://generativelanguage.googleapis.com/v1beta/openai/images/edits`, + "edits" + ); + + // Build the multipart body as a Buffer with an explicit boundary instead of a global + // `FormData`. In production `globalThis.fetch` is patched with node_modules/undici's fetch, + // whose `FormData` class differs from `globalThis.FormData` — passing a native FormData + // makes undici serialize it as the string "[object FormData]" (text/plain), dropping every + // field (including `model`, which reaches the upstream empty). A Buffer body is accepted + // verbatim by any fetch implementation. (#3273) + const boundary = `----OmniRouteImageEdit${randomUUID().replace(/-/g, "")}`; + const CRLF = "\r\n"; + const partBuffers: Buffer[] = []; + const appendField = (name: string, value: string) => { + partBuffers.push( + Buffer.from( + `--${boundary}${CRLF}Content-Disposition: form-data; name="${name}"${CRLF}${CRLF}${value}${CRLF}` + ) + ); + }; + appendField("model", model); + appendField("prompt", prompt); + if (size) appendField("size", size); + if (responseFormat) appendField("response_format", responseFormat); + appendField("n", String(n || 1)); + partBuffers.push( + Buffer.from( + `--${boundary}${CRLF}Content-Disposition: form-data; name="image"; filename="image.png"${CRLF}` + + `Content-Type: ${imageMime || "image/png"}${CRLF}${CRLF}` + ) + ); + partBuffers.push(imageBytes); + partBuffers.push(Buffer.from(`${CRLF}--${boundary}--${CRLF}`)); + const multipartBody = Buffer.concat(partBuffers); + + const headers: Record = { + "Content-Type": `multipart/form-data; boundary=${boundary}`, + }; + const token = credentials?.apiKey || credentials?.accessToken; + if (token) headers["Authorization"] = `Bearer ${token}`; + + if (log) { + log.info( + "IMAGE", + `${provider}/${model} (edit) | prompt: "${prompt.slice(0, 60)}..." -> ${url}` + ); + } + + const result = await fetchImageEndpoint( + url, + headers, + multipartBody as unknown as BodyInit, + provider, + log + ); + + saveCallLog({ + method: "POST", + path: "/v1/images/edits", + status: result.status || (result.success ? 200 : 502), + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + tokens: { prompt_tokens: 0, completion_tokens: 0 }, + error: result.success + ? null + : typeof result.error === "string" + ? result.error.slice(0, 500) + : null, + requestBody: { model, prompt: prompt.slice(0, 200), size: size || "default", n: n || 1 }, + responseBody: result.success ? { images_count: result.data?.data?.length || 0 } : null, + }).catch(() => {}); + + return result; +} + +/** + * Handle OpenRouter's unified Image API reference-image flow. + * + * OpenRouter does not expose `/images/edits`; image-to-image requests use + * `POST /api/v1/images` with `input_references` containing data-URL images. + * Keep this separate from the generic multipart `/images/edits` forwarder, + * whose contract is used by custom OpenAI-compatible nodes (#10197). + */ +export async function handleOpenRouterImageEdit({ + model, + provider, + baseUrl, + credentials, + prompt, + imageBytes, + imageMime, + size, + n = 1, + log, +}: { + model: string; + provider: string; + baseUrl: string; + credentials: + | { + apiKey?: string; + accessToken?: string; + } + | null + | undefined; + prompt: string; + imageBytes: Buffer; + imageMime?: string | null; + size?: string | null; + n?: number; + log?: { info: (tag: string, message: string) => void } | null; +}) { + const startTime = Date.now(); + let url = baseUrl.trim(); + while (url.endsWith("/")) url = url.slice(0, -1); + if (url.endsWith("/images/generations")) { + url = url.slice(0, -"/images/generations".length) + "/images"; + } else if (!url.endsWith("/images")) { + url += "/images"; + } + + const mime = imageMime || "image/png"; + const upstreamBody: Record = { + model, + prompt, + input_references: [ + { + type: "image_url", + image_url: { + url: `data:${mime};base64,${imageBytes.toString("base64")}`, + }, + }, + ], + n: n || 1, + }; + if (size) upstreamBody.size = size; + + const headers: Record = { + "Content-Type": "application/json", + }; + const token = credentials?.apiKey || credentials?.accessToken; + if (token) headers.Authorization = `Bearer ${token}`; + + log?.info( + "IMAGE", + `${provider}/${model} (reference edit) | prompt: "${prompt.slice(0, 60)}..." -> ${url}` + ); + + const result = await fetchImageEndpoint( + url, + headers, + JSON.stringify(upstreamBody), + provider, + log + ); + + saveCallLog({ + method: "POST", + path: "/v1/images/edits", + status: result.status || (result.success ? 200 : 502), + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + tokens: { prompt_tokens: 0, completion_tokens: 0 }, + error: result.success + ? null + : typeof result.error === "string" + ? result.error.slice(0, 500) + : null, + requestBody: { model, prompt: prompt.slice(0, 200), size: size || "default", n: n || 1 }, + responseBody: result.success ? { images_count: result.data?.data?.length || 0 } : null, + }).catch(() => {}); + + return result; +} + diff --git a/open-sse/handlers/imageGeneration/providers/recraft.ts b/open-sse/handlers/imageGeneration/providers/recraft.ts new file mode 100644 index 000000000000..047fadf504af --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/recraft.ts @@ -0,0 +1,78 @@ +import { sanitizeErrorMessage } from "../../../utils/error.ts"; + +import { normalizeProviderImagePayload, saveImageSuccessResult, saveImageErrorResult } from "../shared.ts"; +export async function handleRecraftImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const startTime = Date.now(); + const token = credentials.apiKey || credentials.accessToken; + const upstreamBody: Record = { + model, + prompt: body.prompt, + }; + + if (body.n !== undefined) upstreamBody.n = body.n; + if (body.size !== undefined) upstreamBody.size = body.size; + if (body.response_format !== undefined) upstreamBody.response_format = body.response_format; + if (body.style !== undefined) upstreamBody.style = body.style; + + if (log) { + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log.info("IMAGE", `${provider}/${model} (recraft) | prompt: "${promptPreview}..."`); + } + + try { + const response = await fetch( + `${providerConfig.baseUrl.replace(/\/$/, "")}/v1/images/generations`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(upstreamBody), + } + ); + + if (!response.ok) { + const errorText = await response.text(); + if (log) + log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); + return saveImageErrorResult({ + provider, + model, + status: response.status, + startTime, + error: errorText, + requestBody: upstreamBody, + }); + } + + const payload = await response.json(); + const images = await normalizeProviderImagePayload(payload, body, log, "url"); + return saveImageSuccessResult({ + provider, + model, + startTime, + requestBody: upstreamBody, + responseBody: { images_count: images.length }, + created: payload.created, + images, + }); + } catch (err) { + if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, + }); + } +} + diff --git a/open-sse/handlers/imageGeneration/providers/stability.ts b/open-sse/handlers/imageGeneration/providers/stability.ts new file mode 100644 index 000000000000..be0053a55885 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/stability.ts @@ -0,0 +1,245 @@ +import { sanitizeErrorMessage } from "../../../utils/error.ts"; +import { mapImageSize } from "../../../translator/image/sizeMapper.ts"; +import { extractImageInputs, resolveImageSource, normalizeRequestedImageFormat, normalizeProviderImagePayload, saveImageSuccessResult, saveImageErrorResult } from "../shared.ts"; +const STABILITY_GENERATION_ENDPOINTS = { + "sd3.5-large": "/v2beta/stable-image/generate/sd3", + "sd3.5-large-turbo": "/v2beta/stable-image/generate/sd3", + "sd3.5-medium": "/v2beta/stable-image/generate/sd3", + "sd3.5-flash": "/v2beta/stable-image/generate/sd3", + "stable-image-ultra": "/v2beta/stable-image/generate/ultra", + "stable-image-core": "/v2beta/stable-image/generate/core", +}; + +const STABILITY_EDIT_ENDPOINTS = { + inpaint: "/v2beta/stable-image/edit/inpaint", + outpaint: "/v2beta/stable-image/edit/outpaint", + erase: "/v2beta/stable-image/edit/erase", + "search-and-replace": "/v2beta/stable-image/edit/search-and-replace", + "search-and-recolor": "/v2beta/stable-image/edit/search-and-recolor", + "remove-background": "/v2beta/stable-image/edit/remove-background", + "replace-background-and-relight": "/v2beta/stable-image/edit/replace-background-and-relight", + fast: "/v2beta/stable-image/upscale/fast", + conservative: "/v2beta/stable-image/upscale/conservative", + creative: "/v2beta/stable-image/upscale/creative", + sketch: "/v2beta/stable-image/control/sketch", + structure: "/v2beta/stable-image/control/structure", + style: "/v2beta/stable-image/control/style", + "style-transfer": "/v2beta/stable-image/control/style-transfer", +}; + +const STABILITY_CONTROL_MODELS = new Set(["sketch", "structure", "style", "style-transfer"]); + +function appendOptionalFormValue(formData, key, value) { + if (value === undefined || value === null || value === "") return; + formData.append(key, String(value)); +} + +function appendImageFormValue(formData, key, source, filename) { + formData.append( + key, + new Blob([source.buffer], { + type: source.contentType || "application/octet-stream", + }), + filename + ); +} +export async function handleStabilityAIImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const startTime = Date.now(); + const token = credentials.apiKey || credentials.accessToken; + const endpoint = STABILITY_GENERATION_ENDPOINTS[model] || STABILITY_EDIT_ENDPOINTS[model]; + + if (!endpoint) { + return { + success: false, + status: 400, + error: `Unsupported Stability AI image model: ${model}`, + }; + } + + const { imageUrl, maskUrl } = extractImageInputs(body); + const upstreamBody: Record = { + output_format: + model === "remove-background" + ? normalizeRequestedImageFormat(body, "png", ["png", "webp"]) + : normalizeRequestedImageFormat(body, "png"), + }; + const formData = new FormData(); + + appendOptionalFormValue(formData, "output_format", upstreamBody.output_format); + if (body.prompt) { + upstreamBody.prompt = body.prompt; + appendOptionalFormValue(formData, "prompt", body.prompt); + } + if (body.negative_prompt) { + upstreamBody.negative_prompt = body.negative_prompt; + appendOptionalFormValue(formData, "negative_prompt", body.negative_prompt); + } + if (body.seed !== undefined) { + upstreamBody.seed = body.seed; + appendOptionalFormValue(formData, "seed", body.seed); + } + + try { + if (STABILITY_GENERATION_ENDPOINTS[model]) { + if (model.startsWith("sd3.5")) { + upstreamBody.model = model; + appendOptionalFormValue(formData, "model", model); + } + + if (imageUrl) { + const imageSource = await resolveImageSource(imageUrl); + upstreamBody.mode = "image-to-image"; + appendOptionalFormValue(formData, "mode", "image-to-image"); + upstreamBody.image = imageSource.base64; + appendImageFormValue(formData, "image", imageSource, "image"); + if (body.strength !== undefined) { + upstreamBody.strength = body.strength; + appendOptionalFormValue(formData, "strength", body.strength); + } + } else { + upstreamBody.mode = "text-to-image"; + appendOptionalFormValue(formData, "mode", "text-to-image"); + } + + if (!model.startsWith("sd3.5") || !imageUrl) { + const aspectRatio = body.aspect_ratio || mapImageSize(body.size); + upstreamBody.aspect_ratio = aspectRatio; + appendOptionalFormValue(formData, "aspect_ratio", aspectRatio); + } + + if (body.style_preset) { + upstreamBody.style_preset = body.style_preset; + appendOptionalFormValue(formData, "style_preset", body.style_preset); + } + } else { + if (imageUrl) { + const imageSource = await resolveImageSource(imageUrl); + upstreamBody.image = imageSource.base64; + appendImageFormValue(formData, "image", imageSource, "image"); + } + + if (maskUrl && shouldIncludeStabilityMask(model)) { + const maskSource = await resolveImageSource(maskUrl); + upstreamBody.mask = maskSource.base64; + appendImageFormValue(formData, "mask", maskSource, "mask"); + } + + if (body.search_prompt) { + upstreamBody.search_prompt = body.search_prompt; + appendOptionalFormValue(formData, "search_prompt", body.search_prompt); + } + if (body.grow_mask !== undefined) { + upstreamBody.grow_mask = body.grow_mask; + appendOptionalFormValue(formData, "grow_mask", body.grow_mask); + } + if (body.control_strength !== undefined) { + upstreamBody.control_strength = body.control_strength; + appendOptionalFormValue(formData, "control_strength", body.control_strength); + } + if (body.creativity !== undefined) { + upstreamBody.creativity = body.creativity; + appendOptionalFormValue(formData, "creativity", body.creativity); + } + if (body.left !== undefined) { + upstreamBody.left = body.left; + appendOptionalFormValue(formData, "left", body.left); + } + if (body.right !== undefined) { + upstreamBody.right = body.right; + appendOptionalFormValue(formData, "right", body.right); + } + if (body.up !== undefined) { + upstreamBody.up = body.up; + appendOptionalFormValue(formData, "up", body.up); + } + if (body.down !== undefined) { + upstreamBody.down = body.down; + appendOptionalFormValue(formData, "down", body.down); + } + if (body.style_preset) { + upstreamBody.style_preset = body.style_preset; + appendOptionalFormValue(formData, "style_preset", body.style_preset); + } + + if (STABILITY_CONTROL_MODELS.has(model) && !upstreamBody.prompt) { + upstreamBody.prompt = body.prompt || ""; + appendOptionalFormValue(formData, "prompt", body.prompt || ""); + } + } + + if (log) { + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log.info("IMAGE", `${provider}/${model} (stability-ai) | prompt: "${promptPreview}..."`); + } + + const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}${endpoint}`, { + method: "POST", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text(); + if (log) + log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); + return saveImageErrorResult({ + provider, + model, + status: response.status, + startTime, + error: errorText, + requestBody: upstreamBody, + }); + } + + const contentType = response.headers.get("content-type") || ""; + let payload; + if (contentType.includes("application/json")) { + payload = await response.json(); + } else { + const buffer = Buffer.from(await response.arrayBuffer()); + payload = { image: buffer.toString("base64") }; + } + + const images = await normalizeProviderImagePayload(payload, body, log, "b64_json"); + return saveImageSuccessResult({ + provider, + model, + startTime, + requestBody: upstreamBody, + responseBody: { images_count: images.length }, + created: payload.created, + images, + }); + } catch (err) { + if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, + }); + } +} + +function shouldIncludeStabilityMask(model) { + return new Set([ + "inpaint", + "erase", + "search-and-replace", + "search-and-recolor", + "replace-background-and-relight", + ]).has(model); +} + diff --git a/open-sse/handlers/imageGeneration/providers/topaz.ts b/open-sse/handlers/imageGeneration/providers/topaz.ts new file mode 100644 index 000000000000..aacf36249b21 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/topaz.ts @@ -0,0 +1,91 @@ +import { sanitizeErrorMessage } from "../../../utils/error.ts"; + +import { extractImageInputs, resolveImageSource, saveImageSuccessResult, saveImageErrorResult, parseSizeToDimensions } from "../shared.ts"; +export async function handleTopazImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const startTime = Date.now(); + const token = credentials.apiKey || credentials.accessToken; + const { imageUrl } = extractImageInputs(body); + + if (!imageUrl) { + return { + success: false, + status: 400, + error: `Topaz model ${model} requires an input image`, + }; + } + + try { + const imageSource = await resolveImageSource(imageUrl); + const formData = new FormData(); + const blob = new Blob([imageSource.buffer], { type: imageSource.contentType || "image/png" }); + formData.append("image", blob, "image.png"); + + if (typeof body.size === "string" && body.size.includes("x")) { + const { width, height } = parseSizeToDimensions(body.size, 1024); + formData.append("output_width", String(width)); + formData.append("output_height", String(height)); + } + + if (log) { + const promptPreview = String(body.prompt ?? "enhance image").slice(0, 60); + log.info("IMAGE", `${provider}/${model} (topaz) | prompt: "${promptPreview}..."`); + } + + const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}/image/v1/enhance`, { + method: "POST", + headers: { + Accept: "image/jpeg", + "X-API-Key": token, + }, + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text(); + if (log) + log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); + return saveImageErrorResult({ + provider, + model, + status: response.status, + startTime, + error: errorText, + }); + } + + const contentType = response.headers.get("content-type") || "image/jpeg"; + const buffer = Buffer.from(await response.arrayBuffer()); + const base64 = buffer.toString("base64"); + const wantsBase64 = body.response_format === "b64_json"; + const images = [ + wantsBase64 + ? { b64_json: base64, revised_prompt: body.prompt } + : { url: `data:${contentType};base64,${base64}`, revised_prompt: body.prompt }, + ]; + + return saveImageSuccessResult({ + provider, + model, + startTime, + responseBody: { images_count: images.length }, + images, + }); + } catch (err) { + if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, + }); + } +} + diff --git a/open-sse/handlers/imageGeneration/shared.ts b/open-sse/handlers/imageGeneration/shared.ts new file mode 100644 index 000000000000..cc6f8e750e83 --- /dev/null +++ b/open-sse/handlers/imageGeneration/shared.ts @@ -0,0 +1,486 @@ +import { saveCallLog } from "@/lib/usageDb"; +import { fetchUntrustedRemoteImage } from "@/shared/network/remoteImageFetch"; +import { HTTP_STATUS } from "../../config/constants.ts"; +import { mapImageSize } from "../../translator/image/sizeMapper.ts"; +import { FetchTimeoutError, fetchWithTimeout, getConfiguredTimeout } from "@/shared/utils/fetchTimeout"; +import { sanitizeErrorMessage, sanitizeUpstreamDetails } from "../../utils/error.ts"; +import { isJsonObject } from "../../utils/kieTask.ts"; +import { stringifyImageErrorForLog } from "../imageErrorLog.ts"; +/** + * Resolve the upstream images endpoint for a custom (OpenAI-compatible) image + * provider node (#3205). + * + * Custom provider nodes store their base URL the same way the chat path does: + * in `credentials.providerSpecificData.baseUrl` (e.g. `https://example.com/v1`), + * NOT as a top-level `credentials.baseUrl`. Older callers may still pass a + * top-level `baseUrl`, so we honor that as a secondary source. When neither is + * present we fall back to `fallback` (the built-in Gemini OpenAI endpoint). + * + * Resolution order: providerSpecificData.baseUrl → credentials.baseUrl → fallback. + * + * A node base URL like `https://example.com/v1` is normalized and the + * OpenAI-compatible `/images/generations` path appended (mirroring + * `buildOpenAICompatibleUrl` in services/provider.ts). A node URL that already + * ends in `/images/generations` is returned as-is (no double-append). The + * `fallback` value is assumed to already be a complete URL and is returned + * verbatim. + */ +export function resolveImageBaseUrl( + credentials: + { baseUrl?: unknown; providerSpecificData?: { baseUrl?: unknown } | null } | null | undefined, + fallback: string, + endpoint: "generations" | "edits" = "generations" +): string { + const psd = credentials?.providerSpecificData; + const psdBaseUrl = + psd && typeof psd === "object" && typeof psd.baseUrl === "string" && psd.baseUrl.trim() + ? psd.baseUrl.trim() + : null; + const topLevelBaseUrl = + typeof credentials?.baseUrl === "string" && credentials.baseUrl.trim() + ? credentials.baseUrl.trim() + : null; + const nodeBaseUrl = psdBaseUrl || topLevelBaseUrl; + + if (!nodeBaseUrl) return fallback; + + // A single configured node serves both image routes: honor a base URL that already + // points at the requested OpenAI image path, and rewrite one that points at the other + // image endpoint (e.g. `.../images/generations` requested for edits) (#3214/#3215). + const suffix = `/images/${endpoint}`; + // Trim trailing slashes without a backtracking-prone regex (`/\/+$/` is a + // polynomial-ReDoS pattern on long runs of "/" — CodeQL js/polynomial-redos). + let normalized = nodeBaseUrl; + while (normalized.endsWith("/")) normalized = normalized.slice(0, -1); + if (normalized.endsWith(suffix)) return normalized; + const stripped = normalized.replace(/\/images\/(?:generations|edits)$/, ""); + return `${stripped}${suffix}`; +} +export function parseJsonOrNull(value: string): unknown | null { + try { + return JSON.parse(value); + } catch { + return null; + } +} + +export function sanitizeImageProviderError(errorText: string): unknown { + const parsed = parseJsonOrNull(errorText); + if (parsed !== null) { + return sanitizeUpstreamDetails(parsed) || sanitizeErrorMessage(errorText); + } + return sanitizeErrorMessage(errorText); +} +const FAL_PRESET_SIZES = { + "1024x1024": "square_hd", + "512x512": "square", + "1792x1024": "landscape_16_9", + "1024x1792": "portrait_16_9", + "1024x768": "landscape_4_3", + "768x1024": "portrait_4_3", + "1536x1024": "landscape_3_2", + "1024x1536": "portrait_3_2", + "576x1024": "portrait_16_9", + "1024x576": "landscape_16_9", +}; + +export function extractImageInputs(body) { + const imageUrls = []; + const seen = new Set(); + + const pushCandidate = (candidate) => { + if (typeof candidate !== "string") return; + const trimmed = candidate.trim(); + if (!trimmed || seen.has(trimmed)) return; + seen.add(trimmed); + imageUrls.push(trimmed); + }; + + pushCandidate(body?.image_url); + pushCandidate(body?.image); + + if (Array.isArray(body?.imageUrls)) { + for (const candidate of body.imageUrls) pushCandidate(candidate); + } + + if (Array.isArray(body?.image_urls)) { + for (const candidate of body.image_urls) pushCandidate(candidate); + } + + if (Array.isArray(body?.messages)) { + for (const msg of body.messages) { + if (!Array.isArray(msg?.content)) continue; + for (const part of msg.content) { + if (part?.type === "image_url") { + pushCandidate(part?.image_url?.url); + } + } + } + } + + return { + imageUrl: imageUrls[0] || null, + imageUrls, + maskUrl: + typeof body?.mask_url === "string" + ? body.mask_url + : typeof body?.mask === "string" + ? body.mask + : null, + }; +} + +export async function resolveImageSource(source) { + if (typeof source !== "string" || source.trim().length === 0) { + throw new Error("Invalid image source"); + } + + const trimmed = source.trim(); + const dataUriMatch = /^data:([^;]+);base64,(.+)$/i.exec(trimmed); + if (dataUriMatch) { + const [, contentType, base64] = dataUriMatch; + return { + buffer: Buffer.from(base64, "base64"), + base64, + contentType, + }; + } + + if (isHttpUrl(trimmed)) { + // Caller-input URL — public-only + DNS-pinned policy lives in fetchUntrustedRemoteImage. + const remoteImage = await fetchUntrustedRemoteImage(trimmed); + return { + buffer: remoteImage.buffer, + base64: remoteImage.buffer.toString("base64"), + contentType: remoteImage.contentType, + }; + } + + return { + buffer: Buffer.from(trimmed, "base64"), + base64: trimmed, + contentType: "application/octet-stream", + }; +} + +export function parseSizeToDimensions(size, fallback = 1024) { + if (typeof size !== "string" || !size.includes("x")) { + return { width: fallback, height: fallback }; + } + + const [widthRaw, heightRaw] = size.split("x"); + const width = Number(widthRaw); + const height = Number(heightRaw); + return { + width: Number.isFinite(width) && width > 0 ? width : fallback, + height: Number.isFinite(height) && height > 0 ? height : fallback, + }; +} + +export function normalizeRequestedImageFormat( + body, + fallback = "png", + allowedFormats = ["jpeg", "png", "webp"] +) { + const formatCandidate = + typeof body?.output_format === "string" + ? body.output_format.toLowerCase() + : typeof body?.response_format === "string" && + !["url", "b64_json"].includes(body.response_format.toLowerCase()) + ? body.response_format.toLowerCase() + : fallback; + + if (allowedFormats.includes(formatCandidate)) { + return formatCandidate; + } + + return fallback; +} + +export function mapFalImageSize(size, fallback = "square_hd") { + if (typeof size !== "string") return fallback; + if (FAL_PRESET_SIZES[size]) return FAL_PRESET_SIZES[size]; + if (size.includes("x")) { + const { width, height } = parseSizeToDimensions(size, 1024); + return { width, height }; + } + return fallback; +} + +export function mapFalAspectRatio(size, fallback = "1:1") { + if (!size) return fallback; + return mapImageSize(size); +} + +export function normalizeRecraftStyle(style) { + if (style === "vivid") return "digital_illustration"; + if (style === "natural") return "realistic_image"; + return style; +} + +export async function normalizeProviderImagePayload(payload, body, log, defaultFormat) { + const candidates = []; + + const pushCandidate = (value) => { + if (value === undefined || value === null) return; + candidates.push(value); + }; + + if (Array.isArray(payload?.data)) { + for (const item of payload.data) pushCandidate(item); + } + + if (Array.isArray(payload?.images)) { + for (const item of payload.images) pushCandidate(item); + } + + if (payload?.image) pushCandidate({ b64_json: payload.image }); + if (payload?.url) pushCandidate({ url: payload.url }); + if (payload?.sample) pushCandidate({ url: payload.sample }); + if (payload?.result?.sample) pushCandidate({ url: payload.result.sample }); + if (Array.isArray(payload?.result?.images)) { + for (const item of payload.result.images) pushCandidate(item); + } + + const normalized = []; + for (const candidate of candidates) { + const item = await normalizeProviderImageCandidate(candidate, body, defaultFormat); + if (item) normalized.push(item); + } + + if (normalized.length === 0 && log) { + log.warn( + "IMAGE", + `Provider returned no recognizable image payload: ${JSON.stringify(payload).slice(0, 240)}` + ); + } + + return normalized; +} + +async function normalizeProviderImageCandidate(candidate, body, defaultFormat) { + const wantsBase64 = body?.response_format === "b64_json" || defaultFormat === "b64_json"; + let url = null; + let b64 = null; + + if (typeof candidate === "string") { + const dataUriMatch = /^data:[^;]+;base64,(.+)$/i.exec(candidate); + if (dataUriMatch) { + b64 = dataUriMatch[1]; + } else if (isHttpUrl(candidate)) { + url = candidate; + } else { + b64 = candidate; + } + } else if (candidate && typeof candidate === "object") { + url = + firstString(candidate.url, candidate.image_url, candidate.sample, candidate.file_url) || null; + b64 = + firstString(candidate.b64_json, candidate.image, candidate.base64, candidate.data) || null; + } + + if (wantsBase64 && !b64 && url) { + b64 = (await resolveImageSource(url)).base64; + } + + if (url && !wantsBase64) { + return { url, revised_prompt: body?.prompt }; + } + + if (b64) { + return { b64_json: b64, revised_prompt: body?.prompt }; + } + + if (url) { + return { url, revised_prompt: body?.prompt }; + } + + return null; +} + +export function firstString(...values) { + for (const value of values) { + if (typeof value === "string" && value.length > 0) return value; + } + return null; +} + +export function isHttpUrl(value) { + return typeof value === "string" && /^https?:\/\//i.test(value); +} +export function saveImageSuccessResult({ + provider, + model, + startTime, + requestBody = null, + responseBody = null, + created = null, + images, + path = "/v1/images/generations", +}) { + saveCallLog({ + method: "POST", + path, + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + requestBody, + responseBody, + }).catch(() => {}); + + return { + success: true, + data: { + created: created || Math.floor(Date.now() / 1000), + data: images, + }, + }; +} + +export function saveImageErrorResult({ + provider, + model, + status, + startTime, + error, + requestBody = null, + path = "/v1/images/generations", + // #8307: opt-in signal for executeImageWithCredentialFallback — set by a + // provider handler when the failure is account/session-specific (expired + // or blocked credentials) rather than a generic request/provider error, so + // the retry loop tries the next eligible account even when the upstream + // status isn't a plain 401. Defaults to unset (existing 401-only behavior + // for every other provider is unchanged). + retryable = undefined, +}: { + provider: string; + model: string; + status: number; + startTime: number; + error: unknown; + requestBody?: unknown; + path?: string; + retryable?: boolean; +}) { + saveCallLog({ + method: "POST", + path, + status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: stringifyImageErrorForLog(error).slice(0, 500), + requestBody, + }).catch(() => {}); + + return { + success: false, + status, + error, + ...(retryable !== undefined ? { retryable } : {}), + }; +} + +/** + * Fetch a single image endpoint and normalize response + */ +export async function fetchImageEndpoint(url, headers, body, provider, log) { + try { + let response; + try { + response = await fetchWithTimeout(url, { + method: "POST", + headers, + body, + timeoutMs: getConfiguredTimeout(), + }); + } catch (err: unknown) { + const isAbortError = + typeof err === "object" && + err !== null && + "name" in err && + (err as { name?: unknown }).name === "AbortError"; + if (err instanceof FetchTimeoutError || isAbortError) { + const message = err instanceof Error ? err.message : String(err); + if (log) { + log.error("IMAGE", `${provider} fetch error: ${message}`); + } + return { + success: false, + status: 504, + error: `Image provider error: ${sanitizeErrorMessage(message || err)}`, + }; + } + throw err; + } + + if (!response.ok) { + const errorText = await response.text(); + if (log) { + log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); + } + return { + success: false, + status: response.status, + error: errorText, + }; + } + + const data = await response.json(); + + // Normalize response to OpenAI format + const items = Array.isArray(data?.data) ? data.data : []; + + // Some providers return HTTP 2xx with an empty or malformed image + // payload (empty data array, missing/blank b64_json and url). Treating that + // as success makes image-combo strategies stop on the first leg and hand an + // image-less 200 to the client. Require at least one usable image item and + // surface an empty 2xx as a retryable 502 so combos fall back to the next + // priority leg. + const hasUsableImage = items.some( + (item: unknown) => + isJsonObject(item) && + ((typeof item.b64_json === "string" && item.b64_json.length > 0) || + (typeof item.url === "string" && item.url.length > 0)) + ); + if (!hasUsableImage) { + if (log) { + log.warn( + "IMAGE", + `${provider} returned 200 without a usable image payload; treating as retryable 502` + ); + } + return { + success: false, + status: HTTP_STATUS.BAD_GATEWAY, + error: sanitizeErrorMessage( + "Image provider returned a success status without an image payload" + ), + }; + } + + return { + success: true, + data: { + created: data.created || Math.floor(Date.now() / 1000), + data: items, + }, + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + if (log) { + log.error("IMAGE", `${provider} fetch error: ${message}`); + } + return { + success: false, + status: 502, + error: `Image provider error: ${sanitizeErrorMessage(message || err)}`, + }; + } +} +export function normalizePositiveNumber(value, fallback) { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return fallback; + return Math.floor(n); +} diff --git a/open-sse/services/imageCombo.ts b/open-sse/services/imageCombo.ts index 3b8fd0998f7e..91be3c7cf684 100644 --- a/open-sse/services/imageCombo.ts +++ b/open-sse/services/imageCombo.ts @@ -24,6 +24,10 @@ import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import * as logger from "@/sse/utils/logger"; +import { isCodexImagesModel } from "@omniroute/open-sse/handlers/codexImages.ts"; +import { refreshSelectedCodexImageCredentials } from "@/sse/services/selectedCodexImageCredentials"; +import { resolveProxyForConnection } from "@/lib/db/settings"; +import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; /** * Caller-facing shape of handleImageGeneration(). The handler is untyped and @@ -45,6 +49,7 @@ export interface ImageComboDispatchResult { data?: unknown; status?: number; error?: unknown; + retryable?: boolean; } /** @@ -130,6 +135,12 @@ export async function runImageComboTargets( continue; } + if (typeof credentials === "object" && "blockedByKeyPolicy" in credentials) { + return { outcome: "terminal", provider, status: 403, error: "Image connection is not allowed by API key policy", fallbackCount }; + } + if (typeof credentials === "object" && "allExpired" in credentials) { + return { outcome: "terminal", provider, status: 401, error: "No usable image account credentials", fallbackCount }; + } if (isRateLimited(credentials)) { lastError = { status: 429, @@ -158,7 +169,7 @@ export async function runImageComboTargets( // Terminal failures (400 bad model, 403 banned, etc.) — stop iterating // Non-terminal failures (429, 5xx) — try next target - if (status === 400 || status === 403 || status === 401) { + if (result.retryable === false || status === 400 || status === 403 || status === 401) { return { outcome: "terminal", provider, status, error, fallbackCount }; } @@ -183,7 +194,7 @@ export async function executeImageCombo( body: Record, auth: { request: Request; - policy: { apiKeyInfo?: { id?: string; name?: string } | null }; + policy: { apiKeyInfo?: { id?: string; name?: string; allowedConnections?: string[] } | null }; }, startTime: number, log: typeof logger @@ -221,14 +232,34 @@ export async function executeImageCombo( // /v1/images/edits can reuse the exact same iteration semantics (#12547). const run = await runImageComboTargets(imageTargets, { resolveProvider: (target) => parseImageModel(target.modelStr), - resolveCredentials: (provider) => getProviderCredentialsWithQuotaPreflight(provider), - dispatch: async ({ target, credentials }) => - (await handleImageGeneration({ - body: { ...body, model: target.modelStr }, - credentials, - log, - signal: auth.request?.signal || null, - })) as ImageGenerationResult, + resolveCredentials: (provider, target) => getProviderCredentialsWithQuotaPreflight( + provider, null, auth.policy.apiKeyInfo?.allowedConnections?.length ? auth.policy.apiKeyInfo.allowedConnections : null, target.modelStr + ), + dispatch: async ({ target, provider, model, credentials }) => { + if (provider === "codex" && isCodexImagesModel(model)) { + const refreshed = await refreshSelectedCodexImageCredentials(credentials, auth.request.signal); + if (refreshed.success === false) return refreshed; + const selected = refreshed.credentials; + try { + const proxyInfo = selected.connectionId ? await resolveProxyForConnection(selected.connectionId) : null; + const result = await runWithProxyContext(proxyInfo?.proxy || null, () => handleImageGeneration({ + body: { ...body, model: target.modelStr }, credentials: selected, log, signal: auth.request.signal, + })); + return { + success: result.success, + data: "data" in result ? result.data : undefined, + status: "status" in result ? result.status : undefined, + error: "error" in result ? result.error : undefined, + retryable: "retryable" in result ? result.retryable : undefined, + }; + } catch { + return { success: false, status: 503, error: "Codex Images combo transport failed; do not resubmit an unknown outcome", retryable: false }; + } + } + return (await handleImageGeneration({ + body: { ...body, model: target.modelStr }, credentials, log, signal: auth.request?.signal || null, + })) as ImageGenerationResult; + }, onSuccess: async (credentials) => { await clearRecoveredProviderState(credentials as never); }, diff --git a/scripts/check/image-repair-ci.mjs b/scripts/check/image-repair-ci.mjs new file mode 100644 index 000000000000..4ff760855982 --- /dev/null +++ b/scripts/check/image-repair-ci.mjs @@ -0,0 +1,289 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const baseline = "c1e30b7676975feb298b49eff6ff58923c04b89e"; +const work = path.join(root, ".ci-work/image-repair"); +const home = path.join(work, "isolated-home"); +const temp = path.join(work, "isolated-temp"); +const env = { + PATH: process.env.PATH, + LANG: "C.UTF-8", + CI: "true", + GITHUB_ACTIONS: process.env.GITHUB_ACTIONS, + HOME: home, + USERPROFILE: home, + APPDATA: path.join(home, "AppData/Roaming"), + LOCALAPPDATA: path.join(home, "AppData/Local"), + XDG_CONFIG_HOME: path.join(home, ".config"), + XDG_CACHE_HOME: path.join(home, ".cache"), + DATA_DIR: path.join(work, "isolated-data"), + TMPDIR: temp, + TMP: temp, + TEMP: temp, + npm_config_cache: path.join(work, "npm-cache"), + NEXT_TELEMETRY_DISABLED: "1", + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1", + DISABLE_SQLITE_AUTO_BACKUP: "true", + APP_LOG_TO_FILE: "false", + OMNIROUTE_SKIP_SYSTEM_TRUST: "1", + OMNIROUTE_SKIP_DNS_WRITE: "1", + ADOBE_FIREFLY_BROWSER_REFRESH: "0", + OMNIROUTE_BUILD_BACKEND_ONLY: "1", + OMNIROUTE_USE_TURBOPACK: "0", + NEXT_DIST_DIR: ".build/next", + OMNIROUTE_MAX_TEST_WORKERS: "2", +}; + +function log(level, message) { + console.error(`[${new Date().toISOString()}] [${level}] [image-repair-ci] ${message}`); +} + +function signalGroup(pid, signal) { + try { + process.kill(-pid, signal); + return true; + } catch (error) { + if (error.code === "ESRCH") return false; + throw error; + } +} + +async function sweep(pid) { + if (!signalGroup(pid, 0)) return; + signalGroup(pid, "SIGKILL"); + const deadline = Date.now() + 3000; + while (signalGroup(pid, 0)) { + if (Date.now() >= deadline) throw new Error(`Owned process group ${pid} survived cleanup`); + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +async function run(label, command, args, wall = 240, idle = 60, capture = false) { + log("INFO", `start=${label} wall=${wall}s idle=${idle}s`); + const child = spawn(command, args, { + cwd: root, + env, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + timeout: wall * 1000, + }); + const started = Date.now(); + let output = ""; + let failure; + let escalation; + let idleTimer; + const terminate = (reason) => { + if (failure) return; + failure = new Error(`${label}: ${reason}`); + if (child.pid) signalGroup(child.pid, "SIGTERM"); + escalation = setTimeout(() => { + if (child.pid) signalGroup(child.pid, "SIGKILL"); + }, 2000); + }; + const resetIdle = () => { + clearTimeout(idleTimer); + idleTimer = setTimeout(() => terminate(`idle timeout after ${idle}s`), idle * 1000); + }; + const onInterrupt = (signal) => terminate(`cancelled by ${signal}`); + const onSigint = () => onInterrupt("SIGINT"); + const onSigterm = () => onInterrupt("SIGTERM"); + const wallTimer = setTimeout(() => terminate(`wall timeout after ${wall}s`), wall * 1000); + resetIdle(); + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); + child.stdout.on("data", (data) => { + resetIdle(); + if (capture) output += data.toString("utf8"); + else process.stdout.write(data); + }); + child.stderr.on("data", (data) => { + resetIdle(); + process.stderr.write(data); + }); + try { + const result = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => resolve({ code, signal })); + }); + if (failure) throw failure; + if (result.code !== 0) throw new Error(`${label}: exit=${result.code} signal=${result.signal}`); + log("INFO", `complete=${label} duration=${((Date.now() - started) / 1000).toFixed(1)}s`); + return output; + } finally { + clearTimeout(wallTimer); + clearTimeout(idleTimer); + clearTimeout(escalation); + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + if (child.pid) await sweep(child.pid); + } +} + +function offline(command, args) { + // Set up loopback as root, then drop back to the runner before executing project code. + const script = 'uid="$1"; gid="$2"; shift 2; ip link set lo up; exec setpriv --reuid "$uid" --regid "$gid" --clear-groups -- "$@"'; + return ["sudo", ["-n", "unshare", "--net", "--", "/bin/bash", "-euc", script, "image-ci", + String(process.getuid()), String(process.getgid()), "/usr/bin/env", "-i", + ...Object.entries(env).map(([key, value]) => `${key}=${value}`), command, ...args]]; +} + +async function changedSources() { + const tracked = await run("discover changed roots", "git", + ["diff", "--name-only", "--diff-filter=ACMR", "-z", baseline, "--"], 30, 15, true); + const added = await run("discover new roots", "git", + ["ls-files", "--others", "--exclude-standard", "-z"], 30, 15, true); + return [...new Set(`${tracked}\0${added}`.split("\0").filter((file) => + /^(src|open-sse|tests)\//.test(file) && /\.(?:[cm]?[jt]sx?)$/.test(file) + ))].sort(); +} + +async function typecheck() { + const { default: ts } = await import("typescript"); + const files = JSON.parse(await fs.readFile(path.join(work, "roots.json"), "utf8")); + const configPath = path.join(root, "tsconfig.json"); + const config = ts.readConfigFile(configPath, ts.sys.readFile); + if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n")); + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root); + const program = ts.createProgram(files.map((file) => path.join(root, file)), { + ...parsed.options, noCheck: false, noEmit: true, incremental: false, + }); + const diagnostics = [...parsed.errors, ...ts.getPreEmitDiagnostics(program)]; + if (diagnostics.length) { + console.error(ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: (file) => file, + getCurrentDirectory: () => root, + getNewLine: () => "\n", + })); + throw new Error(`Changed-root compiler: ${diagnostics.length} diagnostics (including transitive imports)`); + } + log("INFO", `compiler roots=${files.length} noCheck=false strict=${parsed.options.strict}`); +} + +const regressionFiles = [ + "10197-openrouter-image-edits-route.test.ts", + "codex-free-plan-image-generation.test.ts", + "codex-spark-image-generation.test.ts", + "hard-session-lease-bypass-inventory.test.ts", + "image-normalize.test.ts", + "t42-image-size-to-aspect-ratio.test.ts", + "fal-image-edit.test.ts", + "fal-image-generation-default.test.ts", + "nanobanana-image-handler.test.ts", + "combo/image-combo.test.ts", + "combo/image-combo-empty-200-fallback.test.ts", +]; + +async function focusedTests(roots) { + const all = await fs.readdir(path.join(root, "tests/unit")); + const selected = all.filter((file) => + /^(?:native-(?:codex|antigravity|image)|antigravity-image-|image-(?:generation|edits|credential|registry|routes|combo-edits|upscale-error))/.test(file) + && file.endsWith(".test.ts") + ).map((file) => `tests/unit/${file}`); + for (const file of regressionFiles) { + await fs.access(path.join(root, "tests/unit", file)); + selected.push(`tests/unit/${file}`); + } + for (const file of roots.filter((file) => /^tests\/.*\.test\.(?:ts|mjs)$/.test(file))) selected.push(file); + const files = [...new Set(selected)].sort(); + assert.ok(files.length > 0, "No focused image suites discovered"); + log("INFO", `focused suites=${files.length} workers=2 native timeout=120s/file`); + for (const file of files) log("INFO", `suite=${file}`); + const args = ["--import", "tsx/esm", "--import", "./tests/_setup/isolateDataDir.ts", + "--import", "./open-sse/utils/setupPolyfill.ts", "--test", "--test-timeout=120000", + "--test-concurrency=1", "--test-reporter=spec"]; + // A preload also runs in the native parent. One file per invocation prevents its + // DATA_DIR from being inherited by multiple workers; run at most two invocations. + const workspace = path.join(root, ".ci-work"); + const before = new Set(await fs.readdir(workspace)); + const deadline = Date.now() + 240_000; + try { + for (let index = 0; index < files.length; index += 2) { + const remaining = Math.ceil((deadline - Date.now()) / 1000); + if (remaining <= 0) throw new Error("Focused image bucket exceeded wall timeout of 240s"); + const results = await Promise.allSettled(files.slice(index, index + 2).map((file) => + run(file, ...offline("/usr/bin/env", ["-u", "DATA_DIR", process.execPath, ...args, file]), + remaining, 60) + )); + const failed = results.filter((result) => result.status === "rejected"); + if (failed.length) throw new AggregateError(failed.map((result) => result.reason), + failed.map((result) => result.reason.message).join("; ")); + } + } finally { + for (const name of await fs.readdir(workspace)) { + if (name.startsWith("isolated-") && !before.has(name)) { + await fs.rm(path.join(workspace, name), { recursive: true, force: true }); + } + } + } +} + +async function verifyBuild() { + const dist = path.join(root, ".build/next"); + const standalone = path.join(dist, "standalone"); + for (const file of [path.join(dist, "BUILD_ID"), path.join(standalone, "server.js"), + path.join(standalone, ".build/next/BUILD_ID")]) { + assert.ok((await fs.stat(file)).isFile(), "Required build output missing"); + assert.ok((await fs.readFile(file, "utf8")).trim(), "Required build output empty"); + } + for (const manifestPath of [path.join(dist, "server/app-paths-manifest.json"), + path.join(standalone, ".build/next/server/app-paths-manifest.json")]) { + const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); + for (const route of ["/api/v1/images/edits/route", "/api/v1/images/generations/route"]) { + assert.equal(typeof manifest[route], "string", `Image route not built: ${route}`); + assert.ok((await fs.stat(path.join(path.dirname(manifestPath), manifest[route]))).size > 0); + } + } + for (const entry of await fs.readdir(standalone, { recursive: true, withFileTypes: true })) { + const name = entry.name; + const relative = path.relative(standalone, path.join(entry.parentPath, name)); + assert.ok(!entry.isSymbolicLink(), `Artifact contains a symlink: ${relative}`); + assert.ok(!/(?:^|\/)(?:\.ai|\.specify|specs|\.claude|\.codex|\.kiro|\.cursor|\.cline|\.agents|\.codebase-memory|graphify-out|\.git|\.ci-work|logs|\.omniroute|_tasks)(?:\/|$)/.test(relative), + `Artifact contains private state: ${relative}`); + assert.ok(!/^(?:secrets\.md|explain-AI\.md|reference\.md|AGENTS\.md|CLAUDE\.md|\.ignoreme|runtime\.env|credentials(?:\.json)?|.*\.(?:sqlite(?:-wal|-shm)?|db(?:-wal|-shm)?|log|key))$/i.test(name), + `Artifact contains excluded state: ${relative}`); + assert.ok(!name.startsWith(".env") || name === ".env.example", `Artifact contains environment state: ${relative}`); + } + log("INFO", "verified standalone server, BUILD_ID, image route manifests and artifact exclusions"); +} + +async function main() { + assert.equal(process.platform, "linux", "This entry point is hosted Ubuntu-only"); + assert.equal(process.env.GITHUB_ACTIONS, "true", "Local process execution is not authorized"); + for (const dir of [home, temp, env.APPDATA, env.LOCALAPPDATA, env.DATA_DIR]) { + await fs.mkdir(dir, { recursive: true }); + } + Object.assign(process.env, env); + if (process.argv[2] === "--typecheck") return typecheck(); + try { + await run("locked npm install", "npm", ["ci", "--no-audit", "--no-fund"], 480, 180); + await run("runner syntax", process.execPath, ["--check", fileURLToPath(import.meta.url)], 30, 15); + const roots = await changedSources(); + assert.ok(roots.length > 0, "No changed/new source roots found against repair baseline"); + await fs.writeFile(path.join(work, "roots.json"), JSON.stringify(roots), "utf8"); + for (const file of roots) log("INFO", `compiler/lint root=${file}`); + await run("changed-root TypeScript", process.execPath, + [fileURLToPath(import.meta.url), "--typecheck"], 240, 120); + await run("changed-source ESLint", process.execPath, + ["node_modules/eslint/bin/eslint.js", "--suppressions-location", + "config/quality/eslint-suppressions.json", ...roots], 240, 120); + await focusedTests(roots); + await run("backend standalone build", ...offline(process.execPath, + ["scripts/build/build-next-isolated.mjs"]), 900, 180); + await verifyBuild(); + } finally { + await fs.rm(work, { recursive: true, force: true }); + } +} + +try { + await main(); +} catch (error) { + log("ERROR", error.message); + process.exitCode = 1; +} diff --git a/src/app/api/v1/images/edits/editDispatch.ts b/src/app/api/v1/images/edits/editDispatch.ts new file mode 100644 index 000000000000..8d242f0c8c10 --- /dev/null +++ b/src/app/api/v1/images/edits/editDispatch.ts @@ -0,0 +1,483 @@ +import { handleGeminiImageGeneration, validateAntigravityReferences } from "@omniroute/open-sse/handlers/geminiImage.ts"; +import { handleAdobeFireflyImageGeneration, handleCodexImageEdit, handleOpenAIImageEdit, handleOpenRouterImageEdit } from "@omniroute/open-sse/handlers/imageGeneration.ts"; +import { handleCodexImages, isCodexImagesModel } from "@omniroute/open-sse/handlers/codexImages.ts"; +import { handleFalAIImageEdit, isFalImageEditModel } from "@omniroute/open-sse/handlers/imageGeneration/providers/fal.ts"; + +import { getProviderCredentialsWithQuotaPreflight, clearRecoveredProviderState } from "@/sse/services/auth"; +import { parseImageModel, getImageProvider, getImageModelEntry } from "@omniroute/open-sse/config/imageRegistry.ts"; +import { errorResponse, unavailableResponse } from "@omniroute/open-sse/utils/error.ts"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; +import { getComboByName, getCombos } from "@/lib/db/combos"; +import { resolveComboTargets } from "@omniroute/open-sse/services/combo.ts"; +import { runImageComboTargets, type ImageComboDispatchResult } from "@omniroute/open-sse/services/imageCombo.ts"; +import { isAllRateLimitedCredentials } from "@/app/api/v1/_shared/rateLimit"; +import * as log from "@/sse/utils/logger"; +import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; + +import { resolveImageModelPrefix, validateCodexImageEditReferences } from "@/lib/images/imageRouteModel"; + +import { resolveProxyForConnection } from "@/lib/db/settings"; +import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; +import { isCodexFreePlan } from "@omniroute/open-sse/executors/codex/tools.ts"; +import { refreshSelectedCodexImageCredentials } from "@/sse/services/selectedCodexImageCredentials"; + + + + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +/** Reduce reference images (multi + single fallback) to data-URL strings for Firefly. */ +function buildAdobeFireflyEditDataUrls( + images: Array<{ bytes: Buffer; mime: string }>, + imageBytes: Buffer | null, + imageMime: string | null +): string[] { + const dataUrls: string[] = []; + const refList = Array.isArray(images) ? images : []; + for (const ref of refList) { + if (!ref || typeof ref !== "object") continue; + const bytes = (ref as { bytes?: Buffer }).bytes; + const mime = + typeof (ref as { mime?: string }).mime === "string" && + String((ref as { mime?: string }).mime).startsWith("image/") + ? String((ref as { mime?: string }).mime) + : "image/png"; + if (Buffer.isBuffer(bytes) && bytes.length > 0) { + dataUrls.push(`data:${mime};base64,${bytes.toString("base64")}`); + } + } + if (dataUrls.length === 0 && imageBytes && imageBytes.length > 0) { + const mime = + typeof imageMime === "string" && imageMime.startsWith("image/") ? imageMime : "image/png"; + dataUrls.push(`data:${mime};base64,${imageBytes.toString("base64")}`); + } + return dataUrls; +} + +/** + * Adobe Firefly edit = storage upload + generate-async referenceBlobs (same as i2i generate). + * Extracted from postHandler to keep cyclomatic/cognitive complexity in check + * (config/quality/complexity-baseline.json ratchet). + */ +export async function handleAdobeFireflyEditRequest(params: { + parsed: ReturnType; + providerConfig: NonNullable>; + allowedConnections: string[] | null; + resolvedModel: string; + prompt: string; + size: string | null; + responseFormat: string | null; + images: Array<{ bytes: Buffer; mime: string }>; + imageBytes: Buffer | null; + imageMime: string | null; +}): Promise { + const { + parsed, + providerConfig, + allowedConnections, + resolvedModel, + prompt, + size, + responseFormat, + images, + imageBytes, + imageMime, + } = params; + + const credentials = await getProviderCredentialsWithQuotaPreflight( + parsed.provider, + null, + allowedConnections, + resolvedModel + ); + if (!credentials || "allExpired" in credentials) { + return errorResponse( + HTTP_STATUS.UNAUTHORIZED, + `No credentials for provider: ${parsed.provider}` + ); + } + if ("blockedByKeyPolicy" in credentials) return errorResponse(403, "Image provider connection is not allowed by API key policy"); + if (isAllRateLimitedCredentials(credentials)) { + return unavailableResponse( + HTTP_STATUS.RATE_LIMITED, + `[${parsed.provider}] All accounts rate limited`, + credentials.retryAfter, + credentials.retryAfterHuman + ); + } + + if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); + // Prefer multi-image list when present; fall back to the primary imageBytes. + const dataUrls = buildAdobeFireflyEditDataUrls(images, imageBytes, imageMime); + if (dataUrls.length === 0) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: image"); + } + + const result = await handleAdobeFireflyImageGeneration({ + provider: parsed.provider, + model: parsed.model, + providerConfig, + body: { + prompt, + size: size ?? undefined, + response_format: responseFormat ?? undefined, + n: 1, + image_url: dataUrls[0], + image: dataUrls.length === 1 ? dataUrls[0] : dataUrls, + image_urls: dataUrls, + images: dataUrls, + }, + credentials, + log, + }); + + if ((result as { success?: boolean }).success) { + await clearRecoveredProviderState(credentials); + return jsonResponse((result as { data?: unknown }).data); + } + return jsonResponse( + toJsonErrorPayload((result as { error?: unknown }).error, "Image edit provider error"), + (result as { status?: number }).status ?? HTTP_STATUS.BAD_GATEWAY + ); +} + +/** Reference/prompt payload an edit dispatch needs, shared by single + combo paths. */ +interface ImageEditContext { + nativeOptions?: Record; + aspectRatio?: string | null; + imageSize?: string | null; + hasMask?: boolean; + prompt: string; + size: string | null; + responseFormat: string | null; + images: Array<{ bytes: Buffer; mime: string }>; + imageBytes: Buffer | null; + imageMime: string | null; + imageInputCount: number; + allowedConnections: string[] | null; + request: Request; +} + +/** A combo target that resolved to an edit-capable provider/node. */ +interface EditComboTarget { + modelStr: string; + parsed: ReturnType; + providerConfig: ReturnType | null; + /** Credential/connection lookup key (built-in provider id, or custom node id). */ + credKey: string; +} + +/** + * Decide whether a prefix-resolved combo target can service an image edit, and + * return the credential key to resolve it with. Mirrors postHandler's provider + * branches: codex-responses, fal-ai edit models, adobe-firefly, built-in + * openrouter, and custom OpenAI-compatible nodes are edit-capable; every other + * built-in provider is not (it exposes no OpenAI-compatible edit endpoint). + */ +function classifyImageEditTarget( + resolvedModel: string, + parsed: ReturnType, + providerConfig: ReturnType | null +): { credKey: string } | null { + if (providerConfig) { + if ( + providerConfig.format === "gemini-image" || + providerConfig.format === "codex-responses" || + providerConfig.format === "adobe-firefly-image" || + (providerConfig.format === "fal-ai" && isFalImageEditModel(parsed.model)) || + providerConfig.id === "openrouter" + ) { + return parsed.provider ? { credKey: parsed.provider } : null; + } + // Other built-in providers do not expose an OpenAI-compatible edit endpoint. + return null; + } + // Custom OpenAI-compatible node: prefix already rewritten to `/model`. + const slash = resolvedModel.indexOf("/"); + if (slash > 0 && slash < resolvedModel.length - 1) { + return { credKey: resolvedModel.slice(0, slash) }; + } + return null; +} + +/** + * Dispatch a single edit-capable target with already-resolved credentials, and + * return a normalized {success,data,status,error}. Reuses the same provider + * handlers postHandler uses for the single-model path. + */ +async function dispatchImageEditTarget( + target: EditComboTarget, + credentials: unknown, + ctx: ImageEditContext +): Promise { + const { parsed, providerConfig, modelStr } = target; + const { prompt, size, responseFormat, images, imageBytes, imageMime, request } = ctx; + + if (providerConfig?.format === "gemini-image") return dispatchAntigravityEdit(target, credentials, ctx) as Promise; + + // Built-in Codex — native Responses hosted tool for reference-image edits. + if (providerConfig?.format === "codex-responses") { + if (isCodexImagesModel(parsed.model)) return dispatchCodexImagesEdit(target, credentials, ctx); + const modelEntry = getImageModelEntry(modelStr); + if (!modelEntry || modelEntry.provider !== "codex" || modelEntry.model !== parsed.model) { + return { success: false, status: HTTP_STATUS.BAD_REQUEST, error: `Unsupported Codex image edit model: ${modelStr}` }; + } + const imageValidationError = validateCodexImageEditReferences(images); + if (imageValidationError) { + return { success: false, status: HTTP_STATUS.BAD_REQUEST, error: imageValidationError }; + } + const credentialDetails = credentials as { + connectionId?: unknown; + providerSpecificData?: unknown; + }; + if (isCodexFreePlan(credentialDetails.providerSpecificData)) { + return { + success: false, + status: HTTP_STATUS.BAD_REQUEST, + error: "Codex image editing requires a paid ChatGPT/Codex plan", + }; + } + const connectionId = + typeof credentialDetails.connectionId === "string" ? credentialDetails.connectionId : null; + let proxyInfo = null; + if (connectionId) { + try { + proxyInfo = await resolveProxyForConnection(connectionId); + } catch { + log.debug("PROXY", `Failed to resolve proxy for image provider: ${parsed.provider}`); + } + } + const editImage = () => + handleCodexImageEdit({ + provider: parsed.provider, + model: parsed.model, + providerConfig, + body: { + prompt, + size: size ?? undefined, + response_format: responseFormat ?? undefined, + }, + referenceImages: images, + credentials: credentials as never, + log, + signal: request.signal, + }); + return (await (connectionId + ? runWithProxyContext(proxyInfo?.proxy || null, editImage).catch(() => ({ + success: false as const, + status: HTTP_STATUS.SERVICE_UNAVAILABLE, + error: "Image edit proxy error", + })) + : editImage())) as ImageComboDispatchResult; + } + + if (providerConfig?.format === "fal-ai" && isFalImageEditModel(parsed.model)) { + return (await handleFalAIImageEdit({ + provider: parsed.provider, + model: parsed.model, + providerConfig, + body: { prompt, size: size ?? undefined, response_format: responseFormat ?? undefined, n: 1 }, + images, + credentials: credentials as never, + log, + })) as ImageComboDispatchResult; + } + + if (providerConfig?.format === "adobe-firefly-image") { + const dataUrls = buildAdobeFireflyEditDataUrls(images, imageBytes, imageMime); + if (dataUrls.length === 0) { + return { success: false, status: HTTP_STATUS.BAD_REQUEST, error: "Missing required field: image" }; + } + return (await handleAdobeFireflyImageGeneration({ + provider: parsed.provider, + model: parsed.model, + providerConfig, + body: { + prompt, + size: size ?? undefined, + response_format: responseFormat ?? undefined, + n: 1, + image_url: dataUrls[0], + image: dataUrls.length === 1 ? dataUrls[0] : dataUrls, + image_urls: dataUrls, + images: dataUrls, + }, + credentials: credentials as never, + log, + })) as ImageComboDispatchResult; + } + + if (providerConfig?.id === "openrouter") { + return (await handleOpenRouterImageEdit({ + provider: parsed.provider, + model: parsed.model, + baseUrl: providerConfig.baseUrl, + credentials: credentials as never, + prompt, + imageBytes, + imageMime, + size: size ?? undefined, + n: 1, + log, + })) as ImageComboDispatchResult; + } + + // Custom OpenAI-compatible node: forward to {base_url}/images/edits. + const slash = modelStr.indexOf("/"); + const customProviderId = slash > 0 ? modelStr.slice(0, slash) : null; + const customModel = slash > 0 ? modelStr.slice(slash + 1) : null; + if (!customProviderId || !customModel) { + return { + success: false, + status: HTTP_STATUS.BAD_REQUEST, + error: `Unknown image provider for model "${modelStr}"`, + }; + } + return (await handleOpenAIImageEdit({ + provider: customProviderId, + model: customModel, + credentials: credentials as never, + prompt, + imageBytes, + imageMime, + size, + responseFormat, + n: 1, + log, + })) as ImageComboDispatchResult; +} + +/** + * #12547: run an image-edit request whose model is a bare combo/alias name over + * the combo's edit-capable targets, mirroring how /v1/images/generations diverts + * bare combos to executeImageCombo (#9239). A combo whose first target isn't + * edit-capable (or lacks credentials) now falls through to a later edit-capable + * target instead of flattening to the first target and hard-erroring. + */ +export async function executeImageEditCombo(comboName: string, ctx: ImageEditContext): Promise { + const combo = await getComboByName(comboName); + if (!combo) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, `Combo not found: ${comboName}`); + } + const allCombos = await getCombos(); + const targets = resolveComboTargets(combo as never, allCombos as never); + if (!targets || targets.length === 0) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, `Combo "${comboName}" has no usable targets`); + } + + // Build the edit-capable target list (prefix-resolved). Non-edit-capable and + // retired targets are skipped here so the loop only iterates dispatchable ones. + const editTargets: EditComboTarget[] = []; + for (const t of targets) { + const raw = + typeof (t as { modelStr?: unknown }).modelStr === "string" + ? ((t as { modelStr: string }).modelStr as string) + : ""; + if (!raw.trim()) continue; + let resolved: string; + try { + resolved = await resolveImageModelPrefix(raw); + } catch { + // retired provider / prefix — skip this target + continue; + } + const parsed = parseImageModel(resolved); + const providerConfig = parsed.provider ? getImageProvider(parsed.provider) : null; + const capability = classifyImageEditTarget(resolved, parsed, providerConfig); + if (!capability) continue; + editTargets.push({ modelStr: resolved, parsed, providerConfig, credKey: capability.credKey }); + } + + if (editTargets.length === 0) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `No image-edit-capable targets in combo "${comboName}"` + ); + } + + const run = await runImageComboTargets(editTargets, { + resolveProvider: (target) => ({ provider: target.credKey, model: target.parsed.model }), + resolveCredentials: (_provider, target) => + getProviderCredentialsWithQuotaPreflight( + target.credKey, + null, + ctx.allowedConnections, + target.modelStr + ), + isRateLimited: isAllRateLimitedCredentials, + dispatch: ({ target, credentials }) => dispatchImageEditTarget(target, credentials, ctx), + onSuccess: async (credentials) => { + await clearRecoveredProviderState(credentials as never); + }, + failureLabel: "Image edit failed", + }); + + if (run.outcome === "terminal") { + return errorResponse(run.status, `[${run.provider}] ${run.error}`); + } + if (run.outcome === "success") { + // Match the single-model edit path: return the provider payload directly. + return jsonResponse(run.data); + } + const errorPayload = toJsonErrorPayload( + run.lastError?.error || "All combo targets failed", + "Image edit combo targets all failed" + ); + return new Response(JSON.stringify(errorPayload), { + status: run.lastError?.status || HTTP_STATUS.BAD_GATEWAY, + headers: { "Content-Type": "application/json" }, + }); +} + +export function validateCodexImagesEditOptions(ctx): string | null { + if (ctx.hasMask || Object.keys(ctx.nativeOptions ?? {}).some((key) => !["n", "quality", "background"].includes(key))) return "Unsupported Codex Images edit parameter; masks and output-format controls are not supported"; + const options = ctx.nativeOptions ?? {}; + if (options.n !== undefined && options.n !== 1) return "Codex Images supports n=1 only"; + if (options.quality !== undefined && !["low", "medium", "high", "auto"].includes(options.quality)) return "Unsupported Codex Images quality"; + if (options.background !== undefined && !["transparent", "opaque", "auto"].includes(options.background)) return "Unsupported Codex Images background"; + if (ctx.responseFormat !== null && ctx.responseFormat !== undefined && ctx.responseFormat !== "b64_json") return "Codex Images returns original b64_json only"; + if (ctx.size !== null && ctx.size !== undefined && !/^(?:auto|[1-9]\d{0,4}x[1-9]\d{0,4})$/.test(ctx.size)) return "Invalid Codex Images size; use auto or WIDTHxHEIGHT"; + return validateCodexImageEditReferences(ctx.images, 5); +} + +export async function dispatchCodexImagesEdit(target, credentials, ctx): Promise { + const { parsed, providerConfig, modelStr } = target; + const entry = getImageModelEntry(modelStr); + const fail = (status, error) => ({ success: false as const, status, error, retryable: false }); + if (!entry || entry.provider !== "codex" || entry.model !== parsed.model || !isCodexImagesModel(parsed.model)) return fail(400, "Unsupported dedicated Codex image edit model"); + const validation = validateCodexImagesEditOptions(ctx); + if (validation) return fail(400, validation); + const refreshed = await refreshSelectedCodexImageCredentials(credentials, ctx.request.signal); + if (refreshed.success === false) return refreshed; + const selected = refreshed.credentials; + try { + const proxyInfo = selected.connectionId ? await resolveProxyForConnection(selected.connectionId) : null; + return await runWithProxyContext(proxyInfo?.proxy || null, () => handleCodexImages({ + provider: parsed.provider, model: parsed.model, providerConfig, + body: { ...ctx.nativeOptions, prompt: ctx.prompt, size: ctx.size ?? undefined, response_format: ctx.responseFormat ?? undefined }, + referenceImages: ctx.images, operation: "edits", credentials: selected, log, signal: ctx.request.signal, + })); + } catch { + return fail(503, "Codex Images edit proxy or transport failed; do not resubmit an unknown outcome"); + } +} + +export async function dispatchAntigravityEdit(target, credentials, ctx) { + const { parsed, providerConfig, modelStr } = target; + const entry = getImageModelEntry(modelStr); + if (!entry || entry.provider !== "antigravity" || entry.model !== parsed.model) return { success: false, status: 400, error: "Unsupported Antigravity image edit model" }; + const validation = validateAntigravityReferences(ctx.images, ctx.hasMask); + if (validation) return { success: false, status: 400, error: validation }; + let proxyInfo = null; + if (credentials.connectionId) proxyInfo = await resolveProxyForConnection(credentials.connectionId); + return runWithProxyContext(proxyInfo?.proxy || null, () => handleGeminiImageGeneration({ + model: parsed.model, providerConfig, credentials, log, signal: ctx.request.signal, + referenceImages: ctx.images, logPath: "/v1/images/edits", + body: { model: modelStr, prompt: ctx.prompt, size: ctx.size, response_format: ctx.responseFormat ?? undefined, aspect_ratio: ctx.aspectRatio, image_size: ctx.imageSize, n: 1 }, + })); +} diff --git a/src/app/api/v1/images/edits/editInput.ts b/src/app/api/v1/images/edits/editInput.ts new file mode 100644 index 000000000000..fee273a20b54 --- /dev/null +++ b/src/app/api/v1/images/edits/editInput.ts @@ -0,0 +1,129 @@ +import { z } from "zod"; +import * as log from "@/sse/utils/logger"; +import { extractImageEditInputFromJson } from "@/lib/images/imageRouteModel"; +import { getBodySizeLimit, readRequestBodyWithLimit } from "@/shared/middleware/bodySizeGuard"; +import { getCachedSettings } from "@/lib/db/readCache"; +// JSON edit body (Open WebUI / OpenAI-style). All fields optional — the prompt +// and resolvable image are enforced after extraction in POST — but the top-level +// shape must be an object with correctly-typed fields, so a malformed body +// (array, string, wrong types) is rejected with 400 instead of silently parsed. +const ImageEditJsonSchema = z + .object({ + prompt: z.string().optional(), + model: z.string().optional(), + size: z.string().optional(), + response_format: z.string().optional(), + image: z.unknown().optional(), + images: z.array(z.unknown()).optional(), + }) + .passthrough(); +export interface EditInput { + nativeOptions: Record; + aspectRatio: string | null; + imageSize: string | null; + hasMask: boolean; + prompt: string; + model: string | null; + size: string | null; + responseFormat: string | null; + imageBytes: Buffer | null; + imageMime: string | null; + images: Array<{ bytes: Buffer; mime: string }>; + imageInputCount: number; +} + +// Keep unsupported controls too so a native transport can reject rather than drop them. +function nativeEditOptions(body: Record): Record { + return Object.fromEntries(Object.entries(body).filter(([key]) => !["model", "prompt", "size", "response_format", "image", "images", "image[]"].includes(key))); +} + +async function readMultipartImage(formData: FormData): Promise { + const promptRaw = formData.get("prompt"); + const prompt = typeof promptRaw === "string" ? promptRaw : ""; + const modelRaw = formData.get("model"); + const model = typeof modelRaw === "string" ? modelRaw.trim() : null; + const sizeRaw = formData.get("size"); + const size = typeof sizeRaw === "string" ? sizeRaw.trim() : null; + const respRaw = formData.get("response_format"); + const responseFormat = typeof respRaw === "string" ? respRaw.trim() : null; + + // OpenAI-style clients may repeat either `image` or `image[]`. Count every submitted + // candidate so provider-specific cardinality checks cannot silently drop extras. + const imageEntries = Array.from(formData.entries()) + .filter(([key]) => key === "image" || key === "image[]") + .map(([, value]) => value); + const images: Array<{ bytes: Buffer; mime: string }> = []; + for (const imageEntry of imageEntries) { + if (typeof imageEntry === "string") continue; + const file = imageEntry as File; + const bytes = Buffer.from(await file.arrayBuffer()); + if (bytes.length === 0) continue; + images.push({ bytes, mime: file.type || "image/png" }); + } + const firstImage = images[0] ?? null; + const nativeOptions = nativeEditOptions(Object.fromEntries(formData.entries())); + if (Object.hasOwn(nativeOptions, "n")) nativeOptions.n = nativeOptions.n === "1" ? 1 : nativeOptions.n; + const duplicateOptions = [...formData.keys()].some((key) => !["image", "image[]"].includes(key) && formData.getAll(key).length > 1); + if (duplicateOptions) nativeOptions.duplicate_options = true; + return { + nativeOptions, + aspectRatio: typeof formData.get("aspect_ratio") === "string" ? String(formData.get("aspect_ratio")) : null, + imageSize: typeof formData.get("image_size") === "string" ? String(formData.get("image_size")) : null, + hasMask: formData.has("mask") || formData.has("mask_url"), + prompt, + model, + size, + responseFormat, + imageBytes: firstImage?.bytes ?? null, + imageMime: firstImage?.mime ?? null, + images, + imageInputCount: imageEntries.length, + }; +} + +/** Read the edit input from either multipart/form-data or a JSON/data-URL body. */ +export async function readEditInput(request: Request): Promise { + const contentType = request.headers.get("content-type") || ""; + let bodySizeSettings: Record | undefined; + try { + bodySizeSettings = await getCachedSettings(); + } catch { + bodySizeSettings = undefined; + } + const bodySizeLimit = getBodySizeLimit("/api/v1/images/edits", bodySizeSettings); + const rawBody = await readRequestBodyWithLimit(request, bodySizeLimit); + if (contentType.includes("multipart/form-data")) { + try { + const formData = await new Response(rawBody, { + headers: { "content-type": contentType }, + }).formData(); + return await readMultipartImage(formData); + } catch (err) { + log.warn("IMAGE", `Invalid multipart body: ${err instanceof Error ? err.message : err}`); + return null; + } + } + if (contentType.includes("application/json")) { + try { + const parsed = ImageEditJsonSchema.safeParse( + JSON.parse(new TextDecoder().decode(rawBody)) as unknown + ); + if (!parsed.success) { + log.warn("IMAGE", `Invalid JSON edit body shape: ${parsed.error.message}`); + return null; + } + return { + ...extractImageEditInputFromJson(parsed.data), + nativeOptions: nativeEditOptions(parsed.data), + prompt: typeof parsed.data.prompt === "string" ? parsed.data.prompt : "", + aspectRatio: typeof parsed.data.aspect_ratio === "string" ? parsed.data.aspect_ratio : null, + imageSize: typeof parsed.data.image_size === "string" ? parsed.data.image_size : null, + hasMask: Object.hasOwn(parsed.data, "mask") || Object.hasOwn(parsed.data, "mask_url"), + }; + } catch (err) { + log.warn("IMAGE", `Invalid JSON edit body: ${err instanceof Error ? err.message : err}`); + return null; + } + } + return null; +} diff --git a/src/app/api/v1/images/edits/route.ts b/src/app/api/v1/images/edits/route.ts index 31da197f94da..c9cfda332fac 100644 --- a/src/app/api/v1/images/edits/route.ts +++ b/src/app/api/v1/images/edits/route.ts @@ -1,89 +1,32 @@ -import { - handleAdobeFireflyImageGeneration, - handleCodexImageEdit, - handleOpenAIImageEdit, - handleOpenRouterImageEdit, -} from "@omniroute/open-sse/handlers/imageGeneration.ts"; -import { - handleFalAIImageEdit, - FAL_IMAGE_EDIT_MAX_REFERENCES, - isFalImageEditModel, -} from "@omniroute/open-sse/handlers/imageGeneration/providers/fal.ts"; +import { handleCodexImageEdit, handleOpenAIImageEdit, handleOpenRouterImageEdit } from "@omniroute/open-sse/handlers/imageGeneration.ts"; +import { handleFalAIImageEdit, FAL_IMAGE_EDIT_MAX_REFERENCES, isFalImageEditModel } from "@omniroute/open-sse/handlers/imageGeneration/providers/fal.ts"; import { createInjectionGuard } from "@/middleware/promptInjectionGuard"; -import { - getProviderCredentialsWithQuotaPreflight, - clearRecoveredProviderState, -} from "@/sse/services/auth"; -import { - parseImageModel, - getImageProvider, - getImageModelEntry, -} from "@omniroute/open-sse/config/imageRegistry.ts"; +import { getProviderCredentialsWithQuotaPreflight, clearRecoveredProviderState } from "@/sse/services/auth"; +import { parseImageModel, getImageProvider, getImageModelEntry } from "@omniroute/open-sse/config/imageRegistry.ts"; import { errorResponse, unavailableResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; -import { getComboByName, getCombos } from "@/lib/db/combos"; -import { resolveComboTargets } from "@omniroute/open-sse/services/combo.ts"; -import { - runImageComboTargets, - type ImageComboDispatchResult, -} from "@omniroute/open-sse/services/imageCombo.ts"; import { isAllRateLimitedCredentials } from "@/app/api/v1/_shared/rateLimit"; +import { getComboByName } from "@/lib/db/combos"; + + + import * as log from "@/sse/utils/logger"; import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; -import { - resolveImageRouteModel, - resolveImageModelPrefix, - extractImageEditInputFromJson, - validateCodexImageEditReferences, -} from "@/lib/images/imageRouteModel"; +import { resolveImageRouteModel, validateCodexImageEditReferences } from "@/lib/images/imageRouteModel"; import { isMicrosoftDesignerWebProviderRetiredError } from "@/shared/constants/designerWebRetirement"; import { resolveProxyForConnection } from "@/lib/db/settings"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { isCodexFreePlan } from "@omniroute/open-sse/executors/codex/tools.ts"; -import { - getBodySizeLimit, - readRequestBodyWithLimit, - RequestBodyTooLargeError, -} from "@/shared/middleware/bodySizeGuard"; -import { getCachedSettings } from "@/lib/db/readCache"; -import { - CHATGPT_WEB_RETIRED_ERROR_CODE, - isCommonChatGptWebRetirementError, -} from "@/shared/constants/chatgptWebRetirement"; -import { z } from "zod"; - -// JSON edit body (Open WebUI / OpenAI-style). All fields optional — the prompt -// and resolvable image are enforced after extraction in POST — but the top-level -// shape must be an object with correctly-typed fields, so a malformed body -// (array, string, wrong types) is rejected with 400 instead of silently parsed. -const ImageEditJsonSchema = z - .object({ - prompt: z.string().optional(), - model: z.string().optional(), - size: z.string().optional(), - response_format: z.string().optional(), - image: z.unknown().optional(), - images: z.array(z.unknown()).optional(), - }) - .passthrough(); +import { RequestBodyTooLargeError } from "@/shared/middleware/bodySizeGuard"; -/** - * /v1/images/edits — OpenAI-compatible image-edit endpoint. - * - * Supported upstream shapes include: - * - **custom OpenAI-compatible providers** (#3214/#3215): forward a multipart edit to - * the node's `{base_url}/images/edits`, mirroring how generations forwards. - * - * Input is accepted as multipart/form-data (Open WebUI's "Image Edit" toggle) or as JSON - * with data-URL images (`images: [{ image_url: "data:..." }]`), since some OpenAI-compatible - * clients send the latter. The model may be a built-in id, a `provider/model`, a custom - * provider prefix, or a combo/alias name — all resolved the same as generations. - * - * Without this route, multipart bodies trip Next.js's Server Action handler (which - * intercepts ALL multipart POSTs) and the client gets a confusing 500. - */ +import { CHATGPT_WEB_RETIRED_ERROR_CODE, isCommonChatGptWebRetirementError } from "@/shared/constants/chatgptWebRetirement"; +import { readEditInput, type EditInput } from "./editInput.ts"; +import { executeImageEditCombo, handleAdobeFireflyEditRequest, dispatchAntigravityEdit, dispatchCodexImagesEdit, validateCodexImagesEditOptions } from "./editDispatch.ts"; +import { isCodexImagesModel } from "@omniroute/open-sse/handlers/codexImages.ts"; +import { validateAntigravityReferences } from "@omniroute/open-sse/handlers/geminiImage.ts"; +const MAX_NON_CODEX_IMAGE_EDIT_REFERENCES = 1; export async function OPTIONS() { return new Response(null, { headers: { @@ -92,496 +35,12 @@ export async function OPTIONS() { }, }); } - -interface EditInput { - prompt: string; - model: string | null; - size: string | null; - responseFormat: string | null; - imageBytes: Buffer | null; - imageMime: string | null; - images: Array<{ bytes: Buffer; mime: string }>; - imageInputCount: number; -} - -const MAX_NON_CODEX_IMAGE_EDIT_REFERENCES = 1; - -async function readMultipartImage(formData: FormData): Promise { - const promptRaw = formData.get("prompt"); - const prompt = typeof promptRaw === "string" ? promptRaw.trim() : ""; - const modelRaw = formData.get("model"); - const model = typeof modelRaw === "string" ? modelRaw.trim() : null; - const sizeRaw = formData.get("size"); - const size = typeof sizeRaw === "string" ? sizeRaw.trim() : null; - const respRaw = formData.get("response_format"); - const responseFormat = typeof respRaw === "string" ? respRaw.trim() : null; - - // OpenAI-style clients may repeat either `image` or `image[]`. Count every submitted - // candidate so provider-specific cardinality checks cannot silently drop extras. - const imageEntries = Array.from(formData.entries()) - .filter(([key]) => key === "image" || key === "image[]") - .map(([, value]) => value); - const images: Array<{ bytes: Buffer; mime: string }> = []; - for (const imageEntry of imageEntries) { - if (typeof imageEntry === "string") continue; - const file = imageEntry as File; - const bytes = Buffer.from(await file.arrayBuffer()); - if (bytes.length === 0) continue; - images.push({ bytes, mime: file.type || "image/png" }); - } - const firstImage = images[0] ?? null; - return { - prompt, - model, - size, - responseFormat, - imageBytes: firstImage?.bytes ?? null, - imageMime: firstImage?.mime ?? null, - images, - imageInputCount: imageEntries.length, - }; -} - -/** Read the edit input from either multipart/form-data or a JSON/data-URL body. */ -async function readEditInput(request: Request): Promise { - const contentType = request.headers.get("content-type") || ""; - let bodySizeSettings: Record | undefined; - try { - bodySizeSettings = await getCachedSettings(); - } catch { - bodySizeSettings = undefined; - } - const bodySizeLimit = getBodySizeLimit("/api/v1/images/edits", bodySizeSettings); - const rawBody = await readRequestBodyWithLimit(request, bodySizeLimit); - if (contentType.includes("multipart/form-data")) { - try { - const formData = await new Response(rawBody, { - headers: { "content-type": contentType }, - }).formData(); - return await readMultipartImage(formData); - } catch (err) { - log.warn("IMAGE", `Invalid multipart body: ${err instanceof Error ? err.message : err}`); - return null; - } - } - if (contentType.includes("application/json")) { - try { - const parsed = ImageEditJsonSchema.safeParse( - JSON.parse(new TextDecoder().decode(rawBody)) as unknown - ); - if (!parsed.success) { - log.warn("IMAGE", `Invalid JSON edit body shape: ${parsed.error.message}`); - return null; - } - return extractImageEditInputFromJson(parsed.data); - } catch (err) { - log.warn("IMAGE", `Invalid JSON edit body: ${err instanceof Error ? err.message : err}`); - return null; - } - } - return null; -} - function jsonResponse(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" }, }); } - -/** Reduce reference images (multi + single fallback) to data-URL strings for Firefly. */ -function buildAdobeFireflyEditDataUrls( - images: Array<{ bytes: Buffer; mime: string }>, - imageBytes: Buffer | null, - imageMime: string | null -): string[] { - const dataUrls: string[] = []; - const refList = Array.isArray(images) ? images : []; - for (const ref of refList) { - if (!ref || typeof ref !== "object") continue; - const bytes = (ref as { bytes?: Buffer }).bytes; - const mime = - typeof (ref as { mime?: string }).mime === "string" && - String((ref as { mime?: string }).mime).startsWith("image/") - ? String((ref as { mime?: string }).mime) - : "image/png"; - if (Buffer.isBuffer(bytes) && bytes.length > 0) { - dataUrls.push(`data:${mime};base64,${bytes.toString("base64")}`); - } - } - if (dataUrls.length === 0 && imageBytes && imageBytes.length > 0) { - const mime = - typeof imageMime === "string" && imageMime.startsWith("image/") ? imageMime : "image/png"; - dataUrls.push(`data:${mime};base64,${imageBytes.toString("base64")}`); - } - return dataUrls; -} - -/** - * Adobe Firefly edit = storage upload + generate-async referenceBlobs (same as i2i generate). - * Extracted from postHandler to keep cyclomatic/cognitive complexity in check - * (config/quality/complexity-baseline.json ratchet). - */ -async function handleAdobeFireflyEditRequest(params: { - parsed: ReturnType; - providerConfig: NonNullable>; - allowedConnections: string[] | null; - resolvedModel: string; - prompt: string; - size: string | null; - responseFormat: string | null; - images: Array<{ bytes: Buffer; mime: string }>; - imageBytes: Buffer | null; - imageMime: string | null; -}): Promise { - const { - parsed, - providerConfig, - allowedConnections, - resolvedModel, - prompt, - size, - responseFormat, - images, - imageBytes, - imageMime, - } = params; - - const credentials = await getProviderCredentialsWithQuotaPreflight( - parsed.provider, - null, - allowedConnections, - resolvedModel - ); - if (!credentials) { - return errorResponse( - HTTP_STATUS.UNAUTHORIZED, - `No credentials for provider: ${parsed.provider}` - ); - } - if (credentials.allRateLimited) { - return unavailableResponse( - HTTP_STATUS.RATE_LIMITED, - `[${parsed.provider}] All accounts rate limited`, - credentials.retryAfter, - credentials.retryAfterHuman - ); - } - - // Prefer multi-image list when present; fall back to the primary imageBytes. - const dataUrls = buildAdobeFireflyEditDataUrls(images, imageBytes, imageMime); - if (dataUrls.length === 0) { - return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: image"); - } - - const result = await handleAdobeFireflyImageGeneration({ - provider: parsed.provider, - model: parsed.model, - providerConfig, - body: { - prompt, - size: size ?? undefined, - response_format: responseFormat ?? undefined, - n: 1, - image_url: dataUrls[0], - image: dataUrls.length === 1 ? dataUrls[0] : dataUrls, - image_urls: dataUrls, - images: dataUrls, - }, - credentials, - log, - }); - - if ((result as { success?: boolean }).success) { - await clearRecoveredProviderState(credentials); - return jsonResponse((result as { data?: unknown }).data); - } - return jsonResponse( - toJsonErrorPayload((result as { error?: unknown }).error, "Image edit provider error"), - (result as { status?: number }).status ?? HTTP_STATUS.BAD_GATEWAY - ); -} - -/** Reference/prompt payload an edit dispatch needs, shared by single + combo paths. */ -interface ImageEditContext { - prompt: string; - size: string | null; - responseFormat: string | null; - images: Array<{ bytes: Buffer; mime: string }>; - imageBytes: Buffer | null; - imageMime: string | null; - imageInputCount: number; - allowedConnections: string[] | null; - request: Request; -} - -/** A combo target that resolved to an edit-capable provider/node. */ -interface EditComboTarget { - modelStr: string; - parsed: ReturnType; - providerConfig: ReturnType | null; - /** Credential/connection lookup key (built-in provider id, or custom node id). */ - credKey: string; -} - -/** - * Decide whether a prefix-resolved combo target can service an image edit, and - * return the credential key to resolve it with. Mirrors postHandler's provider - * branches: codex-responses, fal-ai edit models, adobe-firefly, built-in - * openrouter, and custom OpenAI-compatible nodes are edit-capable; every other - * built-in provider is not (it exposes no OpenAI-compatible edit endpoint). - */ -function classifyImageEditTarget( - resolvedModel: string, - parsed: ReturnType, - providerConfig: ReturnType | null -): { credKey: string } | null { - if (providerConfig) { - if ( - providerConfig.format === "codex-responses" || - providerConfig.format === "adobe-firefly-image" || - (providerConfig.format === "fal-ai" && isFalImageEditModel(parsed.model)) || - providerConfig.id === "openrouter" - ) { - return parsed.provider ? { credKey: parsed.provider } : null; - } - // Other built-in providers do not expose an OpenAI-compatible edit endpoint. - return null; - } - // Custom OpenAI-compatible node: prefix already rewritten to `/model`. - const slash = resolvedModel.indexOf("/"); - if (slash > 0 && slash < resolvedModel.length - 1) { - return { credKey: resolvedModel.slice(0, slash) }; - } - return null; -} - -/** - * Dispatch a single edit-capable target with already-resolved credentials, and - * return a normalized {success,data,status,error}. Reuses the same provider - * handlers postHandler uses for the single-model path. - */ -async function dispatchImageEditTarget( - target: EditComboTarget, - credentials: unknown, - ctx: ImageEditContext -): Promise { - const { parsed, providerConfig, modelStr } = target; - const { prompt, size, responseFormat, images, imageBytes, imageMime, request } = ctx; - - // Built-in Codex — native Responses hosted tool for reference-image edits. - if (providerConfig?.format === "codex-responses") { - const modelEntry = getImageModelEntry(modelStr); - if (!modelEntry || modelEntry.provider !== "codex" || modelEntry.model !== parsed.model) { - return { success: false, status: HTTP_STATUS.BAD_REQUEST, error: `Unsupported Codex image edit model: ${modelStr}` }; - } - const imageValidationError = validateCodexImageEditReferences(images); - if (imageValidationError) { - return { success: false, status: HTTP_STATUS.BAD_REQUEST, error: imageValidationError }; - } - const credentialDetails = credentials as { - connectionId?: unknown; - providerSpecificData?: unknown; - }; - if (isCodexFreePlan(credentialDetails.providerSpecificData)) { - return { - success: false, - status: HTTP_STATUS.BAD_REQUEST, - error: "Codex image editing requires a paid ChatGPT/Codex plan", - }; - } - const connectionId = - typeof credentialDetails.connectionId === "string" ? credentialDetails.connectionId : null; - let proxyInfo = null; - if (connectionId) { - try { - proxyInfo = await resolveProxyForConnection(connectionId); - } catch { - log.debug("PROXY", `Failed to resolve proxy for image provider: ${parsed.provider}`); - } - } - const editImage = () => - handleCodexImageEdit({ - provider: parsed.provider, - model: parsed.model, - providerConfig, - body: { - prompt, - size: size ?? undefined, - response_format: responseFormat ?? undefined, - }, - referenceImages: images, - credentials: credentials as never, - log, - signal: request.signal, - }); - return (await (connectionId - ? runWithProxyContext(proxyInfo?.proxy || null, editImage).catch(() => ({ - success: false as const, - status: HTTP_STATUS.SERVICE_UNAVAILABLE, - error: "Image edit proxy error", - })) - : editImage())) as ImageComboDispatchResult; - } - - if (providerConfig?.format === "fal-ai" && isFalImageEditModel(parsed.model)) { - return (await handleFalAIImageEdit({ - provider: parsed.provider, - model: parsed.model, - providerConfig, - body: { prompt, size: size ?? undefined, response_format: responseFormat ?? undefined, n: 1 }, - images, - credentials: credentials as never, - log, - })) as ImageComboDispatchResult; - } - - if (providerConfig?.format === "adobe-firefly-image") { - const dataUrls = buildAdobeFireflyEditDataUrls(images, imageBytes, imageMime); - if (dataUrls.length === 0) { - return { success: false, status: HTTP_STATUS.BAD_REQUEST, error: "Missing required field: image" }; - } - return (await handleAdobeFireflyImageGeneration({ - provider: parsed.provider, - model: parsed.model, - providerConfig, - body: { - prompt, - size: size ?? undefined, - response_format: responseFormat ?? undefined, - n: 1, - image_url: dataUrls[0], - image: dataUrls.length === 1 ? dataUrls[0] : dataUrls, - image_urls: dataUrls, - images: dataUrls, - }, - credentials: credentials as never, - log, - })) as ImageComboDispatchResult; - } - - if (providerConfig?.id === "openrouter") { - return (await handleOpenRouterImageEdit({ - provider: parsed.provider, - model: parsed.model, - baseUrl: providerConfig.baseUrl, - credentials: credentials as never, - prompt, - imageBytes, - imageMime, - size: size ?? undefined, - n: 1, - log, - })) as ImageComboDispatchResult; - } - - // Custom OpenAI-compatible node: forward to {base_url}/images/edits. - const slash = modelStr.indexOf("/"); - const customProviderId = slash > 0 ? modelStr.slice(0, slash) : null; - const customModel = slash > 0 ? modelStr.slice(slash + 1) : null; - if (!customProviderId || !customModel) { - return { - success: false, - status: HTTP_STATUS.BAD_REQUEST, - error: `Unknown image provider for model "${modelStr}"`, - }; - } - return (await handleOpenAIImageEdit({ - provider: customProviderId, - model: customModel, - credentials: credentials as never, - prompt, - imageBytes, - imageMime, - size, - responseFormat, - n: 1, - log, - })) as ImageComboDispatchResult; -} - -/** - * #12547: run an image-edit request whose model is a bare combo/alias name over - * the combo's edit-capable targets, mirroring how /v1/images/generations diverts - * bare combos to executeImageCombo (#9239). A combo whose first target isn't - * edit-capable (or lacks credentials) now falls through to a later edit-capable - * target instead of flattening to the first target and hard-erroring. - */ -async function executeImageEditCombo(comboName: string, ctx: ImageEditContext): Promise { - const combo = await getComboByName(comboName); - if (!combo) { - return errorResponse(HTTP_STATUS.BAD_REQUEST, `Combo not found: ${comboName}`); - } - const allCombos = await getCombos(); - const targets = resolveComboTargets(combo as never, allCombos as never); - if (!targets || targets.length === 0) { - return errorResponse(HTTP_STATUS.BAD_REQUEST, `Combo "${comboName}" has no usable targets`); - } - - // Build the edit-capable target list (prefix-resolved). Non-edit-capable and - // retired targets are skipped here so the loop only iterates dispatchable ones. - const editTargets: EditComboTarget[] = []; - for (const t of targets) { - const raw = - typeof (t as { modelStr?: unknown }).modelStr === "string" - ? ((t as { modelStr: string }).modelStr as string) - : ""; - if (!raw.trim()) continue; - let resolved: string; - try { - resolved = await resolveImageModelPrefix(raw); - } catch { - // retired provider / prefix — skip this target - continue; - } - const parsed = parseImageModel(resolved); - const providerConfig = parsed.provider ? getImageProvider(parsed.provider) : null; - const capability = classifyImageEditTarget(resolved, parsed, providerConfig); - if (!capability) continue; - editTargets.push({ modelStr: resolved, parsed, providerConfig, credKey: capability.credKey }); - } - - if (editTargets.length === 0) { - return errorResponse( - HTTP_STATUS.BAD_REQUEST, - `No image-edit-capable targets in combo "${comboName}"` - ); - } - - const run = await runImageComboTargets(editTargets, { - resolveProvider: (target) => ({ provider: target.credKey, model: target.parsed.model }), - resolveCredentials: (_provider, target) => - getProviderCredentialsWithQuotaPreflight( - target.credKey, - null, - ctx.allowedConnections, - target.modelStr - ), - isRateLimited: isAllRateLimitedCredentials, - dispatch: ({ target, credentials }) => dispatchImageEditTarget(target, credentials, ctx), - onSuccess: async (credentials) => { - await clearRecoveredProviderState(credentials as never); - }, - failureLabel: "Image edit failed", - }); - - if (run.outcome === "terminal") { - return errorResponse(run.status, `[${run.provider}] ${run.error}`); - } - if (run.outcome === "success") { - // Match the single-model edit path: return the provider payload directly. - return jsonResponse(run.data); - } - const errorPayload = toJsonErrorPayload( - run.lastError?.error || "All combo targets failed", - "Image edit combo targets all failed" - ); - return new Response(JSON.stringify(errorPayload), { - status: run.lastError?.status || HTTP_STATUS.BAD_GATEWAY, - headers: { "Content-Type": "application/json" }, - }); -} - async function postHandler(request: Request, _context?: unknown) { let input: EditInput | null; try { @@ -602,9 +61,9 @@ async function postHandler(request: Request, _context?: unknown) { ); } - const { prompt, model, size, responseFormat, imageBytes, imageMime, images, imageInputCount } = + const { prompt, model, size, responseFormat, imageBytes, imageMime, images, imageInputCount, aspectRatio, imageSize, hasMask, nativeOptions } = input; - if (!prompt) { + if (!prompt.trim()) { return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: prompt"); } const injectionDecision = createInjectionGuard()({ prompt }); @@ -660,6 +119,7 @@ async function postHandler(request: Request, _context?: unknown) { imageBytes, imageMime, imageInputCount, + aspectRatio, imageSize, hasMask, nativeOptions, allowedConnections: comboAllowedConnections, request, }); @@ -712,6 +172,34 @@ async function postHandler(request: Request, _context?: unknown) { : "This image edit provider currently supports only one reference image" ); } + if (providerConfig?.format === "gemini-image") { + const validation = validateAntigravityReferences(images, hasMask); + if (validation) return errorResponse(HTTP_STATUS.BAD_REQUEST, validation); + const entry = getImageModelEntry(resolvedModel); + if (!entry || entry.provider !== "antigravity" || entry.model !== parsed.model) return errorResponse(400, "Unsupported Antigravity image edit model"); + const credentials = await getProviderCredentialsWithQuotaPreflight(parsed.provider, null, allowedConnections, resolvedModel); + if (!credentials || "allExpired" in credentials) return errorResponse(401, "No usable credentials for Antigravity"); + if ("blockedByKeyPolicy" in credentials) return errorResponse(403, "Image provider connection is not allowed by API key policy"); + if (isAllRateLimitedCredentials(credentials)) return unavailableResponse(429, "All Antigravity accounts rate limited", credentials.retryAfter, credentials.retryAfterHuman); + if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); + const result = await dispatchAntigravityEdit({ parsed, providerConfig, modelStr: resolvedModel }, credentials, { prompt, size, responseFormat, images, aspectRatio, imageSize, hasMask, request }); + if (result.success) { await clearRecoveredProviderState(credentials); return jsonResponse(result.data); } + return jsonResponse(toJsonErrorPayload(result.error, "Image edit provider error"), result.status); + } + // Explicit image engines use JSON Images; hosted orchestration models retain their route. + if (providerConfig?.format === "codex-responses" && isCodexImagesModel(parsed.model)) { + const ctx = { prompt, size, responseFormat, images, hasMask, nativeOptions, request }; + const validation = validateCodexImagesEditOptions(ctx); + if (validation) return errorResponse(400, validation); + const credentials = await getProviderCredentialsWithQuotaPreflight(parsed.provider, null, allowedConnections, resolvedModel); + if (!credentials || "allExpired" in credentials) return errorResponse(401, "No usable credentials for Codex Images"); + if ("blockedByKeyPolicy" in credentials) return errorResponse(403, "Image provider connection is not allowed by API key policy"); + if (isAllRateLimitedCredentials(credentials)) return unavailableResponse(429, "All Codex accounts rate limited", credentials.retryAfter, credentials.retryAfterHuman); + if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); + const result = await dispatchCodexImagesEdit({ parsed, providerConfig, modelStr: resolvedModel }, credentials, ctx); + if (result.success) { await clearRecoveredProviderState(credentials); return jsonResponse(result.data); } + return jsonResponse(toJsonErrorPayload(result.error, "Codex Images edit provider error"), result.status); + } // Built-in Codex uses its native Responses hosted tool for stateless reference-image edits. if (providerConfig?.format === "codex-responses") { const modelEntry = getImageModelEntry(resolvedModel); @@ -732,13 +220,14 @@ async function postHandler(request: Request, _context?: unknown) { allowedConnections, resolvedModel ); - if (!credentials) { + if (!credentials || "allExpired" in credentials) { return errorResponse( HTTP_STATUS.UNAUTHORIZED, `No credentials for provider: ${parsed.provider}` ); } - if (credentials.allRateLimited) { + if ("blockedByKeyPolicy" in credentials) return errorResponse(403, "Image provider connection is not allowed by API key policy"); + if (isAllRateLimitedCredentials(credentials)) { return unavailableResponse( HTTP_STATUS.RATE_LIMITED, `[${parsed.provider}] All accounts rate limited`, @@ -746,10 +235,8 @@ async function postHandler(request: Request, _context?: unknown) { credentials.retryAfterHuman ); } - const credentialDetails = credentials as { - connectionId?: unknown; - providerSpecificData?: unknown; - }; + if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); + const credentialDetails = credentials; if (isCodexFreePlan(credentialDetails.providerSpecificData)) { return errorResponse( HTTP_STATUS.BAD_REQUEST, @@ -809,13 +296,14 @@ async function postHandler(request: Request, _context?: unknown) { allowedConnections, resolvedModel ); - if (!credentials) { + if (!credentials || "allExpired" in credentials) { return errorResponse( HTTP_STATUS.UNAUTHORIZED, `No credentials for provider: ${parsed.provider}` ); } - if (credentials.allRateLimited) { + if ("blockedByKeyPolicy" in credentials) return errorResponse(403, "Image provider connection is not allowed by API key policy"); + if (isAllRateLimitedCredentials(credentials)) { return unavailableResponse( HTTP_STATUS.RATE_LIMITED, `[${parsed.provider}] All accounts rate limited`, @@ -824,6 +312,7 @@ async function postHandler(request: Request, _context?: unknown) { ); } + if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); const result = await handleFalAIImageEdit({ provider: parsed.provider, model: parsed.model, @@ -839,14 +328,14 @@ async function postHandler(request: Request, _context?: unknown) { log, }); - if (result.success) { + if (result.success === false && "error" in result) { + return jsonResponse(toJsonErrorPayload(result.error, "Image edit provider error"), result.status); + } + if (result.success === true && "data" in result) { await clearRecoveredProviderState(credentials); return jsonResponse(result.data); } - return jsonResponse( - toJsonErrorPayload(result.error, "Image edit provider error"), - result.status - ); + return errorResponse(502, "Invalid image edit provider result"); } // Adobe Firefly: edit = storage upload + generate-async referenceBlobs (same as i2i generate). @@ -876,13 +365,14 @@ async function postHandler(request: Request, _context?: unknown) { allowedConnections, resolvedModel ); - if (!credentials) { + if (!credentials || "allExpired" in credentials) { return errorResponse( HTTP_STATUS.UNAUTHORIZED, `No credentials for provider: ${parsed.provider}` ); } - if (credentials.allRateLimited) { + if ("blockedByKeyPolicy" in credentials) return errorResponse(403, "Image provider connection is not allowed by API key policy"); + if (isAllRateLimitedCredentials(credentials)) { return unavailableResponse( HTTP_STATUS.RATE_LIMITED, `[${parsed.provider}] All accounts rate limited`, @@ -891,6 +381,7 @@ async function postHandler(request: Request, _context?: unknown) { ); } + if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); const result = await handleOpenRouterImageEdit({ provider: parsed.provider, model: parsed.model, @@ -941,13 +432,14 @@ async function postHandler(request: Request, _context?: unknown) { allowedConnections, resolvedModel ); - if (!credentials) { + if (!credentials || "allExpired" in credentials) { return errorResponse( HTTP_STATUS.BAD_REQUEST, `No credentials for custom image provider: ${customProviderId}` ); } - if (credentials.allRateLimited) { + if ("blockedByKeyPolicy" in credentials) return errorResponse(403, "Image provider connection is not allowed by API key policy"); + if (isAllRateLimitedCredentials(credentials)) { return unavailableResponse( HTTP_STATUS.RATE_LIMITED, `[${customProviderId}] All accounts rate limited`, @@ -956,6 +448,7 @@ async function postHandler(request: Request, _context?: unknown) { ); } + if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); const result = await handleOpenAIImageEdit({ provider: customProviderId, model: customModel, diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index e642a99ac94a..11b60b3bdc00 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -40,6 +40,8 @@ import { getSpecialtyModelsResponse } from "@/app/api/v1/_shared/specialtyCatalo import { enforceClientApiRouteAuth } from "@/shared/utils/clientApiRouteAuth"; import { runWithCallLogApiKeyContext } from "@/lib/usage/callLogApiKeyContext"; import { executeImageWithCredentialFallback } from "@/sse/services/imageCredentialRetry"; +import { refreshSelectedCodexImageCredentials } from "@/sse/services/selectedCodexImageCredentials"; +import { isCodexImagesModel } from "@omniroute/open-sse/handlers/codexImages.ts"; import { AUTHZ_HEADER_PEER_LOCALITY } from "@/server/authz/headers"; import { assertCommonChatGptWebModelAvailable, @@ -253,13 +255,15 @@ async function postHandler(request, context) { ); } + // Keep account selection inside the API key's connection allowlist. + const allowedConnections = policy.apiKeyInfo?.allowedConnections?.length ? policy.apiKeyInfo.allowedConnections : null; // Get credentials — skip for local providers (authType: "none") let credentials = null; if (providerConfig && providerConfig.authType !== "none") { credentials = await getProviderCredentialsWithQuotaPreflight( provider, null, - null, + allowedConnections, requestedModel ); if (!credentials) { @@ -304,7 +308,7 @@ async function postHandler(request, context) { const localCredentials = await getProviderCredentialsWithQuotaPreflight( provider, null, - null, + allowedConnections, requestedModel ); if (localCredentials && !isAllRateLimitedCredentials(localCredentials)) { @@ -312,16 +316,14 @@ async function postHandler(request, context) { } } - const execution = await executeImageWithCredentialFallback({ - provider, - requestedModel, - credentials, - execute: async (attemptCredentials) => { + const dedicatedCodexImages = provider === "codex" && isCodexImagesModel(requestedModel); + const executeImage = async (attemptCredentials) => { let proxyInfo = null; if (attemptCredentials?.connectionId) { try { proxyInfo = await resolveProxyForConnection(attemptCredentials.connectionId); } catch { + if (dedicatedCodexImages) return { success: false, status: 503, error: "Codex Images proxy resolution failed", retryable: false }; log.debug("PROXY", `Failed to resolve proxy for image provider: ${provider}`); } } @@ -352,12 +354,20 @@ async function postHandler(request, context) { return attemptCredentials?.connectionId ? runWithProxyContext(proxyInfo?.proxy || null, generateImage).catch((err: any) => ({ success: false, - status: err.statusCode || 500, - error: err.message, + status: dedicatedCodexImages ? 503 : err.statusCode || 500, + error: dedicatedCodexImages ? "Codex Images proxy or transport failed; do not resubmit an unknown outcome" : err.message, + ...(dedicatedCodexImages ? { retryable: false } : {}), })) : generateImage(); - }, - }); + }; + const executeSelectedCodexImages = async () => { + const selected = await refreshSelectedCodexImageCredentials(credentials, request.signal); + if (selected.success === false) return { credentials, result: selected }; + return { credentials: selected.credentials, result: await executeImage(selected.credentials) }; + }; + const execution = dedicatedCodexImages + ? await executeSelectedCodexImages() + : await executeImageWithCredentialFallback({ provider, requestedModel, credentials, execute: executeImage }); credentials = execution.credentials; const result = execution.result; diff --git a/src/sse/services/imageCredentialRetry.ts b/src/sse/services/imageCredentialRetry.ts index beda34ac639d..8b39f87af707 100644 --- a/src/sse/services/imageCredentialRetry.ts +++ b/src/sse/services/imageCredentialRetry.ts @@ -135,9 +135,10 @@ export async function executeImageWithCredentialFallback({ lastCredentials = currentCredentials; lastResult = await execute(currentCredentials); const shouldTryAnotherAccount = - Number(lastResult.status) === 401 || - lastResult.retryable === true || - isAntigravityImageQuotaExhausted(provider, lastResult); + lastResult.retryable !== false && + (Number(lastResult.status) === 401 || + lastResult.retryable === true || + isAntigravityImageQuotaExhausted(provider, lastResult)); if (lastResult.success || !shouldTryAnotherAccount || !connectionId) { return { credentials: lastCredentials, result: lastResult }; } diff --git a/src/sse/services/selectedCodexImageCredentials.ts b/src/sse/services/selectedCodexImageCredentials.ts new file mode 100644 index 000000000000..b4a6d917d69f --- /dev/null +++ b/src/sse/services/selectedCodexImageCredentials.ts @@ -0,0 +1,51 @@ +import { checkAndRefreshToken } from "./tokenRefresh"; +import { getRefreshLeadMs } from "@omniroute/open-sse/services/tokenRefresh.ts"; +import { isAllRateLimitedCredentials } from "@/app/api/v1/_shared/rateLimit"; +import { isCodexFreePlan } from "@omniroute/open-sse/config/codexPlan.ts"; + +export type SelectedCodexImageCredentials = { + accessToken?: string; + apiKey?: string; + refreshToken?: string; + expiresAt?: string; + connectionId?: string; + providerSpecificData?: Record; +}; +function isSelectedCredentials(value: object): value is SelectedCodexImageCredentials { + return "accessToken" in value && typeof value.accessToken === "string" && + (!("expiresAt" in value) || value.expiresAt === null || typeof value.expiresAt === "string") && + (!("connectionId" in value) || typeof value.connectionId === "string") && + (!("providerSpecificData" in value) || value.providerSpecificData === null || (typeof value.providerSpecificData === "object" && !Array.isArray(value.providerSpecificData))); +} +type SelectedResult = + | { success: true; credentials: SelectedCodexImageCredentials } + | { success: false; status: number; error: string; retryable: false }; + +/** Refresh the selected image account once; never select, rotate or retry another account. */ +export async function refreshSelectedCodexImageCredentials( + credentials: unknown, + signal?: AbortSignal | null +): Promise { + const fail = (status: number, error: string): SelectedResult => ({ success: false, status, error, retryable: false }); + if (signal?.aborted) return fail(499, "Codex Images request cancelled before submission"); + if (!credentials || typeof credentials !== "object" || "allExpired" in credentials) return fail(401, "No usable selected Codex account credentials"); + if ("blockedByKeyPolicy" in credentials) return fail(403, "Selected image connection is not allowed by API key policy"); + if (isAllRateLimitedCredentials(credentials) || "allRateLimited" in credentials) return fail(429, "Selected Codex account is unavailable"); + if (!isSelectedCredentials(credentials)) return fail(401, "No usable selected Codex account credentials"); + if (isCodexFreePlan(credentials.providerSpecificData)) return fail(403, "Codex Images requires a paid ChatGPT/Codex plan"); + try { + const originalExpiry = credentials.expiresAt ? Date.parse(credentials.expiresAt) : null; + const refreshDue = originalExpiry !== null && originalExpiry - Date.now() < getRefreshLeadMs("codex", credentials.providerSpecificData); + const selected = await checkAndRefreshToken("codex", credentials); + const expiry = selected.expiresAt ? Date.parse(selected.expiresAt) : null; + const refreshFailed = refreshDue && selected.accessToken === credentials.accessToken && selected.expiresAt === credentials.expiresAt; + if (!selected.accessToken || refreshFailed || (expiry !== null && (!Number.isFinite(expiry) || expiry <= Date.now()))) { + return fail(401, "Selected Codex account refresh failed; reconnect that account"); + } + if (selected.connectionId !== credentials.connectionId || selected.providerSpecificData?.workspaceId !== credentials.providerSpecificData?.workspaceId) return fail(401, "Selected Codex account changed during refresh; submission refused"); + if (signal?.aborted) return fail(499, "Codex Images request cancelled before submission"); + return { success: true, credentials: selected }; + } catch { + return fail(401, "Selected Codex account refresh failed; reconnect that account"); + } +} diff --git a/tests/_setup/isolateDataDir.ts b/tests/_setup/isolateDataDir.ts index 2e1d4bf65a7e..efe91f724c8a 100644 --- a/tests/_setup/isolateDataDir.ts +++ b/tests/_setup/isolateDataDir.ts @@ -18,7 +18,6 @@ // so concurrent test processes never collide. Tests that set DATA_DIR explicitly keep // winning — this only fills in an isolated default when none was chosen. import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; // File logger worker threads can outlive a test's temporary DATA_DIR cleanup and then @@ -27,8 +26,18 @@ import path from "node:path"; process.env.APP_LOG_TO_FILE ||= "false"; if (!process.env.DATA_DIR) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-")); - process.env.DATA_DIR = dir; + const workspace = path.resolve(".ci-work"); + fs.mkdirSync(workspace, { recursive: true }); + const dir = fs.mkdtempSync(path.join(workspace, "isolated-")); + for (const name of ["data", "home", "appdata", "localappdata", "temp"]) { + fs.mkdirSync(path.join(dir, name)); + } + Object.assign(process.env, { + DATA_DIR: path.join(dir, "data"), + HOME: path.join(dir, "home"), USERPROFILE: path.join(dir, "home"), + APPDATA: path.join(dir, "appdata"), LOCALAPPDATA: path.join(dir, "localappdata"), + TEMP: path.join(dir, "temp"), TMP: path.join(dir, "temp"), TMPDIR: path.join(dir, "temp"), + }); // Best-effort cleanup so a long suite run does not leak hundreds of temp DBs. process.on("exit", () => { diff --git a/tests/unit/hard-session-lease-bypass-inventory.test.ts b/tests/unit/hard-session-lease-bypass-inventory.test.ts index 7d21b58d18a2..f9c48da97d27 100644 --- a/tests/unit/hard-session-lease-bypass-inventory.test.ts +++ b/tests/unit/hard-session-lease-bypass-inventory.test.ts @@ -49,6 +49,10 @@ const EXPECTED: Record> = { // of the retirement-check one hoisted before enforceApiKeyPolicy) was // removed as dead redundant code, 6->5. #12653 added combo target // resolution with the same shape as imageCombo, 5->6. + // Firefly/combo selection moved to editDispatch; native Antigravity/Codex Images + // add two route calls. All eight retain the policy connection allowlist and + // preflight selector's unleased occupancy filter (class B, not managed dispatch). + "src/app/api/v1/images/edits/editDispatch.ts": 2, "src/app/api/v1/images/edits/route.ts": 6, "src/app/api/v1/images/generations/route.ts": 3, "src/app/api/v1/images/upscale/route.ts": 1, diff --git a/tests/unit/image-registry-gpt56.test.ts b/tests/unit/image-registry-gpt56.test.ts index c4515c72b3ba..6c9bca1b1369 100644 --- a/tests/unit/image-registry-gpt56.test.ts +++ b/tests/unit/image-registry-gpt56.test.ts @@ -24,7 +24,7 @@ test("text-only ChatGPT Web and its retired alias stay absent from the image cat }); }); -test("Codex image catalog exposes only the GPT-5.6 Sol, Terra, and Luna models", () => { +test("Codex image catalog separates hosted GPT-5.6/6.1 models from requested image engines", () => { assert.deepEqual(IMAGE_PROVIDERS.codex.models, [ { id: "gpt-5.6-sol", @@ -41,19 +41,45 @@ test("Codex image catalog exposes only the GPT-5.6 Sol, Terra, and Luna models", catalogId: "gpt-5.6-luna-image", name: "GPT 5.6 Luna (Codex Image)", }, + { + id: "gpt-6.1-sol", + catalogId: "gpt-6.1-sol-image", + name: "GPT 6.1 Sol (Codex Image)", + }, + { + id: "gpt-image-2", + name: "GPT Image 2 (Codex requested engine)", + inputModalities: ["text", "image"], + description: + "Dedicated Codex Images request; served engine, account access and actual dimensions remain unverified.", + }, + { + id: "gpt-image-2.5-sunburst", + name: "GPT Image 2.5 Sunburst (Codex requested engine)", + inputModalities: ["text", "image"], + description: + "Explicit Sunburst request through Codex Images; OAuth entitlement and served engine are unknown.", + }, ]); - for (const model of ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]) { + for (const model of ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-6.1-sol"]) { assert.deepEqual(parseImageModel(`cx/${model}`), { provider: "codex", model }); assert.deepEqual(parseImageModel(`cx/${model}-image`), { provider: "codex", model }); } }); -test("Codex GPT-5.6 image listings do not shadow the chat catalog ids", () => { +test("Codex image listings do not shadow the hosted chat catalog ids", () => { const imageModels = getAllImageModels().filter((model) => model.provider === "codex"); assert.deepEqual( imageModels.map((model) => model.id), - ["codex/gpt-5.6-sol-image", "codex/gpt-5.6-terra-image", "codex/gpt-5.6-luna-image"] + [ + "codex/gpt-5.6-sol-image", + "codex/gpt-5.6-terra-image", + "codex/gpt-5.6-luna-image", + "codex/gpt-6.1-sol-image", + "codex/gpt-image-2", + "codex/gpt-image-2.5-sunburst", + ] ); const chat = { @@ -69,5 +95,6 @@ test("Codex GPT-5.6 image listings do not shadow the chat catalog ids", () => { const deduped = dedupeExactCatalogIds([chat, image]); assert.equal(deduped.length, 2); + assert.ok("context_length" in deduped[0]); assert.equal(deduped[0].context_length, 872000); }); diff --git a/tests/unit/image-upscale-error-log.test.ts b/tests/unit/image-upscale-error-log.test.ts index baaae70b6c24..10051a3b0824 100644 --- a/tests/unit/image-upscale-error-log.test.ts +++ b/tests/unit/image-upscale-error-log.test.ts @@ -35,22 +35,22 @@ test.after(() => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); -// ── imageGeneration.ts must not carry its own copy (LEDGER-22 follow-up) ── +// ── generation log sink must not carry its own copy (LEDGER-22 follow-up) ── -test("imageGeneration.ts imports the shared stringifyImageErrorForLog instead of redefining it", () => { +test("generation log sink imports the shared stringifyImageErrorForLog instead of redefining it", () => { const handlerPath = fileURLToPath( - new URL("../../open-sse/handlers/imageGeneration.ts", import.meta.url) + new URL("../../open-sse/handlers/imageGeneration/shared.ts", import.meta.url) ); const source = fs.readFileSync(handlerPath, "utf8"); assert.ok( !/^function stringifyImageErrorForLog\(/m.test(source), - "imageGeneration.ts must not redefine stringifyImageErrorForLog — import it from ./imageErrorLog" + "generation log sink must not redefine stringifyImageErrorForLog — import it from ../imageErrorLog" ); assert.ok( - /import\s*\{[^}]*\bstringifyImageErrorForLog\b[^}]*\}\s*from\s*["']\.\/imageErrorLog(?:\.ts)?["']/.test( + /import\s*\{[^}]*\bstringifyImageErrorForLog\b[^}]*\}\s*from\s*["']\.\.\/imageErrorLog(?:\.ts)?["']/.test( source ), - "imageGeneration.ts must import stringifyImageErrorForLog from ./imageErrorLog" + "generation log sink must import stringifyImageErrorForLog from ../imageErrorLog" ); }); diff --git a/tests/unit/native-antigravity-image-repair.test.ts b/tests/unit/native-antigravity-image-repair.test.ts new file mode 100644 index 000000000000..eb0cfb25e0fd --- /dev/null +++ b/tests/unit/native-antigravity-image-repair.test.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +const isolatedRoot = path.resolve(process.env.DATA_DIR || ""); +assert.ok(process.env.DATA_DIR && isolatedRoot.startsWith(path.resolve(".ci-work") + path.sep), "Run with a fresh project-contained DATA_DIR"); +for (const key of ["HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA"]) { + assert.ok(process.env[key] && path.resolve(process.env[key]!).startsWith(path.resolve(".ci-work") + path.sep), `Run with private ${key}`); +} +const state = mkdtempSync(path.join(isolatedRoot, "native-antigravity-")); +process.env.DATA_DIR = state; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; +process.env.API_KEY_SECRET = "native-antigravity-fixture-only"; +process.env.JWT_SECRET = "native-antigravity-fixture-only"; +const originalFetch = globalThis.fetch; +const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts"); +const core = await import("../../src/lib/db/core.ts"); +const providers = await import("../../src/lib/db/providers.ts"); +const { POST } = await import("../../src/app/api/v1/images/edits/route.ts"); + +// A complete tiny PNG, not a MIME-only dummy. No real account or network is used. +const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", "base64"); +const DATA_URL = `data:image/png;base64,${PNG.toString("base64")}`; +const model = "antigravity/gemini-3.1-flash-image"; +const credentials = { accessToken: "fixture-token", projectId: "fixture-project" }; + +test.after(async () => { + globalThis.fetch = originalFetch; + const { closeCallLogSaves } = await import("../../src/lib/usage/callLogs.ts"); + await closeCallLogSaves(1000); + core.resetDbInstance(); + rmSync(state, { recursive: true, force: true }); +}); + +test("Antigravity public generator forwards original reference with selected native settings", { timeout: 5000 }, async () => { + let calls = 0; + let captured: any; + globalThis.fetch = async (_url, init) => { + calls++; + captured = JSON.parse(String(init?.body)); + assert.equal(new Headers(init?.headers).get("Authorization"), "Bearer fixture-token"); + return Response.json({ response: { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }] } }] } }); + }; + const result = await handleImageGeneration({ body: { model, prompt: " رنگ آبی — café ", image: DATA_URL, aspect_ratio: "16:9", image_size: "4K" }, credentials, log: null }); + assert.equal(result.success, true); + assert.equal(calls, 1); + assert.equal(captured.model, "gemini-3.1-flash-image"); + assert.equal(captured.project, "fixture-project"); + assert.equal(captured.requestType, "image_gen"); + assert.equal(captured.userAgent, "antigravity"); + assert.deepEqual(captured.request.contents[0].parts, [ + { text: " رنگ آبی — café " }, + { inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }, + ]); + assert.deepEqual(captured.request.generationConfig.imageConfig, { aspectRatio: "16:9", imageSize: "4K" }); +}); + +test("Antigravity HTTP edit preserves reference, ratio and tier for JSON and multipart", { timeout: 5000 }, async () => { + await providers.createProviderConnection({ provider: "antigravity", authType: "oauth", name: "fixture", accessToken: "fixture-token", isActive: true, testStatus: "active", providerSpecificData: { projectId: "fixture-project" } }); + for (const multipart of [false, true]) { + let captured: any; + globalThis.fetch = async (_url, init) => { + captured = JSON.parse(String(init?.body)); + return Response.json({ response: { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }] } }] } }); + }; + const form = new FormData(); + form.set("model", model); form.set("prompt", "make it blue"); + form.set("aspect_ratio", "16:9"); form.set("image_size", "4K"); + form.set("image", new Blob([PNG], { type: "image/png" }), "reference.png"); + const request = new Request("http://localhost/api/v1/images/edits", { + method: "POST", + ...(multipart ? {} : { headers: { "content-type": "application/json" } }), + body: multipart ? form : JSON.stringify({ model, prompt: "make it blue", image: DATA_URL, aspect_ratio: "16:9", image_size: "4K" }), + }); + const response = await POST(request); + assert.equal(response.status, 200, JSON.stringify(await response.clone().json())); + assert.deepEqual(captured.request.contents[0].parts[1], { inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }); + assert.deepEqual(captured.request.generationConfig.imageConfig, { aspectRatio: "16:9", imageSize: "4K" }); + } +}); + +test("Antigravity rejects unsupported masks, invalid MIME, excess and oversized references before submission", { timeout: 5000 }, async () => { + let submissions = 0; + globalThis.fetch = async () => { submissions++; return Response.json({}, { status: 500 }); }; + for (const extra of [ + { mask: DATA_URL }, + { image: `data:image/jpeg;base64,${PNG.toString("base64")}` }, + { images: [DATA_URL, DATA_URL], image: undefined }, + { image: "data:image/svg+xml;base64,PHN2Zz4=" }, + { image: "data:image/png;base64,%%%=" }, + { model: "antigravity/not-an-image-model" }, + ]) { + const response = await POST(new Request("http://localhost/api/v1/images/edits", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model, prompt: "make it blue", image: DATA_URL, ...extra }) })); + assert.equal(response.status, 400); + const result = await handleImageGeneration({ body: { model, prompt: "make it blue", image: DATA_URL, ...extra }, credentials, log: null }); + assert.equal(result.success, false); + } + const oversized = Buffer.alloc(20 * 1024 * 1024 + 1); + PNG.copy(oversized); + const result = await handleImageGeneration({ body: { model, prompt: "make it blue", image: `data:image/png;base64,${oversized.toString("base64")}` }, credentials, log: null }); + assert.equal(result.success, false); + assert.equal(submissions, 0, "Invalid references must not submit upstream"); +}); + +test("Antigravity reference edit rejects URL response format before submission", { timeout: 5000 }, async () => { + let submissions = 0; + globalThis.fetch = async () => { + submissions++; + return Response.json({ response: { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }] } }] } }); + }; + const result = await handleImageGeneration({ body: { model, prompt: "make it blue", image: DATA_URL, response_format: "url" }, credentials, log: null }); + assert.equal(result.success, false); + assert.ok(result.success === false); + assert.equal(result.status, 400); + assert.equal(submissions, 0); +}); + +test("Antigravity submitted reference edit failures are never retryable", { timeout: 5000 }, async () => { + for (const mode of ["upstream", "empty", "transport", "parse"]) { + let submissions = 0; + globalThis.fetch = async () => { + submissions++; + if (mode === "transport") throw new Error("fixture transport failure"); + if (mode === "parse") return new Response("not json"); + if (mode === "upstream") return Response.json({ error: { message: "fixture rejection" } }, { status: 401 }); + return Response.json({ response: { candidates: [] } }); + }; + const result = await handleImageGeneration({ body: { model, prompt: "make it blue", image: DATA_URL }, credentials, log: null }); + assert.equal(result.success, false, mode); + assert.ok(result.success === false, mode); + assert.equal(result.retryable, false, mode); + assert.equal(submissions, 1, mode); + } +}); diff --git a/tests/unit/native-codex-image-repair.test.ts b/tests/unit/native-codex-image-repair.test.ts new file mode 100644 index 000000000000..ae556c2e9f8b --- /dev/null +++ b/tests/unit/native-codex-image-repair.test.ts @@ -0,0 +1,84 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + getAllImageModels, + getImageModelEntry, + getImageProvider, + parseImageModel, +} from "../../open-sse/config/imageRegistry.ts"; + +test("exact Codex image admission preserves hosted orchestration and requested engine identities", { timeout: 1000 }, () => { + for (const input of [ + "cx/gpt-6.1-sol", + "codex/gpt-6.1-sol", + "gpt-6.1-sol", + "cx/gpt-6.1-sol-image", + "codex/gpt-6.1-sol-image", + "gpt-6.1-sol-image", + ]) { + const entry = getImageModelEntry(input); + assert.ok(entry, `${input} must pass exact registry admission`); + assert.deepEqual(parseImageModel(input), { provider: "codex", model: "gpt-6.1-sol" }); + assert.equal(entry.provider, "codex"); + assert.equal(entry.model, "gpt-6.1-sol"); + } + for (const model of ["gpt-image-2", "gpt-image-2.5-sunburst"]) { + for (const prefix of ["cx", "codex"]) { + const input = `${prefix}/${model}`; + const entry = getImageModelEntry(input); + assert.ok(entry, `${input} must pass exact registry admission`); + assert.deepEqual(parseImageModel(input), { provider: "codex", model }); + assert.equal(entry.provider, "codex"); + assert.equal(entry.model, model); + assert.deepEqual(entry.inputModalities, ["text", "image"]); + } + } +}); + +test("Codex admission rejects lookalikes and unknown image engines", { timeout: 1000 }, () => { + for (const model of [ + "gpt-6.1", + "gpt-6.1-sol-high", + "gpt-6.1-sol-image-extra", + "gpt-6.1-terra", + "gpt-6.1-luna", + "gpt-image2", + "gpt-image-2.5", + "gpt-image-2.5-sunburst-extra", + "GPT-6.1-SOL", + ]) { + const input = `cx/${model}`; + // Prefix parsing is intentionally permissive; edit admission requires membership. + assert.deepEqual(parseImageModel(input), { provider: "codex", model }); + assert.equal(getImageModelEntry(input), null); + } + for (const input of [ + "cx/../gpt-6.1-sol", + "cx/gpt-6.1-sol?engine=sunburst", + "cx/gpt-6.1-sol#image", + "codex/gpt-6.1-sol%2fextra", + ]) { + assert.deepEqual(parseImageModel(input), { provider: null, model: null }); + assert.equal(getImageModelEntry(input), null); + } + assert.equal(getImageModelEntry("openai/gpt-6.1-sol"), null); +}); + +test("Codex catalog separates hosted chat identities from dedicated image engines", { timeout: 1000 }, () => { + const catalog = getAllImageModels().filter((entry) => entry.provider === "codex"); + assert.deepEqual(catalog.map((entry) => entry.id), [ + "codex/gpt-5.6-sol-image", + "codex/gpt-5.6-terra-image", + "codex/gpt-5.6-luna-image", + "codex/gpt-6.1-sol-image", + "codex/gpt-image-2", + "codex/gpt-image-2.5-sunburst", + ]); + const provider = getImageProvider("cx"); + assert.equal(provider.id, "codex"); + assert.equal(provider.format, "codex-responses"); + assert.equal(provider.baseUrl, "https://chatgpt.com/backend-api/codex/responses"); + assert.equal(provider.authType, "oauth"); + assert.deepEqual(provider.supportedSizes, ["1024x1024", "1024x1536", "1536x1024"]); +}); diff --git a/tests/unit/native-codex-images-http.test.ts b/tests/unit/native-codex-images-http.test.ts new file mode 100644 index 000000000000..1d72cc8462e3 --- /dev/null +++ b/tests/unit/native-codex-images-http.test.ts @@ -0,0 +1,210 @@ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +// The isolated runner provisions every state/home variable before any application import. +for (const key of ["DATA_DIR", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA"]) { + assert.ok(process.env[key]?.includes(`${path.sep}isolated-`), `${key} must be isolated`); +} +const originalFetch = globalThis.fetch; +const core = await import("../../src/lib/db/core.ts"); +const providers = await import("../../src/lib/db/providers.ts"); +const { createCombo } = await import("../../src/lib/db/combos.ts"); +const { createApiKey } = await import("../../src/lib/db/apiKeys.ts"); +const { POST } = await import("../../src/app/api/v1/images/edits/route.ts"); +const { POST: generate } = await import("../../src/app/api/v1/images/generations/route.ts"); +const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts"); +const { getImageModelEntry, parseImageModel } = await import("../../open-sse/config/imageRegistry.ts"); +const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", "base64"); +const DATA_URL = `data:image/png;base64,${PNG.toString("base64")}`; +const payload = { created: 1778832973, data: [{ b64_json: PNG.toString("base64"), generation_id: "fixture-generation" }], size: "1024x1536", usage: { total_tokens: 3 } }; +const credentials = { accessToken: "fixture-token", providerSpecificData: { workspaceId: "fixture-account", workspacePlanType: "plus" } }; + +let selectedConnectionId: string; +test.before(async () => { + const connection = await providers.createProviderConnection({ provider: "codex", authType: "oauth", name: "fixture", accessToken: credentials.accessToken, isActive: true, testStatus: "active", providerSpecificData: credentials.providerSpecificData, priority: 1 }); + selectedConnectionId = connection.id as string; +}); +test.after(() => { globalThis.fetch = originalFetch; core.resetDbInstance(); }); + +function editRequest(extra: Record = {}, multipart = false) { + const body = { model: "cx/gpt-image-2.5-sunburst", prompt: " رنگ آبی — café ", image: DATA_URL, ...extra }; + if (!multipart) return new Request("http://localhost/api/v1/images/edits", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + const form = new FormData(); + for (const [key, value] of Object.entries(body)) { + if (key === "image") form.set(key, new Blob([PNG], { type: "image/png" }), "reference.png"); + else form.set(key, String(value)); + } + return new Request("http://localhost/api/v1/images/edits", { method: "POST", body: form }); +} + +test("public Codex engine edit sends original references and native options for JSON and multipart", { timeout: 5000 }, async () => { + let calls = 0; + globalThis.fetch = async (url, init) => { + calls++; + assert.equal(String(url), "https://chatgpt.com/backend-api/codex/images/edits"); + assert.deepEqual(JSON.parse(String(init?.body)), { model: "gpt-image-2.5-sunburst", prompt: " رنگ آبی — café ", n: 1, size: "3840x2160", quality: "high", background: "opaque", images: [{ image_url: DATA_URL }] }); + assert.equal(new Headers(init?.headers).get("authorization"), "Bearer fixture-token"); + assert.equal(new Headers(init?.headers).get("chatgpt-account-id"), "fixture-account"); + return Response.json(payload); + }; + for (const multipart of [false, true]) { + const response = await POST(editRequest({ n: 1, size: "3840x2160", quality: "high", background: "opaque", response_format: "b64_json" }, multipart)); + assert.equal(response.status, 200, JSON.stringify(await response.clone().json())); + assert.deepEqual(await response.json(), payload); + } + assert.equal(calls, 2); + assert.ok(existsSync(path.join(process.env.DATA_DIR!, "storage.sqlite")), "HTTP fixture uses only its isolated database"); +}); + +test("public dedicated edits reject unsupported options and invalid references without a POST", { timeout: 5000 }, async () => { + let calls = 0; + globalThis.fetch = async () => { calls++; return Response.json(payload); }; + const negatives: Record[] = [ + { n: 2 }, { n: 0 }, { quality: "max" }, { background: "invalid" }, + { mask: DATA_URL }, { output_format: "png" }, { output_compression: 80 }, + { response_format: "url" }, { size: "4K" }, + { image: "https://reference.invalid/image.png" }, { image: "" }, + { image: undefined, images: [{ image_url: DATA_URL }, { image_url: "https://reference.invalid/image.png" }] }, + { image: undefined, images: Array(6).fill({ image_url: DATA_URL }) }, + { image: "data:image/jpeg;base64," + PNG.toString("base64") }, + { model: "cx/gpt-image-unknown" }, + ]; + for (const multipart of [false, true]) { + for (const extra of negatives.filter((item) => !multipart || !Object.hasOwn(item, "image"))) { + const response = await POST(editRequest(extra, multipart)); + assert.equal(response.status, 400, JSON.stringify(extra)); + } + } + assert.equal(calls, 0); +}); + +test("public image generation keeps exact engine identity and terminal failures", { timeout: 5000 }, async () => { + for (const model of ["gpt-image-2", "gpt-image-2.5-sunburst"]) { + assert.equal(getImageModelEntry(`cx/${model}`)?.model, model); + assert.deepEqual(parseImageModel(`cx/${model}`), { provider: "codex", model }); + let calls = 0; + globalThis.fetch = async (url, init) => { + calls++; + assert.equal(String(url), "https://chatgpt.com/backend-api/codex/images/generations"); + assert.deepEqual(JSON.parse(String(init?.body)), { model, prompt: "fixture", n: 1, size: "3840x2160" }); + return Response.json(payload); + }; + const response = await generate(new Request("http://localhost/api/v1/images/generations", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: `cx/${model}`, prompt: "fixture", n: 1, size: "3840x2160" }) })); + assert.equal(response.status, 200, JSON.stringify(await response.clone().json())); + assert.deepEqual(await response.json(), payload); + assert.equal(calls, 1); + } + let rejectedCalls = 0; + globalThis.fetch = async (url, init) => { + rejectedCalls++; + assert.equal(String(url), "https://chatgpt.com/backend-api/codex/images/generations"); + assert.equal(new Headers(init?.headers).get("authorization"), "Bearer fixture-token"); + assert.equal(new Headers(init?.headers).get("chatgpt-account-id"), "fixture-account"); + assert.deepEqual(JSON.parse(String(init?.body)), { model: "gpt-image-2", prompt: "fixture", n: 1, size: "3840x2160" }); + return Response.json({ error: { message: "provider rejected fixture" } }, { status: 401 }); + }; + const rejected = await generate(new Request("http://localhost/api/v1/images/generations", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "cx/gpt-image-2", prompt: "fixture", size: "3840x2160" }) })); + assert.equal(rejected.status, 401); + assert.equal(rejectedCalls, 1); + rejectedCalls = 0; + for (const extra of [{ n: 2 }, { quality: "max" }, { background: "invalid" }, { mask: DATA_URL }, { output_format: "png" }]) { + const invalid = await generate(new Request("http://localhost/api/v1/images/generations", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "cx/gpt-image-2", prompt: "fixture", ...extra }) })); + assert.equal(invalid.status, 400, JSON.stringify(extra)); + } + assert.equal(rejectedCalls, 0); + let calls = 0; + globalThis.fetch = async () => { calls++; return Response.json({ error: { message: "provider rejected fixture" } }, { status: 401 }); }; + const result = await handleImageGeneration({ body: { model: "cx/gpt-image-2", prompt: "fixture" }, credentials, log: null }); + assert.equal(result.success, false); + assert.ok(result.success === false && "retryable" in result); + assert.equal(result.retryable, false); + assert.equal(calls, 1); +}); + +test("Antigravity edit response format and generation cancellation reach the native guard", { timeout: 5000 }, async () => { + await providers.createProviderConnection({ provider: "antigravity", authType: "oauth", name: "fixture-ag", accessToken: "fixture-token", isActive: true, testStatus: "active", providerSpecificData: { projectId: "fixture-project" } }); + let calls = 0; + globalThis.fetch = async () => { calls++; return Response.json(payload); }; + const response = await POST(editRequest({ model: "antigravity/gemini-3.1-flash-image", response_format: "url" })); + assert.equal(response.status, 400); + const controller = new AbortController(); + controller.abort(); + let cancellationReachedFetch = false; + globalThis.fetch = async (_url, init) => { + cancellationReachedFetch = init?.signal?.aborted === true; + assert.equal(cancellationReachedFetch, true); + throw new DOMException("Fixture cancellation", "AbortError"); + }; + const result = await handleImageGeneration({ body: { model: "antigravity/gemini-3.1-flash-image", prompt: "fixture" }, credentials: { accessToken: "fixture-token", projectId: "fixture-project" }, log: null, signal: controller.signal }); + assert.equal(result.success, false); + assert.equal(cancellationReachedFetch, true); + assert.equal(calls, 0); +}); + +test("dedicated generation never submits an expired selected token or rotates to a sibling after refresh rejection", { timeout: 5000 }, async () => { + await providers.updateProviderConnection(selectedConnectionId, { expiresAt: "2000-01-01T00:00:00.000Z", refreshToken: "fixture-refresh-selected", priority: 1 }); + await providers.createProviderConnection({ provider: "codex", authType: "oauth", name: "fixture-sibling", accessToken: "fixture-token-sibling", isActive: true, testStatus: "active", priority: 2, providerSpecificData: { workspaceId: "fixture-sibling-account", workspacePlanType: "plus" } }); + let refreshCalls = 0; + let imageCalls = 0; + globalThis.fetch = async (url, init) => { + if (String(url) === "https://auth.openai.com/oauth/token") { + refreshCalls++; + assert.equal(new URLSearchParams(String(init?.body)).get("refresh_token"), "fixture-refresh-selected"); + return Response.json({ error: "invalid_grant" }, { status: 400 }); + } + imageCalls++; + return Response.json(payload); + }; + const response = await generate(new Request("http://localhost/api/v1/images/generations", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "cx/gpt-image-2", prompt: "fixture" }) })); + assert.equal(refreshCalls, 1); + assert.equal(imageCalls, 0, "Neither expired selected credentials nor a sibling account may submit an image"); + assert.equal(response.status, 401); +}); + +test("public image combos enforce connection allowlists and fail selected refresh without another submission", { timeout: 5000 }, async () => { + const allowed = await providers.createProviderConnection({ provider: "codex", authType: "oauth", name: "fixture-allowed", accessToken: "fixture-allowed-token", isActive: true, testStatus: "active", priority: 10, providerSpecificData: { workspaceId: "fixture-allowed-account", workspacePlanType: "plus" } }); + assert.equal(typeof allowed.id, "string"); + if (typeof allowed.id !== "string") assert.fail("Fixture connection ID missing"); + const comboName = "fixture-native-image-combo"; + await createCombo({ name: comboName, strategy: "priority", models: ["cx/gpt-image-2", "cx/gpt-image-2.5-sunburst"] }); + const key = await createApiKey("fixture-image-key", "fixture-machine", [], { allowedConnections: [allowed.id], allowedCombos: [comboName] }); + const headers = { "content-type": "application/json", authorization: `Bearer ${key.key}` }; + let calls = 0; + globalThis.fetch = async (_url, init) => { + calls++; + assert.equal(new Headers(init?.headers).get("authorization"), "Bearer fixture-allowed-token"); + assert.equal(new Headers(init?.headers).get("chatgpt-account-id"), "fixture-allowed-account"); + return Response.json(payload); + }; + const request = () => new Request("http://localhost/api/v1/images/generations", { method: "POST", headers, body: JSON.stringify({ model: comboName, prompt: "fixture" }) }); + const response = await generate(request()); + assert.equal(response.status, 200, JSON.stringify(await response.clone().json())); + assert.equal(calls, 1); + await providers.updateProviderConnection(allowed.id, { expiresAt: "2000-01-01T00:00:00.000Z", refreshToken: "fixture-combo-refresh" }); + let refreshCalls = 0; + calls = 0; + globalThis.fetch = async (url, init) => { + if (String(url) === "https://auth.openai.com/oauth/token") { + refreshCalls++; + assert.equal(new URLSearchParams(String(init?.body)).get("refresh_token"), "fixture-combo-refresh"); + return Response.json({ error: "invalid_grant" }, { status: 400 }); + } + calls++; + return Response.json(payload); + }; + const rejected = await generate(request()); + assert.equal(rejected.status, 401); + assert.equal(refreshCalls, 1); + assert.equal(calls, 0); + refreshCalls = 0; + const edit = await POST(new Request("http://localhost/api/v1/images/edits", { method: "POST", headers, body: JSON.stringify({ model: comboName, prompt: "fixture", image: DATA_URL }) })); + assert.equal(edit.status, 401); + assert.equal(refreshCalls, 1); + assert.equal(calls, 0); + await providers.updateProviderConnection(allowed.id, { isActive: false }); + const blocked = await generate(request()); + assert.equal(blocked.status, 403); + assert.equal(calls, 0); +}); diff --git a/tests/unit/native-codex-images.test.ts b/tests/unit/native-codex-images.test.ts new file mode 100644 index 000000000000..6386eb152d94 --- /dev/null +++ b/tests/unit/native-codex-images.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; + +process.env.FETCH_TIMEOUT_MS = "50"; +const originalFetch = globalThis.fetch; +const { handleCodexImages } = await import("../../open-sse/handlers/codexImages.ts"); +const { getCodexBackendIdentityHeaders } = await import("../../open-sse/config/codexClient.ts"); +const credentials = { accessToken: "fixture-token", providerSpecificData: { workspaceId: "fixture-account", workspacePlanType: "plus" } }; +const options = { model: "gpt-image-2", provider: "codex", providerConfig: { baseUrl: "https://fixture.invalid/backend-api/codex/responses" }, credentials, log: null }; +const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII="; +const DATA_URL = `data:image/png;base64,${PNG}`; +const payload = { created: 1778832973, data: [{ b64_json: PNG, generation_id: "fixture-generation" }], size: "1024x1536", quality: "high", background: "opaque", usage: { total_tokens: 3 } }; + +test.after(() => { globalThis.fetch = originalFetch; }); + +test("dedicated Codex Images forwards an exact engine request, selected auth and original JSON", { timeout: 2000 }, async () => { + let calls = 0; + globalThis.fetch = async (url, init) => { + calls++; + assert.equal(String(url), "https://fixture.invalid/backend-api/codex/images/generations"); + assert.equal(init?.method, "POST"); + assert.deepEqual(JSON.parse(String(init?.body)), { model: "gpt-image-2.5-sunburst", prompt: " رنگ آبی — café ", n: 1, size: "3840x2160", quality: "high", background: "opaque" }); + const headers = new Headers(init?.headers); + assert.equal(headers.get("authorization"), "Bearer fixture-token"); + assert.equal(headers.get("chatgpt-account-id"), "fixture-account"); + assert.equal(headers.get("session_id"), "fixture-account"); + assert.equal(headers.get("content-type"), "application/json"); + assert.equal(headers.get("accept"), "application/json"); + for (const [name, value] of Object.entries(getCodexBackendIdentityHeaders())) assert.equal(headers.get(name), value); + return Response.json(payload, { headers: { "x-codex-imagegen-request-id": "fixture-image-request", "x-request-id": "fixture-outer-request" } }); + }; + const result = await handleCodexImages({ ...options, model: "gpt-image-2.5-sunburst", body: { prompt: " رنگ آبی — café ", n: 1, size: "3840x2160", quality: "high", background: "opaque" } }); + assert.equal(result.success, true); + if (!result.success) assert.fail("expected image JSON"); + assert.deepEqual(result.data, payload); + assert.match(result.metadata.warning, /not verified/); + assert.equal(result.metadata.imagegenRequestId, "fixture-image-request"); + assert.equal(result.metadata.requestId, "fixture-outer-request"); + assert.equal(calls, 1); +}); + +test("dedicated Codex rejects six references without submitting", { timeout: 2000 }, async () => { + let calls = 0; + globalThis.fetch = async () => { calls++; return Response.json(payload); }; + const result = await handleCodexImages({ ...options, operation: "edits", body: { prompt: "fixture", images: Array(6).fill({ image_url: DATA_URL }) } }); + assert.equal(result.success, false); + if (result.success) assert.fail("expected reference rejection"); + assert.equal(result.status, 400); + assert.equal(calls, 0); +}); + +test("dedicated Codex transport never initializes persistent database state", { timeout: 2000 }, () => { + assert.ok(process.env.DATA_DIR); + assert.equal(existsSync(join(process.env.DATA_DIR!, "storage.sqlite")), false); +}); diff --git a/tests/unit/native-image-terminal-failure.test.ts b/tests/unit/native-image-terminal-failure.test.ts new file mode 100644 index 000000000000..1e4f92a4813c --- /dev/null +++ b/tests/unit/native-image-terminal-failure.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { resolve, relative } from "node:path"; + +const workspace = resolve(".ci-work"); +for (const key of ["DATA_DIR", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA"]) { + const value = process.env[key]; + assert.ok(value, `isolated ${key} required before imports`); + const path = relative(workspace, resolve(value)); + assert.ok(path && !path.startsWith("..") && !resolve(value).includes("omniroute-data")); +} +const { runImageComboTargets } = await import("../../open-sse/services/imageCombo.ts"); +const core = await import("../../src/lib/db/core.ts"); +const { executeImageWithCredentialFallback } = await import("../../src/sse/services/imageCredentialRetry.ts"); +test.after(() => core.resetDbInstance()); + +test("image combos stop after a terminal submitted failure without another model", { timeout: 2000 }, async () => { + for (const status of [429, 502, 504]) { + let calls = 0; + const result = await runImageComboTargets([{ modelStr: "first" }, { modelStr: "second" }], { + resolveProvider: () => ({ provider: "fixture", model: "fixture-model" }), + resolveCredentials: async () => ({ connectionId: "fixture" }), + isRateLimited: () => false, + dispatch: async () => { calls++; return { success: false, status, error: "fixture outcome unknown", retryable: false }; }, + }); + assert.equal(result.outcome, "terminal"); + assert.equal(calls, 1); + } +}); + +test("explicit terminal image failure does not rotate the selected account", { timeout: 2000 }, async () => { + let calls = 0; + let selections = 0; + const result = await executeImageWithCredentialFallback({ + provider: "codex", requestedModel: "codex/gpt-image-2", + credentials: { connectionId: "fixture-first", accessToken: "fixture-token" }, + execute: async () => { calls++; return { success: false, status: 401, error: "fixture failure", retryable: false }; }, + selectNextCredentials: async () => { selections++; return null; }, + }); + assert.equal(result.result.success, false); + assert.equal(calls, 1); + assert.equal(selections, 0); +}); From d63ce0a47be8b5abdb90ca0ada97bde9e997b4f9 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 03:41:33 +0330 Subject: [PATCH 02/39] fix: reject unavailable image leases --- docs/CODEX-IMAGES.md | 4 +++- src/app/api/v1/images/edits/editDispatch.ts | 1 + src/app/api/v1/images/edits/route.ts | 6 ++++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/CODEX-IMAGES.md b/docs/CODEX-IMAGES.md index b87b70f6be6f..ba98224001f9 100644 --- a/docs/CODEX-IMAGES.md +++ b/docs/CODEX-IMAGES.md @@ -28,7 +28,9 @@ most five original PNG/JPEG/WebP references, at most 20 MiB decoded total; JSON inline data URLs and multipart file inputs are normalized without resizing. Masks, remote reference URLs, file IDs and unimplemented output controls are rejected rather than discarded. Selected account policy, proxy and cancellation -remain in effect. Refresh failure terminates before image submission. +remain in effect. Refresh failure terminates before image submission. A selected +account already held by an exclusive lease returns 429 without forwarding its +credential sentinel to an image adapter. A numeric `size` is forwarded, not enforced by local resampling. Inspect decoded original output dimensions to establish what was returned. In particular, a diff --git a/src/app/api/v1/images/edits/editDispatch.ts b/src/app/api/v1/images/edits/editDispatch.ts index 8d242f0c8c10..0cf1810aac40 100644 --- a/src/app/api/v1/images/edits/editDispatch.ts +++ b/src/app/api/v1/images/edits/editDispatch.ts @@ -112,6 +112,7 @@ export async function handleAdobeFireflyEditRequest(params: { } if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); + if ("leaseUnavailable" in credentials) return errorResponse(429, "Selected image account is already leased; try again later"); // Prefer multi-image list when present; fall back to the primary imageBytes. const dataUrls = buildAdobeFireflyEditDataUrls(images, imageBytes, imageMime); if (dataUrls.length === 0) { diff --git a/src/app/api/v1/images/edits/route.ts b/src/app/api/v1/images/edits/route.ts index c9cfda332fac..6bb201be9a86 100644 --- a/src/app/api/v1/images/edits/route.ts +++ b/src/app/api/v1/images/edits/route.ts @@ -182,6 +182,7 @@ async function postHandler(request: Request, _context?: unknown) { if ("blockedByKeyPolicy" in credentials) return errorResponse(403, "Image provider connection is not allowed by API key policy"); if (isAllRateLimitedCredentials(credentials)) return unavailableResponse(429, "All Antigravity accounts rate limited", credentials.retryAfter, credentials.retryAfterHuman); if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); + if ("leaseUnavailable" in credentials) return errorResponse(429, "Selected image account is already leased; try again later"); const result = await dispatchAntigravityEdit({ parsed, providerConfig, modelStr: resolvedModel }, credentials, { prompt, size, responseFormat, images, aspectRatio, imageSize, hasMask, request }); if (result.success) { await clearRecoveredProviderState(credentials); return jsonResponse(result.data); } return jsonResponse(toJsonErrorPayload(result.error, "Image edit provider error"), result.status); @@ -196,6 +197,7 @@ async function postHandler(request: Request, _context?: unknown) { if ("blockedByKeyPolicy" in credentials) return errorResponse(403, "Image provider connection is not allowed by API key policy"); if (isAllRateLimitedCredentials(credentials)) return unavailableResponse(429, "All Codex accounts rate limited", credentials.retryAfter, credentials.retryAfterHuman); if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); + if ("leaseUnavailable" in credentials) return errorResponse(429, "Selected image account is already leased; try again later"); const result = await dispatchCodexImagesEdit({ parsed, providerConfig, modelStr: resolvedModel }, credentials, ctx); if (result.success) { await clearRecoveredProviderState(credentials); return jsonResponse(result.data); } return jsonResponse(toJsonErrorPayload(result.error, "Codex Images edit provider error"), result.status); @@ -236,6 +238,7 @@ async function postHandler(request: Request, _context?: unknown) { ); } if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); + if ("leaseUnavailable" in credentials) return errorResponse(429, "Selected image account is already leased; try again later"); const credentialDetails = credentials; if (isCodexFreePlan(credentialDetails.providerSpecificData)) { return errorResponse( @@ -313,6 +316,7 @@ async function postHandler(request: Request, _context?: unknown) { } if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); + if ("leaseUnavailable" in credentials) return errorResponse(429, "Selected image account is already leased; try again later"); const result = await handleFalAIImageEdit({ provider: parsed.provider, model: parsed.model, @@ -382,6 +386,7 @@ async function postHandler(request: Request, _context?: unknown) { } if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); + if ("leaseUnavailable" in credentials) return errorResponse(429, "Selected image account is already leased; try again later"); const result = await handleOpenRouterImageEdit({ provider: parsed.provider, model: parsed.model, @@ -449,6 +454,7 @@ async function postHandler(request: Request, _context?: unknown) { } if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); + if ("leaseUnavailable" in credentials) return errorResponse(429, "Selected image account is already leased; try again later"); const result = await handleOpenAIImageEdit({ provider: customProviderId, model: customModel, From 954d78e96c61eb5292d5c707cdec245aa91e8616 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 03:55:46 +0330 Subject: [PATCH 03/39] fix: validate image credential selection --- src/app/api/v1/images/edits/editDispatch.ts | 1 + src/app/api/v1/images/edits/route.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/app/api/v1/images/edits/editDispatch.ts b/src/app/api/v1/images/edits/editDispatch.ts index 0cf1810aac40..149c23bcd774 100644 --- a/src/app/api/v1/images/edits/editDispatch.ts +++ b/src/app/api/v1/images/edits/editDispatch.ts @@ -113,6 +113,7 @@ export async function handleAdobeFireflyEditRequest(params: { if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); if ("leaseUnavailable" in credentials) return errorResponse(429, "Selected image account is already leased; try again later"); + if (!("apiKey" in credentials) && !("accessToken" in credentials)) return errorResponse(401, "Image credential selection did not return usable credentials"); // Prefer multi-image list when present; fall back to the primary imageBytes. const dataUrls = buildAdobeFireflyEditDataUrls(images, imageBytes, imageMime); if (dataUrls.length === 0) { diff --git a/src/app/api/v1/images/edits/route.ts b/src/app/api/v1/images/edits/route.ts index 6bb201be9a86..4653e5569282 100644 --- a/src/app/api/v1/images/edits/route.ts +++ b/src/app/api/v1/images/edits/route.ts @@ -183,6 +183,7 @@ async function postHandler(request: Request, _context?: unknown) { if (isAllRateLimitedCredentials(credentials)) return unavailableResponse(429, "All Antigravity accounts rate limited", credentials.retryAfter, credentials.retryAfterHuman); if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); if ("leaseUnavailable" in credentials) return errorResponse(429, "Selected image account is already leased; try again later"); + if (!("apiKey" in credentials) && !("accessToken" in credentials)) return errorResponse(401, "Image credential selection did not return usable credentials"); const result = await dispatchAntigravityEdit({ parsed, providerConfig, modelStr: resolvedModel }, credentials, { prompt, size, responseFormat, images, aspectRatio, imageSize, hasMask, request }); if (result.success) { await clearRecoveredProviderState(credentials); return jsonResponse(result.data); } return jsonResponse(toJsonErrorPayload(result.error, "Image edit provider error"), result.status); @@ -198,6 +199,7 @@ async function postHandler(request: Request, _context?: unknown) { if (isAllRateLimitedCredentials(credentials)) return unavailableResponse(429, "All Codex accounts rate limited", credentials.retryAfter, credentials.retryAfterHuman); if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); if ("leaseUnavailable" in credentials) return errorResponse(429, "Selected image account is already leased; try again later"); + if (!("apiKey" in credentials) && !("accessToken" in credentials)) return errorResponse(401, "Image credential selection did not return usable credentials"); const result = await dispatchCodexImagesEdit({ parsed, providerConfig, modelStr: resolvedModel }, credentials, ctx); if (result.success) { await clearRecoveredProviderState(credentials); return jsonResponse(result.data); } return jsonResponse(toJsonErrorPayload(result.error, "Codex Images edit provider error"), result.status); @@ -239,8 +241,9 @@ async function postHandler(request: Request, _context?: unknown) { } if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); if ("leaseUnavailable" in credentials) return errorResponse(429, "Selected image account is already leased; try again later"); + if (!("apiKey" in credentials) && !("accessToken" in credentials)) return errorResponse(401, "Image credential selection did not return usable credentials"); const credentialDetails = credentials; - if (isCodexFreePlan(credentialDetails.providerSpecificData)) { + if (isCodexFreePlan("providerSpecificData" in credentialDetails ? credentialDetails.providerSpecificData : undefined)) { return errorResponse( HTTP_STATUS.BAD_REQUEST, "Codex image editing requires a paid ChatGPT/Codex plan" @@ -248,7 +251,7 @@ async function postHandler(request: Request, _context?: unknown) { } const connectionId = - typeof credentialDetails.connectionId === "string" ? credentialDetails.connectionId : null; + "connectionId" in credentialDetails && typeof credentialDetails.connectionId === "string" ? credentialDetails.connectionId : null; let proxyInfo = null; if (connectionId) { try { @@ -317,6 +320,7 @@ async function postHandler(request: Request, _context?: unknown) { if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); if ("leaseUnavailable" in credentials) return errorResponse(429, "Selected image account is already leased; try again later"); + if (!("apiKey" in credentials) && !("accessToken" in credentials)) return errorResponse(401, "Image credential selection did not return usable credentials"); const result = await handleFalAIImageEdit({ provider: parsed.provider, model: parsed.model, @@ -387,6 +391,7 @@ async function postHandler(request: Request, _context?: unknown) { if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); if ("leaseUnavailable" in credentials) return errorResponse(429, "Selected image account is already leased; try again later"); + if (!("apiKey" in credentials) && !("accessToken" in credentials)) return errorResponse(401, "Image credential selection did not return usable credentials"); const result = await handleOpenRouterImageEdit({ provider: parsed.provider, model: parsed.model, @@ -455,6 +460,7 @@ async function postHandler(request: Request, _context?: unknown) { if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); if ("leaseUnavailable" in credentials) return errorResponse(429, "Selected image account is already leased; try again later"); + if (!("apiKey" in credentials) && !("accessToken" in credentials)) return errorResponse(401, "Image credential selection did not return usable credentials"); const result = await handleOpenAIImageEdit({ provider: customProviderId, model: customModel, From 297e5dc9e21271a42359230877d2d8c5b0cd07ce Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 04:08:00 +0330 Subject: [PATCH 04/39] fix: narrow usable image credentials --- docs/CODEX-IMAGES.md | 3 ++- src/app/api/v1/images/edits/editDispatch.ts | 3 ++- src/app/api/v1/images/edits/route.ts | 13 +++++++------ src/sse/services/imageCredentials.ts | 10 ++++++++++ 4 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 src/sse/services/imageCredentials.ts diff --git a/docs/CODEX-IMAGES.md b/docs/CODEX-IMAGES.md index ba98224001f9..6b9f1f80fab6 100644 --- a/docs/CODEX-IMAGES.md +++ b/docs/CODEX-IMAGES.md @@ -30,7 +30,8 @@ Masks, remote reference URLs, file IDs and unimplemented output controls are rejected rather than discarded. Selected account policy, proxy and cancellation remain in effect. Refresh failure terminates before image submission. A selected account already held by an exclusive lease returns 429 without forwarding its -credential sentinel to an image adapter. +credential sentinel to an image adapter. Other diagnostic selection results and +empty or non-string API keys/access tokens are rejected before adapter dispatch. A numeric `size` is forwarded, not enforced by local resampling. Inspect decoded original output dimensions to establish what was returned. In particular, a diff --git a/src/app/api/v1/images/edits/editDispatch.ts b/src/app/api/v1/images/edits/editDispatch.ts index 149c23bcd774..aa5d98116db6 100644 --- a/src/app/api/v1/images/edits/editDispatch.ts +++ b/src/app/api/v1/images/edits/editDispatch.ts @@ -11,6 +11,7 @@ import { getComboByName, getCombos } from "@/lib/db/combos"; import { resolveComboTargets } from "@omniroute/open-sse/services/combo.ts"; import { runImageComboTargets, type ImageComboDispatchResult } from "@omniroute/open-sse/services/imageCombo.ts"; import { isAllRateLimitedCredentials } from "@/app/api/v1/_shared/rateLimit"; +import { isUsableImageCredentials } from "@/sse/services/imageCredentials"; import * as log from "@/sse/utils/logger"; import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; @@ -113,7 +114,7 @@ export async function handleAdobeFireflyEditRequest(params: { if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); if ("leaseUnavailable" in credentials) return errorResponse(429, "Selected image account is already leased; try again later"); - if (!("apiKey" in credentials) && !("accessToken" in credentials)) return errorResponse(401, "Image credential selection did not return usable credentials"); + if (!isUsableImageCredentials(credentials)) return errorResponse(401, "Image credential selection did not return usable credentials"); // Prefer multi-image list when present; fall back to the primary imageBytes. const dataUrls = buildAdobeFireflyEditDataUrls(images, imageBytes, imageMime); if (dataUrls.length === 0) { diff --git a/src/app/api/v1/images/edits/route.ts b/src/app/api/v1/images/edits/route.ts index 4653e5569282..52d5a799fdd3 100644 --- a/src/app/api/v1/images/edits/route.ts +++ b/src/app/api/v1/images/edits/route.ts @@ -6,6 +6,7 @@ import { parseImageModel, getImageProvider, getImageModelEntry } from "@omnirout import { errorResponse, unavailableResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import { isAllRateLimitedCredentials } from "@/app/api/v1/_shared/rateLimit"; +import { isUsableImageCredentials } from "@/sse/services/imageCredentials"; import { getComboByName } from "@/lib/db/combos"; @@ -183,7 +184,7 @@ async function postHandler(request: Request, _context?: unknown) { if (isAllRateLimitedCredentials(credentials)) return unavailableResponse(429, "All Antigravity accounts rate limited", credentials.retryAfter, credentials.retryAfterHuman); if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); if ("leaseUnavailable" in credentials) return errorResponse(429, "Selected image account is already leased; try again later"); - if (!("apiKey" in credentials) && !("accessToken" in credentials)) return errorResponse(401, "Image credential selection did not return usable credentials"); + if (!isUsableImageCredentials(credentials)) return errorResponse(401, "Image credential selection did not return usable credentials"); const result = await dispatchAntigravityEdit({ parsed, providerConfig, modelStr: resolvedModel }, credentials, { prompt, size, responseFormat, images, aspectRatio, imageSize, hasMask, request }); if (result.success) { await clearRecoveredProviderState(credentials); return jsonResponse(result.data); } return jsonResponse(toJsonErrorPayload(result.error, "Image edit provider error"), result.status); @@ -199,7 +200,7 @@ async function postHandler(request: Request, _context?: unknown) { if (isAllRateLimitedCredentials(credentials)) return unavailableResponse(429, "All Codex accounts rate limited", credentials.retryAfter, credentials.retryAfterHuman); if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); if ("leaseUnavailable" in credentials) return errorResponse(429, "Selected image account is already leased; try again later"); - if (!("apiKey" in credentials) && !("accessToken" in credentials)) return errorResponse(401, "Image credential selection did not return usable credentials"); + if (!isUsableImageCredentials(credentials)) return errorResponse(401, "Image credential selection did not return usable credentials"); const result = await dispatchCodexImagesEdit({ parsed, providerConfig, modelStr: resolvedModel }, credentials, ctx); if (result.success) { await clearRecoveredProviderState(credentials); return jsonResponse(result.data); } return jsonResponse(toJsonErrorPayload(result.error, "Codex Images edit provider error"), result.status); @@ -241,7 +242,7 @@ async function postHandler(request: Request, _context?: unknown) { } if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); if ("leaseUnavailable" in credentials) return errorResponse(429, "Selected image account is already leased; try again later"); - if (!("apiKey" in credentials) && !("accessToken" in credentials)) return errorResponse(401, "Image credential selection did not return usable credentials"); + if (!isUsableImageCredentials(credentials)) return errorResponse(401, "Image credential selection did not return usable credentials"); const credentialDetails = credentials; if (isCodexFreePlan("providerSpecificData" in credentialDetails ? credentialDetails.providerSpecificData : undefined)) { return errorResponse( @@ -320,7 +321,7 @@ async function postHandler(request: Request, _context?: unknown) { if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); if ("leaseUnavailable" in credentials) return errorResponse(429, "Selected image account is already leased; try again later"); - if (!("apiKey" in credentials) && !("accessToken" in credentials)) return errorResponse(401, "Image credential selection did not return usable credentials"); + if (!isUsableImageCredentials(credentials)) return errorResponse(401, "Image credential selection did not return usable credentials"); const result = await handleFalAIImageEdit({ provider: parsed.provider, model: parsed.model, @@ -391,7 +392,7 @@ async function postHandler(request: Request, _context?: unknown) { if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); if ("leaseUnavailable" in credentials) return errorResponse(429, "Selected image account is already leased; try again later"); - if (!("apiKey" in credentials) && !("accessToken" in credentials)) return errorResponse(401, "Image credential selection did not return usable credentials"); + if (!isUsableImageCredentials(credentials)) return errorResponse(401, "Image credential selection did not return usable credentials"); const result = await handleOpenRouterImageEdit({ provider: parsed.provider, model: parsed.model, @@ -460,7 +461,7 @@ async function postHandler(request: Request, _context?: unknown) { if ("allRateLimited" in credentials) return errorResponse(503, "Image provider credentials unavailable"); if ("leaseUnavailable" in credentials) return errorResponse(429, "Selected image account is already leased; try again later"); - if (!("apiKey" in credentials) && !("accessToken" in credentials)) return errorResponse(401, "Image credential selection did not return usable credentials"); + if (!isUsableImageCredentials(credentials)) return errorResponse(401, "Image credential selection did not return usable credentials"); const result = await handleOpenAIImageEdit({ provider: customProviderId, model: customModel, diff --git a/src/sse/services/imageCredentials.ts b/src/sse/services/imageCredentials.ts new file mode 100644 index 000000000000..2160756ca289 --- /dev/null +++ b/src/sse/services/imageCredentials.ts @@ -0,0 +1,10 @@ +import type { ProviderCredentials } from "@omniroute/open-sse/executors/base.ts"; + +/** Reject diagnostic sentinels before credentials cross an image-adapter boundary. */ +export function isUsableImageCredentials(value: unknown): value is ProviderCredentials { + if (!value || typeof value !== "object") return false; + const token = "accessToken" in value ? value.accessToken : undefined; + const key = "apiKey" in value ? value.apiKey : undefined; + return (typeof token === "string" && token.trim().length > 0) || + (typeof key === "string" && key.trim().length > 0); +} From 2371770777c21e00f865b6dc72dca8e92378a4a2 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 04:15:31 +0330 Subject: [PATCH 05/39] test: correct image contract assertions --- tests/unit/hard-session-lease-bypass-inventory.test.ts | 2 -- tests/unit/native-antigravity-image-repair.test.ts | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/hard-session-lease-bypass-inventory.test.ts b/tests/unit/hard-session-lease-bypass-inventory.test.ts index f9c48da97d27..0a7893952ec8 100644 --- a/tests/unit/hard-session-lease-bypass-inventory.test.ts +++ b/tests/unit/hard-session-lease-bypass-inventory.test.ts @@ -124,8 +124,6 @@ const EXPECTED: Record> = { "open-sse/services/combo/executeTargetGates.ts": 1, "open-sse/services/combo/providerWildcard.ts": 1, "open-sse/services/tokenRefresh.ts": 1, - "src/lib/providers/volcPlanAutoSyncBackfill.ts": 1, - "src/lib/providers/volcenginePlanBinding.ts": 1, "src/app/(dashboard)/dashboard/tools/agent-bridge/page.tsx": 1, "src/app/api/cloud/auth/route.ts": 1, "src/app/api/cloud/credentials/update/route.ts": 1, diff --git a/tests/unit/native-antigravity-image-repair.test.ts b/tests/unit/native-antigravity-image-repair.test.ts index eb0cfb25e0fd..c038c245d2b6 100644 --- a/tests/unit/native-antigravity-image-repair.test.ts +++ b/tests/unit/native-antigravity-image-repair.test.ts @@ -112,6 +112,7 @@ test("Antigravity reference edit rejects URL response format before submission", const result = await handleImageGeneration({ body: { model, prompt: "make it blue", image: DATA_URL, response_format: "url" }, credentials, log: null }); assert.equal(result.success, false); assert.ok(result.success === false); + assert.ok("status" in result); assert.equal(result.status, 400); assert.equal(submissions, 0); }); @@ -129,6 +130,7 @@ test("Antigravity submitted reference edit failures are never retryable", { time const result = await handleImageGeneration({ body: { model, prompt: "make it blue", image: DATA_URL }, credentials, log: null }); assert.equal(result.success, false, mode); assert.ok(result.success === false, mode); + assert.ok("retryable" in result, mode); assert.equal(result.retryable, false, mode); assert.equal(submissions, 1, mode); } From 0070a1fcc0624b41fdfa90f91d2f9d69d5e9f0c0 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 04:25:20 +0330 Subject: [PATCH 06/39] fix: satisfy native image lint checks --- open-sse/handlers/geminiImage.ts | 2 -- scripts/check/image-repair-ci.mjs | 2 +- src/app/api/v1/images/edits/editDispatch.ts | 2 +- tests/unit/native-antigravity-image-repair.test.ts | 14 ++++++++++++-- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/open-sse/handlers/geminiImage.ts b/open-sse/handlers/geminiImage.ts index 0762f0a0887c..170cc95a71ad 100644 --- a/open-sse/handlers/geminiImage.ts +++ b/open-sse/handlers/geminiImage.ts @@ -180,8 +180,6 @@ export async function handleGeminiImageGeneration({ model, providerConfig, body, if (!response.ok) { const errorText = await response.text(); const safeError = sanitizeImageProviderError(errorText); - const safeErrorLog = - typeof safeError === "string" ? safeError : JSON.stringify(safeError ?? {}); if (log) { log.error("IMAGE", `antigravity error ${response.status}`); } diff --git a/scripts/check/image-repair-ci.mjs b/scripts/check/image-repair-ci.mjs index 4ff760855982..74e44288c905 100644 --- a/scripts/check/image-repair-ci.mjs +++ b/scripts/check/image-repair-ci.mjs @@ -271,7 +271,7 @@ async function main() { [fileURLToPath(import.meta.url), "--typecheck"], 240, 120); await run("changed-source ESLint", process.execPath, ["node_modules/eslint/bin/eslint.js", "--suppressions-location", - "config/quality/eslint-suppressions.json", ...roots], 240, 120); + "config/quality/eslint-suppressions.json", "--pass-on-unpruned-suppressions", ...roots], 240, 120); await focusedTests(roots); await run("backend standalone build", ...offline(process.execPath, ["scripts/build/build-next-isolated.mjs"]), 900, 180); diff --git a/src/app/api/v1/images/edits/editDispatch.ts b/src/app/api/v1/images/edits/editDispatch.ts index aa5d98116db6..a8819ece8056 100644 --- a/src/app/api/v1/images/edits/editDispatch.ts +++ b/src/app/api/v1/images/edits/editDispatch.ts @@ -19,7 +19,7 @@ import { resolveImageModelPrefix, validateCodexImageEditReferences } from "@/lib import { resolveProxyForConnection } from "@/lib/db/settings"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; -import { isCodexFreePlan } from "@omniroute/open-sse/executors/codex/tools.ts"; +import { isCodexFreePlan } from "@omniroute/open-sse/config/codexPlan.ts"; import { refreshSelectedCodexImageCredentials } from "@/sse/services/selectedCodexImageCredentials"; diff --git a/tests/unit/native-antigravity-image-repair.test.ts b/tests/unit/native-antigravity-image-repair.test.ts index c038c245d2b6..cebffed8a423 100644 --- a/tests/unit/native-antigravity-image-repair.test.ts +++ b/tests/unit/native-antigravity-image-repair.test.ts @@ -24,6 +24,16 @@ const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR const DATA_URL = `data:image/png;base64,${PNG.toString("base64")}`; const model = "antigravity/gemini-3.1-flash-image"; const credentials = { accessToken: "fixture-token", projectId: "fixture-project" }; +type CapturedImageRequest = { + model: string; + project: string; + requestType: string; + userAgent: string; + request: { + contents: { parts: unknown[] }[]; + generationConfig: { imageConfig: { aspectRatio: string; imageSize: string } }; + }; +}; test.after(async () => { globalThis.fetch = originalFetch; @@ -35,7 +45,7 @@ test.after(async () => { test("Antigravity public generator forwards original reference with selected native settings", { timeout: 5000 }, async () => { let calls = 0; - let captured: any; + let captured: CapturedImageRequest; globalThis.fetch = async (_url, init) => { calls++; captured = JSON.parse(String(init?.body)); @@ -59,7 +69,7 @@ test("Antigravity public generator forwards original reference with selected nat test("Antigravity HTTP edit preserves reference, ratio and tier for JSON and multipart", { timeout: 5000 }, async () => { await providers.createProviderConnection({ provider: "antigravity", authType: "oauth", name: "fixture", accessToken: "fixture-token", isActive: true, testStatus: "active", providerSpecificData: { projectId: "fixture-project" } }); for (const multipart of [false, true]) { - let captured: any; + let captured: CapturedImageRequest; globalThis.fetch = async (_url, init) => { captured = JSON.parse(String(init?.body)); return Response.json({ response: { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }] } }] } }); From 664100260b80c3ab8d34a683b841b2441de7d844 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 14:42:53 +0330 Subject: [PATCH 07/39] test: settle image fixture logging --- docs/CODEX-IMAGES.md | 7 ++++ scripts/check/image-repair-ci.mjs | 19 ++++++++--- tests/_setup/imageCallLogLifecycle.ts | 17 ++++++++++ ...-generation-size-and-payload-guard.test.ts | 32 +++++++---------- tests/unit/native-image-log-lifecycle.test.ts | 34 +++++++++++++++++++ 5 files changed, 85 insertions(+), 24 deletions(-) create mode 100644 tests/_setup/imageCallLogLifecycle.ts create mode 100644 tests/unit/native-image-log-lifecycle.test.ts diff --git a/docs/CODEX-IMAGES.md b/docs/CODEX-IMAGES.md index 6b9f1f80fab6..78c57b6727da 100644 --- a/docs/CODEX-IMAGES.md +++ b/docs/CODEX-IMAGES.md @@ -58,3 +58,10 @@ option forwarding and terminal outcomes. They do not establish live entitlement, image quality or actual native 4K. Hosted CI/build results must refer to the exact repair commit. The existing gateway, owner credentials and private state are not included in the fork or CI artifacts. + +The hosted image bucket drains asynchronous call-log saves between fixture cases +and closes its artifact workers before fixture database teardown. It retains a +240-second bucket limit, two isolated workers and failing exit codes; it does not +force-exit passing assertions or run a duplicate local CI pass. Antigravity size +fixtures correlate newly created log IDs, verify `prompt_chars` and assert that +raw `prompt` is absent. Unsupported-size warnings do not echo caller values. diff --git a/scripts/check/image-repair-ci.mjs b/scripts/check/image-repair-ci.mjs index 74e44288c905..d9288859ec07 100644 --- a/scripts/check/image-repair-ci.mjs +++ b/scripts/check/image-repair-ci.mjs @@ -197,6 +197,15 @@ async function focusedTests(roots) { const args = ["--import", "tsx/esm", "--import", "./tests/_setup/isolateDataDir.ts", "--import", "./open-sse/utils/setupPolyfill.ts", "--test", "--test-timeout=120000", "--test-concurrency=1", "--test-reporter=spec"]; + const lifecycleSuites = new Set([ + "10197-openrouter-image-edits-route.test.ts", "combo/image-combo.test.ts", + "combo/image-combo-empty-200-fallback.test.ts", "fal-image-edit.test.ts", + "fal-image-generation-default.test.ts", "image-combo-edits-fallback-12547.test.ts", + "image-edits-multipart-3273.test.ts", "image-generation-fetch-timeout.test.ts", + "image-generation-handler.test.ts", "image-generation-route-auth.test.ts", + "image-generation-route.test.ts", "image-generation-size-and-payload-guard.test.ts", + "image-routes-combo-edits-3214-3215.test.ts", + ].map((file) => `tests/unit/${file}`)); // A preload also runs in the native parent. One file per invocation prevents its // DATA_DIR from being inherited by multiple workers; run at most two invocations. const workspace = path.join(root, ".ci-work"); @@ -206,10 +215,12 @@ async function focusedTests(roots) { for (let index = 0; index < files.length; index += 2) { const remaining = Math.ceil((deadline - Date.now()) / 1000); if (remaining <= 0) throw new Error("Focused image bucket exceeded wall timeout of 240s"); - const results = await Promise.allSettled(files.slice(index, index + 2).map((file) => - run(file, ...offline("/usr/bin/env", ["-u", "DATA_DIR", process.execPath, ...args, file]), - remaining, 60) - )); + const results = await Promise.allSettled(files.slice(index, index + 2).map((file) => { + const lifecycle = lifecycleSuites.has(file) + ? ["--import", "./tests/_setup/imageCallLogLifecycle.ts"] : []; + return run(file, ...offline("/usr/bin/env", ["-u", "DATA_DIR", process.execPath, + ...args, ...lifecycle, file]), remaining, 60); + })); const failed = results.filter((result) => result.status === "rejected"); if (failed.length) throw new AggregateError(failed.map((result) => result.reason), failed.map((result) => result.reason.message).join("; ")); diff --git a/tests/_setup/imageCallLogLifecycle.ts b/tests/_setup/imageCallLogLifecycle.ts new file mode 100644 index 000000000000..5671859fc3b4 --- /dev/null +++ b/tests/_setup/imageCallLogLifecycle.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// Preloads also run in the native runner parent. Only the per-file worker owns saves. +if (process.argv[1]?.endsWith(".test.ts")) { + const callLogs = () => import("../../src/lib/usage/callLogs.ts"); + + // Import lazily: each fixture selects DATA_DIR before importing application modules. + test.afterEach(async () => { + assert.ok(await (await callLogs()).waitForCallLogSaves(5_000), "Image fixture call-log saves did not settle"); + }, { timeout: 6_000 }); + + // Registered before file-level hooks so workers close before fixtures reset SQLite. + test.after(async () => { + await (await callLogs()).closeCallLogSaves(5_000); + }, { timeout: 6_000 }); +} diff --git a/tests/unit/image-generation-size-and-payload-guard.test.ts b/tests/unit/image-generation-size-and-payload-guard.test.ts index 60955e85b064..7322dfe9f86c 100644 --- a/tests/unit/image-generation-size-and-payload-guard.test.ts +++ b/tests/unit/image-generation-size-and-payload-guard.test.ts @@ -146,6 +146,8 @@ async function captureAntigravityImageRequest(extraBody: Record ); }; try { + assert.ok(await waitForCallLogSaves(5_000), "previous call-log save did not settle"); + const existingIds = new Set((await getCallLogs({ provider: "antigravity", limit: 50 })).map((row) => row.id)); const result = await handleImageGeneration({ body: { model: ANTIGRAVITY_IMAGE_MODEL, prompt, aspect_ratio: "3:4", ...extraBody }, credentials: { accessToken: "ag-token", projectId: "project-123" }, @@ -155,25 +157,15 @@ async function captureAntigravityImageRequest(extraBody: Record assert.equal(result.data.data.length, 1); assert.equal(result.data.data[0].b64_json, "ZmFrZQ=="); - assert.ok(await waitForCallLogSaves(60_000), "call-log save did not settle"); - // The persisted `model` carries the resolved alias (`…-flash-image`), so match on provider - // + the unique prompt rather than on the requested model string. `getCallLogs` only ever - // returns the list-level SUMMARY row — `requestSummary`, populated exclusively for - // `requestType: "search"` rows (see `buildRequestSummary`) — never the full `requestBody` - // this handler persists via `saveCallLog`. That body lives solely in the per-row artifact - // file, reachable only through `getCallLogById`, so each candidate row must be re-read - // through it before its `requestBody.prompt` can be compared. + assert.ok(await waitForCallLogSaves(5_000), "call-log save did not settle"); const rows = await getCallLogs({ provider: "antigravity", limit: 50 }); - let row: Awaited> | null = null; - for (const candidate of rows) { - const detail = await getCallLogById(candidate.id); - const candidateBody = detail?.requestBody as Record | null | undefined; - if (candidateBody && candidateBody.prompt === prompt) { - row = detail; - break; - } - } - assert.ok(row, `no call log persisted for prompt ${prompt}`); + const added = rows.filter((candidate) => !existingIds.has(candidate.id)); + assert.equal(added.length, 1, "expected exactly one new Antigravity call log"); + const row = await getCallLogById(added[0].id); + assert.ok(row, "new Antigravity call log has no detail"); + const requestBody = row.requestBody as Record; + assert.equal(requestBody.prompt_chars, prompt.length); + assert.equal(Object.hasOwn(requestBody, "prompt"), false); return { imageConfig: captured.request.generationConfig.imageConfig, requestBody: row.requestBody as Record, @@ -216,9 +208,9 @@ test("handleImageGeneration clamps an unrecognised Antigravity image_size string image_size: "1024x1024", }); assert.deepEqual(imageConfig, { aspectRatio: "3:4", imageSize: "1K" }); - // LEDGER-48: the downgrade is greppable — exactly one warn line naming the raw value. + // The downgrade is observable without echoing arbitrary caller values in warnings. assert.equal(warnings.length, 1, `expected one warn line, got ${JSON.stringify(warnings)}`); - assert.match(warnings[0], /unsupported image_size "1024x1024"/); + assert.match(warnings[0], /unsupported image_size/); assert.match(warnings[0], /clamped to 1K/); // LEDGER-54/59/60: the call log keeps the caller's raw value next to what went upstream. assert.equal(requestBody.image_size, "1024x1024"); diff --git a/tests/unit/native-image-log-lifecycle.test.ts b/tests/unit/native-image-log-lifecycle.test.ts new file mode 100644 index 000000000000..587d0eedc458 --- /dev/null +++ b/tests/unit/native-image-log-lifecycle.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +await import("../_setup/imageCallLogLifecycle.ts"); +const core = await import("../../src/lib/db/core.ts"); +const { saveCallLog, getCallLogById } = await import("../../src/lib/usage/callLogs.ts"); + +let previousId: string | undefined; + +test.beforeEach(async () => { + if (previousId) { + const row = await getCallLogById(previousId); + assert.ok(row, "The previous save must commit before a fixture resets SQLite"); + assert.deepEqual(row.requestBody, { prompt_chars: 4 }); + } + core.resetDbInstance(); +}); + +test.after(async () => { + const row = await getCallLogById(previousId!); + assert.ok(row, "The final save must commit before final database cleanup"); + core.resetDbInstance(); +}); + +for (const id of ["image-lifecycle-first", "image-lifecycle-second"]) { + test(`image fixture drains fire-and-forget save ${id}`, { timeout: 5_000 }, () => { + previousId = id; + void saveCallLog({ + id, timestamp: "2026-10-04T00:00:00.000Z", status: 200, + provider: "fixture", model: "fixture-image", requestType: "image", + requestBody: { prompt_chars: 4 }, + }); + }); +} From ac263376e9f89757b343e930dfacfbe654374b54 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 14:49:11 +0330 Subject: [PATCH 08/39] test: narrow image fixture results --- ...image-generation-size-and-payload-guard.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/unit/image-generation-size-and-payload-guard.test.ts b/tests/unit/image-generation-size-and-payload-guard.test.ts index 7322dfe9f86c..becf302348e5 100644 --- a/tests/unit/image-generation-size-and-payload-guard.test.ts +++ b/tests/unit/image-generation-size-and-payload-guard.test.ts @@ -43,6 +43,7 @@ test("handleImageGeneration treats a 200 whose only item has a blank url as a re try { const result = await handleImageGeneration(usableImageRequest); assert.equal(result.success, false); + assert.ok("status" in result && "error" in result); assert.equal(result.status, 502); assert.match(String(result.error), /without an image payload/); } finally { @@ -56,6 +57,7 @@ test("handleImageGeneration treats a 200 whose only item is not an object as a r try { const result = await handleImageGeneration(usableImageRequest); assert.equal(result.success, false); + assert.ok("status" in result && "error" in result); assert.equal(result.status, 502); // LEDGER-66 (same run): pin the message so this case proves `isJsonObject(item)` rejected // the item rather than any 502 the outer catch would also produce. @@ -74,7 +76,8 @@ test("handleImageGeneration keeps a well-formed 200 image payload as success", a try { const result = await handleImageGeneration(usableImageRequest); assert.equal(result.success, true); - assert.equal(result.status, undefined); + assert.ok("data" in result); + assert.equal("status" in result ? result.status : undefined, undefined); assert.deepEqual(result.data, { created: 123, data: [{ url: "" }, { b64_json: "ZmFrZQ==" }] }); } finally { globalThis.fetch = originalFetch; @@ -132,7 +135,7 @@ async function captureAntigravityImageRequest(extraBody: Record }, }; let captured; - globalThis.fetch = async (_url, options = {}) => { + globalThis.fetch = async (_url, options: RequestInit = {}) => { captured = JSON.parse(String(options.body || "{}")); // LEDGER-47: a REAL success payload, so these cases fail when the request/response wiring // breaks rather than only when the envelope changes. @@ -154,8 +157,11 @@ async function captureAntigravityImageRequest(extraBody: Record log, }); assert.equal(result.success, true); - assert.equal(result.data.data.length, 1); - assert.equal(result.data.data[0].b64_json, "ZmFrZQ=="); + assert.ok("data" in result); + const images = result.data.data; + assert.ok(Array.isArray(images)); + assert.equal(images.length, 1); + assert.equal(images[0].b64_json, "ZmFrZQ=="); assert.ok(await waitForCallLogSaves(5_000), "call-log save did not settle"); const rows = await getCallLogs({ provider: "antigravity", limit: 50 }); From 104983ffb082363063dccec37f234711608582e4 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 15:04:40 +0330 Subject: [PATCH 09/39] test: repair offline image route fixtures --- scripts/check/image-repair-ci.mjs | 3 +- tests/unit/image-generation-proxy.test.ts | 380 ++++++++++++++++++++++ tests/unit/image-generation-route.test.ts | 316 +----------------- 3 files changed, 393 insertions(+), 306 deletions(-) create mode 100644 tests/unit/image-generation-proxy.test.ts diff --git a/scripts/check/image-repair-ci.mjs b/scripts/check/image-repair-ci.mjs index d9288859ec07..2625deaafa1b 100644 --- a/scripts/check/image-repair-ci.mjs +++ b/scripts/check/image-repair-ci.mjs @@ -203,7 +203,8 @@ async function focusedTests(roots) { "fal-image-generation-default.test.ts", "image-combo-edits-fallback-12547.test.ts", "image-edits-multipart-3273.test.ts", "image-generation-fetch-timeout.test.ts", "image-generation-handler.test.ts", "image-generation-route-auth.test.ts", - "image-generation-route.test.ts", "image-generation-size-and-payload-guard.test.ts", + "image-generation-route.test.ts", "image-generation-proxy.test.ts", + "image-generation-size-and-payload-guard.test.ts", "image-routes-combo-edits-3214-3215.test.ts", ].map((file) => `tests/unit/${file}`)); // A preload also runs in the native parent. One file per invocation prevents its diff --git a/tests/unit/image-generation-proxy.test.ts b/tests/unit/image-generation-proxy.test.ts new file mode 100644 index 000000000000..419b7bbd7167 --- /dev/null +++ b/tests/unit/image-generation-proxy.test.ts @@ -0,0 +1,380 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-image-generation-proxy-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "image-route-test-api-key-secret"; + +await import("../_setup/imageCallLogLifecycle.ts"); + +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 providerImageRoute = + await import("../../src/app/api/v1/providers/[provider]/images/generations/route.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +const { setPinnedFetchTestOverride } = await import("../../src/shared/network/remoteImageFetch.ts"); + +const originalFetch = globalThis.fetch; + +interface ImageResponseBody { + data: Array<{ b64_json?: string; url?: string }>; +} + +interface ErrorResponseBody { + error: { message: string; code?: string }; +} + +async function resetStorage() { + globalThis.fetch = originalFetch; + setPinnedFetchTestOverride(undefined); + apiKeysDb.resetApiKeyState(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + // #6303 moved this route onto the shared unified catalog (getUnifiedModelsResponse), + // which #6408 wrapped in a 1.5s TTL response cache keyed only by (prefix, isCodex + // client, apiKey) — NOT by DB state. Without clearing it between test cases, a test + // running within the TTL window of a previous one gets served the previous test's + // stale serialized catalog instead of a fresh build reflecting this test's DB state. + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); +} + +async function seedConnection( + provider: string, + overrides: { + authType?: string; + apiKey?: string | null; + accessToken?: string; + refreshToken?: string; + expiresAt?: string; + projectId?: string; + priority?: number; + providerSpecificData?: Record; + } = {} +) { + const authType = overrides.authType ?? "apikey"; + return providersDb.createProviderConnection({ + provider, + authType, + name: `${provider}-${Math.random().toString(16).slice(2, 8)}`, + ...(authType === "apikey" ? { apiKey: overrides.apiKey ?? "test-key" } : {}), + ...(overrides.accessToken ? { accessToken: overrides.accessToken } : {}), + ...(overrides.refreshToken ? { refreshToken: overrides.refreshToken } : {}), + ...(overrides.expiresAt ? { expiresAt: overrides.expiresAt } : {}), + ...(overrides.projectId ? { projectId: overrides.projectId } : {}), + ...(overrides.priority ? { priority: overrides.priority } : {}), + isActive: true, + testStatus: "active", + providerSpecificData: overrides.providerSpecificData ?? {}, + }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + globalThis.fetch = originalFetch; + setPinnedFetchTestOverride(undefined); + apiKeysDb.resetApiKeyState(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +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", String(connection.id), { + type: "http", + host: "127.0.0.1", + port: 1, // intentionally unreachable — proves proxy path was taken + }); + + // #9100 non-blocking probe: keep the request in flight so the fast-fail can + // abort it with the proxy-specific 503 (see the edit-route case above). + globalThis.fetch = async (_url, options: RequestInit = {}) => new Promise((_resolve, reject) => { + const abort = () => reject(new DOMException("Fixture request aborted", "AbortError")); + if (options.signal?.aborted) abort(); + else options.signal?.addEventListener("abort", abort, { once: true }); + }); + + 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 ErrorResponseBody; + assert.match(body.error.message, /unreachable/i); +}); + +test("v1 image generation POST executes directly when proxy resolution fails gracefully", async () => { + 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 ImageResponseBody; + 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 ImageResponseBody; + assert.equal(response.status, 200); + assert.ok(body.data, "should have image data"); +}); + +test("v1 image generation POST rotates to the next account after an upstream 401", async () => { + await seedConnection("openai", { apiKey: "expired-image-key", priority: 1 }); + await seedConnection("openai", { apiKey: "healthy-image-key", priority: 2 }); + const authorizationHeaders: string[] = []; + + globalThis.fetch = async (url, options: RequestInit = {}) => { + assert.equal(String(url), "https://api.openai.com/v1/images/generations"); + const authorization = new Headers(options.headers).get("authorization") ?? ""; + authorizationHeaders.push(authorization); + if (authorization === "Bearer expired-image-key") { + return new Response(JSON.stringify({ error: { message: "expired access token" } }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + assert.equal(authorization, "Bearer healthy-image-key"); + return new Response( + JSON.stringify({ created: 123, data: [{ url: "https://cdn.example.com/rotated.png" }] }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + 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: "rotate image account" }), + }) + ); + const body = (await response.json()) as ImageResponseBody; + + assert.equal(response.status, 200); + assert.equal(body.data[0].url, "https://cdn.example.com/rotated.png"); + assert.deepEqual(authorizationHeaders, ["Bearer expired-image-key", "Bearer healthy-image-key"]); +}); + +test("provider-scoped image generation POST uses the shared 401 account fallback", async () => { + await seedConnection("openai", { apiKey: "provider-expired-key", priority: 1 }); + await seedConnection("openai", { apiKey: "provider-healthy-key", priority: 2 }); + const authorizationHeaders: string[] = []; + + globalThis.fetch = async (_url, options: RequestInit = {}) => { + const authorization = new Headers(options.headers).get("authorization") ?? ""; + authorizationHeaders.push(authorization); + if (authorization === "Bearer provider-expired-key") { + return new Response(JSON.stringify({ error: { message: "expired access token" } }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + return new Response( + JSON.stringify({ created: 123, data: [{ url: "https://cdn.example.com/provider.png" }] }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + const response = await providerImageRoute.POST( + new Request("http://localhost/api/v1/providers/openai/images/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "gpt-image-2", prompt: "provider route rotation" }), + }), + { params: Promise.resolve({ provider: "openai" }) } + ); + + assert.equal(response.status, 200); + assert.deepEqual(authorizationHeaders, [ + "Bearer provider-expired-key", + "Bearer provider-healthy-key", + ]); +}); + +test("v1 image generation POST normalizes a terminal upstream 401 to the OpenAI-standard error shape", async () => { + await seedConnection("openai", { apiKey: "single-expired-image-key" }); + + globalThis.fetch = async (url, options: RequestInit = {}) => { + assert.equal(String(url), "https://api.openai.com/v1/images/generations"); + const authorization = new Headers(options.headers).get("authorization") ?? ""; + assert.equal(authorization, "Bearer single-expired-image-key"); + return new Response(JSON.stringify({ error: { message: "expired access token" } }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + }; + + 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: "normalize terminal 401" }), + }) + ); + const body = (await response.json()) as ErrorResponseBody; + + assert.equal(response.status, 401); + assert.deepEqual(body.error, { + message: "expired access token", + type: "authentication_error", + code: "invalid_api_key", + }); +}); + +test("provider-scoped image generation POST normalizes a terminal upstream 401 to the OpenAI-standard error shape", async () => { + await seedConnection("openai", { apiKey: "provider-single-expired-key" }); + + globalThis.fetch = async (url, options: RequestInit = {}) => { + assert.equal(String(url), "https://api.openai.com/v1/images/generations"); + const authorization = new Headers(options.headers).get("authorization") ?? ""; + assert.equal(authorization, "Bearer provider-single-expired-key"); + return new Response(JSON.stringify({ error: { message: "expired provider token" } }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + }; + + const response = await providerImageRoute.POST( + new Request("http://localhost/api/v1/providers/openai/images/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "gpt-image-2", prompt: "normalize provider terminal 401" }), + }), + { params: Promise.resolve({ provider: "openai" }) } + ); + const body = (await response.json()) as ErrorResponseBody; + + assert.equal(response.status, 401); + assert.deepEqual(body.error, { + message: "expired provider token", + type: "authentication_error", + code: "invalid_api_key", + }); +}); + +test("v1 image generation POST refreshes an expired Antigravity token before dispatch", async () => { + await seedConnection("antigravity", { + authType: "oauth", + accessToken: "expired-antigravity-token", + refreshToken: "valid-antigravity-refresh-token", + expiresAt: new Date(Date.now() - 60_000).toISOString(), + projectId: "test-cloud-code-project", + }); + const calls: Array<{ url: string; authorization: string }> = []; + + globalThis.fetch = async (url, options: RequestInit = {}) => { + const stringUrl = String(url); + const authorization = new Headers(options.headers).get("authorization") ?? ""; + calls.push({ url: stringUrl, authorization }); + + if (stringUrl.includes("oauth2.googleapis.com/token")) { + return new Response( + JSON.stringify({ + access_token: "fresh-antigravity-token", + expires_in: 3600, + token_type: "Bearer", + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + assert.equal(stringUrl, "https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent"); + assert.equal(authorization, "Bearer fresh-antigravity-token"); + return new Response( + JSON.stringify({ + response: { + candidates: [ + { + content: { + parts: [{ inlineData: { mimeType: "image/jpeg", data: "ZnJlc2gtaW1hZ2U=" } }], + }, + }, + ], + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + const response = await imageRoute.POST( + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "antigravity/gemini-3.1-flash-image", + prompt: "refresh before image generation", + }), + }) + ); + const body = (await response.json()) as ImageResponseBody; + + assert.equal(response.status, 200); + assert.equal(body.data[0].b64_json, "ZnJlc2gtaW1hZ2U="); + assert.equal(calls.filter((call) => call.url.includes("oauth2.googleapis.com/token")).length, 1); +}); diff --git a/tests/unit/image-generation-route.test.ts b/tests/unit/image-generation-route.test.ts index f3c27f6d3905..39c7f7629120 100644 --- a/tests/unit/image-generation-route.test.ts +++ b/tests/unit/image-generation-route.test.ts @@ -8,6 +8,8 @@ const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-image-rou process.env.DATA_DIR = TEST_DATA_DIR; process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "image-route-test-api-key-secret"; +await import("../_setup/imageCallLogLifecycle.ts"); + 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"); @@ -227,7 +229,8 @@ test("v1 image models GET exposes current Codex image models and hides inactive assert.equal(response.status, 200); assert.deepEqual( ids.filter((id) => id.startsWith("codex/")), - ["codex/gpt-5.6-sol-image", "codex/gpt-5.6-terra-image", "codex/gpt-5.6-luna-image"] + ["codex/gpt-5.6-sol-image", "codex/gpt-5.6-terra-image", "codex/gpt-5.6-luna-image", + "codex/gpt-6.1-sol-image", "codex/gpt-image-2", "codex/gpt-image-2.5-sunburst"] ); assert.ok(!ids.includes("codex/gpt-5.5")); assert.ok(!ids.includes("openai/gpt-image-2")); @@ -239,13 +242,6 @@ test("v1 image generation POST accepts promptless requests for image-only models const mockFetchImpl = async (url, options: RequestInit = {}) => { const stringUrl = String(url); - if (stringUrl === "https://example.com/topaz-input.png") { - return new Response(new Uint8Array([1, 2, 3]), { - status: 200, - headers: { "content-type": "image/png" }, - }); - } - if (stringUrl === "https://api.topazlabs.com/image/v1/enhance") { const formData = options.body as FormData; assert.ok(formData.get("image") instanceof File); @@ -269,7 +265,7 @@ test("v1 image generation POST accepts promptless requests for image-only models headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: "topaz/topaz-enhance", - image_url: "https://example.com/topaz-input.png", + image_url: "data:image/png;base64,AQID", size: "2048x2048", response_format: "b64_json", }), @@ -647,11 +643,12 @@ test("v1 image edit POST executes Codex through the configured connection proxy" // probe aborts the request only while it is still in flight (t14 pattern). The // mock must stay pending: an instantly-throwing fetch would settle the race // first and surface as a generic 502 upstream error instead of the proxy 503. - // Never resolved on purpose so the aborted continuation cannot proceed. - globalThis.fetch = async () => { - await new Promise(() => {}); - throw new Error("unreachable"); - }; + // A real fetch settles on cancellation so its owned timeout can be cleared. + globalThis.fetch = async (_url, options: RequestInit = {}) => new Promise((_resolve, reject) => { + const abort = () => reject(new DOMException("Fixture request aborted", "AbortError")); + if (options.signal?.aborted) abort(); + else options.signal?.addEventListener("abort", abort, { once: true }); + }); const response = await imageEditRoute.POST( new Request("http://localhost/api/v1/images/edits", { @@ -664,294 +661,3 @@ test("v1 image edit POST executes Codex through the configured connection proxy" assert.equal(response.status, 503); assert.match(body.error.message, /proxy/i); }); - -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", String(connection.id), { - type: "http", - host: "127.0.0.1", - port: 1, // intentionally unreachable — proves proxy path was taken - }); - - // #9100 non-blocking probe: keep the request in flight so the fast-fail can - // abort it with the proxy-specific 503 (see the edit-route case above). - globalThis.fetch = async () => { - await new Promise(() => {}); - throw new Error("unreachable"); - }; - - 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 ErrorResponseBody; - 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 ImageResponseBody; - 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 ImageResponseBody; - assert.equal(response.status, 200); - assert.ok(body.data, "should have image data"); -}); - -test("v1 image generation POST rotates to the next account after an upstream 401", async () => { - await seedConnection("openai", { apiKey: "expired-image-key", priority: 1 }); - await seedConnection("openai", { apiKey: "healthy-image-key", priority: 2 }); - const authorizationHeaders: string[] = []; - - globalThis.fetch = async (url, options: RequestInit = {}) => { - assert.equal(String(url), "https://api.openai.com/v1/images/generations"); - const authorization = new Headers(options.headers).get("authorization") ?? ""; - authorizationHeaders.push(authorization); - if (authorization === "Bearer expired-image-key") { - return new Response(JSON.stringify({ error: { message: "expired access token" } }), { - status: 401, - headers: { "content-type": "application/json" }, - }); - } - assert.equal(authorization, "Bearer healthy-image-key"); - return new Response( - JSON.stringify({ created: 123, data: [{ url: "https://cdn.example.com/rotated.png" }] }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }; - - 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: "rotate image account" }), - }) - ); - const body = (await response.json()) as ImageResponseBody; - - assert.equal(response.status, 200); - assert.equal(body.data[0].url, "https://cdn.example.com/rotated.png"); - assert.deepEqual(authorizationHeaders, ["Bearer expired-image-key", "Bearer healthy-image-key"]); -}); - -test("provider-scoped image generation POST uses the shared 401 account fallback", async () => { - await seedConnection("openai", { apiKey: "provider-expired-key", priority: 1 }); - await seedConnection("openai", { apiKey: "provider-healthy-key", priority: 2 }); - const authorizationHeaders: string[] = []; - - globalThis.fetch = async (_url, options: RequestInit = {}) => { - const authorization = new Headers(options.headers).get("authorization") ?? ""; - authorizationHeaders.push(authorization); - if (authorization === "Bearer provider-expired-key") { - return new Response(JSON.stringify({ error: { message: "expired access token" } }), { - status: 401, - headers: { "content-type": "application/json" }, - }); - } - return new Response( - JSON.stringify({ created: 123, data: [{ url: "https://cdn.example.com/provider.png" }] }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }; - - const response = await providerImageRoute.POST( - new Request("http://localhost/api/v1/providers/openai/images/generations", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: "gpt-image-2", prompt: "provider route rotation" }), - }), - { params: Promise.resolve({ provider: "openai" }) } - ); - - assert.equal(response.status, 200); - assert.deepEqual(authorizationHeaders, [ - "Bearer provider-expired-key", - "Bearer provider-healthy-key", - ]); -}); - -test("v1 image generation POST normalizes a terminal upstream 401 to the OpenAI-standard error shape", async () => { - await seedConnection("openai", { apiKey: "single-expired-image-key" }); - - globalThis.fetch = async (url, options: RequestInit = {}) => { - assert.equal(String(url), "https://api.openai.com/v1/images/generations"); - const authorization = new Headers(options.headers).get("authorization") ?? ""; - assert.equal(authorization, "Bearer single-expired-image-key"); - return new Response(JSON.stringify({ error: { message: "expired access token" } }), { - status: 401, - headers: { "content-type": "application/json" }, - }); - }; - - 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: "normalize terminal 401" }), - }) - ); - const body = (await response.json()) as ErrorResponseBody; - - assert.equal(response.status, 401); - assert.deepEqual(body.error, { - message: "expired access token", - type: "authentication_error", - code: "invalid_api_key", - }); -}); - -test("provider-scoped image generation POST normalizes a terminal upstream 401 to the OpenAI-standard error shape", async () => { - await seedConnection("openai", { apiKey: "provider-single-expired-key" }); - - globalThis.fetch = async (url, options: RequestInit = {}) => { - assert.equal(String(url), "https://api.openai.com/v1/images/generations"); - const authorization = new Headers(options.headers).get("authorization") ?? ""; - assert.equal(authorization, "Bearer provider-single-expired-key"); - return new Response(JSON.stringify({ error: { message: "expired provider token" } }), { - status: 401, - headers: { "content-type": "application/json" }, - }); - }; - - const response = await providerImageRoute.POST( - new Request("http://localhost/api/v1/providers/openai/images/generations", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: "gpt-image-2", prompt: "normalize provider terminal 401" }), - }), - { params: Promise.resolve({ provider: "openai" }) } - ); - const body = (await response.json()) as ErrorResponseBody; - - assert.equal(response.status, 401); - assert.deepEqual(body.error, { - message: "expired provider token", - type: "authentication_error", - code: "invalid_api_key", - }); -}); - -test("v1 image generation POST refreshes an expired Antigravity token before dispatch", async () => { - await seedConnection("antigravity", { - authType: "oauth", - accessToken: "expired-antigravity-token", - refreshToken: "valid-antigravity-refresh-token", - expiresAt: new Date(Date.now() - 60_000).toISOString(), - projectId: "test-cloud-code-project", - }); - const calls: Array<{ url: string; authorization: string }> = []; - - globalThis.fetch = async (url, options: RequestInit = {}) => { - const stringUrl = String(url); - const authorization = new Headers(options.headers).get("authorization") ?? ""; - calls.push({ url: stringUrl, authorization }); - - if (stringUrl.includes("oauth2.googleapis.com/token")) { - return new Response( - JSON.stringify({ - access_token: "fresh-antigravity-token", - expires_in: 3600, - token_type: "Bearer", - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - - assert.equal(stringUrl, "https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent"); - assert.equal(authorization, "Bearer fresh-antigravity-token"); - return new Response( - JSON.stringify({ - response: { - candidates: [ - { - content: { - parts: [{ inlineData: { mimeType: "image/jpeg", data: "ZnJlc2gtaW1hZ2U=" } }], - }, - }, - ], - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }; - - const response = await imageRoute.POST( - new Request("http://localhost/api/v1/images/generations", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: "antigravity/gemini-3.1-flash-image", - prompt: "refresh before image generation", - }), - }) - ); - const body = (await response.json()) as ImageResponseBody; - - assert.equal(response.status, 200); - assert.equal(body.data[0].b64_json, "ZnJlc2gtaW1hZ2U="); - assert.equal(calls.filter((call) => call.url.includes("oauth2.googleapis.com/token")).length, 1); -}); From 0993ac86bed392cc35143f26dd74295415bfbe39 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 15:13:49 +0330 Subject: [PATCH 10/39] fix: complete image compiler contracts --- scripts/check/image-repair-ci.mjs | 5 ++++- .../[provider]/images/generations/route.ts | 19 +++++++++++-------- src/lib/proxySubscription/parse.ts | 15 ++++++++++++--- tests/unit/image-generation-route.test.ts | 2 +- 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/scripts/check/image-repair-ci.mjs b/scripts/check/image-repair-ci.mjs index 2625deaafa1b..9ed0c09eed25 100644 --- a/scripts/check/image-repair-ci.mjs +++ b/scripts/check/image-repair-ci.mjs @@ -150,7 +150,9 @@ async function typecheck() { const config = ts.readConfigFile(configPath, ts.sys.readFile); if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n")); const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root); - const program = ts.createProgram(files.map((file) => path.join(root, file)), { + const declarations = [...parsed.fileNames.filter((file) => file.endsWith(".d.ts")), + path.join(root, "open-sse/types.d.ts")]; + const program = ts.createProgram([...files.map((file) => path.join(root, file)), ...declarations], { ...parsed.options, noCheck: false, noEmit: true, incremental: false, }); const diagnostics = [...parsed.errors, ...ts.getPreEmitDiagnostics(program)]; @@ -170,6 +172,7 @@ const regressionFiles = [ "codex-free-plan-image-generation.test.ts", "codex-spark-image-generation.test.ts", "hard-session-lease-bypass-inventory.test.ts", + "proxySubscription.parse.test.ts", "image-normalize.test.ts", "t42-image-size-to-aspect-ratio.test.ts", "fal-image-edit.test.ts", diff --git a/src/app/api/v1/providers/[provider]/images/generations/route.ts b/src/app/api/v1/providers/[provider]/images/generations/route.ts index 112968e38cd1..a1a3dcadbd23 100644 --- a/src/app/api/v1/providers/[provider]/images/generations/route.ts +++ b/src/app/api/v1/providers/[provider]/images/generations/route.ts @@ -7,7 +7,8 @@ import { } from "@/sse/services/auth"; import { getImageProvider } from "@omniroute/open-sse/config/imageRegistry.ts"; import * as log from "@/sse/utils/logger"; -import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; +import { toJsonErrorPayload, extractErrorMessage } from "@/shared/utils/upstreamError"; +import { isUsableImageCredentials } from "@/sse/services/imageCredentials"; import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; import { v1ImageGenerationSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; @@ -88,15 +89,19 @@ export async function POST(request, { params }) { `No credentials for image provider: ${rawProvider}` ); } - if (credentials.allRateLimited) { + if ("allRateLimited" in credentials && credentials.allRateLimited) { return unavailableResponse( HTTP_STATUS.RATE_LIMITED, `[${rawProvider}] All accounts rate limited`, - credentials.retryAfter, - credentials.retryAfterHuman + "retryAfter" in credentials ? credentials.retryAfter : null, + "retryAfterHuman" in credentials ? credentials.retryAfterHuman : null ); } + if (!isUsableImageCredentials(credentials)) { + return errorResponse(401, "Image credential selection did not return usable credentials"); + } + const execution = await executeImageWithCredentialFallback({ provider: rawProvider, requestedModel, @@ -122,9 +127,7 @@ export async function POST(request, { params }) { } const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error"); - const message = - typeof errorPayload?.error?.message === "string" - ? errorPayload.error.message - : "Image generation provider error"; + const message = extractErrorMessage("error" in errorPayload ? errorPayload.error : errorPayload) + ?? "Image generation provider error"; return errorResponse((result as any).status, message); } diff --git a/src/lib/proxySubscription/parse.ts b/src/lib/proxySubscription/parse.ts index 4dac7cf1e62b..2b2d4b910230 100644 --- a/src/lib/proxySubscription/parse.ts +++ b/src/lib/proxySubscription/parse.ts @@ -225,12 +225,18 @@ function collectFromArray( for (const item of items) { if (typeof item === "string") { const n = nodeFromUri(item.trim()); - if (n) (isDirect(n) ? nodes : needsCore).push(n); + if (n) { + if (isDirect(n)) nodes.push(n); + else needsCore.push(n); + } continue; } if (item && typeof item === "object") { const n = nodeFromClashObject(item as Record); - if (n) (isDirect(n) ? nodes : needsCore).push(n); + if (n) { + if (isDirect(n)) nodes.push(n); + else needsCore.push(n); + } } } return { nodes, needsCore, format }; @@ -263,7 +269,10 @@ function parseLineList(lines: string[]): ParsedSubscription { const needsCore: NeedsCoreNode[] = []; for (const line of lines) { const n = nodeFromUri(line); - if (n) (isDirect(n) ? nodes : needsCore).push(n); + if (n) { + if (isDirect(n)) nodes.push(n); + else needsCore.push(n); + } } return { nodes, needsCore, format: "lines" }; } diff --git a/tests/unit/image-generation-route.test.ts b/tests/unit/image-generation-route.test.ts index 39c7f7629120..5c3908e4bfd1 100644 --- a/tests/unit/image-generation-route.test.ts +++ b/tests/unit/image-generation-route.test.ts @@ -66,7 +66,7 @@ function createCodexEditForm( formData.set("model", options.model ?? "codex/gpt-5.6-sol"); formData.set( "image", - new File([options.bytes ?? VALID_PNG_BYTES], "reference.png", { + new File([new Uint8Array(options.bytes ?? VALID_PNG_BYTES)], "reference.png", { type: options.mime ?? "image/png", }) ); From 982c4ee58a230e4d5ab8521dc59db0374c059ba7 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 15:19:27 +0330 Subject: [PATCH 11/39] fix: narrow proxy environment input --- src/sse/handlers/chatHelpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index 264a08fdc433..6b8964820d13 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -1006,7 +1006,7 @@ export function isEarlyEofSiblingFailoverOn(): boolean { export function decideProxyResolutionFailure( err: unknown, - env: { PROXY_FAIL_OPEN?: string } = process.env + env: { PROXY_FAIL_OPEN?: string } = { PROXY_FAIL_OPEN: process.env.PROXY_FAIL_OPEN } ): null { if ((env.PROXY_FAIL_OPEN ?? "").trim().toLowerCase() === "true") { log.warn( From 2e3277f35fdbd4906891e95636fd3001cb98d697 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 15:29:23 +0330 Subject: [PATCH 12/39] test: settle proxy fetch fixture --- tests/unit/image-generation-proxy.test.ts | 44 +++++++++++++---------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/tests/unit/image-generation-proxy.test.ts b/tests/unit/image-generation-proxy.test.ts index 419b7bbd7167..7a79cdc74a2f 100644 --- a/tests/unit/image-generation-proxy.test.ts +++ b/tests/unit/image-generation-proxy.test.ts @@ -87,7 +87,7 @@ test.after(() => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); -test("v1 image generation POST resolves proxy and executes with proxy context when credentials.connectionId exists", async () => { +test("v1 image generation POST resolves proxy and executes with proxy context when credentials.connectionId exists", { timeout: 5_000 }, async () => { // Create a connection — it gets an auto-generated id used as credentials.connectionId const connection = await seedConnection("openai", { apiKey: "image-proxy-key" }); @@ -100,26 +100,34 @@ test("v1 image generation POST resolves proxy and executes with proxy context wh // #9100 non-blocking probe: keep the request in flight so the fast-fail can // abort it with the proxy-specific 503 (see the edit-route case above). - globalThis.fetch = async (_url, options: RequestInit = {}) => new Promise((_resolve, reject) => { - const abort = () => reject(new DOMException("Fixture request aborted", "AbortError")); - if (options.signal?.aborted) abort(); - else options.signal?.addEventListener("abort", abort, { once: true }); + let releaseFetch: () => void; + const fetchSettled = new Promise((resolve) => { + globalThis.fetch = async () => { + await new Promise((release) => { releaseFetch = release; }); + resolve(); + throw new DOMException("Fixture request aborted", "AbortError"); + }; }); - 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", - }), - }) - ); + try { + 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 ErrorResponseBody; - assert.match(body.error.message, /unreachable/i); + assert.equal(response.status, 503); + const body = (await response.json()) as ErrorResponseBody; + assert.match(body.error.message, /unreachable/i); + } finally { + releaseFetch?.(); + await fetchSettled; + } }); test("v1 image generation POST executes directly when proxy resolution fails gracefully", async () => { From 70d844bf0a79c40c1bd9785c2f8b666b94e2df72 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 15:38:09 +0330 Subject: [PATCH 13/39] test: initialize isolated image key fixture --- tests/unit/native-codex-images-http.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/native-codex-images-http.test.ts b/tests/unit/native-codex-images-http.test.ts index 1d72cc8462e3..85d3ad3344e0 100644 --- a/tests/unit/native-codex-images-http.test.ts +++ b/tests/unit/native-codex-images-http.test.ts @@ -7,6 +7,8 @@ import test from "node:test"; for (const key of ["DATA_DIR", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA"]) { assert.ok(process.env[key]?.includes(`${path.sep}isolated-`), `${key} must be isolated`); } +process.env.API_KEY_SECRET = "native-codex-image-fixture-only"; +await import("../_setup/imageCallLogLifecycle.ts"); const originalFetch = globalThis.fetch; const core = await import("../../src/lib/db/core.ts"); const providers = await import("../../src/lib/db/providers.ts"); From ad1d8261ae65af4f10867d120ca7f14dd99fa785 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 15:50:57 +0330 Subject: [PATCH 14/39] fix: exclude verification state from artifacts --- next.config.mjs | 8 ++++++++ scripts/check/image-repair-ci.mjs | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/next.config.mjs b/next.config.mjs index 834c7e0783c8..3f9cb6b30194 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -301,6 +301,14 @@ const nextConfig = { // Using "**/*" ensures the exclusion applies across all app and API routes, not just "/". "**/*": [ "**/.git/**", + "**/.ci-work/**", + "**/.ai/**", + "**/.specify/**", + "**/specs/**", + "**/graphify-out/**", + "**/.codebase-memory/**", + "**/secrets.md", + "**/.env*", "**/.eslintcache", "**/_tasks/**", "**/_references/**", diff --git a/scripts/check/image-repair-ci.mjs b/scripts/check/image-repair-ci.mjs index 9ed0c09eed25..653cf965f6e4 100644 --- a/scripts/check/image-repair-ci.mjs +++ b/scripts/check/image-repair-ci.mjs @@ -208,7 +208,8 @@ async function focusedTests(roots) { "image-generation-handler.test.ts", "image-generation-route-auth.test.ts", "image-generation-route.test.ts", "image-generation-proxy.test.ts", "image-generation-size-and-payload-guard.test.ts", - "image-routes-combo-edits-3214-3215.test.ts", + "image-routes-combo-edits-3214-3215.test.ts", "image-upscale-error-log.test.ts", + "nanobanana-image-handler.test.ts", "t42-image-size-to-aspect-ratio.test.ts", ].map((file) => `tests/unit/${file}`)); // A preload also runs in the native parent. One file per invocation prevents its // DATA_DIR from being inherited by multiple workers; run at most two invocations. From 872edc390e5c49235c179dad5a93534480f02328 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 16:05:42 +0330 Subject: [PATCH 15/39] fix: sanitize portable build environment --- scripts/check/image-repair-ci.mjs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/check/image-repair-ci.mjs b/scripts/check/image-repair-ci.mjs index 653cf965f6e4..9357930af5ce 100644 --- a/scripts/check/image-repair-ci.mjs +++ b/scripts/check/image-repair-ci.mjs @@ -255,6 +255,17 @@ async function verifyBuild() { assert.ok((await fs.stat(path.join(path.dirname(manifestPath), manifest[route]))).size > 0); } } + // Next copies build-time dotenv files independently of NFT exclusions. They are + // generated CI configuration, never part of the portable artifact. + for (const name of await fs.readdir(standalone)) { + if (name.startsWith(".env") && name !== ".env.example") { + const file = path.join(standalone, name); + assert.ok((await fs.lstat(file)).isFile(), "Unexpected environment artifact type"); + await fs.readFile(file, "utf8"); + await fs.rm(file); + log("INFO", "excluded build-time environment file from portable artifact"); + } + } for (const entry of await fs.readdir(standalone, { recursive: true, withFileTypes: true })) { const name = entry.name; const relative = path.relative(standalone, path.join(entry.parentPath, name)); From 9bbf5d72d240fb6b11f4b7a2616b6888ca83c8b1 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 16:14:58 +0330 Subject: [PATCH 16/39] ci: bound cold backend compilation --- scripts/check/image-repair-ci.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/check/image-repair-ci.mjs b/scripts/check/image-repair-ci.mjs index 9357930af5ce..fb80f96c3943 100644 --- a/scripts/check/image-repair-ci.mjs +++ b/scripts/check/image-repair-ci.mjs @@ -301,7 +301,8 @@ async function main() { "config/quality/eslint-suppressions.json", "--pass-on-unpruned-suppressions", ...roots], 240, 120); await focusedTests(roots); await run("backend standalone build", ...offline(process.execPath, - ["scripts/build/build-next-isolated.mjs"]), 900, 180); + // Hosted cold builds measured260-394s and webpack can be quiet during compilation. + ["scripts/build/build-next-isolated.mjs"]), 900, 450); await verifyBuild(); } finally { await fs.rm(work, { recursive: true, force: true }); From d2c05e3b6ff55e2ea444e37ddf06922ad5487114 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 16:27:43 +0330 Subject: [PATCH 17/39] fix: strip development metadata from artifacts --- scripts/check/image-repair-ci.mjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/check/image-repair-ci.mjs b/scripts/check/image-repair-ci.mjs index fb80f96c3943..f8420043efde 100644 --- a/scripts/check/image-repair-ci.mjs +++ b/scripts/check/image-repair-ci.mjs @@ -266,6 +266,16 @@ async function verifyBuild() { log("INFO", "excluded build-time environment file from portable artifact"); } } + const dependencyRoot = path.join(standalone, "node_modules"); + for (const entry of await fs.readdir(dependencyRoot, { recursive: true, withFileTypes: true })) { + if (entry.isDirectory() && [".claude", ".codex", ".kiro", ".cursor", ".cline", ".agents", ".ai", "graphify-out", ".codebase-memory"].includes(entry.name)) { + const dir = path.join(entry.parentPath, entry.name); + // Dependency package copies can carry upstream development-only metadata. + // Inspect its manifest before dropping that non-runtime subtree. + await fs.readdir(dir); + await fs.rm(dir, { recursive: true }); + } + } for (const entry of await fs.readdir(standalone, { recursive: true, withFileTypes: true })) { const name = entry.name; const relative = path.relative(standalone, path.join(entry.parentPath, name)); From eb829dbbc6d85904fe8e612e4f31df8be3a8ce90 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 16:37:29 +0330 Subject: [PATCH 18/39] fix: exclude package command links from artifacts --- scripts/check/image-repair-ci.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check/image-repair-ci.mjs b/scripts/check/image-repair-ci.mjs index f8420043efde..93d873e8c571 100644 --- a/scripts/check/image-repair-ci.mjs +++ b/scripts/check/image-repair-ci.mjs @@ -268,7 +268,7 @@ async function verifyBuild() { } const dependencyRoot = path.join(standalone, "node_modules"); for (const entry of await fs.readdir(dependencyRoot, { recursive: true, withFileTypes: true })) { - if (entry.isDirectory() && [".claude", ".codex", ".kiro", ".cursor", ".cline", ".agents", ".ai", "graphify-out", ".codebase-memory"].includes(entry.name)) { + if (entry.isDirectory() && [".bin", ".claude", ".codex", ".kiro", ".cursor", ".cline", ".agents", ".ai", "graphify-out", ".codebase-memory"].includes(entry.name)) { const dir = path.join(entry.parentPath, entry.name); // Dependency package copies can carry upstream development-only metadata. // Inspect its manifest before dropping that non-runtime subtree. From 29ae0fdf64551cee658bcea609d8931e5da362af Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 16:51:55 +0330 Subject: [PATCH 19/39] fix: sanitize copied artifact documentation --- scripts/check/image-repair-ci.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/check/image-repair-ci.mjs b/scripts/check/image-repair-ci.mjs index 93d873e8c571..05c8e2094ebb 100644 --- a/scripts/check/image-repair-ci.mjs +++ b/scripts/check/image-repair-ci.mjs @@ -276,6 +276,13 @@ async function verifyBuild() { await fs.rm(dir, { recursive: true }); } } + for (const entry of await fs.readdir(standalone, { recursive: true, withFileTypes: true })) { + if (entry.isFile() && /^(?:AGENTS\.md|CLAUDE\.md|explain-AI\.md|reference\.md)$/i.test(entry.name)) { + const file = path.join(entry.parentPath, entry.name); + await fs.readFile(file, "utf8"); + await fs.rm(file); + } + } for (const entry of await fs.readdir(standalone, { recursive: true, withFileTypes: true })) { const name = entry.name; const relative = path.relative(standalone, path.join(entry.parentPath, name)); From cd4544eaf7c42a420f69363b5e977ae8c006c046 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 17:04:30 +0330 Subject: [PATCH 20/39] fix: distinguish route assets in artifact checks --- scripts/check/image-repair-ci.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/check/image-repair-ci.mjs b/scripts/check/image-repair-ci.mjs index 05c8e2094ebb..19bed0c14336 100644 --- a/scripts/check/image-repair-ci.mjs +++ b/scripts/check/image-repair-ci.mjs @@ -283,16 +283,24 @@ async function verifyBuild() { await fs.rm(file); } } + const violations = []; for (const entry of await fs.readdir(standalone, { recursive: true, withFileTypes: true })) { const name = entry.name; const relative = path.relative(standalone, path.join(entry.parentPath, name)); + try { assert.ok(!entry.isSymbolicLink(), `Artifact contains a symlink: ${relative}`); - assert.ok(!/(?:^|\/)(?:\.ai|\.specify|specs|\.claude|\.codex|\.kiro|\.cursor|\.cline|\.agents|\.codebase-memory|graphify-out|\.git|\.ci-work|logs|\.omniroute|_tasks)(?:\/|$)/.test(relative), + const runtimeRoute = /^(?:\.build\/next\/(?:static\/chunks\/app|server\/app)|src\/app)\/api\/logs(?:\/|$)/.test(relative); + assert.ok(!/(?:^|\/)(?:\.ai|\.specify|specs|\.claude|\.codex|\.kiro|\.cursor|\.cline|\.agents|\.codebase-memory|graphify-out|\.git|\.ci-work|\.omniroute|_tasks)(?:\/|$)/.test(relative) + && (runtimeRoute || !/(?:^|\/)logs(?:\/|$)/.test(relative)), `Artifact contains private state: ${relative}`); assert.ok(!/^(?:secrets\.md|explain-AI\.md|reference\.md|AGENTS\.md|CLAUDE\.md|\.ignoreme|runtime\.env|credentials(?:\.json)?|.*\.(?:sqlite(?:-wal|-shm)?|db(?:-wal|-shm)?|log|key))$/i.test(name), `Artifact contains excluded state: ${relative}`); assert.ok(!name.startsWith(".env") || name === ".env.example", `Artifact contains environment state: ${relative}`); + } catch (error) { + violations.push(error.message); + } } + assert.equal(violations.length, 0, violations.join("\n")); log("INFO", "verified standalone server, BUILD_ID, image route manifests and artifact exclusions"); } From 4e32042a974db90ea75c4339841e38c16cce2ff2 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 17:16:28 +0330 Subject: [PATCH 21/39] fix: verify artifact route provenance --- scripts/check/image-repair-ci.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/check/image-repair-ci.mjs b/scripts/check/image-repair-ci.mjs index 19bed0c14336..6e9bf94779b9 100644 --- a/scripts/check/image-repair-ci.mjs +++ b/scripts/check/image-repair-ci.mjs @@ -289,11 +289,16 @@ async function verifyBuild() { const relative = path.relative(standalone, path.join(entry.parentPath, name)); try { assert.ok(!entry.isSymbolicLink(), `Artifact contains a symlink: ${relative}`); - const runtimeRoute = /^(?:\.build\/next\/(?:static\/chunks\/app|server\/app)|src\/app)\/api\/logs(?:\/|$)/.test(relative); + const routePath = relative.replace(/^(?:\.build\/next\/(?:static\/chunks\/app|server\/app)|src\/app)\//, ""); + const logSegment = routePath.split("/").indexOf("logs"); + const runtimeRoute = routePath !== relative && logSegment >= 0 + && (await fs.stat(path.join(root, "src/app", ...routePath.split("/").slice(0, logSegment + 1))).catch(() => null))?.isDirectory(); assert.ok(!/(?:^|\/)(?:\.ai|\.specify|specs|\.claude|\.codex|\.kiro|\.cursor|\.cline|\.agents|\.codebase-memory|graphify-out|\.git|\.ci-work|\.omniroute|_tasks)(?:\/|$)/.test(relative) && (runtimeRoute || !/(?:^|\/)logs(?:\/|$)/.test(relative)), `Artifact contains private state: ${relative}`); - assert.ok(!/^(?:secrets\.md|explain-AI\.md|reference\.md|AGENTS\.md|CLAUDE\.md|\.ignoreme|runtime\.env|credentials(?:\.json)?|.*\.(?:sqlite(?:-wal|-shm)?|db(?:-wal|-shm)?|log|key))$/i.test(name), + const credentialRoute = name === "credentials" && entry.isDirectory() && routePath !== relative + && (await fs.stat(path.join(root, "src/app", routePath)).catch(() => null))?.isDirectory(); + assert.ok(credentialRoute || !/^(?:secrets\.md|explain-AI\.md|reference\.md|AGENTS\.md|CLAUDE\.md|\.ignoreme|runtime\.env|credentials(?:\.json)?|.*\.(?:sqlite(?:-wal|-shm)?|db(?:-wal|-shm)?|log|key))$/i.test(name), `Artifact contains excluded state: ${relative}`); assert.ok(!name.startsWith(".env") || name === ".env.example", `Artifact contains environment state: ${relative}`); } catch (error) { From 6bc02d52757e6ea6e1d306212afd2ff09a07ba73 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 17:32:05 +0330 Subject: [PATCH 22/39] chore: update ignore rules [skip ci] --- .gitignore | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.gitignore b/.gitignore index c2bf75bd4686..d7032090fb4d 100644 --- a/.gitignore +++ b/.gitignore @@ -323,3 +323,15 @@ docker-compose.yml.bak # run-translation.mjs writes the state atomically via a per-process temp file /.i18n-state.json.*.tmp + +# Hook Maker: local/private files (auto-managed) +/.env +/.env.* +!/.env.example +!/.env.sample +!/.env.template +!/.env.dist +# Public templates intentionally shipped by the upstream repository +!/.env.devin-bridge.example +!/.env.homolog.example +!/.env.selfhost.example From 8ee972b3b16ad95f0a9f43f769ab7a6a13b465ca Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 17:59:13 +0330 Subject: [PATCH 23/39] ci: package complete Windows runtime --- .github/workflows/image-repair.yml | 22 + AGENTS.md | 783 ------------------------ CLAUDE.md | 70 --- docs/CODEX-IMAGES.md | 11 + scripts/check/image-artifact.mjs | 74 +++ scripts/check/image-repair-ci.mjs | 72 +-- scripts/check/image-runtime-package.mjs | 82 +++ 7 files changed, 191 insertions(+), 923 deletions(-) delete mode 100644 AGENTS.md delete mode 100644 CLAUDE.md create mode 100644 scripts/check/image-artifact.mjs create mode 100644 scripts/check/image-runtime-package.mjs diff --git a/.github/workflows/image-repair.yml b/.github/workflows/image-repair.yml index 1e84708a2abb..0b7c73e4ce8a 100644 --- a/.github/workflows/image-repair.yml +++ b/.github/workflows/image-repair.yml @@ -41,3 +41,25 @@ jobs: - name: Remove owned CI state if: always() run: rm -rf -- .ci-work/image-repair + + windows-runtime: + needs: image-repair + runs-on: windows-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24.21.0" + - name: Build full Windows runtime and verify portable assets + run: node scripts/check/image-runtime-package.mjs + - name: Upload verified full Windows runtime + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: native-image-windows-${{ github.sha }} + path: .build/next/standalone/ + include-hidden-files: true + if-no-files-found: error + retention-days: 3 diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index fd7e838472b6..000000000000 --- a/AGENTS.md +++ /dev/null @@ -1,783 +0,0 @@ -# OmniRoute agent guide - -> **Single source of truth.** This file holds ALL project rules, conventions, architecture notes -> and Hard Rules for every AI assistant working this repository (Claude Code, Gemini, Codex, -> Copilot, and any other agent). `CLAUDE.md` and `GEMINI.md` only add assistant-specific deltas -> and point back here. When a rule needs to change, change it HERE — never re-fork it into an -> assistant-specific file. - -## Quick Start - -```bash -npm install # Install deps (auto-generates .env from .env.example) -npm run dev # Dev server at http://localhost:20128 -npm run build # Production build (Next.js 16 standalone) -npm run build:release # Release build -npm run lint # ESLint (0 errors expected; warnings are pre-existing) -npm run typecheck:core # TypeScript check (should be clean) -npm run typecheck:noimplicit:core # Strict check (no implicit any) -npm run test:coverage # Unit tests + coverage gate (60/60/60/60 — statements/lines/functions/branches) -npm run check # lint + test combined -npm run check:cycles # Detect circular dependencies -npm run check:docs-all # Run after changing documentation (includes fabricated-docs validation) -``` - -### Running Tests - -Run the most focused test for changed code first: - -```bash -# Single test file (Node.js native test runner — most tests) -node --import tsx/esm --test tests/unit/your-file.test.ts - -# Vitest (MCP server, autoCombo, cache) -npm run test:vitest - -# All suites -npm run test:all -``` - -Other suites: `npm run test:e2e`, `npm run test:protocols:e2e`, `npm run test:ecosystem`. - -For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep architecture, see the -Repository map and Reference Documentation sections below. - ---- - -## Project at a Glance - -**OmniRoute** — unified AI proxy/router. One endpoint, 358 LLM providers, auto-fallback. - -| Layer | Location | Purpose | -| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| API Routes | `src/app/api/v1/` | Next.js App Router — entry points | -| Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) | -| Executors | `open-sse/executors/` | Provider-specific HTTP dispatch | -| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) | -| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions | -| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc | -| Database | `src/lib/db/` | SQLite domain modules (193 migrations) | -| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic | -| MCP Server | `open-sse/mcp-server/` | 110 tools (45 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes | -| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol | -| Skills | `src/lib/skills/` | Extensible skill framework | -| Memory | `src/lib/memory/` | Persistent conversational memory | - -Monorepo: `src/` (Next.js 16 app), `open-sse/` (streaming engine workspace), `electron/` (desktop app), `tests/`, `bin/` (CLI entry point). - ---- - -## Request Pipeline - -``` -Client → /v1/chat/completions (Next.js route) - → CORS → Zod validation → auth? → policy check → prompt injection guard - → handleChatCore() [open-sse/handlers/chatCore.ts] - → cache check → rate limit → combo routing? - → resolveComboTargets() → handleSingleModel() per target - → translateRequest() → getExecutor() → executor.execute() - → fetch() upstream → retry w/ backoff - → response translation → SSE stream or JSON - → If Responses API: responsesTransformer.ts TransformStream -``` - -API routes follow a consistent pattern: `Route → CORS preflight → Zod body validation → Optional auth (extractApiKey/isValidApiKey) → API key policy enforcement → Handler delegation (open-sse)`. No global Next.js middleware — interception is route-specific. - -**Combo routing** (`open-sse/services/combo.ts`): 19 public strategies (priority, weighted, fill-first, round-robin, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. The `fusion` strategy is the exception: it fans out to a panel of models in parallel, then a judge model synthesizes one final answer (`open-sse/services/fusion.ts`). See `docs/routing/AUTO-COMBO.md` for the 16-factor Auto-Combo scoring + the full strategy table and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers. - ---- - -## Resilience Runtime State - -OmniRoute has three related but distinct temporary-failure mechanisms. Keep their -scope separate when debugging routing behavior. See the -[3-layer resilience diagram](./docs/diagrams/exported/resilience-3layers.svg) -(source: [docs/diagrams/resilience-3layers.mmd](./docs/diagrams/resilience-3layers.mmd)) -for an at-a-glance map. - -### Provider Circuit Breaker - -**Scope**: whole provider, e.g. `glm`, `openai`, `anthropic`. - -**Purpose**: stop sending traffic to a provider that is repeatedly failing at the -upstream/service level, so one unhealthy provider does not slow down every request. - -**Implementation**: - -- Core class: `src/shared/utils/circuitBreaker.ts` -- Chat gate/execution wiring: `src/sse/handlers/chatHelpers.ts`, `src/sse/handlers/chat.ts` -- Runtime status API: `src/app/api/monitoring/health/route.ts` -- Shared wrappers: `open-sse/services/accountFallback.ts` -- Persisted state table: `domain_circuit_breakers` - -**States** (4 — `src/shared/utils/circuitBreaker.ts`): - -- `CLOSED`: normal traffic is allowed. -- `DEGRADED`: early-warning band — failures crossed the degradation threshold but not the - breaker threshold yet; traffic still flows, dashboards show the warning. -- `OPEN`: provider is temporarily blocked; callers get a provider-circuit-open response - or combo routing skips to another target. -- `HALF_OPEN`: reset timeout has elapsed; allow a probe request. Success closes the - breaker, failure opens it again. - -**Defaults** (`open-sse/config/constants.ts` → `PROVIDER_PROFILES`, consumed via -`DEFAULT_RESILIENCE_SETTINGS.providerBreaker` in `src/lib/resilience/settings.ts` → -`getCircuitBreaker(provider, …)` in `src/sse/handlers/chatHelpers.ts`). The whole-provider -breaker runs on `circuitBreakerThreshold` / `circuitBreakerReset`: - -| Profile | degrades at | opens at (`circuitBreakerThreshold`) | reset (`circuitBreakerReset`) | -| ------- | ----------: | -----------------------------------: | ----------------------------: | -| OAuth | `5` | `8` | `60s` | -| API key | `7` | `12` | `30s` | -| Local | (derived) | `2` | `15s` | - -`PROVIDER_PROFILES` also defines `providerFailureThreshold` (10/15/2), -`providerFailureWindowMs` (15/30/5 min) and `providerCooldownMs` (5/10/1 min): these power the -**window gate of the opt-in global Provider Cooldown** (`PROVIDER_COOLDOWN_ENABLED`, default -off) — a provider-level entry in `open-sse/services/providerCooldownTracker.ts` only counts as -cooling after `providerFailureThreshold` failures inside `providerFailureWindowMs`, and then -cools for `providerCooldownMs`. They are NOT the live breaker's thresholds — do not tune them -expecting breaker behavior. Every default is overridable through the -`OMNIROUTE_PROVIDER_BREAKER_*` and `OMNIROUTE_CIRCUIT_BREAKER_*` env vars; the -runtime-accurate reference table lives in `docs/architecture/RESILIENCE_GUIDE.md`. - -Only provider-level failure statuses should trip the provider breaker: - -```ts -(408, 500, 502, 503, 504); -``` - -Do not trip the whole-provider breaker for normal account/key/model errors like most -`401`, `403`, or `429` cases. Those usually belong to connection cooldown or model -lockout. A generic API-key provider `403` should be recoverable unless it is classified -as a terminal provider/account error. - -The breaker uses lazy recovery, not a background timer. When `OPEN` expires, reads such -as `getStatus()`, `canExecute()`, and `getRetryAfterMs()` refresh the state to -`HALF_OPEN`, so dashboards and combo candidate builders do not keep excluding an -expired provider forever. - -### Connection Cooldown - -**Scope**: one provider connection/account/key. - -**Purpose**: temporarily skip one bad key/account while allowing other connections for -the same provider to continue serving requests. - -**Implementation**: - -- Write/update path: `src/sse/services/auth.ts::markAccountUnavailable()` -- Account selection/filtering: `src/sse/services/auth.ts::getProviderCredentials...` -- Cooldown calculation: `open-sse/services/accountFallback.ts::checkFallbackError()` -- Settings: `src/lib/resilience/settings.ts` - -Important fields on provider connections: - -```ts -rateLimitedUntil; -testStatus: "unavailable"; -lastError; -lastErrorType; -errorCode; -backoffLevel; -``` - -During account selection, a connection is skipped while: - -```ts -new Date(rateLimitedUntil).getTime() > Date.now(); -``` - -Cooldowns are also lazy: when `rateLimitedUntil` is in the past, the connection becomes -eligible again. On successful use, `clearAccountError()` clears `testStatus`, -`rateLimitedUntil`, error fields, and `backoffLevel`. - -Default connection cooldown behavior: - -- OAuth base cooldown: `5s`. -- API-key base cooldown: `3s`. -- API-key `429` should prefer upstream retry hints (`Retry-After`, reset headers, or - parseable reset text) when available. -- Repeated recoverable failures use exponential backoff: - -```ts -baseCooldownMs * 2 ** failureIndex; -``` - -The anti-thundering-herd guard prevents concurrent failures on the same connection from -repeatedly extending the cooldown or double-incrementing `backoffLevel`. - -Terminal states are not cooldowns. `banned`, `expired` (which becomes terminal only after N bounded retries via `EXPIRED_RETRY_MAX`), and `credits_exhausted` are -intended to stay unavailable until credentials/settings change or an operator resets -them. Do not overwrite terminal states with transient cooldown state. - -### Model Lockout - -**Scope**: provider + connection + model. - -**Purpose**: avoid disabling a whole connection when only one model is unavailable or -quota-limited for that connection. - -Examples: - -- Per-model quota providers returning `429`. -- Local providers returning `404` for one missing model. -- Provider-specific mode/model permission failures such as selected Grok modes. - -Model lockout lives in `open-sse/services/accountFallback.ts` and lets the same -connection continue serving other models. - -### Debugging Guidance - -- If all keys for a provider are skipped, inspect both provider breaker state and each - connection's `rateLimitedUntil`/`testStatus`. -- If a provider appears permanently excluded after the reset window, check whether code - is reading raw `state` instead of using `getStatus()`/`canExecute()`. -- If one provider key fails but others should work, prefer connection cooldown over - provider breaker. -- If only one model fails, prefer model lockout over connection cooldown. -- If a state should self-recover, it should have a future timestamp/reset timeout and a - read path that refreshes expired state. Permanent statuses require manual credential - or config changes. - ---- - -## Repository map - -Read the nearest `AGENTS.md` and the linked deep-dive before making a non-trivial change. - -| Area | Location | Start here | -| ---------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| API routes | `src/app/api/v1/` | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) | -| Streaming request handling | `open-sse/handlers/` | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) | -| Provider execution and translation | `open-sse/executors/`, `open-sse/translator/` | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) | -| Routing and resilience | `open-sse/services/` | [`open-sse/services/AGENTS.md`](open-sse/services/AGENTS.md), [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) | -| Database and migrations | `src/lib/db/`, `src/lib/db/migrations/` | [`src/lib/db/AGENTS.md`](src/lib/db/AGENTS.md) | -| Domain policy | `src/domain/` | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) | -| MCP and A2A | `open-sse/mcp-server/`, `src/lib/a2a/` | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md), [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) | -| Agent features | `src/lib/{acp,memory,skills,cloudAgent}/` | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md), [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) | -| Safety and governance | `src/lib/{guardrails,compliance}/`, `src/server/authz/` | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md), [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) | -| Operations | `src/mitm/`, tunnel modules, `electron/` | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md), [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) | - ---- - -## File placement & repo-root hygiene - -- **Test files**: ALL unit tests, integration tests, ecosystem tests, or Vitest files MUST strictly be placed within the `tests/` directory (e.g., `tests/unit/`, `tests/integration/`). NEVER create test files in the project root (`/`). -- **Scripts and utilities**: ALL maintenance, debugging, generation, or experimental scripts (`.cjs`, `.mjs`, `.js`, `.ts`) MUST be placed strictly inside one of the `scripts/` subfolders (`build/`, `dev/`, `check/`, `docs/`, `i18n/`, `ad-hoc/`, `quality/`, `release/`, `ci/`, `ops/`, `perf/`, `research/`, `sre/`, `vps/`, `homolog/`, `packs/`, `skills/`, `test/`, `cli/`, `compression/`, `compression-eval/`, `devin-bridge/`, `docker/`, `features/`, `router-eval/`). One-shot or experimental code goes under `scripts/ad-hoc/`. NEVER dump loose scripts in the project root (`/`) or the top-level `scripts/` folder. - -**The project root MUST ONLY contain:** - -- Configuration files (`vitest.config.ts`, `next.config.mjs`, `eslint.config.mjs`, `tsconfig*.json`, `playwright.config.ts`, `prettier.config.mjs`, `postcss.config.mjs`, `sonar-project.properties`, `fly.toml`, `docker-compose*.yml`, `Dockerfile`) -- Dependency files (`package.json`, `package-lock.json`) -- Documentation files (`README.md`, `CHANGELOG.md`, `ROADMAP.md`, `LICENSE`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `CONTRIBUTING.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, `llm.txt`) -- CI/CD files and ignore definitions (`.gitignore`, `.dockerignore`, `.npmignore`, `.npmrc`, `.node-version`, `.nvmrc`, `.env.example`) - -When creating _any_ validation tests or one-off logic scripts, default to `scripts/ad-hoc/` or `tests/unit/` according to your goals. Do not pollute the `/` root context. - -- **Root `_*` paths are private and NEVER tracked** (`_tasks/`, `_references/`, `_mono_repo/`, - `_ideia/`, `_cache/` and any future `_`): they live on disk only, are gitignored by the - anchored patterns `/_*/` + `/_*`, and some are full git repositories of their own (`_tasks` → - private remote `_tasks_omniroute`). Never `git add` anything inside them (a plain `add` is - already blocked by the ignore; never use `-f`), and never "clean them up" from the main repo — - untracking is done with `git rm --cached` so the disk content stays. The - `check:tracked-artifacts` gate (pre-commit + CI) fails on ANY tracked root path starting with - `_`, present or future. See Hard Rule #23 for the `_tasks` specifics. - ---- - -## Key Conventions - -### Code Style - -- **2 spaces**, semicolons, double quotes, 100 char width, es5 trailing commas (enforced by lint-staged via Prettier) — run Prettier on changed files -- **Imports**: external → internal (`@/`, `@omniroute/open-sse`) → relative -- **Naming**: files=camelCase/kebab, components=PascalCase, constants=UPPER_SNAKE -- **ESLint**: `no-eval`, `no-implied-eval`, `no-new-func` = error everywhere; `no-explicit-any` = **error** in `open-sse/` and `tests/` (since #6218 — pre-existing violations are frozen in `config/quality/eslint-suppressions.json`, new ones must be fixed; `npm run lint` applies the suppressions and is what CI runs) -- **TypeScript**: `strict: false`, target ES2022, module esnext, resolution bundler. Prefer explicit types. - -### Database - -- **Always** go through `src/lib/db/` domain modules — **never** write raw SQL in routes or handlers -- **Never** barrel-import from `localDb.ts` — import specific `src/lib/db/*` modules -- DB singleton: `getDbInstance()` from `src/lib/db/core.ts` (WAL journaling) -- Migrations: `src/lib/db/migrations/` — versioned SQL files, idempotent, run in transactions - -### Error Handling - -- try/catch with specific error types, log with pino context -- Never swallow errors in SSE streams — use abort signals for cleanup -- Return proper HTTP status codes (4xx/5xx) - -### Security - -- **Never** use `eval()`, `new Function()`, or implied eval -- Validate all inputs with Zod schemas -- Encrypt credentials at rest (AES-256-GCM); never log SQLite encryption keys -- Sanitize user HTML with DOMPurify -- Upstream header denylist: `src/shared/constants/upstreamHeaders.ts` — keep sanitize, Zod schemas, and unit tests aligned when editing -- **Public upstream credentials** (for example, OAuth client_id/secret values or Firebase Web keys extracted from public CLIs): **MUST** be embedded via `resolvePublicCred()` from `open-sse/utils/publicCreds.ts` — **never** as string literals. See `docs/security/PUBLIC_CREDS.md` for the mandatory pattern. -- **Error responses** (HTTP / SSE / executor / MCP handler): **MUST** route through `buildErrorBody()` or `sanitizeErrorMessage()` from `open-sse/utils/error.ts` — **never** put raw `err.stack` or `err.message` in a response body. See `docs/security/ERROR_SANITIZATION.md`. -- **Shell commands built from variables**: when calling `exec()`/`spawn()` with a script that needs runtime values, pass them via the `env` option (shell-escaped automatically) — **never** string-interpolate untrusted/external paths into the script body. Reference: `src/mitm/cert/install.ts::updateNssDatabases`. -- **Secure-by-default libraries** ([tldrsec/awesome-secure-defaults](https://github.com/tldrsec/awesome-secure-defaults)): prefer Helmet.js, DOMPurify, ssrf-req-filter, safe-regex, Google Tink over custom implementations whenever adding new security-sensitive surfaces. - ---- - -## Documentation accuracy - -Documentation must describe verified behavior, not plausible behavior. - -1. Before documenting an API name, endpoint, path, CLI command, or environment variable, - search for it: `rg -n "name" src/ open-sse/ bin/`. If it has no source match, do not - document it. -2. Measure mutable counts instead of writing them from memory: use `wc -l ` or a - directory-specific count command. -3. Copy code examples from working usage or run them. Prefer a source link such as - `path/to/file.ts:line` to an invented signature. -4. Run `npm run check:docs-all` for edits under `docs/`; it includes the fabricated-docs - validation. - ---- - -## Common Modification Scenarios - -### Adding a New Provider - -0. Check `docs/reference/REMOVED_PROVIDERS.md` first — providers removed at their operator's request must never be reintroduced (guarded by `tests/unit/removed-providers-blocklist.test.ts`) -1. Register in `src/shared/constants/providers.ts` (Zod-validated at load) -2. Add executor in `open-sse/executors/` if custom logic needed (extend `BaseExecutor`) -3. Add translator in `open-sse/translator/` if non-OpenAI format -4. Add OAuth config in `src/lib/oauth/constants/oauth.ts` if OAuth-based — if the upstream CLI ships a public client_id/secret, embed via `resolvePublicCred()` (see `docs/security/PUBLIC_CREDS.md`), **never** as a literal -5. Register models in `open-sse/config/providerRegistry.ts` -6. Write tests in `tests/unit/` (include the publicCreds shape assertion if you added a new embedded default) - -### Adding a New API Route - -1. Create directory under `src/app/api/v1/your-route/` -2. Create `route.ts` with `GET`/`POST` handlers -3. Follow pattern: CORS → Zod body validation → optional auth → handler delegation -4. Handler goes in `open-sse/handlers/` (import from there, not inline) -5. Error responses use `buildErrorBody()` / `errorResponse()` from `open-sse/utils/error.ts` (auto-sanitized — never put `err.stack` or `err.message` raw in the body). See `docs/security/ERROR_SANITIZATION.md`. -6. Add tests — including at least one assertion that error responses do not leak stack traces (`!body.error.message.includes("at /")`) - -### Adding a New DB Module - -1. Create `src/lib/db/yourModule.ts` — import `getDbInstance` from `./core.ts` -2. Export CRUD functions for your domain table(s) -3. Add migration in `src/lib/db/migrations/` if new tables needed -4. Write tests - -### Adding a New MCP Tool - -1. Add tool definition in `open-sse/mcp-server/tools/` with Zod input schema + async handler -2. Register in tool set (wired by `createMcpServer()`) -3. Assign to appropriate scope(s) -4. Write tests (tool invocation logged to the `mcp_tool_audit` table) - -### Adding a New A2A Skill - -1. Create skill in `src/lib/a2a/skills/` (6 already exist: smart-routing, quota-management, provider-discovery, cost-analysis, health-report, list-capabilities) -2. Skill receives task context (messages, metadata) → returns structured result -3. Register in `A2A_SKILL_HANDLERS` in `src/lib/a2a/taskExecution.ts` -4. Expose in `src/app/.well-known/agent.json/route.ts` (Agent Card) -5. Write tests in `tests/unit/` -6. Document in `docs/frameworks/A2A-SERVER.md` skill table - -### Adding a New Cloud Agent - -1. Create agent class in `src/lib/cloudAgent/agents/` extending `CloudAgentBase` (4 already exist: codex-cloud, devin, jules, cursor-cloud) -2. Implement `createTask`, `getStatus`, `approvePlan`, `sendMessage`, `listSources` -3. Register in `src/lib/cloudAgent/registry.ts` -4. Add OAuth/credentials handling if needed (`src/lib/oauth/providers/`) -5. Tests + document in `docs/frameworks/CLOUD_AGENT.md` - -### Adding a New Embedded Service - -1. Create installer in `src/lib/services/installers/{name}.ts` modeled on `ninerouter.ts` (use `runNpm` from `installers/utils.ts` — no shell interpolation, hard rule #13). -2. Register the service in `src/lib/services/bootstrap.ts` (add to `SERVICES[]` array and extend `buildSpawnArgsFactory()`). -3. Add a DB seed row for the new service in `src/lib/db/migrations/` (`version_manager` table, `status='not_installed'`, `auto_start=0`). -4. Create 8 API endpoints under `src/app/api/services/{name}/` (`_lib.ts`, `install`, `start`, `stop`, `restart`, `update`, `status`, `auto-start`, `auto-restart-adopted`). All delegate errors through `createErrorResponse()`. The shared `logs` endpoint is already wired via `[name]/logs/route.ts`. -5. Verify `/api/services/` is in `LOCAL_ONLY_API_PREFIXES` in `src/server/authz/routeGuard.ts`; add a test asserting `isLocalOnlyPath()` returns `true` for the new prefix if you add one (hard rule #17). -6. Add a UI tab in `src/app/(dashboard)/dashboard/providers/services/tabs/` reusing `ServiceStatusCard`, `ServiceLifecycleButtons`, `ServiceLogsPanel`. -7. Document in `docs/frameworks/EMBEDDED-SERVICES.md` (update §1 service table + §4 API reference) and `docs/openapi.yaml`. -8. Write tests: unit (`tests/unit/services/`), integration (`tests/integration/services/`, gated by `RUN_SERVICES_INT=1`), and update `docs/ops/RELEASE_CHECKLIST.md` smoke section. - -### Adding a New Guardrail / Eval / Skill / Webhook event - -- Guardrail: `src/lib/guardrails/` → docs: `docs/security/GUARDRAILS.md` -- Eval suite: `src/lib/evals/` → docs: `docs/frameworks/EVALS.md` -- Skill (sandbox): `src/lib/skills/` → docs: `docs/frameworks/SKILLS.md` -- Webhook event: `src/lib/webhookDispatcher.ts` → docs: `docs/frameworks/WEBHOOKS.md` -- Log-export destination: add `src/lib/logExport/destinations/.ts` + one line in - `src/lib/logExport/registry.ts` → docs: `docs/frameworks/LOG-EXPORT.md`. The runner, REST layer - and dashboard form all read the registry, so nothing else changes. - ---- - -## Reference Documentation - -For any non-trivial change, read the matching deep-dive first: - -| Area | Doc | -| --------------------------------------------- | ------------------------------------------------------- | -| Repo navigation | `docs/architecture/REPOSITORY_MAP.md` | -| Architecture | `docs/architecture/ARCHITECTURE.md` | -| Engineering reference | `docs/architecture/CODEBASE_DOCUMENTATION.md` | -| Auto-Combo (16-factor scoring, 19 strategies) | `docs/routing/AUTO-COMBO.md` | -| Resilience (3 mechanisms) | `docs/architecture/RESILIENCE_GUIDE.md` | -| Reasoning replay | `docs/routing/REASONING_REPLAY.md` | -| Skills framework | `docs/frameworks/SKILLS.md` | -| Radar (free-model catalog overlay) | `docs/frameworks/RADAR.md` | -| Memory system (FTS5 + Qdrant) | `docs/frameworks/MEMORY.md` | -| Cloud agents | `docs/frameworks/CLOUD_AGENT.md` | -| Guardrails (PII / injection / vision) | `docs/security/GUARDRAILS.md` | -| Public upstream credentials (Gemini/etc.) | `docs/security/PUBLIC_CREDS.md` | -| Error message sanitization | `docs/security/ERROR_SANITIZATION.md` | -| Evals | `docs/frameworks/EVALS.md` | -| Compliance / audit | `docs/security/COMPLIANCE.md` | -| Webhooks | `docs/frameworks/WEBHOOKS.md` | -| Log export (call logs → BigQuery/…) | `docs/frameworks/LOG-EXPORT.md` | -| Authorization pipeline | `docs/architecture/AUTHZ_GUIDE.md` | -| Stealth (TLS / fingerprint) | `docs/security/STEALTH_GUIDE.md` | -| Agent protocols (A2A / ACP / Cloud) | `docs/frameworks/AGENT_PROTOCOLS_GUIDE.md` | -| MCP server | `docs/frameworks/MCP-SERVER.md` | -| A2A server | `docs/frameworks/A2A-SERVER.md` | -| API reference + OpenAPI | `docs/reference/API_REFERENCE.md` + `docs/openapi.yaml` | -| Provider catalog (auto-generated) | `docs/reference/PROVIDER_REFERENCE.md` | -| Tunnels | `docs/ops/TUNNELS_GUIDE.md` | -| Electron desktop app | `docs/guides/ELECTRON_GUIDE.md` | -| VS Code Copilot Chat (OmniCopilot extension) | `docs/guides/VSCODE-COPILOT.md` | -| Release flow | `docs/ops/RELEASE_CHECKLIST.md` | -| Embedded services | `docs/frameworks/EMBEDDED-SERVICES.md` | -| Quality gates (~90 scripts, allowlist policy) | `docs/architecture/QUALITY_GATES.md` | - ---- - -## Testing - -| What | Command | -| ----------------------- | ----------------------------------------------------------------------------- | -| Unit tests | `npm run test:unit` | -| Single file | `node --import tsx/esm --test tests/unit/your-file.test.ts` | -| Vitest (MCP, autoCombo) | `npm run test:vitest` | -| E2E (Playwright) | `npm run test:e2e` | -| Protocol E2E (MCP+A2A) | `npm run test:protocols:e2e` (CI job `test-protocols-e2e`, advisory — #10049) | -| Ecosystem | `npm run test:ecosystem` (CI job `test-ecosystem`, blocking) | -| Coverage gate | `npm run test:coverage` (60/60/60/60 — statements/lines/functions/branches) | -| Coverage report | `npm run coverage:report` | - -**PR rule**: If you change production code in `src/`, `open-sse/`, `electron/`, or `bin/`, you must include or update tests in the same PR. - -**Test layer preference**: unit first → integration (multi-module or DB state) → e2e (UI/workflow only). Encode bug reproductions as automated tests before or alongside the fix. - -**Both test runners must pass**: `npm run test:unit` (Node native — most tests) AND `npm run test:vitest` (MCP server, autoCombo, cache) cover **non-overlapping files**. Both are wired in CI (jobs `test-unit` and `test-vitest`) and must be green before merging. A PR where only one suite passes may silently ship broken MCP tools or routing regressions. - -**Bug fix / issue triage protocol (Hard Rule #18)**: Every fix for a reported issue must be validated by one of the following — no exceptions: - -1. **TDD (preferred)** — write a failing test reproducing the bug → fix it → confirm the test passes. The test becomes the permanent regression guard. Touch only the files the test proves need changing; nothing more. -2. **Real-environment test (when TDD is not possible)** — deploy to the production VPS (`root@192.168.0.15`) and run a documented live test. Record the exact command + result in the PR description. Applies to: OAuth upstream flows, Cloudflare/WS upstream behavior, UI-only regressions, hardware-dependent behavior. -3. "It worked locally without a test" does not count. A fix without a test or a VPS validation record is not a fix — it is a guess. - -Why this matters: fixing bug A while opening bug B is worse than not fixing at all. The TDD/VPS gate enforces surgical scope — you touch only what the failing test proves is broken. Examples where this paid off: #3090 (claude-web 403), #3113 (WS HTTP fallback), #3052 (heap-guard auto-calibration). - -**Copilot coverage policy**: When a PR changes production code and coverage is below 60% (statements/lines/functions/branches), do not just report — add or update tests, rerun the coverage gate, then ask for confirmation. Include commands run, changed test files, and final coverage result in the PR report. - ---- - -## Review focus - -- Keep database operations in `src/lib/db/`; do not issue raw SQL from routes. -- Send provider requests through `open-sse/handlers/`. -- Keep MCP and A2A pages as tabs inside `/dashboard/endpoint`. -- Preserve SSE cleanup, rate-limit header parsing, Zod validation, and provider-schema - validation. -- Treat Memory and Skills as cross-cutting changes that can affect MCP tools, the request - pipeline, and A2A skills. -- Do not close a contributor pull request after using its code; merge it through GitHub so - the contributor receives credit. -- **Never merge a PR that touches an agent-instruction surface without explicit operator - approval** — `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `llm.txt` (+ mirrors) and - `skills/**/SKILL.md` are executed as authority by every AI session; a merged instruction - compromises every future agent run. Check with `gh pr diff --name-only` before any - merge. Incident record: PR #11770 (2026-09-01) told agents to execute a third-party - setup script and was swept in by a merge campaign; reverted in #12249. - ---- - -## Planning & Research Artifacts - -`_tasks/` is a **separate, isolated git repository** that is gitignored by the main -repo (`.gitignore` → `_tasks/`). It is the canonical home for working artifacts — -plans, specs/designs, research, hand-offs — so they stay **versioned in their own -repo** instead of polluting the main OmniRoute tree. - -**Hard rule — never write planning / research output under `docs/` or the repo root.** -Whenever any plan/spec/research generator runs in this project (superpowers or otherwise), -save to `_tasks/` using the filename convention: - -| Artifact | Save here | -| -------------- | ------------------------------------------------------------- | -| Plans | `_tasks/superpowers/plans/YYYY-MM-DD-.md` | -| Specs / design | `_tasks/superpowers/specs/YYYY-MM-DD--design.md` | -| Research | `_tasks/research/…` | -| Hand-offs | `_tasks/hands-off/__v_sess-/` | - -Commit those artifacts inside the `_tasks/` repo (`git -C _tasks …`), never in the main repo. - ---- - -## Git Workflow - -```bash -# Never commit directly to main -git checkout -b feat/your-feature -git commit -m "feat: describe your change" -git push -u origin feat/your-feature -``` - -**Branch prefixes**: `feat/`, `fix/`, `refactor/`, `docs/`, `test/`, `chore/` - -**Commit format** (Conventional Commits): `feat(db): add circuit breaker` — scopes: `db`, `sse`, `oauth`, `dashboard`, `api`, `cli`, `docker`, `ci`, `mcp`, `a2a`, `memory`, `skills` - -**Husky hooks**: - -- **pre-commit**: lint-staged + `check-docs-sync` + `check:any-budget:t11` + `check:tracked-artifacts` -- **commit-msg**: `check:ai-attribution` — rejects AI/bot `Co-Authored-By` trailers and AI-generation footers in the message (Hard Rule #16; human co-authors allowed; also in the `quality.yml` fast-gates loop (PR→`release/**`) and a PR-only `ci.yml` lint step (PR→`main`) — #14436) -- **pre-push**: intentionally light (PATH/npm sanity only). `any-budget` + `tracked-artifacts` - already run on pre-commit; re-running them on every push was pure double-pay. CI still - enforces both. (Was Fase 6A.12 full pre-push gate; folded into pre-commit in #6716.) - -### Worktree isolation (MANDATORY for every development task) - -Multiple sessions/agents work this repo in parallel. The main checkout is **shared**, so a -`git checkout`/branch switch in it silently discards another session's uncommitted work and -yanks the branch out from under whatever else is running (incidents: 2026-06-05, 2026-06-13). - -**Rule: never develop on the shared main checkout. Every task gets its own git worktree on its -own dedicated branch, and you MUST confirm the base branch with the operator before creating it.** - -1. **Ask first — which base branch?** Before creating anything, ask the operator (unless they - already told you) from which branch the new worktree/branch should be cut. Do NOT assume - `main` or "whatever I'm on" — the answer is usually the active `release/vX.Y.Z`, but it can - be another feature/release branch. Get the base explicitly. -2. **Create an isolated worktree + branch off that base** (never reuse the main checkout). - **🔴 MANDATORY PATH: every worktree lives under `.claude/worktrees/` — and nowhere else.** - This is the single canonical location. It is gitignored AND in the `tsconfig.json` / - `.dockerignore` excludes, so worktrees never leak into the build scope. **Never** use - `.worktrees/`, repo-root, or any other path — a worktree outside `.claude/worktrees/` - (a) escapes the build-scope excludes and poisons `next build` (the `tsconfig` - `include: **/*` globs ~70× the codebase → OOM; incident 2026-06-25) and (b) scatters - worktrees across two dirs. - - ```bash - BASE_BRANCH="release/vX.Y.Z" # ← the branch the operator confirmed in step 1 - TASK="feat/your-feature" # feat/ fix/ refactor/ docs/ test/ chore/ - git fetch origin "$BASE_BRANCH" - git worktree add ".claude/worktrees/${TASK##*/}" -b "$TASK" "origin/$BASE_BRANCH" - cd ".claude/worktrees/${TASK##*/}" - # Reuse the main checkout's node_modules to skip a per-worktree npm install. - # HARD LINKS (`cp -al`), never a symlink: ~5s for the whole tree and near-zero extra - # disk (the inodes are shared), and unlike a symlink it does not break the dev server. - cp -al "$(git -C rev-parse --show-toplevel)/node_modules" node_modules - # `.husky/_` is gitignored, so a fresh worktree does NOT have it and - # `core.hooksPath=.husky/_` then points at a directory that does not exist — - # every pre-commit gate goes silently mute. Copy it too. - cp -a "$(git -C rev-parse --show-toplevel)/.husky/_" .husky/_ - ``` - - `scripts/dev/new-worktree.sh [base]` does all of the above (canonical path, - hard-linked `node_modules`, `.husky/_`) and then **verifies** the hook is actually - executable, so prefer it over running the steps by hand. - - **Never `ln -s` node_modules.** Turbopack rejects a symlink that resolves outside the - project root, so `npm run dev` dies with a FATAL panic (`Symlink [project]/node_modules -is invalid, it points out of the filesystem root`) while typecheck, lint and the test - runners all keep passing — the error names "filesystem root", not the worktree, so it - reads like a Next/build bug and costs real time to trace (incident 2026-07-31, #9043). - - **A worktree without `.husky/_` runs NO pre-commit gate — and says nothing.** `git` - resolves `core.hooksPath` relative to the worktree top; when the directory is missing it - simply finds no hook and commits. Nothing is printed, the commit succeeds, and the - identity/lint/docs gates never ran. This is how 59 commits carrying a stale identity - override (name of a contributor + the maintainer's e-mail) got past - `scripts/check/check-git-identity.sh` between 2026-08-29 and 09-02 — they were all made in - `cp -al` worktrees. Verify with `ls .husky/_/pre-commit` inside a new worktree, or just use - `scripts/dev/new-worktree.sh`, which fails loudly when the hook is not executable. - -3. **Work, commit, push, open the PR — all from inside the worktree.** Never `git checkout` a - different branch inside a worktree another session might share. -4. **Tear down only your own** worktree + branch when done, from the main checkout: - `git worktree remove .claude/worktrees/` then `git branch -D `. Never blanket-delete - `fix/*`/`feat/*` — other sessions keep their own; delete only the branches you created, by name. -5. **Never touch another session's worktree, branch, or uncommitted changes.** If `git worktree -list` shows worktrees you didn't create, leave them alone. End every session with the main - checkout back on the branch it started on (the active `release/vX.Y.Z`, never `main`). - -### Base-green check (PRs must not be born red) - -Before cutting a branch, merging the base into a PR branch, mass-retargeting PRs, or opening a -PR: check whether the base tip is green. The `Release-Green (continuous)` workflow -(`.github/workflows/nightly-release-green.yml`) publishes the verdict in a single deduplicated -issue titled `🔴 Release branch not green: ` (label `base-red`). One call replaces any -local suite run for this purpose: - -```bash -gh issue list --repo diegosouzapw/OmniRoute --state open \ - --search "Release branch not green: in:title" -``` - -If the base is red: never treat the inherited failures as your branch's defect; never "fix" them -inside your feature branch (a base-red fix is its own freeze-gated `fix/release-vX.Y.Z-basereds` -PR); and if you must open a PR anyway, add `⚠️ base-red inherited: #` to the PR body so -reviewers and CI babysitters do not chase ghosts. - -### Sync-back landings are fast-forward, never squash - -A `main → release/vX+1` sync-back (Phase 5 of `/generate-release`, or any later "bring main's -post-release commits over" PR) must reach the release branch as the merge commit it already is: -`git merge-base --is-ancestor origin/release/vX+1 ` then -`git push origin :refs/heads/release/vX+1` (GitHub marks the PR merged). Squash-merging it -drops `main` from the release branch's ancestry and the next sync-back re-conflicts on every file -main touched (551 conflicts on the v3.8.50 → v3.8.51 sync before the two-step merge). After -landing, `git merge-base --is-ancestor origin/main origin/release/vX+1` must be true — and check -that `config/quality/eslint-suppressions.json` / `quality-baseline.json` carried main's freezes -(they merge as "ours" silently). Details: `.agents/skills/generate-release/phases/phase-5-next-cycle.md`. - ---- - -## Upstream contributions - -This checkout is a fork of `diegosouzapw/OmniRoute`. Keep fork-only deployment and personal -automation changes out of upstream PRs. - -Start upstream work from the active upstream default branch, not `main`: - -```bash -git fetch upstream -git switch -c upstream/ -``` - -Target that same release branch in the pull request. Stage only the intended files, run the -focused checks, and use a Conventional Commit message (for example, `docs: slim AGENTS.md`). - ---- - -## Environment - -- **Runtime**: Node.js ≥22.22.2 <23 || ≥24.0.0 <27, ES Modules. This is the **only supported** runtime for the published `omniroute` CLI, the server, and the test suites (`node:test` + vitest) — `engines.node` is authoritative and end users never need Bun. A **best-effort `bun:sqlite` compatibility path** exists so a global Bun install (`bun install -g omniroute`) can start without `better-sqlite3` (driver adapter + Bun-aware process spawning); it is **not** a supported runtime — no support guarantees — and every Bun-specific runtime change MUST preserve the Node driver/fallback chain and ship a Bun test (`test:bun:db`) or an explicit reason why the path is Node-only. -- **Bun (build/dev script runner + compatibility smoke only)**: Bun `1.4.2` is pinned as an **exact devDependency** (provisioned through the existing `npm ci` via the lockfile's `@oven/bun-*` platform binaries — no `setup-bun`/ad-hoc install). It is used **only** to execute a small, allow-listed set of TypeScript **gate/generator scripts** (replacing `node --import tsx` for startup speed): the CI checks `check:provider-consistency`, `check:compression-budget`, `check:known-symbols`, and the non-CI `gen:provider-reference`, `bench:compression` — plus the focused `test:bun:db` compatibility smoke suite for the best-effort `bun:sqlite` path. **Do NOT** widen Bun to `npm install`, the build (`build:cli*`), `check:pack-artifact`, the supported published runtime, or the main test runners — those stay on Node. Any new Bun-invoking gate/generator script must be validated byte-identical against its `node --import tsx` output first. After pulling the lockfile change, run `npm install` so `bun` resolves locally (a stale `node_modules` will fail those scripts with `bun: not found`). -- **TypeScript**: 6.0+, target ES2022, module esnext, resolution bundler -- **Path aliases**: `@/*` → `src/`, `@omniroute/open-sse` → `open-sse/`, `@omniroute/open-sse/*` → `open-sse/*` -- **Default port**: 20128 (API + dashboard on same port) -- **Data directory**: `DATA_DIR` env var, defaults to `~/.omniroute/` -- **Key env vars**: `PORT`, `JWT_SECRET`, `API_KEY_SECRET`, `INITIAL_PASSWORD`, `REQUIRE_API_KEY`, `APP_LOG_LEVEL` -- Setup: `cp .env.example .env` then generate `JWT_SECRET` (`openssl rand -base64 48`) and `API_KEY_SECRET` (`openssl rand -hex 32`) - ---- - -## Quality Gates & Ratchets - -OmniRoute has **~90 quality-gate scripts** (`scripts/check/` + `scripts/quality/`) wired -across **9 gate-running jobs** in `.github/workflows/ci.yml` (`lint`, `quality-gate`, -`quality-extended`, `docs-sync-strict`, `i18n-ui-coverage`, `i18n`, `pr-test-policy`, -`test-vitest`, `sonarqube`), plus the `quality.yml` fast-gates job (PR→`release/**`) and -5 quality nightly workflows (`nightly-property`, `nightly-resilience`, -`nightly-llm-security`, `nightly-mutation`, `nightly-schemathesis`). Full inventory, per-job breakdown, and operational -procedures are in [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md). - -**Quick reference:** - -- Gates in jobs `lint` + `docs-sync-strict`: pass/fail policy gates — - fix the violation or add an allowlist entry with a justification comment + tracking issue. -- Gates in job `quality-gate`: ratchet — metrics (ESLint warnings, code coverage, duplication, - complexity) must not regress vs `quality-baseline.json`. Update via - `npm run quality:ratchet -- --update` when a metric genuinely improves. -- Job `test-vitest` runs `npm run test:vitest` (MCP tools, autoCombo, cache) — blocking. - `test:vitest:ui` has been blocking since PR #7127. -- **Velocity phase (2026-08-30 → v4.0)**: every numeric baseline is loosened by 20% and - `--require-tighten` is advisory (`quality-baseline.json` → `_policy`); the nightly - `baseline-headroom` job tracks how much of the budget is left in the issue - "📈 Baseline headroom". See `docs/architecture/QUALITY_GATES.md` → "Velocity phase". - -**Allowlist policy (short form):** Fix the cause; use the allowlist only for pre-existing -violations you cannot fix in the same PR. Add a comment with justification + issue number. -Stale allowlist entries (suppressing a violation that no longer exists) will be caught by -the stale-enforcement added in Fase 6A.3. - ---- - -## Hard Rules - -1. Never commit secrets or credentials -2. Never barrel-import from `localDb.ts` — import specific `src/lib/db/*` modules -3. Never use `eval()` / `new Function()` / implied eval -4. Never commit directly to `main` -5. Never write raw SQL in routes — use `src/lib/db/` modules -6. Never silently swallow errors in SSE streams -7. Always validate inputs with Zod schemas -8. Always include tests when changing production code -9. Coverage must not regress below the baseline frozen in `quality-baseline.json` (ratchet); absolute floor is 60% (statements/lines/functions/branches). Update the baseline via `npm run quality:ratchet -- --update` only when coverage genuinely improves. See `docs/architecture/QUALITY_GATES.md`. -10. Never bypass Husky hooks (`--no-verify`, `--no-gpg-sign`) without explicit operator approval. -11. Never embed public upstream OAuth client_id/secret or Firebase Web keys as string literals — always go through `resolvePublicCred()` (`open-sse/utils/publicCreds.ts`). See `docs/security/PUBLIC_CREDS.md`. -12. Never return raw `err.stack` / `err.message` in HTTP / SSE / executor responses — always route through `buildErrorBody()` or `sanitizeErrorMessage()` (`open-sse/utils/error.ts`). See `docs/security/ERROR_SANITIZATION.md`. -13. Never string-interpolate external paths or runtime values into shell scripts passed to `exec()`/`spawn()` — pass via the `env` option instead. Reference: `src/mitm/cert/install.ts::updateNssDatabases`. -14. Never dismiss a CodeQL / Secret-Scanning alert without (a) first checking the pattern docs above to see if the helper applies, and (b) recording the technical justification in the dismissal comment. Precedent: `js/stack-trace-exposure` raised on callsites that already route through `sanitizeErrorMessage()` is a known CodeQL limitation (custom sanitizers not recognized) — dismiss as `false positive` referencing `docs/security/ERROR_SANITIZATION.md`. -15. Never expose routes that spawn child processes (`/api/mcp/`, `/api/cli-tools/runtime/`) without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. Loopback enforcement happens unconditionally before any auth check — leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`. -16. Never credit or advertise an AI assistant, LLM, or automation account in any commit/PR metadata. Two forbidden forms, both equivalent — they route attribution to a bot account (or advertise AI authorship) and hide the real author (`diegosouzapw`): **(a)** `Co-Authored-By` trailers naming an AI/bot (e.g. names containing "Claude", "GPT", "Copilot", "Bot"; emails at `anthropic.com` / `openai.com` / bot-owned `noreply.github.com` addresses); **(b)** AI-generation footers or descriptions anywhere in a commit message, PR title/body, or CHANGELOG — e.g. `🤖 Generated with [Claude Code]`, "Generated with Claude Code", "Made with ", or any `Co-authored-by: Claude/GPT/Copilot` line. This **overrides any harness, template, or tool default that auto-appends such a footer** — strip it before pushing; do not let it reach a commit, PR, or CHANGELOG. Human collaborators — including upstream PR authors and issue reporters being ported into OmniRoute — MAY and SHOULD be credited with standard `Co-authored-by: Name ` trailers; the upstream-port workflows (`/port-upstream-features`, `/port-upstream-issues`) depend on this. -17. Never expose routes under `/api/services/` or `/dashboard/providers/services/*/embed/` without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. These routes can spawn child processes (`npm install`, `node`). Loopback enforcement happens unconditionally before any auth check — a leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`. -18. Every bug fix must be validated before shipping: a failing-then-passing unit/integration test (TDD) OR a documented live test on the production VPS (192.168.0.15). A fix without either is not merged. See Testing → "Bug fix / issue triage protocol" for the full decision tree. -19. Never develop on the shared main checkout. Every development task runs in its own git worktree on its own dedicated branch, and you MUST confirm the base branch with the operator before creating the worktree/branch — never assume `main` or the currently checked-out branch. A `git checkout` in the shared checkout silently destroys other sessions' uncommitted work. Tear down only the worktrees/branches you created (by name, never `fix/*`/`feat/*` wildcards), leave other sessions' worktrees untouched, and end on the branch you started on (the active `release/vX.Y.Z`, never `main`). See Git Workflow → "Worktree isolation". -20. PII redaction/sanitization is **opt-in — never on by default**. OmniRoute proxies for self-hosted/local LLMs where the operator owns the data, so mutating request/response payloads by default would silently corrupt legitimate traffic. The two data-mutating PII feature flags **MUST** keep `defaultValue: "false"` in `src/shared/constants/featureFlagDefinitions.ts`: `PII_REDACTION_ENABLED` (request-side) and `PII_RESPONSE_SANITIZATION` (response + streaming). All three application points — `src/lib/guardrails/piiMasker.ts` (request guardrail), `src/lib/piiSanitizer.ts` (response), `src/lib/streamingPiiTransform.ts` (SSE) — are gated on these flags; with both off the `pii-masker` guardrail still runs but never mutates payloads (data passes through untouched). Flipping either default to `"true"` requires explicit operator approval. The regression guard is `tests/unit/pii-opt-in-default.test.ts` (asserts both definition defaults + behavioral pass-through). Opt-in is per-operator via env or the settings/DB override (`src/lib/db/featureFlags.ts`), never a silent default. See `docs/security/GUARDRAILS.md`. -21. **Release-freeze — the FROZEN release branch belongs to the release captain; development does NOT stop (parallel-cycle model, 2026-07-04).** `/generate-release` opens a marker issue labeled `release-freeze` at the start of reconciliation (Phase 0a), **immediately cuts the next cycle's branch `release/vX+1` from the frozen tip (Phase 0a.0b — bump + living release PR + re-home of open PRs)**, and closes the freeze once the release PR squash-merges to `main`. Before merging **any** PR, every campaign workflow (`/review-prs`, `/review-group-prs`, `/merge-prs`, `/triage-fix-bugs`, `/implement-fix-bugs`, `/triage-features`, `/implement-features`, `/green-prs`, `/port-upstream-*`) **MUST** check `gh issue list --repo diegosouzapw/OmniRoute --label release-freeze --state open` — if a freeze is active: **NEVER merge into the frozen `release/vX.Y.Z` named in the freeze title**; instead resolve the ACTIVE development branch (the **highest** `release/v*` by semver — normally `release/vX+1`, announced in a freeze-issue comment) and **retarget the PR there** (`gh pr edit --base release/vX+1`, then VERIFY with `gh pr view --json baseRefName` — the edit fails silently) and merge normally. **HOLD only when the highest release/v\* branch IS the frozen one** (the short window before 0a.0b completes, or a pre-parallel-cycle release) — in that case leave the PR ready and open, tell the operator, and resume when the next branch appears or the freeze lifts. The just-shipped fixes reach `release/vX+1` via the Phase 5 sync-back (`scripts/release/sync-next-cycle.mjs`); do not try to sync mid-release. This is a **coordination signal, not a permission lock**: the release captain and the campaign sessions share the `diegosouzapw` identity, so a GitHub branch-protection lock cannot distinguish them — only this honored marker prevents the mid-release commit races that forced full CHANGELOG re-reconciliation in v3.8.40/v3.8.41 (a parallel campaign advanced `release/vX.Y.Z` by 34 commits mid-run). The release captain's own reconciliation/cycle-open pushes are exempt — they _are_ the release. Fixes that must land during a freeze (a homologation finding) follow the post-merge read-only rule: land on `main` first via `fix/release-vX.Y.Z-*`. **⛔ ONLY `/generate-release` may raise a release-freeze, and ONLY at its Phase 0a (start of generating a new version) — lifted at Phase 12c after the squash-merge to `main`.** No campaign, session, or agent may open a `release-freeze` marker at any other time — a freeze is **never** a mid-development coordination tool. If a session ever believes a freeze is genuinely, unavoidably necessary outside the `/generate-release` flow, it **MUST first ask the operator (`diegosouzapw`) in chat, explicitly alert "estou criando um freeze" and get an explicit yes** — never open, extend, or re-open a `release-freeze` autonomously. Conversely, do **not** close/lift an active `/generate-release` freeze to unblock campaign merges: it protects the captain's single clean CI run and auto-lifts at Phase 12c — closing it early re-triggers the exact commit race it prevents. Verify a freeze is legitimate before acting on it: an open `release-freeze` whose title/body references an **OPEN** release PR (`gh pr view --json state`) is the authorized captain freeze — hold, don't touch. (Cycle-model proposal: `_tasks/finished/release-flow/2026-07-04_proposta-ciclo-paralelo-v2.md`.) -22. **Cross-session safety — this repo is worked by MANY parallel sessions/agents at once; never step on another's in-flight work.** Two absolute bans, both recurring incidents (this rule exists because they keep happening): - - **(a) Never `git stash` / `git stash pop` — ANYWHERE in this repo, including inside an isolated worktree, and including inside any subagent you dispatch.** `git stash` operates on the **shared repository object store**, not the per-worktree working tree — so a stash pushed or popped in one session can silently clobber or resurrect another parallel session's uncommitted changes. This is not hypothetical: 2026-07-02 a `#5923` quotaCache change leaked into the unrelated `#2296` worktree via a global `stash pop`, and the same class reincided through a **subagent**. To compare working changes against a base ref **without** stashing, use `git show :` or `git diff -- `; to confirm a typecheck/lint error is pre-existing on the base, inspect the base ref directly (`git show origin/release/vX.Y.Z:`) — never stash your tree away to "get it clean". **Put this ban verbatim in the prompt of every subagent that touches git** (agents don't inherit this file's context — the recurrence was a subagent). - - **(b) Never merge, push, rebase, or force-push a PR / branch / worktree that another session is actively working.** An open PR whose head is a live fix worktree in `.claude/worktrees/` you did **not** create (e.g. `fix-5852`/`fix-5923` carrying fresh commits, even when they share your `diegosouzapw` identity), or any branch another session owns, is **off-limits — HOLD**, and let the owning session merge it. **Before** merging or pushing to any PR you did not create _this_ session, run `git worktree list` to check for a matching in-flight worktree and re-check `gh pr view --json state,headRefOid`. Only the owning session merges its own in-flight PR; mid-flight merges race the owner and re-trigger the exact commit/CHANGELOG races Rule #19 and Rule #21 guard against. (Reinforces Rule #19.) -23. **`_tasks/` é INTOCÁVEL como estrutura — append/edit-only.** É um repositório git SEPARADO - (remote privado `diegosouzapw/_tasks_omniroute`) montado como diretório real na raiz do - checkout principal. Regras absolutas: (a) NUNCA mover, renomear, deletar, esvaziar ou - transformar `_tasks` em symlink; sessões só podem CRIAR ou EDITAR arquivos dentro dele; - (b) NUNCA rastrear `_tasks` (nem como symlink) no repo principal — o blob rastreado foi a - causa-raiz de DOIS wipes (2026-08-08 e 2026-08-10: `git reset --hard` materializou o - symlink rastreado por cima do diretório real e o git apagou todo o conteúdo ignorado sem - aviso); (c) após qualquer escrita relevante, `git -C _tasks add -A && git -C _tasks commit -&& git -C _tasks push` — o push frequente é o backup real; (d) repetir esta proibição - VERBATIM no prompt de todo subagente que toque git; (e) se `_tasks` aparecer como symlink - quebrado, NÃO commitar nada — restaurar do remote e avisar o operador. O gate - `check:tracked-artifacts` (pre-commit + CI) bloqueia `_tasks` rastreado em qualquer forma. - ---- - -## PII & Stream Sanitization Learnings - -### 1. Regex Security (ReDoS) - -All regex patterns matching variable-length strings (e.g. IPv6 address, credit cards) must use strictly bounded, non-overlapping sequences (e.g., limit occurrences with bounded ranges `{1,7}`) to prevent catastrophic backtracking when processing untrusted inputs. - -### 2. SSE Snapshot Handling - -When parsing streaming LLM responses (e.g. Responses API), check if a chunk represents a final snapshot (`done` or `completed` events). Snapshot text must be sanitized directly as a standalone string (bypassing rolling delta buffers) to prevent text duplication at the end of the stream. - -### 3. Database Handles in Tests - -Ensure that any unit tests that trigger database migrations or establish SQLite connections call `resetDbInstance()` and properly clean up/close all DB handles in a `test.after(...)` hook. Failure to release database connection handles will cause Node's native test runner to hang indefinitely. - ---- - -## Local development access - -The dashboard is reachable at the operator's chosen URL/port (default `http://localhost:20128`). Credentials are operator-specific: - -- **Initial admin password** is read from the `INITIAL_PASSWORD` env var on first install (defaults to `CHANGEME` in `.env.example`; rotate immediately after first login). -- **Local VPS / shared dev environments**: ask the operator for the URL and current credentials — they live in their personal vault, NOT in this repo. - -> Any credential observed in a previous version of this file was a non-production demo value; treat it as compromised and do not reuse it. - - - -# This is NOT the Next.js you know - -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. - -This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. - - diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index fb76678e0cff..000000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,70 +0,0 @@ -# CLAUDE.md - -@AGENTS.md - -**All project rules live in [`AGENTS.md`](AGENTS.md)** — the single source of truth for every AI -assistant (architecture, conventions, testing, quality gates, git workflow, the 23 Hard Rules, -PII learnings). Read it in full; do not re-add project rules here. Everything below applies ONLY -to Claude Code — operational refinements of rules already defined in `AGENTS.md`. - -## Worktree isolation — Claude Code specifics - -The full mandatory worktree protocol (base-branch confirmation, `.claude/worktrees/` canonical -path, `cp -al` node_modules, teardown rules) is in `AGENTS.md` → Git Workflow → "Worktree -isolation". Claude-Code-specific points: - -- Confirm the base branch with the operator via `AskUserQuestion` (Hard Rule #19) unless they - already told you. -- Prefer the native `EnterWorktree` tool — it already creates worktrees under - `.claude/worktrees/` (the canonical path). Create the worktree with the documented `git -worktree add` command, then call `EnterWorktree` with its `path`. - -## Cross-session safety — Claude Code specifics - -Hard Rules #19/#21/#22 (in `AGENTS.md`) govern parallel sessions. Operational reminders for this -harness: - -- **Replicate the `git stash` ban verbatim in the prompt of every subagent that touches git** - (Agent tool / Workflow scripts) — subagents do not inherit this file, and the recorded - recurrence of the stash incident came through a subagent. -- Before merging or pushing to any PR you did not create _this session_, run `git worktree list` - and re-check `gh pr view --json state,headRefOid` (Hard Rule #22b). -- End every session with the main checkout on the branch it started on. - -## Superpowers / planning artifacts — path overrides - -The `_tasks/` convention is defined in `AGENTS.md` → "Planning & Research Artifacts". The -superpowers skills ship with defaults that point at `docs/…` — those defaults are **overridden -here**. When a superpowers skill announces a path like "saved to `docs/superpowers/plans/…`", -rewrite it to the `_tasks/…` equivalent before writing: - -| Artifact (skill) | Default (do NOT use) | Save here instead | -| ---------------------------------- | ------------------------- | ------------------------------------------------------------- | -| Plans (`writing-plans`) | `docs/superpowers/plans/` | `_tasks/superpowers/plans/YYYY-MM-DD-.md` | -| Specs / design (`brainstorming`) | `docs/superpowers/specs/` | `_tasks/superpowers/specs/YYYY-MM-DD--design.md` | -| Research (`deep-research`, ad-hoc) | `docs/research/` | `_tasks/research/…` | -| Hand-offs (`/handoff`) | — | `_tasks/hands-off/__v_sess-/` | - -Commit those artifacts inside the `_tasks/` repo (`git -C _tasks …`), never in the main repo. - -## Scratch / temporary files — use `_artifacts/`, not `/tmp` - -This project overrides the harness's default session scratchpad (`/tmp/claude-*/…`). Write -temporary/working files — exports, generated zips, one-off intermediate outputs, anything you'd -otherwise put in `/tmp` — to `/home/diegosouzapw/dev/proxys/OmniRoute/_artifacts/` instead. - -- `_artifacts/` is a root `_*` path: already gitignored (`AGENTS.md` → "Root `_*` paths"), lives - on disk only, never tracked. -- Reason: keeping scratch output inside the project (vs `/tmp`) makes it trivial for the operator - to find and delete everything temporary in one place, instead of hunting across ephemeral - session-specific `/tmp` directories that vanish or accumulate untracked. -- Do **not** confuse this with `_tasks/` (Hard Rule #23, its own private git repo for durable - plans/specs/research/hand-offs) — `_artifacts/` is for disposable working files only, nothing - here needs to survive or be versioned. - -## Base-green before opening PRs - -Before cutting a branch or opening a PR, run the base-green check (`AGENTS.md` → Git Workflow → -"Base-green check"; project skills reference it as `.agents/skills/_shared/base-green.md`). A PR -opened while the base tip is red must carry `⚠️ base-red inherited: #` in its body. To -drain an accumulated red state (base tip + red PRs), use the `/sweep-reds` skill. diff --git a/docs/CODEX-IMAGES.md b/docs/CODEX-IMAGES.md index 78c57b6727da..3cef9e9506a6 100644 --- a/docs/CODEX-IMAGES.md +++ b/docs/CODEX-IMAGES.md @@ -51,6 +51,17 @@ account request follows an ambiguous outcome. uses base64 original bytes and their MIME type. This contract is distinct from account availability; see [MODEL-AVAILABILITY.md](MODEL-AVAILABILITY.md). +## Recoverable Windows rollout + +The backend-only Ubuntu artifact is a routing verification artifact, not a full +Windows desktop/dashboard replacement. The Windows packaging job runs the full +canonical build including postbuild worker co-location, verifies native Windows +modules, worker ESM scopes, dashboard assets and portable paths, and excludes +private runtime state before upload. Keep the previous installed runtime and +private state recoverable before switching the selected service. Live image +acceptance must preserve selected models and original references; missing account +entitlement is reported, never replaced with a different model or local resize. + ## Verification limits Offline fixtures establish routing, original-byte preservation, negative guards, diff --git a/scripts/check/image-artifact.mjs b/scripts/check/image-artifact.mjs new file mode 100644 index 000000000000..2fcac985f990 --- /dev/null +++ b/scripts/check/image-artifact.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; + +export async function verifyImageArtifact(root) { + const dist = path.join(root, ".build/next"); + const standalone = path.join(dist, "standalone"); + for (const file of [path.join(dist, "BUILD_ID"), path.join(standalone, "server.js"), + path.join(standalone, ".build/next/BUILD_ID")]) { + assert.ok((await fs.stat(file)).isFile(), "Required build output missing"); + assert.ok((await fs.readFile(file, "utf8")).trim(), "Required build output empty"); + } + for (const manifestPath of [path.join(dist, "server/app-paths-manifest.json"), + path.join(standalone, ".build/next/server/app-paths-manifest.json")]) { + const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); + for (const route of ["/api/v1/images/edits/route", "/api/v1/images/generations/route"]) { + assert.equal(typeof manifest[route], "string", `Image route not built: ${route}`); + assert.ok((await fs.stat(path.join(path.dirname(manifestPath), manifest[route]))).size > 0); + } + } + // Next copies build-time dotenv files independently of NFT exclusions. They are + // generated CI configuration, never part of the portable artifact. + for (const name of await fs.readdir(standalone)) { + if (name.startsWith(".env") && name !== ".env.example") { + const file = path.join(standalone, name); + assert.ok((await fs.lstat(file)).isFile(), "Unexpected environment artifact type"); + await fs.readFile(file, "utf8"); + await fs.rm(file); + console.error("Excluded build-time environment file from portable artifact"); + } + } + const dependencyRoot = path.join(standalone, "node_modules"); + for (const entry of await fs.readdir(dependencyRoot, { recursive: true, withFileTypes: true })) { + if (entry.isDirectory() && [".bin", ".claude", ".codex", ".kiro", ".cursor", ".cline", ".agents", ".ai", "graphify-out", ".codebase-memory"].includes(entry.name)) { + const dir = path.join(entry.parentPath, entry.name); + // Dependency package copies can carry upstream development-only metadata. + // Inspect its manifest before dropping that non-runtime subtree. + await fs.readdir(dir); + await fs.rm(dir, { recursive: true }); + } + } + for (const entry of await fs.readdir(standalone, { recursive: true, withFileTypes: true })) { + if (entry.isFile() && /^(?:AGENTS\.md|CLAUDE\.md|explain-AI\.md|reference\.md)$/i.test(entry.name)) { + const file = path.join(entry.parentPath, entry.name); + await fs.readFile(file, "utf8"); + await fs.rm(file); + } + } + const violations = []; + for (const entry of await fs.readdir(standalone, { recursive: true, withFileTypes: true })) { + const name = entry.name; + const relative = path.relative(standalone, path.join(entry.parentPath, name)).replaceAll("\\", "/"); + try { + assert.ok(!entry.isSymbolicLink(), `Artifact contains a symlink: ${relative}`); + const routePath = relative.replace(/^(?:\.build\/next\/(?:static\/chunks\/app|server\/app)|src\/app)\//, ""); + const logSegment = routePath.split("/").indexOf("logs"); + const runtimeRoute = routePath !== relative && logSegment >= 0 + && (await fs.stat(path.join(root, "src/app", ...routePath.split("/").slice(0, logSegment + 1))).catch(() => null))?.isDirectory(); + assert.ok(!/(?:^|\/)(?:\.ai|\.specify|specs|\.claude|\.codex|\.kiro|\.cursor|\.cline|\.agents|\.codebase-memory|graphify-out|\.git|\.ci-work|\.omniroute|_tasks)(?:\/|$)/.test(relative) + && (runtimeRoute || !/(?:^|\/)logs(?:\/|$)/.test(relative)), + `Artifact contains private state: ${relative}`); + const credentialRoute = name === "credentials" && entry.isDirectory() && routePath !== relative + && (await fs.stat(path.join(root, "src/app", routePath)).catch(() => null))?.isDirectory(); + assert.ok(credentialRoute || !/^(?:secrets\.md|explain-AI\.md|reference\.md|AGENTS\.md|CLAUDE\.md|\.ignoreme|runtime\.env|credentials(?:\.json)?|.*\.(?:sqlite(?:-wal|-shm)?|db(?:-wal|-shm)?|log|key))$/i.test(name), + `Artifact contains excluded state: ${relative}`); + assert.ok(!name.startsWith(".env") || name === ".env.example", `Artifact contains environment state: ${relative}`); + } catch (error) { + violations.push(error.message); + } + } + assert.equal(violations.length, 0, violations.join("\n")); + console.error("Verified standalone server, BUILD_ID, image route manifests and artifact exclusions"); +} + diff --git a/scripts/check/image-repair-ci.mjs b/scripts/check/image-repair-ci.mjs index 6e9bf94779b9..fdb80f8d2478 100644 --- a/scripts/check/image-repair-ci.mjs +++ b/scripts/check/image-repair-ci.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import assert from "node:assert/strict"; +import { verifyImageArtifact } from "./image-artifact.mjs"; import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; @@ -239,75 +240,6 @@ async function focusedTests(roots) { } } -async function verifyBuild() { - const dist = path.join(root, ".build/next"); - const standalone = path.join(dist, "standalone"); - for (const file of [path.join(dist, "BUILD_ID"), path.join(standalone, "server.js"), - path.join(standalone, ".build/next/BUILD_ID")]) { - assert.ok((await fs.stat(file)).isFile(), "Required build output missing"); - assert.ok((await fs.readFile(file, "utf8")).trim(), "Required build output empty"); - } - for (const manifestPath of [path.join(dist, "server/app-paths-manifest.json"), - path.join(standalone, ".build/next/server/app-paths-manifest.json")]) { - const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); - for (const route of ["/api/v1/images/edits/route", "/api/v1/images/generations/route"]) { - assert.equal(typeof manifest[route], "string", `Image route not built: ${route}`); - assert.ok((await fs.stat(path.join(path.dirname(manifestPath), manifest[route]))).size > 0); - } - } - // Next copies build-time dotenv files independently of NFT exclusions. They are - // generated CI configuration, never part of the portable artifact. - for (const name of await fs.readdir(standalone)) { - if (name.startsWith(".env") && name !== ".env.example") { - const file = path.join(standalone, name); - assert.ok((await fs.lstat(file)).isFile(), "Unexpected environment artifact type"); - await fs.readFile(file, "utf8"); - await fs.rm(file); - log("INFO", "excluded build-time environment file from portable artifact"); - } - } - const dependencyRoot = path.join(standalone, "node_modules"); - for (const entry of await fs.readdir(dependencyRoot, { recursive: true, withFileTypes: true })) { - if (entry.isDirectory() && [".bin", ".claude", ".codex", ".kiro", ".cursor", ".cline", ".agents", ".ai", "graphify-out", ".codebase-memory"].includes(entry.name)) { - const dir = path.join(entry.parentPath, entry.name); - // Dependency package copies can carry upstream development-only metadata. - // Inspect its manifest before dropping that non-runtime subtree. - await fs.readdir(dir); - await fs.rm(dir, { recursive: true }); - } - } - for (const entry of await fs.readdir(standalone, { recursive: true, withFileTypes: true })) { - if (entry.isFile() && /^(?:AGENTS\.md|CLAUDE\.md|explain-AI\.md|reference\.md)$/i.test(entry.name)) { - const file = path.join(entry.parentPath, entry.name); - await fs.readFile(file, "utf8"); - await fs.rm(file); - } - } - const violations = []; - for (const entry of await fs.readdir(standalone, { recursive: true, withFileTypes: true })) { - const name = entry.name; - const relative = path.relative(standalone, path.join(entry.parentPath, name)); - try { - assert.ok(!entry.isSymbolicLink(), `Artifact contains a symlink: ${relative}`); - const routePath = relative.replace(/^(?:\.build\/next\/(?:static\/chunks\/app|server\/app)|src\/app)\//, ""); - const logSegment = routePath.split("/").indexOf("logs"); - const runtimeRoute = routePath !== relative && logSegment >= 0 - && (await fs.stat(path.join(root, "src/app", ...routePath.split("/").slice(0, logSegment + 1))).catch(() => null))?.isDirectory(); - assert.ok(!/(?:^|\/)(?:\.ai|\.specify|specs|\.claude|\.codex|\.kiro|\.cursor|\.cline|\.agents|\.codebase-memory|graphify-out|\.git|\.ci-work|\.omniroute|_tasks)(?:\/|$)/.test(relative) - && (runtimeRoute || !/(?:^|\/)logs(?:\/|$)/.test(relative)), - `Artifact contains private state: ${relative}`); - const credentialRoute = name === "credentials" && entry.isDirectory() && routePath !== relative - && (await fs.stat(path.join(root, "src/app", routePath)).catch(() => null))?.isDirectory(); - assert.ok(credentialRoute || !/^(?:secrets\.md|explain-AI\.md|reference\.md|AGENTS\.md|CLAUDE\.md|\.ignoreme|runtime\.env|credentials(?:\.json)?|.*\.(?:sqlite(?:-wal|-shm)?|db(?:-wal|-shm)?|log|key))$/i.test(name), - `Artifact contains excluded state: ${relative}`); - assert.ok(!name.startsWith(".env") || name === ".env.example", `Artifact contains environment state: ${relative}`); - } catch (error) { - violations.push(error.message); - } - } - assert.equal(violations.length, 0, violations.join("\n")); - log("INFO", "verified standalone server, BUILD_ID, image route manifests and artifact exclusions"); -} async function main() { assert.equal(process.platform, "linux", "This entry point is hosted Ubuntu-only"); @@ -333,7 +265,7 @@ async function main() { await run("backend standalone build", ...offline(process.execPath, // Hosted cold builds measured260-394s and webpack can be quiet during compilation. ["scripts/build/build-next-isolated.mjs"]), 900, 450); - await verifyBuild(); + await verifyImageArtifact(root); } finally { await fs.rm(work, { recursive: true, force: true }); } diff --git a/scripts/check/image-runtime-package.mjs b/scripts/check/image-runtime-package.mjs new file mode 100644 index 000000000000..48aca918804a --- /dev/null +++ b/scripts/check/image-runtime-package.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { assemblePathSanitize } from "../build/assembleStandalone.mjs"; +import { verifyImageArtifact } from "./image-artifact.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +assert.equal(process.platform, "win32", "Windows hosted packaging only"); +assert.equal(process.env.GITHUB_ACTIONS, "true", "Local builds are not authorized"); +const work = path.join(root, ".ci-work/windows-runtime"); +const home = path.join(work, "home"); +const temp = path.join(work, "temp"); +const env = Object.fromEntries(["PATH", "Path", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT"].filter((key) => process.env[key]).map((key) => [key, process.env[key]])); +Object.assign(env, { + CI: "true", GITHUB_ACTIONS: "true", HOME: home, USERPROFILE: home, + APPDATA: path.join(home, "AppData/Roaming"), LOCALAPPDATA: path.join(home, "AppData/Local"), + DATA_DIR: path.join(work, "data"), TEMP: temp, TMP: temp, TMPDIR: temp, + npm_config_cache: path.join(work, "npm-cache"), HUSKY: "0", + OMNIROUTE_SKIP_POSTINSTALL: "1", OMNIROUTE_SKIP_SYSTEM_TRUST: "1", + OMNIROUTE_SKIP_DNS_WRITE: "1", ADOBE_FIREFLY_BROWSER_REFRESH: "0", + NEXT_TELEMETRY_DISABLED: "1", PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1", + OMNIROUTE_USE_TURBOPACK: "0", NEXT_DIST_DIR: ".build/next", + OMNIROUTE_BUILD_MEMORY_MB: "8192", NODE_OPTIONS: "--max-old-space-size=8192", + OMNIROUTE_DISABLE_BACKGROUND_SERVICES: "true", OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK: "true", +}); + +async function run(label, args, wall, idle) { + const npm = path.join(path.dirname(process.execPath), "node_modules/npm/bin/npm-cli.js"); + await fs.access(npm); + const child = spawn(process.execPath, [npm, ...args], { cwd: root, env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); + let failure; + let idleTimer; + const kill = (reason) => { + if (failure) return; + failure = new Error(`${label}: ${reason}`); + if (child.pid) spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }); + }; + const progress = (data) => { + clearTimeout(idleTimer); + idleTimer = setTimeout(() => kill("idle timeout"), idle * 1000); + process.stderr.write(data); + }; + const wallTimer = setTimeout(() => kill("wall timeout"), wall * 1000); + idleTimer = setTimeout(() => kill("idle timeout"), idle * 1000); + child.stdout.on("data", progress); child.stderr.on("data", progress); + console.error(`[${new Date().toISOString()}] [INFO] [runtime-package] start=${label} pid=${child.pid}`); + try { + const code = await new Promise((resolve, reject) => { child.once("error", reject); child.once("close", resolve); }); + if (failure) throw failure; + assert.equal(code, 0, `${label} failed`); + } finally { clearTimeout(wallTimer); clearTimeout(idleTimer); } +} + +try { + for (const dir of [home, temp, env.APPDATA, env.LOCALAPPDATA, env.DATA_DIR]) await fs.mkdir(dir, { recursive: true }); + Object.assign(process.env, env); + await run("locked Windows install", ["ci", "--no-audit", "--no-fund"], 600, 180); + await run("full build with canonical postbuild", ["run", "build"], 1800, 600); + const out = path.join(root, ".build/next/standalone"); + assemblePathSanitize(root, out, ".build/next"); + await fs.writeFile(path.join(out, "BUILD_SHA"), process.env.GITHUB_SHA ?? "unknown", "utf8"); + const workers = ["src/lib/db/healthCheckWorker.js", "src/lib/usage/callLogArtifactWorker.js", "open-sse/services/compression/compressionWorker.js"]; + for (const worker of workers) { + const file = path.join(out, worker); + assert.ok((await fs.stat(file)).size > 0, `Missing worker ${worker}`); + const pkg = JSON.parse(await fs.readFile(path.join(path.dirname(file), "package.json"), "utf8")); + assert.equal(pkg.type, "module", `Missing worker ESM scope ${worker}`); + } + const manifest = JSON.parse(await fs.readFile(path.join(out, ".build/next/server/app-paths-manifest.json"), "utf8")); + assert.ok(Object.keys(manifest).some((route) => route.includes("dashboard") && route.endsWith("/page")), "Full dashboard absent"); + for (const file of ["server-ws.mjs", "node_modules/sql.js/dist/sql-wasm.wasm", "node_modules/tiktoken/tiktoken_bg.wasm"]) assert.ok((await fs.stat(path.join(out, file))).size > 0, `Missing runtime asset ${file}`); + const native = path.join(out, "node_modules/keytar/build/Release/keytar.node"); + const bytes = await fs.readFile(native); + assert.equal(bytes.subarray(0, 2).toString("ascii"), "MZ", "Windows native module is not PE"); + const pkg = JSON.parse(await fs.readFile(path.join(out, "package.json"), "utf8")); + assert.notEqual(pkg.type, "module", "Standalone server requires CommonJS root"); + await verifyImageArtifact(root); + console.error(`[${new Date().toISOString()}] [INFO] [runtime-package] full Windows runtime verified`); +} finally { await fs.rm(work, { recursive: true, force: true }); } From 921b12e88db668746a627f967b6c98b6a3d9546c Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 18:32:36 +0330 Subject: [PATCH 24/39] fix: sanitize Windows traced build state --- scripts/check/image-artifact.mjs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/scripts/check/image-artifact.mjs b/scripts/check/image-artifact.mjs index 2fcac985f990..3fb96cb9c51e 100644 --- a/scripts/check/image-artifact.mjs +++ b/scripts/check/image-artifact.mjs @@ -29,6 +29,16 @@ export async function verifyImageArtifact(root) { console.error("Excluded build-time environment file from portable artifact"); } } + const tracedWork = path.join(standalone, ".ci-work"); + const tracedWorkStat = await fs.lstat(tracedWork).catch((error) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (tracedWorkStat) { + assert.ok(tracedWorkStat.isDirectory() && !tracedWorkStat.isSymbolicLink(), "Unexpected traced CI state type"); + await fs.readdir(tracedWork); + await fs.rm(tracedWork, { recursive: true }); + } const dependencyRoot = path.join(standalone, "node_modules"); for (const entry of await fs.readdir(dependencyRoot, { recursive: true, withFileTypes: true })) { if (entry.isDirectory() && [".bin", ".claude", ".codex", ".kiro", ".cursor", ".cline", ".agents", ".ai", "graphify-out", ".codebase-memory"].includes(entry.name)) { @@ -54,8 +64,10 @@ export async function verifyImageArtifact(root) { assert.ok(!entry.isSymbolicLink(), `Artifact contains a symlink: ${relative}`); const routePath = relative.replace(/^(?:\.build\/next\/(?:static\/chunks\/app|server\/app)|src\/app)\//, ""); const logSegment = routePath.split("/").indexOf("logs"); + const sourceLogPath = routePath.split("/").slice(0, logSegment + 1).join("/"); const runtimeRoute = routePath !== relative && logSegment >= 0 - && (await fs.stat(path.join(root, "src/app", ...routePath.split("/").slice(0, logSegment + 1))).catch(() => null))?.isDirectory(); + && ((await fs.stat(path.join(root, "src/app", sourceLogPath)).catch(() => null))?.isDirectory() + || (sourceLogPath.startsWith("dashboard/") && (await fs.stat(path.join(root, "src/app/(dashboard)", sourceLogPath)).catch(() => null))?.isDirectory())); assert.ok(!/(?:^|\/)(?:\.ai|\.specify|specs|\.claude|\.codex|\.kiro|\.cursor|\.cline|\.agents|\.codebase-memory|graphify-out|\.git|\.ci-work|\.omniroute|_tasks)(?:\/|$)/.test(relative) && (runtimeRoute || !/(?:^|\/)logs(?:\/|$)/.test(relative)), `Artifact contains private state: ${relative}`); @@ -71,4 +83,3 @@ export async function verifyImageArtifact(root) { assert.equal(violations.length, 0, violations.join("\n")); console.error("Verified standalone server, BUILD_ID, image route manifests and artifact exclusions"); } - From 5ee877a05db4e702c84dc380f5ce43aab06d23a6 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 20:35:05 +0330 Subject: [PATCH 25/39] fix: normalize Windows artifact paths --- scripts/check/image-runtime-package.mjs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/check/image-runtime-package.mjs b/scripts/check/image-runtime-package.mjs index 48aca918804a..2eb1eaa6d1fd 100644 --- a/scripts/check/image-runtime-package.mjs +++ b/scripts/check/image-runtime-package.mjs @@ -61,6 +61,15 @@ try { await run("full build with canonical postbuild", ["run", "build"], 1800, 600); const out = path.join(root, ".build/next/standalone"); assemblePathSanitize(root, out, ".build/next"); + // Generated JSON embeds Windows paths with escaped separators; the canonical + // sanitizer handles slash paths only. Keep the portable artifact relocatable. + const escapedRoot = JSON.stringify(root).slice(1, -1); + for (const relative of ["server.js", ".build/next/required-server-files.json"]) { + const file = path.join(out, relative); + const text = (await fs.readFile(file, "utf8")).replaceAll(escapedRoot, "."); + assert.ok(!text.includes(escapedRoot), "Build-machine path remains in portable artifact"); + await fs.writeFile(file, text, "utf8"); + } await fs.writeFile(path.join(out, "BUILD_SHA"), process.env.GITHUB_SHA ?? "unknown", "utf8"); const workers = ["src/lib/db/healthCheckWorker.js", "src/lib/usage/callLogArtifactWorker.js", "open-sse/services/compression/compressionWorker.js"]; for (const worker of workers) { From 76c92a9b6d7acfad8f2dedbff0f3ae113371b2f3 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 21:31:48 +0330 Subject: [PATCH 26/39] docs: record verified rollout [skip ci] --- docs/CODEX-IMAGES.md | 27 +++++++++++++++++++++++++++ docs/MODEL-AVAILABILITY.md | 22 ++++++++++++++++++---- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/docs/CODEX-IMAGES.md b/docs/CODEX-IMAGES.md index 3cef9e9506a6..af5d2d68f5ad 100644 --- a/docs/CODEX-IMAGES.md +++ b/docs/CODEX-IMAGES.md @@ -62,6 +62,33 @@ private state recoverable before switching the selected service. Live image acceptance must preserve selected models and original references; missing account entitlement is reported, never replaced with a different model or local resize. +## Verified rollout observations — 2026-10-04 + +Hosted run [37219170454](https://github.com/KiaroSama/OmniRoute/actions/runs/37219170454) +passed both jobs for repair commit +`6d4c14f3958155c5db50ea718f7d1c0961dc754d`: 39 discovered image test files, +243 passing tests, no failures, cancellations or skips, and full Windows packaging. +That Windows artifact was installed recoverably; service health returned HTTP 200, +the login page rendered, and the connected Numera health probe succeeded. +No duplicate local CI or build was run. + +Live reference operations on the preceding routing-equivalent build returned: + +| Selected route | Operation | Original returned pixels | Result | +| --- | --- | --- | --- | +| `antigravity/gemini-3.1-flash-image` | Reference edit | 1024 × 1024 | Completed; connected MCP edit also completed | +| `antigravity/gemini-3.1-flash-image` | Native upscale | 4096 × 4096 | Completed; original JPEG bytes verified, no local resize | +| `cx/gpt-6.1-sol` | Reference edit | 1254 × 1254 | Completed; orchestration route, not engine identity proof | +| `cx/gpt-image-2.5-sunburst` | Reference edit | 1254 × 1254 | Completed; forwarded identifier does not prove served engine | +| `cx/gpt-image-2.5-sunburst` | Requested 4096 × 4096 upscale | 1254 × 1254 | Partial; not native 4K | +| `antigravity/gemini-3-pro-image` | Reference edit | None | HTTP 400; unsupported edit admission, not entitlement proof | + +The final artifact change normalizes escaped Windows build paths only; paid image +operations were not repeated for that packaging-only deployment. Numera conservatively +records the failed Pro operation as an unknown generation outcome. Do not resubmit +it automatically, even though inspected gateway source rejects that unregistered +edit model before provider dispatch. + ## Verification limits Offline fixtures establish routing, original-byte preservation, negative guards, diff --git a/docs/MODEL-AVAILABILITY.md b/docs/MODEL-AVAILABILITY.md index c9e3aa0d2de9..9889b2ab42dd 100644 --- a/docs/MODEL-AVAILABILITY.md +++ b/docs/MODEL-AVAILABILITY.md @@ -75,9 +75,23 @@ submission, switch accounts/models, upload a reference elsewhere or resize local manufacture success. Account availability remains **unverified** until that evidence exists. +## Authorized rollout observation — 2026-10-04 + +A subsequent authorized reference-edit request selecting the exact native Pro ID +returned HTTP 400 with no saved image. The repaired dispatcher rejects the +unregistered Pro edit model before provider dispatch, as described above. This is +not a reproduction of the historical generation 404 and does not establish OAuth +entitlement or a replacement model ID. + +Numera conservatively records the failed operation's generation outcome as unknown; +it was not automatically resubmitted. Working Flash, Sol and dedicated Sunburst edit +observations are recorded separately in [CODEX-IMAGES.md](CODEX-IMAGES.md). They do +not resolve Pro availability. No selected model or account was substituted. + ## Verification scope -This document is a read-only source and public-documentation diagnosis. Existing -fixture tests prove local reference dispatch and guards, not Pro availability or -paid-provider success. No runtime, full suite, deployment or live model check was run -for this document. No source behavior or chosen model was changed. +The original diagnosis was read-only source and public-documentation analysis. +Subsequent hosted fixtures, recoverable deployment and the bounded live observation +above add evidence for repaired admission, not upstream Pro availability. The exact +Pro generation 404 remains historical and was not repeated. No Pro-enabling source +behavior was added. From f8e613323b77be58d4a1e3d8eb71d6c1535ea7eb Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 22:35:29 +0330 Subject: [PATCH 27/39] docs: clarify native image limits [skip ci] --- docs/CODEX-IMAGES.md | 26 ++++++++++++++++++++++++++ docs/MODEL-AVAILABILITY.md | 18 ++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/docs/CODEX-IMAGES.md b/docs/CODEX-IMAGES.md index af5d2d68f5ad..94c24913b359 100644 --- a/docs/CODEX-IMAGES.md +++ b/docs/CODEX-IMAGES.md @@ -38,6 +38,32 @@ original output dimensions to establish what was returned. In particular, a request for `3840x2160` does not establish a 4K result. The historical Sol request returned 1672x941; it must not be described as native 4K. +## Why Sol does not establish native 4K + +Checked 2026-10-04. The repaired Codex provider forwards a supplied `size` into +`tools[0].size` and a supplied quality into the hosted image tool. It does not +resize the returned image. `gpt-6.1-sol` is the orchestration model; the Codex +backend selects the image engine when no explicit tool engine is requested. + +Open [Codex issue 28723](https://github.com/openai/codex/issues/28723) reports the +OAuth-backed image path accepting explicit dimensions but returning automatic, +smaller outputs. Its controlled A/B report includes both the Responses tool and +the dedicated Codex Images endpoint. This is independently reported upstream +behavior consistent with our 1672 × 941 and 1254 × 1254 observations, not proof +that every account behaves identically. Our saved outputs prove the returned +pixels; without retained response tool metadata, a specific backend rewrite for +our account remains unverified. Changing the mainline model name or repeating +paid requests is not a verified resolution fix. + +The current public [OpenAI image guide](https://developers.openai.com/api/docs/guides/image-generation#customize-image-output) +documents custom sizes for Sunburst/Flare: dimensions divisible by 16, aspect ratio +between 1:3 and 3:1, neither edge above 3840, and at most 8,294,400 pixels. +Resolutions above 2560 × 1440 are experimental. Consequently **4096 × 4096 is not +a valid public GPT Image 4K request**; 3840 × 2160 fits the documented constraints. +These public API limits do not guarantee the separate Codex OAuth backend honors +an otherwise valid requested size. Gemini's verified 4096 × 4096 output belongs +to a different provider contract. + ## Antigravity edits The registered Flash image route forwards one validated original reference as diff --git a/docs/MODEL-AVAILABILITY.md b/docs/MODEL-AVAILABILITY.md index 9889b2ab42dd..c68e7d54f5c3 100644 --- a/docs/MODEL-AVAILABILITY.md +++ b/docs/MODEL-AVAILABILITY.md @@ -88,6 +88,24 @@ it was not automatically resubmitted. Working Flash, Sol and dedicated Sunburst observations are recorded separately in [CODEX-IMAGES.md](CODEX-IMAGES.md). They do not resolve Pro availability. No selected model or account was substituted. +## Configured model order versus current behavior + +The checked configuration orders Flash first, Gemini Pro second and Sol third; +the preview-spelled Pro entry is a fourth alias candidate. A configured menu entry +is not proof of gateway image-registry membership or upstream availability. + +- **Second, `antigravity/gemini-3-pro-image`:** the current reference-edit failure + is explained by exact catalog admission: only Flash is registered for this + Antigravity image contract. The older generation `NOT_FOUND` has a different + failure boundary and its account/project root cause remains unverified. +- **Third, `cx/gpt-6.1-sol`:** reference editing now succeeds after its exact image + registry entry and dispatch were repaired. The remaining observed failure is + deterministic native resolution, not inability to produce or edit an image. + See the Codex OAuth size diagnosis in [CODEX-IMAGES.md](CODEX-IMAGES.md). +- **Preview spelling:** the existing alias map is not evidence that renaming a + request enables Pro. Do not switch identity or bypass admission to manufacture + a successful result. + ## Verification scope The original diagnosis was read-only source and public-documentation analysis. From 5fa2ecb1f0970ef442fc1057d7a599b515180cff Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sun, 4 Oct 2026 23:08:28 +0330 Subject: [PATCH 28/39] fix: register canonical Pro image routes --- docs/CODEX-IMAGES.md | 16 +++++++++ docs/MODEL-AVAILABILITY.md | 35 ++++++++++++------- open-sse/config/imageProviderCatalog.ts | 11 ++---- open-sse/config/imageRegistry.ts | 6 ++++ .../registry/antigravity/imageModels.ts | 15 ++++++++ .../native-antigravity-image-repair.test.ts | 31 ++++++++++++++-- 6 files changed, 89 insertions(+), 25 deletions(-) create mode 100644 open-sse/config/providers/registry/antigravity/imageModels.ts diff --git a/docs/CODEX-IMAGES.md b/docs/CODEX-IMAGES.md index 94c24913b359..521ec23a89ec 100644 --- a/docs/CODEX-IMAGES.md +++ b/docs/CODEX-IMAGES.md @@ -64,6 +64,22 @@ These public API limits do not guarantee the separate Codex OAuth backend honors an otherwise valid requested size. Gemini's verified 4096 × 4096 output belongs to a different provider contract. +## Manually adding a Codex model does not create native 4K or 8K + +OmniRoute's provider Custom Models UI and `/api/provider-models` support adding +routing entries, including an images endpoint designation. This changes routing +metadata, not the remote model or its entitlement. Image edit admission still +requires a verified registered image contract. A custom name containing `4k` or +`8k` cannot make the Codex backend produce that resolution. + +The explicit registered Codex image engines in this repair are `gpt-image-2` and +`gpt-image-2.5-sunburst`; Sol entries orchestrate a hosted tool. The public image +API documents experimental 3840 × 2160 support, but existing Codex OAuth evidence +shows smaller output even through the dedicated endpoint. No verified ready-made +or manually added Codex route in this investigation establishes native 8K; the +current public GPT Image maximum edge/pixel limits exclude it. Native generation +and later enlargement must be named separately. + ## Antigravity edits The registered Flash image route forwards one validated original reference as diff --git a/docs/MODEL-AVAILABILITY.md b/docs/MODEL-AVAILABILITY.md index c68e7d54f5c3..d929c5130bd8 100644 --- a/docs/MODEL-AVAILABILITY.md +++ b/docs/MODEL-AVAILABILITY.md @@ -33,12 +33,15 @@ performed for this diagnosis. upstream without being an advertised image-catalog entry. See [pinned parser](https://github.com/diegosouzapw/OmniRoute/blob/c1e30b7676975feb298b49eff6ff58923c04b89e/open-sse/config/imageRegistry.ts) and [pinned image generator, lines 1098–1119](https://github.com/diegosouzapw/OmniRoute/blob/c1e30b7676975feb298b49eff6ff58923c04b89e/open-sse/handlers/imageGeneration.ts#L1098-L1119). -4. The local repair preserves the selected native ID and envelope. Reference edits - require exact image-catalog membership, so the unregistered Pro model is rejected - locally with 400 before submission. That guard is not a claim that Google lacks - the model; it says this checkout has not established that edit route. Relevant - local contracts are `open-sse/handlers/geminiImage.ts`, - `open-sse/config/imageProviderCatalog.ts` and +4. The earlier repair retained a Flash-only image catalog, so Pro reference edits + were rejected locally with 400 before submission. The follow-up registers the + exact stable Pro model and resolves the same-provider preview alias to that + canonical ID before generation/edit admission. Original references, selected + credentials and negative guards remain intact. This fixes local admission and + preview wire spelling; it does not establish Cloud Code OAuth availability or + explain an upstream 404 for the already-canonical native generation request. + Relevant contracts are `open-sse/config/providers/registry/antigravity/imageModels.ts`, + `open-sse/config/imageRegistry.ts`, `open-sse/handlers/geminiImage.ts` and `src/app/api/v1/images/edits/editDispatch.ts`. ## Public model existence is not OAuth availability @@ -94,17 +97,23 @@ The checked configuration orders Flash first, Gemini Pro second and Sol third; the preview-spelled Pro entry is a fourth alias candidate. A configured menu entry is not proof of gateway image-registry membership or upstream availability. -- **Second, `antigravity/gemini-3-pro-image`:** the current reference-edit failure - is explained by exact catalog admission: only Flash is registered for this - Antigravity image contract. The older generation `NOT_FOUND` has a different - failure boundary and its account/project root cause remains unverified. +- **Second, `antigravity/gemini-3-pro-image`:** the earlier reference-edit failure + was exact catalog admission. The follow-up registers Pro as text/image-capable; + its upstream generation `NOT_FOUND` has a different failure boundary and the + account/project root cause remains unverified. - **Third, `cx/gpt-6.1-sol`:** reference editing now succeeds after its exact image registry entry and dispatch were repaired. The remaining observed failure is deterministic native resolution, not inability to produce or edit an image. See the Codex OAuth size diagnosis in [CODEX-IMAGES.md](CODEX-IMAGES.md). -- **Preview spelling:** the existing alias map is not evidence that renaming a - request enables Pro. Do not switch identity or bypass admission to manufacture - a successful result. +- **Fourth, preview spelling:** image parsing now resolves the same-provider + preview alias to the stable Pro ID before admission, as the existing native + alias contract does. Both paths target Pro, never Flash. Canonicalization fixes + local wire consistency, not the selected account's upstream entitlement. + +Current [Antigravity model documentation](https://antigravity.google/docs/models) +identifies Nano Banana 2 as its generative image tool; it does not promise Pro +image availability. Successful deterministic fixtures must not be represented as +working Pro generation on the operator's OAuth account. ## Verification scope diff --git a/open-sse/config/imageProviderCatalog.ts b/open-sse/config/imageProviderCatalog.ts index e157533562d0..3a189816de2c 100644 --- a/open-sse/config/imageProviderCatalog.ts +++ b/open-sse/config/imageProviderCatalog.ts @@ -12,6 +12,7 @@ import { AI_HORDE_IMAGE_PROVIDER } from "./providers/registry/aihorde/imageModel import type { ImageProviderConfig } from "./imageRegistryTypes.ts"; import { CODEX_IMAGE_PROVIDER } from "./providers/registry/codex/imageModels.ts"; +import { ANTIGRAVITY_IMAGE_PROVIDER } from "./providers/registry/antigravity/imageModels.ts"; export const IMAGE_PROVIDERS: Record = { agnes: { @@ -212,15 +213,7 @@ export const IMAGE_PROVIDERS: Record = { supportedSizes: ["1024x1024", "512x512"], }, - antigravity: { - id: "antigravity", - baseUrl: "https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent", - authType: "oauth", - authHeader: "bearer", - format: "gemini-image", // Special format: uses Gemini generateContent API - models: [{ id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" }], - supportedSizes: ["1024x1024"], - }, + antigravity: ANTIGRAVITY_IMAGE_PROVIDER, //Curruntly no models serving nebius: { diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index c4f80764c8e3..b6b3771e69e2 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -33,6 +33,12 @@ interface ImageCatalogModelEntry { } const IMAGE_MODEL_ALIASES: Record = { + "gemini-3-pro-image-preview": { + provider: "antigravity", + model: "gemini-3-pro-image", + name: "Gemini 3 Pro Image", + listInCatalog: false, + }, "gemini-3.1-flash-image-preview": { provider: "antigravity", model: "gemini-3.1-flash-image", diff --git a/open-sse/config/providers/registry/antigravity/imageModels.ts b/open-sse/config/providers/registry/antigravity/imageModels.ts new file mode 100644 index 000000000000..9b68595a954a --- /dev/null +++ b/open-sse/config/providers/registry/antigravity/imageModels.ts @@ -0,0 +1,15 @@ +import type { ImageProviderConfig } from "../../../imageRegistryTypes.ts"; + +export const ANTIGRAVITY_IMAGE_PROVIDER: ImageProviderConfig = { + id: "antigravity", + baseUrl: "https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent", + authType: "oauth", + authHeader: "bearer", + format: "gemini-image", + models: [ + { id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image", inputModalities: ["text", "image"] }, + // Registration enables exact route admission, not OAuth account entitlement. + { id: "gemini-3-pro-image", name: "Gemini 3 Pro Image", inputModalities: ["text", "image"] }, + ], + supportedSizes: ["1024x1024"], +}; diff --git a/tests/unit/native-antigravity-image-repair.test.ts b/tests/unit/native-antigravity-image-repair.test.ts index cebffed8a423..744e3ea10c3b 100644 --- a/tests/unit/native-antigravity-image-repair.test.ts +++ b/tests/unit/native-antigravity-image-repair.test.ts @@ -66,25 +66,50 @@ test("Antigravity public generator forwards original reference with selected nat assert.deepEqual(captured.request.generationConfig.imageConfig, { aspectRatio: "16:9", imageSize: "4K" }); }); +test("Pro model aliases preserve the canonical Pro identity at registry and generator seams", { timeout: 5000 }, async () => { + const { parseImageModel, getImageModelEntry } = await import("../../open-sse/config/imageRegistry.ts"); + for (const selected of ["antigravity/gemini-3-pro-image", "antigravity/gemini-3-pro-image-preview"]) { + assert.deepEqual(parseImageModel(selected), { provider: "antigravity", model: "gemini-3-pro-image" }); + assert.equal(getImageModelEntry(selected)?.model, "gemini-3-pro-image"); + for (const editing of [false, true]) { + let submissions = 0; + globalThis.fetch = async (_url, init) => { + submissions++; + const request = JSON.parse(String(init?.body)) as CapturedImageRequest; + assert.equal(request.model, "gemini-3-pro-image"); + assert.equal(request.project, "fixture-project"); + assert.deepEqual(request.request.generationConfig.imageConfig, { aspectRatio: "1:1", imageSize: "4K" }); + assert.equal(request.request.contents[0].parts.length, editing ? 2 : 1); + if (editing) assert.deepEqual(request.request.contents[0].parts[1], { inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }); + return Response.json({ response: { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }] } }] } }); + }; + const result = await handleImageGeneration({ body: { model: selected, prompt: "Pro fixture", size: "1024x1024", image_size: "4K", ...(editing ? { image: DATA_URL } : {}) }, credentials, log: null }); + assert.equal(result.success, true); + assert.equal(submissions, 1); + } + } +}); + test("Antigravity HTTP edit preserves reference, ratio and tier for JSON and multipart", { timeout: 5000 }, async () => { await providers.createProviderConnection({ provider: "antigravity", authType: "oauth", name: "fixture", accessToken: "fixture-token", isActive: true, testStatus: "active", providerSpecificData: { projectId: "fixture-project" } }); - for (const multipart of [false, true]) { + for (const [selected, multipart] of [[model, false], [model, true], ["antigravity/gemini-3-pro-image", false], ["antigravity/gemini-3-pro-image-preview", true]] as const) { let captured: CapturedImageRequest; globalThis.fetch = async (_url, init) => { captured = JSON.parse(String(init?.body)); return Response.json({ response: { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }] } }] } }); }; const form = new FormData(); - form.set("model", model); form.set("prompt", "make it blue"); + form.set("model", selected); form.set("prompt", "make it blue"); form.set("aspect_ratio", "16:9"); form.set("image_size", "4K"); form.set("image", new Blob([PNG], { type: "image/png" }), "reference.png"); const request = new Request("http://localhost/api/v1/images/edits", { method: "POST", ...(multipart ? {} : { headers: { "content-type": "application/json" } }), - body: multipart ? form : JSON.stringify({ model, prompt: "make it blue", image: DATA_URL, aspect_ratio: "16:9", image_size: "4K" }), + body: multipart ? form : JSON.stringify({ model: selected, prompt: "make it blue", image: DATA_URL, aspect_ratio: "16:9", image_size: "4K" }), }); const response = await POST(request); assert.equal(response.status, 200, JSON.stringify(await response.clone().json())); + assert.equal(captured.model, selected.includes("gemini-3-pro-image") ? "gemini-3-pro-image" : "gemini-3.1-flash-image"); assert.deepEqual(captured.request.contents[0].parts[1], { inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }); assert.deepEqual(captured.request.generationConfig.imageConfig, { aspectRatio: "16:9", imageSize: "4K" }); } From 27c19861e0b5e5d7714cc3b275afaf264a121f5f Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Mon, 5 Oct 2026 21:17:57 +0330 Subject: [PATCH 29/39] fix: honor Codex quota filter opt-out --- open-sse/services/quotaPreflight.ts | 10 +- src/domain/quotaCache.ts | 6 +- src/lib/providers/codexQuotaFiltering.ts | 23 +++++ tests/unit/codex-quota-optout.test.ts | 118 +++++++++++++++++++++++ 4 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 src/lib/providers/codexQuotaFiltering.ts create mode 100644 tests/unit/codex-quota-optout.test.ts diff --git a/open-sse/services/quotaPreflight.ts b/open-sse/services/quotaPreflight.ts index 24d8d83f5739..3dc4a3571180 100644 --- a/open-sse/services/quotaPreflight.ts +++ b/open-sse/services/quotaPreflight.ts @@ -21,6 +21,7 @@ import { isCompatibleProviderConnectionId } from "@/shared/utils/compatibleProviderId"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; import { isClaudeExtraUsageAllowed } from "@/lib/providers/claudeExtraUsage"; +import { isCodexQuotaFilteringDisabled } from "@/lib/providers/codexQuotaFiltering"; // #14359 — import the leaf, NOT "@/domain/quotaCache": quotaCache → usage.ts → usage/openrouter.ts → // openrouterQuotaFetcher.ts → this file, so importing quotaCache here closes an ESM init cycle // that deadlocks the esbuild MCP bundle (tests/unit/build/mcp-bundle-startup.test.ts). @@ -323,10 +324,11 @@ export function evaluateQuotaCutoff( scope?: QuotaCutoffScope ): PreflightQuotaResult { if (!quota) return { proceed: true }; - // Operator-enabled Claude extra usage is billed after the 5h session quota - // is gone. Pre-dispatch must not skip the account before Anthropic sees the - // request; blockExtraUsage=false is the only opt-in. - if (isClaudeExtraUsageAllowed(scope?.provider, scope?.providerSpecificData)) { + // Explicit local quota opt-outs leave billing eligibility to the upstream service. + if ( + isClaudeExtraUsageAllowed(scope?.provider, scope?.providerSpecificData) || + isCodexQuotaFilteringDisabled(scope?.provider, scope?.providerSpecificData) + ) { return { proceed: true, quotaPercent: quota.percentUsed }; } // #14359 — same escape as the dispatch-time predicates: a recent success is not exhaustion. diff --git a/src/domain/quotaCache.ts b/src/domain/quotaCache.ts index 4fdb474dcfaa..abe53b29fd56 100644 --- a/src/domain/quotaCache.ts +++ b/src/domain/quotaCache.ts @@ -40,6 +40,7 @@ import { } from "@omniroute/open-sse/services/codexAccount/index.ts"; import { selectAntigravityQuotaWindowNames } from "@omniroute/open-sse/services/antigravityQuotaFamily.ts"; import { isClaudeExtraUsageAllowed } from "@/lib/providers/claudeExtraUsage"; +import { isCodexQuotaFilteringDisabled } from "@/lib/providers/codexQuotaFiltering"; import { resolveProviderId } from "@/shared/constants/providers"; import { claudeQuotaMatchesModel, @@ -57,8 +58,7 @@ import { isQuotaHealthy, } from "./quotaCacheState"; -// #14359 — re-exported so existing callers (chat.ts, tests) keep importing from here. Only -// markQuotaHealthy has outside callers; the rest are internal and stay unexported (dead-code gate). +// Keep markQuotaHealthy's public import path; the remaining leaf state stays internal. export { markQuotaHealthy } from "./quotaCacheState"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -604,8 +604,8 @@ export function isQuotaExhaustedForRequest( requestedModel: string | null = null, providerSpecificData?: unknown ): boolean { - // #14359 — a recent successful dispatch stands the predicates down for a park window. if (isQuotaHealthy(connectionId)) return false; + if (isCodexQuotaFilteringDisabled(provider, providerSpecificData)) return false; if (isClaudeExtraUsageAllowed(provider, providerSpecificData)) return false; const entry = getState().cache.get(connectionId) || hydrateQuotaCacheFromSnapshots(connectionId); if (!entry) return false; diff --git a/src/lib/providers/codexQuotaFiltering.ts b/src/lib/providers/codexQuotaFiltering.ts new file mode 100644 index 000000000000..7690c759541d --- /dev/null +++ b/src/lib/providers/codexQuotaFiltering.ts @@ -0,0 +1,23 @@ +/** Both existing controls must explicitly opt this Codex connection out of local quota filtering. */ +export function isCodexQuotaFilteringDisabled( + provider: string | null | undefined, + providerSpecificData: unknown +): boolean { + if ( + provider !== "codex" || + !providerSpecificData || + typeof providerSpecificData !== "object" || + Array.isArray(providerSpecificData) + ) { + return false; + } + const data = providerSpecificData as Record; + const policy = data.limitPolicy; + return ( + data.quotaPreflightEnabled === false && + !!policy && + typeof policy === "object" && + !Array.isArray(policy) && + (policy as Record).enabled === false + ); +} diff --git a/tests/unit/codex-quota-optout.test.ts b/tests/unit/codex-quota-optout.test.ts new file mode 100644 index 000000000000..d70dfd8743bb --- /dev/null +++ b/tests/unit/codex-quota-optout.test.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const data = fs.mkdtempSync(path.join(os.tmpdir(), "omni-codex-quota-optout-")); +process.env.DATA_DIR = data; +process.env.APP_LOG_TO_FILE = "false"; + +const core = await import("../../src/lib/db/core.ts"); +const providers = await import("../../src/lib/db/providers.ts"); +const quotaCache = await import("../../src/domain/quotaCache.ts"); +const { evaluateQuotaCutoff, registerQuotaFetcher } = + await import("../../open-sse/services/quotaPreflight.ts"); +const { getProviderCredentialsWithQuotaPreflight } = + await import("../../src/sse/services/auth.ts"); +const { invalidateDbCache } = await import("../../src/lib/db/readCache.ts"); + +const optedOut = { limitPolicy: { enabled: false }, quotaPreflightEnabled: false }; +const resetAt = new Date(Date.now() + 4 * 24 * 60 * 60 * 1000).toISOString(); +const exhausted = { + used: 100, total: 100, percentUsed: 1, resetAt, limitReached: true, + windows: { session: { percentUsed: 1, resetAt } }, +}; +let connectionId = ""; + +test.before(async () => { + const connection = await providers.createProviderConnection({ + provider: "codex", authType: "oauth", name: "quota-optout-fixture", + accessToken: "fixture-access", isActive: true, testStatus: "active", + providerSpecificData: {}, + }); + connectionId = connection.id; +}); + +test.after(async () => { + await (await import("../../src/lib/usage/callLogs.ts")).closeCallLogSaves(5_000); + core.resetDbInstance(); + fs.rmSync(data, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}, { timeout: 6_000 }); + +test("Codex combined opt-out preserves observations and bypasses cached/live quota cutoffs", + { timeout: 5_000 }, async () => { + quotaCache.setQuotaCache(connectionId, "codex", { + session: { remainingPercentage: 0, resetAt }, + }); + const previous = structuredClone(quotaCache.getQuotaCache(connectionId)); + assert.equal(quotaCache.isQuotaExhaustedForRequest(connectionId, "codex", "gpt-6.1-sol"), true); + assert.equal(evaluateQuotaCutoff(exhausted, undefined, { provider: "codex" }).proceed, false); + + await providers.updateProviderConnection(connectionId, { providerSpecificData: optedOut }); + invalidateDbCache("connections", connectionId); + assert.equal( + quotaCache.isQuotaExhaustedForRequest(connectionId, "codex", "gpt-6.1-sol", optedOut), + false + ); + assert.equal(evaluateQuotaCutoff(exhausted, undefined, { + provider: "codex", providerSpecificData: optedOut, + }).proceed, true); + assert.deepEqual(quotaCache.getQuotaCache(connectionId), previous); + + registerQuotaFetcher("codex", async () => { + assert.fail("Explicit opt-out must not perform an upstream quota fetch"); + }); + const selected = await getProviderCredentialsWithQuotaPreflight( + "codex", null, [connectionId], "gpt-6.1-sol" + ); + assert.equal(selected?.connectionId, connectionId); + }); + +test("Partial, malformed and other-provider settings retain exhausted-window filtering", + { timeout: 1_000 }, () => { + for (const settings of [ + {}, { quotaPreflightEnabled: false }, { limitPolicy: { enabled: false } }, + { limitPolicy: { enabled: "false" }, quotaPreflightEnabled: false }, + { limitPolicy: { enabled: false }, quotaPreflightEnabled: "false" }, + ]) { + assert.equal( + quotaCache.isQuotaExhaustedForRequest(connectionId, "codex", "gpt-6.1-sol", settings), + true + ); + assert.equal(evaluateQuotaCutoff(exhausted, undefined, { + provider: "codex", providerSpecificData: settings, + }).proceed, false); + } + assert.equal(evaluateQuotaCutoff(exhausted, undefined, { + provider: "openai", providerSpecificData: optedOut, + }).proceed, false); + quotaCache.setQuotaCache("other-provider", "openai", { + session: { remainingPercentage: 0, resetAt }, + }); + assert.equal( + quotaCache.isQuotaExhaustedForRequest("other-provider", "openai", "gpt-6.1-sol", optedOut), + true + ); + }); + +test("Restoring filtering and terminal account protection remain effective", + { timeout: 5_000 }, async () => { + await providers.updateProviderConnection(connectionId, { + providerSpecificData: {}, testStatus: "active", + }); + invalidateDbCache("connections", connectionId); + const restored = await getProviderCredentialsWithQuotaPreflight( + "codex", null, [connectionId], "gpt-6.1-sol" + ); + assert.equal(restored?.allRateLimited, true); + + await providers.updateProviderConnection(connectionId, { + providerSpecificData: optedOut, testStatus: "banned", + }); + invalidateDbCache("connections", connectionId); + const banned = await getProviderCredentialsWithQuotaPreflight( + "codex", null, [connectionId], "gpt-6.1-sol" + ); + assert.notEqual(banned?.connectionId, connectionId); + }); From 897b8b17a40bae9de45537e23bf112b87a40f18b Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Mon, 5 Oct 2026 21:22:51 +0330 Subject: [PATCH 30/39] test: narrow credential selection results --- tests/unit/codex-quota-optout.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/unit/codex-quota-optout.test.ts b/tests/unit/codex-quota-optout.test.ts index d70dfd8743bb..f734ae91e435 100644 --- a/tests/unit/codex-quota-optout.test.ts +++ b/tests/unit/codex-quota-optout.test.ts @@ -31,6 +31,7 @@ test.before(async () => { accessToken: "fixture-access", isActive: true, testStatus: "active", providerSpecificData: {}, }); + assert.ok(typeof connection.id === "string"); connectionId = connection.id; }); @@ -66,7 +67,8 @@ test("Codex combined opt-out preserves observations and bypasses cached/live quo const selected = await getProviderCredentialsWithQuotaPreflight( "codex", null, [connectionId], "gpt-6.1-sol" ); - assert.equal(selected?.connectionId, connectionId); + assert.ok(selected && "connectionId" in selected); + assert.equal(selected.connectionId, connectionId); }); test("Partial, malformed and other-provider settings retain exhausted-window filtering", @@ -105,7 +107,8 @@ test("Restoring filtering and terminal account protection remain effective", const restored = await getProviderCredentialsWithQuotaPreflight( "codex", null, [connectionId], "gpt-6.1-sol" ); - assert.equal(restored?.allRateLimited, true); + assert.ok(restored && "allRateLimited" in restored); + assert.equal(restored.allRateLimited, true); await providers.updateProviderConnection(connectionId, { providerSpecificData: optedOut, testStatus: "banned", @@ -114,5 +117,5 @@ test("Restoring filtering and terminal account protection remain effective", const banned = await getProviderCredentialsWithQuotaPreflight( "codex", null, [connectionId], "gpt-6.1-sol" ); - assert.notEqual(banned?.connectionId, connectionId); + assert.ok(!banned || !("connectionId" in banned) || banned.connectionId !== connectionId); }); From 1a04e0aa407b0f7ca3bfdb131285da7b9189603e Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Mon, 5 Oct 2026 21:38:38 +0330 Subject: [PATCH 31/39] chore: keep image repair scoped [skip ci] --- open-sse/services/quotaPreflight.ts | 10 +- src/domain/quotaCache.ts | 6 +- src/lib/providers/codexQuotaFiltering.ts | 23 ----- tests/unit/codex-quota-optout.test.ts | 121 ----------------------- 4 files changed, 7 insertions(+), 153 deletions(-) delete mode 100644 src/lib/providers/codexQuotaFiltering.ts delete mode 100644 tests/unit/codex-quota-optout.test.ts diff --git a/open-sse/services/quotaPreflight.ts b/open-sse/services/quotaPreflight.ts index 3dc4a3571180..24d8d83f5739 100644 --- a/open-sse/services/quotaPreflight.ts +++ b/open-sse/services/quotaPreflight.ts @@ -21,7 +21,6 @@ import { isCompatibleProviderConnectionId } from "@/shared/utils/compatibleProviderId"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; import { isClaudeExtraUsageAllowed } from "@/lib/providers/claudeExtraUsage"; -import { isCodexQuotaFilteringDisabled } from "@/lib/providers/codexQuotaFiltering"; // #14359 — import the leaf, NOT "@/domain/quotaCache": quotaCache → usage.ts → usage/openrouter.ts → // openrouterQuotaFetcher.ts → this file, so importing quotaCache here closes an ESM init cycle // that deadlocks the esbuild MCP bundle (tests/unit/build/mcp-bundle-startup.test.ts). @@ -324,11 +323,10 @@ export function evaluateQuotaCutoff( scope?: QuotaCutoffScope ): PreflightQuotaResult { if (!quota) return { proceed: true }; - // Explicit local quota opt-outs leave billing eligibility to the upstream service. - if ( - isClaudeExtraUsageAllowed(scope?.provider, scope?.providerSpecificData) || - isCodexQuotaFilteringDisabled(scope?.provider, scope?.providerSpecificData) - ) { + // Operator-enabled Claude extra usage is billed after the 5h session quota + // is gone. Pre-dispatch must not skip the account before Anthropic sees the + // request; blockExtraUsage=false is the only opt-in. + if (isClaudeExtraUsageAllowed(scope?.provider, scope?.providerSpecificData)) { return { proceed: true, quotaPercent: quota.percentUsed }; } // #14359 — same escape as the dispatch-time predicates: a recent success is not exhaustion. diff --git a/src/domain/quotaCache.ts b/src/domain/quotaCache.ts index abe53b29fd56..4fdb474dcfaa 100644 --- a/src/domain/quotaCache.ts +++ b/src/domain/quotaCache.ts @@ -40,7 +40,6 @@ import { } from "@omniroute/open-sse/services/codexAccount/index.ts"; import { selectAntigravityQuotaWindowNames } from "@omniroute/open-sse/services/antigravityQuotaFamily.ts"; import { isClaudeExtraUsageAllowed } from "@/lib/providers/claudeExtraUsage"; -import { isCodexQuotaFilteringDisabled } from "@/lib/providers/codexQuotaFiltering"; import { resolveProviderId } from "@/shared/constants/providers"; import { claudeQuotaMatchesModel, @@ -58,7 +57,8 @@ import { isQuotaHealthy, } from "./quotaCacheState"; -// Keep markQuotaHealthy's public import path; the remaining leaf state stays internal. +// #14359 — re-exported so existing callers (chat.ts, tests) keep importing from here. Only +// markQuotaHealthy has outside callers; the rest are internal and stay unexported (dead-code gate). export { markQuotaHealthy } from "./quotaCacheState"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -604,8 +604,8 @@ export function isQuotaExhaustedForRequest( requestedModel: string | null = null, providerSpecificData?: unknown ): boolean { + // #14359 — a recent successful dispatch stands the predicates down for a park window. if (isQuotaHealthy(connectionId)) return false; - if (isCodexQuotaFilteringDisabled(provider, providerSpecificData)) return false; if (isClaudeExtraUsageAllowed(provider, providerSpecificData)) return false; const entry = getState().cache.get(connectionId) || hydrateQuotaCacheFromSnapshots(connectionId); if (!entry) return false; diff --git a/src/lib/providers/codexQuotaFiltering.ts b/src/lib/providers/codexQuotaFiltering.ts deleted file mode 100644 index 7690c759541d..000000000000 --- a/src/lib/providers/codexQuotaFiltering.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** Both existing controls must explicitly opt this Codex connection out of local quota filtering. */ -export function isCodexQuotaFilteringDisabled( - provider: string | null | undefined, - providerSpecificData: unknown -): boolean { - if ( - provider !== "codex" || - !providerSpecificData || - typeof providerSpecificData !== "object" || - Array.isArray(providerSpecificData) - ) { - return false; - } - const data = providerSpecificData as Record; - const policy = data.limitPolicy; - return ( - data.quotaPreflightEnabled === false && - !!policy && - typeof policy === "object" && - !Array.isArray(policy) && - (policy as Record).enabled === false - ); -} diff --git a/tests/unit/codex-quota-optout.test.ts b/tests/unit/codex-quota-optout.test.ts deleted file mode 100644 index f734ae91e435..000000000000 --- a/tests/unit/codex-quota-optout.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -const data = fs.mkdtempSync(path.join(os.tmpdir(), "omni-codex-quota-optout-")); -process.env.DATA_DIR = data; -process.env.APP_LOG_TO_FILE = "false"; - -const core = await import("../../src/lib/db/core.ts"); -const providers = await import("../../src/lib/db/providers.ts"); -const quotaCache = await import("../../src/domain/quotaCache.ts"); -const { evaluateQuotaCutoff, registerQuotaFetcher } = - await import("../../open-sse/services/quotaPreflight.ts"); -const { getProviderCredentialsWithQuotaPreflight } = - await import("../../src/sse/services/auth.ts"); -const { invalidateDbCache } = await import("../../src/lib/db/readCache.ts"); - -const optedOut = { limitPolicy: { enabled: false }, quotaPreflightEnabled: false }; -const resetAt = new Date(Date.now() + 4 * 24 * 60 * 60 * 1000).toISOString(); -const exhausted = { - used: 100, total: 100, percentUsed: 1, resetAt, limitReached: true, - windows: { session: { percentUsed: 1, resetAt } }, -}; -let connectionId = ""; - -test.before(async () => { - const connection = await providers.createProviderConnection({ - provider: "codex", authType: "oauth", name: "quota-optout-fixture", - accessToken: "fixture-access", isActive: true, testStatus: "active", - providerSpecificData: {}, - }); - assert.ok(typeof connection.id === "string"); - connectionId = connection.id; -}); - -test.after(async () => { - await (await import("../../src/lib/usage/callLogs.ts")).closeCallLogSaves(5_000); - core.resetDbInstance(); - fs.rmSync(data, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); -}, { timeout: 6_000 }); - -test("Codex combined opt-out preserves observations and bypasses cached/live quota cutoffs", - { timeout: 5_000 }, async () => { - quotaCache.setQuotaCache(connectionId, "codex", { - session: { remainingPercentage: 0, resetAt }, - }); - const previous = structuredClone(quotaCache.getQuotaCache(connectionId)); - assert.equal(quotaCache.isQuotaExhaustedForRequest(connectionId, "codex", "gpt-6.1-sol"), true); - assert.equal(evaluateQuotaCutoff(exhausted, undefined, { provider: "codex" }).proceed, false); - - await providers.updateProviderConnection(connectionId, { providerSpecificData: optedOut }); - invalidateDbCache("connections", connectionId); - assert.equal( - quotaCache.isQuotaExhaustedForRequest(connectionId, "codex", "gpt-6.1-sol", optedOut), - false - ); - assert.equal(evaluateQuotaCutoff(exhausted, undefined, { - provider: "codex", providerSpecificData: optedOut, - }).proceed, true); - assert.deepEqual(quotaCache.getQuotaCache(connectionId), previous); - - registerQuotaFetcher("codex", async () => { - assert.fail("Explicit opt-out must not perform an upstream quota fetch"); - }); - const selected = await getProviderCredentialsWithQuotaPreflight( - "codex", null, [connectionId], "gpt-6.1-sol" - ); - assert.ok(selected && "connectionId" in selected); - assert.equal(selected.connectionId, connectionId); - }); - -test("Partial, malformed and other-provider settings retain exhausted-window filtering", - { timeout: 1_000 }, () => { - for (const settings of [ - {}, { quotaPreflightEnabled: false }, { limitPolicy: { enabled: false } }, - { limitPolicy: { enabled: "false" }, quotaPreflightEnabled: false }, - { limitPolicy: { enabled: false }, quotaPreflightEnabled: "false" }, - ]) { - assert.equal( - quotaCache.isQuotaExhaustedForRequest(connectionId, "codex", "gpt-6.1-sol", settings), - true - ); - assert.equal(evaluateQuotaCutoff(exhausted, undefined, { - provider: "codex", providerSpecificData: settings, - }).proceed, false); - } - assert.equal(evaluateQuotaCutoff(exhausted, undefined, { - provider: "openai", providerSpecificData: optedOut, - }).proceed, false); - quotaCache.setQuotaCache("other-provider", "openai", { - session: { remainingPercentage: 0, resetAt }, - }); - assert.equal( - quotaCache.isQuotaExhaustedForRequest("other-provider", "openai", "gpt-6.1-sol", optedOut), - true - ); - }); - -test("Restoring filtering and terminal account protection remain effective", - { timeout: 5_000 }, async () => { - await providers.updateProviderConnection(connectionId, { - providerSpecificData: {}, testStatus: "active", - }); - invalidateDbCache("connections", connectionId); - const restored = await getProviderCredentialsWithQuotaPreflight( - "codex", null, [connectionId], "gpt-6.1-sol" - ); - assert.ok(restored && "allRateLimited" in restored); - assert.equal(restored.allRateLimited, true); - - await providers.updateProviderConnection(connectionId, { - providerSpecificData: optedOut, testStatus: "banned", - }); - invalidateDbCache("connections", connectionId); - const banned = await getProviderCredentialsWithQuotaPreflight( - "codex", null, [connectionId], "gpt-6.1-sol" - ); - assert.ok(!banned || !("connectionId" in banned) || banned.connectionId !== connectionId); - }); From d7d65e5a48ba4fc25357e615e648d4d31b21fd5f Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Mon, 12 Oct 2026 02:12:39 +0330 Subject: [PATCH 32/39] test: correct Codex image credentials --- tests/unit/codex-images-dedicated-endpoint.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/codex-images-dedicated-endpoint.test.ts b/tests/unit/codex-images-dedicated-endpoint.test.ts index 6f08cb83df13..b3d71c336763 100644 --- a/tests/unit/codex-images-dedicated-endpoint.test.ts +++ b/tests/unit/codex-images-dedicated-endpoint.test.ts @@ -237,9 +237,9 @@ test("Codex Images errors keep plan guards, terminal outcomes, and privacy", asy test("v1 image generation route serves codex/gpt-image-2.5-flare from the Images route", async () => { await providersDb.createProviderConnection({ provider: "codex", - authType: "apikey", + authType: "oauth", name: "codex-images", - apiKey: "codex-oauth-token", + accessToken: "codex-oauth-token", isActive: true, testStatus: "active", providerSpecificData: {}, @@ -269,9 +269,9 @@ test("v1 image generation route serves codex/gpt-image-2.5-flare from the Images test("v1 image edit route sends Codex GPT Image references to the Images edits route", async () => { await providersDb.createProviderConnection({ provider: "codex", - authType: "apikey", + authType: "oauth", name: "codex-images-edit", - apiKey: "codex-oauth-token", + accessToken: "codex-oauth-token", isActive: true, testStatus: "active", providerSpecificData: {}, From 92e5be3e2fafda5be639cacd8810f646b93c075c Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Mon, 12 Oct 2026 09:42:10 +0330 Subject: [PATCH 33/39] test: remove duplicate image fixtures --- .../unit/image-generation-resilience.test.ts | 294 ------------------ 1 file changed, 294 deletions(-) diff --git a/tests/unit/image-generation-resilience.test.ts b/tests/unit/image-generation-resilience.test.ts index bd41ea5d1fee..b0be3278ef92 100644 --- a/tests/unit/image-generation-resilience.test.ts +++ b/tests/unit/image-generation-resilience.test.ts @@ -14,10 +14,7 @@ await import("../_setup/imageCallLogLifecycle.ts"); 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 providerImageRoute = - await import("../../src/app/api/v1/providers/[provider]/images/generations/route.ts"); const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); const { setPinnedFetchTestOverride } = await import("../../src/shared/network/remoteImageFetch.ts"); @@ -93,297 +90,6 @@ test.after(() => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); -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", String(connection.id), { - type: "http", - host: "127.0.0.1", - port: 1, // intentionally unreachable — proves proxy path was taken - }); - - // #9100 non-blocking probe: keep the request in flight so the fast-fail can - // abort it with the proxy-specific 503 (see the edit-route case above). - globalThis.fetch = async () => { - await new Promise(() => {}); - throw new Error("unreachable"); - }; - - 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 ErrorResponseBody; - assert.match(body.error.message, /unreachable/i); -}); - -test("v1 image generation POST executes directly when proxy resolution fails gracefully", async () => { - 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 ImageResponseBody; - 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 ImageResponseBody; - assert.equal(response.status, 200); - assert.ok(body.data, "should have image data"); -}); - -test("v1 image generation POST rotates to the next account after an upstream 401", async () => { - await seedConnection("openai", { apiKey: "expired-image-key", priority: 1 }); - await seedConnection("openai", { apiKey: "healthy-image-key", priority: 2 }); - const authorizationHeaders: string[] = []; - - globalThis.fetch = async (url, options: RequestInit = {}) => { - assert.equal(String(url), "https://api.openai.com/v1/images/generations"); - const authorization = new Headers(options.headers).get("authorization") ?? ""; - authorizationHeaders.push(authorization); - if (authorization === "Bearer expired-image-key") { - return new Response(JSON.stringify({ error: { message: "expired access token" } }), { - status: 401, - headers: { "content-type": "application/json" }, - }); - } - assert.equal(authorization, "Bearer healthy-image-key"); - return new Response( - JSON.stringify({ created: 123, data: [{ url: "https://cdn.example.com/rotated.png" }] }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }; - - 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: "rotate image account" }), - }) - ); - const body = (await response.json()) as ImageResponseBody; - - assert.equal(response.status, 200); - assert.equal(body.data[0].url, "https://cdn.example.com/rotated.png"); - assert.deepEqual(authorizationHeaders, ["Bearer expired-image-key", "Bearer healthy-image-key"]); -}); - -test("provider-scoped image generation POST uses the shared 401 account fallback", async () => { - await seedConnection("openai", { apiKey: "provider-expired-key", priority: 1 }); - await seedConnection("openai", { apiKey: "provider-healthy-key", priority: 2 }); - const authorizationHeaders: string[] = []; - - globalThis.fetch = async (_url, options: RequestInit = {}) => { - const authorization = new Headers(options.headers).get("authorization") ?? ""; - authorizationHeaders.push(authorization); - if (authorization === "Bearer provider-expired-key") { - return new Response(JSON.stringify({ error: { message: "expired access token" } }), { - status: 401, - headers: { "content-type": "application/json" }, - }); - } - return new Response( - JSON.stringify({ created: 123, data: [{ url: "https://cdn.example.com/provider.png" }] }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }; - - const response = await providerImageRoute.POST( - new Request("http://localhost/api/v1/providers/openai/images/generations", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: "gpt-image-2", prompt: "provider route rotation" }), - }), - { params: Promise.resolve({ provider: "openai" }) } - ); - - assert.equal(response.status, 200); - assert.deepEqual(authorizationHeaders, [ - "Bearer provider-expired-key", - "Bearer provider-healthy-key", - ]); -}); - -test("v1 image generation POST normalizes a terminal upstream 401 to the OpenAI-standard error shape", async () => { - await seedConnection("openai", { apiKey: "single-expired-image-key" }); - - globalThis.fetch = async (url, options: RequestInit = {}) => { - assert.equal(String(url), "https://api.openai.com/v1/images/generations"); - const authorization = new Headers(options.headers).get("authorization") ?? ""; - assert.equal(authorization, "Bearer single-expired-image-key"); - return new Response(JSON.stringify({ error: { message: "expired access token" } }), { - status: 401, - headers: { "content-type": "application/json" }, - }); - }; - - 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: "normalize terminal 401" }), - }) - ); - const body = (await response.json()) as ErrorResponseBody; - - assert.equal(response.status, 401); - assert.deepEqual(body.error, { - message: "expired access token", - type: "authentication_error", - code: "invalid_api_key", - }); -}); - -test("provider-scoped image generation POST normalizes a terminal upstream 401 to the OpenAI-standard error shape", async () => { - await seedConnection("openai", { apiKey: "provider-single-expired-key" }); - - globalThis.fetch = async (url, options: RequestInit = {}) => { - assert.equal(String(url), "https://api.openai.com/v1/images/generations"); - const authorization = new Headers(options.headers).get("authorization") ?? ""; - assert.equal(authorization, "Bearer provider-single-expired-key"); - return new Response(JSON.stringify({ error: { message: "expired provider token" } }), { - status: 401, - headers: { "content-type": "application/json" }, - }); - }; - - const response = await providerImageRoute.POST( - new Request("http://localhost/api/v1/providers/openai/images/generations", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: "gpt-image-2", prompt: "normalize provider terminal 401" }), - }), - { params: Promise.resolve({ provider: "openai" }) } - ); - const body = (await response.json()) as ErrorResponseBody; - - assert.equal(response.status, 401); - assert.deepEqual(body.error, { - message: "expired provider token", - type: "authentication_error", - code: "invalid_api_key", - }); -}); - -test("v1 image generation POST refreshes an expired Antigravity token before dispatch", async () => { - await seedConnection("antigravity", { - authType: "oauth", - accessToken: "expired-antigravity-token", - refreshToken: "valid-antigravity-refresh-token", - expiresAt: new Date(Date.now() - 60_000).toISOString(), - projectId: "test-cloud-code-project", - }); - const calls: Array<{ url: string; authorization: string }> = []; - - globalThis.fetch = async (url, options: RequestInit = {}) => { - const stringUrl = String(url); - const authorization = new Headers(options.headers).get("authorization") ?? ""; - calls.push({ url: stringUrl, authorization }); - - if (stringUrl.includes("oauth2.googleapis.com/token")) { - return new Response( - JSON.stringify({ - access_token: "fresh-antigravity-token", - expires_in: 3600, - token_type: "Bearer", - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - - assert.equal(stringUrl, "https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent"); - assert.equal(authorization, "Bearer fresh-antigravity-token"); - return new Response( - JSON.stringify({ - response: { - candidates: [ - { - content: { - parts: [{ inlineData: { mimeType: "image/jpeg", data: "ZnJlc2gtaW1hZ2U=" } }], - }, - }, - ], - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }; - - const response = await imageRoute.POST( - new Request("http://localhost/api/v1/images/generations", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: "antigravity/gemini-3.1-flash-image", - prompt: "refresh before image generation", - }), - }) - ); - const body = (await response.json()) as ImageResponseBody; - - assert.equal(response.status, 200); - assert.equal(body.data[0].b64_json, "ZnJlc2gtaW1hZ2U="); - assert.equal(calls.filter((call) => call.url.includes("oauth2.googleapis.com/token")).length, 1); -}); - for (const [provider, alias] of [ ["grok-cli", "gc"], ["xai-oauth", "xao"], From 88328e810ff0bd318776c2d4e7b4117ff548c430 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Mon, 12 Oct 2026 10:08:07 +0330 Subject: [PATCH 34/39] fix: align image verification contracts --- config/quality/test-masking-allowlist.json | 1 + docs/architecture/CODEBASE_DOCUMENTATION.md | 7 ++----- docs/reference/API_REFERENCE.md | 2 +- src/app/api/v1/images/generations/route.ts | 8 +++++--- tests/unit/codex-images-dedicated-endpoint.test.ts | 5 +++++ 5 files changed, 14 insertions(+), 9 deletions(-) diff --git a/config/quality/test-masking-allowlist.json b/config/quality/test-masking-allowlist.json index 27fe356ba265..9c4ac7fbc0fd 100644 --- a/config/quality/test-masking-allowlist.json +++ b/config/quality/test-masking-allowlist.json @@ -1,5 +1,6 @@ { "_comment": "Anti test-masking allowlist (check-test-masking.mjs). Files here are EXEMPT ONLY from the net-assert-REDUCTION signal, when the reduction is a verified-legitimate refactor or field removal (NOT weakening to go green). New tautologies (assert.ok(true)), new .skip/.todo/.only, and test-file deletions are STILL flagged for these files. Every entry needs a reason with the PR ref. Prune an entry once the release that introduced the reduction has merged to main (the merge-base then already reflects the reduced state, so the signal no longer fires).", + "tests/unit/image-generation-route.test.ts": "PR #15524: responsibility-based extraction preserves all 120 original assertions: 77 remain here, 26 are in the independently discovered image-generation-proxy.test.ts, and 17 in image-generation-resilience.test.ts. The proxy suite retains bounded fetch cleanup; duplicate copies were removed from resilience, not unique coverage. No skips, tautologies or deleted test files. Prune after #15524 merges to main.", "tests/unit/appearance-widget-settings-schema.test.ts": "v3.8.27 #4033: the `showTokenSaverOnEndpoint` schema field was removed (renamed/consolidated into the settings surface); its 2 asserts were removed accordingly — the field no longer exists in source. Verified legitimate, not masking.", "tests/unit/dashboard-shell-tabs.test.ts": "v3.8.27 #3973: settings UI refactored from a tabbed client component to redirect-based routing; 5 old-structure asserts (tabpanel/aria/initialTab) were replaced by 4 new-structure asserts (redirect/resolveSettingsRoute), net -1. Asserts replaced, not weakened. Verified legitimate.", "tests/integration/combo-routing-e2e.test.ts": "v3.8.29 #4129: integration tests aligned to post-v3.8.28 routing behavior — 3 separate per-call status asserts collapsed into a single loop assertion and seenProviders expectations updated to the new routing order (42→39). Asserts restructured/updated, not weakened. Verified legitimate. Prune after v3.8.29 merges to main.", diff --git a/docs/architecture/CODEBASE_DOCUMENTATION.md b/docs/architecture/CODEBASE_DOCUMENTATION.md index f8d728b3a98a..5b469085f511 100644 --- a/docs/architecture/CODEBASE_DOCUMENTATION.md +++ b/docs/architecture/CODEBASE_DOCUMENTATION.md @@ -723,8 +723,7 @@ Client request | Connection cooldown | One account/key | `markAccountUnavailable()` in `src/sse/services/auth.ts`; consumed by `accountFallback.checkFallbackError()` | | Model lockout | Provider + connection + model | `open-sse/services/accountFallback.ts`, persisted in `domain_lockout_state` | -See [RESILIENCE_GUIDE.md](./RESILIENCE_GUIDE.md) and the dedicated section in -[CLAUDE.md](../../CLAUDE.md). +See [RESILIENCE_GUIDE.md](./RESILIENCE_GUIDE.md) for runtime scopes and defaults. --- @@ -850,6 +849,4 @@ See [A2A-SERVER.md § Adding a New Skill](../frameworks/A2A-SERVER.md). Skills l - [ELECTRON_GUIDE.md](../guides/ELECTRON_GUIDE.md) (if present), [DOCKER_GUIDE.md](../guides/DOCKER_GUIDE.md), [FLY_IO_DEPLOYMENT_GUIDE.md](../ops/FLY_IO_DEPLOYMENT_GUIDE.md), [VM_DEPLOYMENT_GUIDE.md](../ops/VM_DEPLOYMENT_GUIDE.md), [TERMUX_GUIDE.md](../guides/TERMUX_GUIDE.md), [PWA_GUIDE.md](../guides/PWA_GUIDE.md) — deployment targets. - [TROUBLESHOOTING.md](../guides/TROUBLESHOOTING.md) — common operational issues. - [CONTRIBUTING.md](../../CONTRIBUTING.md) — contributor workflow. -- [CLAUDE.md](../../CLAUDE.md) — repo rules for Claude Code (the source of truth - for many of the conventions above). -- [AGENTS.md](../../AGENTS.md) — deeper architecture reference used by agents. +- [AGENTS.md](../../AGENTS.md) — repository conventions and architecture reference. diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 3e5c3abb0b4b..1b4e45f6eb7a 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -1567,7 +1567,7 @@ curl -X DELETE http://localhost:20128/api/resilience/model-cooldowns \ -d '{"all":true}' ``` -Full conceptual reference and breaker defaults: see [`CLAUDE.md`](../../CLAUDE.md) → "Resilience Runtime State". +Full conceptual reference and breaker defaults: see [Resilience Guide](../architecture/RESILIENCE_GUIDE.md). --- diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index b41a59b730d1..ca7fc15c0569 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -367,9 +367,11 @@ async function postHandler(request, context) { ? runWithProxyContext(proxyInfo?.proxy || null, generateImage).catch((err: any) => ({ success: false, status: dedicatedCodexImages ? 503 : err.statusCode || 500, - error: dedicatedCodexImages - ? "Codex Images proxy or transport failed; do not resubmit an unknown outcome" - : sanitizeErrorMessage(err.message), + error: sanitizeErrorMessage( + dedicatedCodexImages + ? "Codex Images proxy or transport failed; do not resubmit an unknown outcome" + : err.message + ), ...(dedicatedCodexImages ? { retryable: false } : {}), })) : generateImage(); diff --git a/tests/unit/codex-images-dedicated-endpoint.test.ts b/tests/unit/codex-images-dedicated-endpoint.test.ts index b3d71c336763..7cbe56c5cf13 100644 --- a/tests/unit/codex-images-dedicated-endpoint.test.ts +++ b/tests/unit/codex-images-dedicated-endpoint.test.ts @@ -174,6 +174,7 @@ test("Codex Images errors keep plan guards, terminal outcomes, and privacy", asy log: null, }); assert.equal(freePlan.success, false); + assert.equal(freePlan.status, 403); assert.equal(freePlan.retryable, false); assert.equal(fetchCalls, 0); @@ -190,6 +191,7 @@ test("Codex Images errors keep plan guards, terminal outcomes, and privacy", asy credentials: { accessToken: "codex-token" }, log: null, }); + assert.equal(accessError.success, false); assert.equal(accessError.status, 400); assert.equal(accessError.retryable, false); @@ -203,6 +205,7 @@ test("Codex Images errors keep plan guards, terminal outcomes, and privacy", asy credentials: { accessToken: "codex-token" }, log: null, }); + assert.equal(upstreamError.success, false); assert.equal(upstreamError.status, 403); assert.equal(upstreamError.retryable, false); assert.doesNotMatch(JSON.stringify(upstreamError.error), /upstream-secret/); @@ -213,7 +216,9 @@ test("Codex Images errors keep plan guards, terminal outcomes, and privacy", asy credentials: { accessToken: "codex-token" }, log: null, }); + assert.equal(empty.success, false); assert.equal(empty.status, 502); + assert.equal(empty.retryable, false); assert.match(String(empty.error), /no usable image data/); globalThis.fetch = async () => From 918e5f35fdcdadaf30d069be4225120c87458058 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Mon, 12 Oct 2026 20:14:16 +0330 Subject: [PATCH 35/39] fix: preserve native image dispatch contracts --- open-sse/handlers/codexImages.ts | 305 +++++---- open-sse/handlers/geminiImage.ts | 407 +++++++----- .../providers/blackForestLabs.ts | 100 +-- .../imageGeneration/providers/codex.ts | 356 +++++++---- .../providers/falGeneration.ts | 95 +-- .../handlers/imageGeneration/providers/kie.ts | 288 ++++++--- .../imageGeneration/providers/nanoBanana.ts | 529 ++++++++------- .../imageGeneration/providers/openAI.ts | 286 +++++---- .../imageGeneration/providers/stability.ts | 250 ++++---- open-sse/handlers/imageGeneration/shared.ts | 165 +++-- open-sse/services/imageCombo.ts | 165 +++-- src/app/api/v1/images/edits/editDispatch.ts | 601 +++++++----------- src/app/api/v1/images/edits/editInput.ts | 79 ++- .../api/v1/images/edits/editNativeDispatch.ts | 121 ++++ .../[provider]/images/generations/route.ts | 47 +- .../services/selectedCodexImageCredentials.ts | 91 ++- tests/_setup/isolateDataDir.ts | 19 +- tests/_setup/nativeImageState.ts | 39 ++ tests/unit/codex-tools-split.test.ts | 6 +- .../gemini-antigravity-image-edits.test.ts | 43 +- .../native-antigravity-image-repair.test.ts | 402 ++++++++---- tests/unit/native-codex-images-http.test.ts | 554 +++++++++++----- .../native-image-terminal-failure.test.ts | 68 +- 23 files changed, 3121 insertions(+), 1895 deletions(-) create mode 100644 src/app/api/v1/images/edits/editNativeDispatch.ts create mode 100644 tests/_setup/nativeImageState.ts diff --git a/open-sse/handlers/codexImages.ts b/open-sse/handlers/codexImages.ts index ff89e7c71266..e577bf344bd9 100644 --- a/open-sse/handlers/codexImages.ts +++ b/open-sse/handlers/codexImages.ts @@ -62,118 +62,111 @@ export function isCodexImagesModel(model: string): boolean { return IMAGE_MODELS.has(model); } -/** Single JSON POST through the selected fetch/proxy context, never upload, retry or fallback. */ -export async function handleCodexImages({ - model, - provider, - providerConfig, - body, - credentials, - log, - referenceImages, - operation, - signal = null, -}: { - model: string; - provider: string; - providerConfig: { baseUrl: string }; - body: Record; - credentials?: { - accessToken?: string; - apiKey?: string; - providerSpecificData?: Record; - } | null; - log?: { - info: (tag: string, message: string) => void; - warn: (tag: string, message: string) => void; - error: (tag: string, message: string) => void; - } | null; - referenceImages?: ReferenceImage[]; - operation?: "generations" | "edits"; - signal?: AbortSignal | null; -}): Promise { - const metadata: Metadata = { warning: WARNING }; - const fail = (status: number, error: string): Result => { - log?.error("IMAGE", `Codex Images request failed (${status}); details and inputs withheld`); - return { success: false, status, error, retryable: false, metadata }; - }; +type Failure = { status: number; error: string }; +const validationError = (error: string): Failure => ({ status: 400, error }); +function validateRequest( + provider: string, + model: string, + body: Record +): Failure | null { if (provider !== "codex" || !isCodexImagesModel(model)) - return fail(400, "Unsupported dedicated Codex image model"); + return validationError("Unsupported dedicated Codex image model"); if (!body || typeof body !== "object" || Array.isArray(body)) - return fail(400, "Invalid Codex Images request body"); + return validationError("Invalid Codex Images request body"); if (Object.keys(body).some((key) => !BODY_FIELDS.has(key))) - return fail( - 400, + return validationError( "Unsupported Codex Images parameter; masks and output-format controls are not supported" ); if (typeof body.prompt !== "string" || !body.prompt.trim()) - return fail(400, "Prompt is required for Codex Images"); - if (body.n !== undefined && body.n !== 1) return fail(400, "Codex Images supports n=1 only"); + return validationError("Prompt is required for Codex Images"); + if (body.n !== undefined && body.n !== 1) + return validationError("Codex Images supports n=1 only"); if (body.response_format !== undefined && body.response_format !== "b64_json") - return fail(400, "Codex Images returns original b64_json only"); + return validationError("Codex Images returns original b64_json only"); + return validateOptions(body); +} +function validateOptions(body: Record): Failure | null { if ( body.size !== undefined && (typeof body.size !== "string" || !/^(?:auto|[1-9]\d{0,4}x[1-9]\d{0,4})$/.test(body.size)) ) - return fail(400, "Invalid Codex Images size; use auto or WIDTHxHEIGHT"); + return validationError("Invalid Codex Images size; use auto or WIDTHxHEIGHT"); if ( body.quality !== undefined && !["low", "medium", "high", "auto"].includes(body.quality as string) ) - return fail(400, "Unsupported Codex Images quality"); + return validationError("Unsupported Codex Images quality"); if ( body.background !== undefined && !["transparent", "opaque", "auto"].includes(body.background as string) ) - return fail(400, "Unsupported Codex Images background"); + return validationError("Unsupported Codex Images background"); + return null; +} +function prepareReferences( + body: Record, + referenceImages: ReferenceImage[] | undefined, + operation: "generations" | "edits" | undefined +): Failure | { references: ReferenceImage[]; endpoint: string } { const hasImage = Object.hasOwn(body, "image"); const hasImages = Object.hasOwn(body, "images"); if ((hasImage && hasImages) || (referenceImages !== undefined && (hasImage || hasImages))) - return fail(400, "Ambiguous Codex image references; supply one reference set"); - let images: Array; - if (referenceImages !== undefined) images = referenceImages; - else { - if (hasImages && !Array.isArray(body.images)) - return fail(400, "Codex Images images must be an array"); - const candidates = hasImages ? (body.images as unknown[]) : hasImage ? [body.image] : []; - images = candidates.map((value) => { - if (value && typeof value === "object" && !Array.isArray(value)) { - const ref = value as Record; - if (Object.keys(ref).length !== 1 || !Object.hasOwn(ref, "image_url")) return null; - return parseInlineReference(ref.image_url); - } - return parseInlineReference(value); - }); - } + return validationError("Ambiguous Codex image references; supply one reference set"); + const images = decodeReferences(body, referenceImages, hasImage, hasImages); + if ("error" in images) return images; if ( !Array.isArray(images) || images.some( (image) => !image || !Buffer.isBuffer(image.bytes) || typeof image.mime !== "string" ) ) - return fail( - 400, + return validationError( "Invalid Codex reference; use inline PNG, JPEG or WebP bytes, not remote URLs or file IDs" ); const references = images as ReferenceImage[]; const referenceError = validateReferences(references); - if (referenceError) return fail(400, referenceError); + if (referenceError) return validationError(referenceError); const endpoint = operation ?? (references.length ? "edits" : "generations"); + const mismatch = operationMismatch(endpoint, references.length, hasImage || hasImages); + if (mismatch) return validationError(mismatch); + return { references, endpoint }; +} +function decodeReferences( + body: Record, + referenceImages: ReferenceImage[] | undefined, + hasImage: boolean, + hasImages: boolean +): Failure | Array { + if (referenceImages !== undefined) return referenceImages; + { + if (hasImages && !Array.isArray(body.images)) + return validationError("Codex Images images must be an array"); + const candidates = hasImages ? (body.images as unknown[]) : hasImage ? [body.image] : []; + return candidates.map((value) => { + if (value && typeof value === "object" && !Array.isArray(value)) { + const ref = value as Record; + if (Object.keys(ref).length !== 1 || !Object.hasOwn(ref, "image_url")) return null; + return parseInlineReference(ref.image_url); + } + return parseInlineReference(value); + }); + } +} +function operationMismatch(endpoint: string, count: number, supplied: boolean): string | null { if ( !["generations", "edits"].includes(endpoint) || - (endpoint === "edits" && !references.length) || - (endpoint === "generations" && references.length) || - ((hasImage || hasImages) && !references.length) + (endpoint === "edits" && !count) || + (endpoint === "generations" && count) || + (supplied && !count) ) - return fail(400, "Reference images do not match the Codex Images operation"); - const token = credentials?.accessToken || credentials?.apiKey; - if (!token) - return fail(401, "Codex credentials missing accessToken — reconnect the Codex provider"); - if (isCodexFreePlan(credentials?.providerSpecificData)) - return fail(403, "Codex Images requires a paid ChatGPT/Codex plan"); + return "Reference images do not match the Codex Images operation"; + return null; +} + +function resolveEndpoint(baseUrl: string, endpoint: string): Failure | URL { let url: URL; try { - url = new URL(providerConfig.baseUrl); + url = new URL(baseUrl); if ( !["https:", "http:"].includes(url.protocol) || url.username || @@ -181,11 +174,45 @@ export async function handleCodexImages({ url.hash || !/\/codex\/responses\/?$/.test(url.pathname) ) - return fail(500, "Codex Images requires a configured Codex Responses endpoint"); + return { status: 500, error: "Codex Images requires a configured Codex Responses endpoint" }; url.pathname = url.pathname.replace(/\/responses\/?$/, `/images/${endpoint}`); } catch { - return fail(500, "Invalid Codex Images provider endpoint"); + return { status: 500, error: "Invalid Codex Images provider endpoint" }; } + return url; +} +function readPayload(data: unknown): Failure | Record { + if (!data || typeof data !== "object" || Array.isArray(data)) + return { + status: 502, + error: "Codex Images returned invalid image JSON; do not resubmit an unknown outcome", + }; + const payload = data as Record; + if ( + !Number.isSafeInteger(payload.created) || + (payload.created as number) < 0 || + !Array.isArray(payload.data) || + !payload.data.length || + payload.data.some((item) => !item || typeof item.b64_json !== "string" || !item.b64_json.trim()) + ) + return { + status: 502, + error: "Codex Images returned no usable image data; do not resubmit an unknown outcome", + }; + return payload; +} + +type Credentials = + | { accessToken?: string; apiKey?: string; providerSpecificData?: Record } + | null + | undefined; +function buildWire( + model: string, + body: Record, + references: ReferenceImage[], + credentials: Credentials, + token: string +) { const upstreamBody: Record = { model, prompt: body.prompt, n: 1 }; for (const key of ["size", "quality", "background"]) if (body[key] !== undefined) upstreamBody[key] = body[key]; @@ -204,11 +231,49 @@ export async function handleCodexImages({ headers["chatgpt-account-id"] = workspaceId; headers.session_id = workspaceId; } - if (signal?.aborted) return fail(499, "Codex Images request cancelled before submission"); - const timeoutMs = getConfiguredTimeout(); - if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 0xffffffff) - return fail(500, "Invalid Codex Images timeout configuration"); - const timeout = AbortSignal.timeout(timeoutMs); + return { upstreamBody, headers }; +} +function captureRequestIds(response: Response, metadata: Metadata) { + for (const [header, key] of [ + ["x-codex-imagegen-request-id", "imagegenRequestId"], + ["x-request-id", "requestId"], + ] as const) { + const id = response.headers.get(header); + if (id && /^[A-Za-z0-9._:-]{1,200}$/.test(id)) metadata[key] = id; + } +} + +type CodexImagesOptions = { + model: string; + provider: string; + providerConfig: { baseUrl: string }; + body: Record; + credentials?: { + accessToken?: string; + apiKey?: string; + providerSpecificData?: Record; + } | null; + log?: { + info: (tag: string, message: string) => void; + warn: (tag: string, message: string) => void; + error: (tag: string, message: string) => void; + } | null; + referenceImages?: ReferenceImage[]; + operation?: "generations" | "edits"; + signal?: AbortSignal | null; +}; + +type Fail = (status: number, error: string) => Result; +async function submitCodexImage( + url: URL, + upstreamBody: Record, + headers: Record, + signal: AbortSignal | null, + timeout: AbortSignal, + metadata: Metadata, + log: CodexImagesOptions["log"], + fail: Fail +): Promise { const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout; log?.info("IMAGE", "Submitting one dedicated Codex Images request; inputs and account withheld"); try { @@ -218,41 +283,17 @@ export async function handleCodexImages({ body: JSON.stringify(upstreamBody), signal: requestSignal, }); - for (const [header, key] of [ - ["x-codex-imagegen-request-id", "imagegenRequestId"], - ["x-request-id", "requestId"], - ] as const) { - const id = response.headers.get(header); - if (id && /^[A-Za-z0-9._:-]{1,200}$/.test(id)) metadata[key] = id; - } + captureRequestIds(response, metadata); if (!response.ok) { await response.body?.cancel(); - // Upstream error bodies may echo prompts, images or credentials; never expose them. return fail( response.status, `Codex Images provider rejected the request (HTTP ${response.status}); details withheld` ); } - const data: unknown = await response.json(); - if (!data || typeof data !== "object" || Array.isArray(data)) - return fail( - 502, - "Codex Images returned invalid image JSON; do not resubmit an unknown outcome" - ); - const payload = data as Record; - if ( - !Number.isSafeInteger(payload.created) || - (payload.created as number) < 0 || - !Array.isArray(payload.data) || - !payload.data.length || - payload.data.some( - (item) => !item || typeof item.b64_json !== "string" || !item.b64_json.trim() - ) - ) - return fail( - 502, - "Codex Images returned no usable image data; do not resubmit an unknown outcome" - ); + const payload = readPayload(await response.json()); + if ("error" in payload && "status" in payload) + return fail(payload.status as number, payload.error as string); log?.warn("IMAGE", WARNING); return { success: true, data: payload, metadata }; } catch { @@ -266,3 +307,49 @@ export async function handleCodexImages({ ); } } +function validateCredentials(credentials: Credentials): Failure | string { + const token = credentials?.accessToken || credentials?.apiKey; + if (!token) + return { + status: 401, + error: "Codex credentials missing accessToken — reconnect the Codex provider", + }; + if (isCodexFreePlan(credentials?.providerSpecificData)) + return { status: 403, error: "Codex Images requires a paid ChatGPT/Codex plan" }; + return token; +} + +/** Single JSON POST through the selected fetch/proxy context, never upload, retry or fallback. */ +export async function handleCodexImages({ + model, + provider, + providerConfig, + body, + credentials, + log, + referenceImages, + operation, + signal = null, +}: CodexImagesOptions): Promise { + const metadata: Metadata = { warning: WARNING }; + const fail = (status: number, error: string): Result => { + log?.error("IMAGE", `Codex Images request failed (${status}); details and inputs withheld`); + return { success: false, status, error, retryable: false, metadata }; + }; + const requestError = validateRequest(provider, model, body); + if (requestError) return fail(requestError.status, requestError.error); + const prepared = prepareReferences(body, referenceImages, operation); + if ("error" in prepared) return fail(prepared.status, prepared.error); + const { references, endpoint } = prepared; + const token = validateCredentials(credentials); + if (typeof token !== "string") return fail(token.status, token.error); + const url = resolveEndpoint(providerConfig.baseUrl, endpoint); + if (!(url instanceof URL)) return fail(url.status, url.error); + const { upstreamBody, headers } = buildWire(model, body, references, credentials, token); + if (signal?.aborted) return fail(499, "Codex Images request cancelled before submission"); + const timeoutMs = getConfiguredTimeout(); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 0xffffffff) + return fail(500, "Invalid Codex Images timeout configuration"); + const timeout = AbortSignal.timeout(timeoutMs); + return submitCodexImage(url, upstreamBody, headers, signal, timeout, metadata, log, fail); +} diff --git a/open-sse/handlers/geminiImage.ts b/open-sse/handlers/geminiImage.ts index 170cc95a71ad..240aab784ad8 100644 --- a/open-sse/handlers/geminiImage.ts +++ b/open-sse/handlers/geminiImage.ts @@ -6,22 +6,33 @@ import { mapImageSize } from "../translator/image/sizeMapper.ts"; import { getImageModelEntry } from "../config/imageRegistry.ts"; import { saveImageErrorResult, sanitizeImageProviderError } from "./imageGeneration/shared.ts"; import { parseDataUrl, validateCodexImageEditReferences } from "@/lib/images/imageRouteModel"; -import { FetchTimeoutError, fetchWithTimeout, getConfiguredTimeout } from "@/shared/utils/fetchTimeout"; +import { + FetchTimeoutError, + fetchWithTimeout, + getConfiguredTimeout, +} from "@/shared/utils/fetchTimeout"; function resolveReferences(body, referenceImages) { if (referenceImages !== undefined) return referenceImages; + if (body.imageBytes !== undefined) return [{ bytes: body.imageBytes, mime: body.imageMime }]; const candidates = []; for (const key of ["image", "image_url", "images", "image_urls", "imageUrls"]) { - if (body[key] !== undefined) candidates.push(...(Array.isArray(body[key]) ? body[key] : [body[key]])); + if (body[key] !== undefined) + candidates.push(...(Array.isArray(body[key]) ? body[key] : [body[key]])); } return (Array.isArray(candidates) ? candidates : [candidates]).map((value) => - parseDataUrl(value && typeof value === "object" ? value.image_url ?? value.url : value) + parseDataUrl(value && typeof value === "object" ? (value.image_url ?? value.url) : value) ); } export function validateAntigravityReferences(images, hasMask = false): string | null { if (hasMask) return "Antigravity image editing does not support masks"; - if (!Array.isArray(images) || images.some((image) => !image || !Buffer.isBuffer(image.bytes) || typeof image.mime !== "string")) { + if ( + !Array.isArray(images) || + images.some( + (image) => !image || !Buffer.isBuffer(image.bytes) || typeof image.mime !== "string" + ) + ) { return "Invalid Antigravity reference image; provide an inline PNG, JPEG, or WebP data URL"; } return validateCodexImageEditReferences(images, 1)?.replace(/Codex/g, "Antigravity") ?? null; @@ -61,21 +72,117 @@ function normalizeImageGenerationSize(value: unknown): { * Handle Gemini-format image generation (Antigravity / Nano Banana) * Uses Gemini's generateContent API with responseModalities: ["TEXT", "IMAGE"] */ -export async function handleGeminiImageGeneration({ model, providerConfig, body, credentials, log, referenceImages = undefined, signal = null, logPath = "/v1/images/generations" }) { +export async function handleGeminiImageGeneration({ + model, + providerConfig, + body, + credentials, + log, + referenceImages = undefined, + signal = null, + logPath = "/v1/images/generations", +}) { const startTime = Date.now(); const references = resolveReferences(body, referenceImages); - const referenceError = validateAntigravityReferences(references, body.mask !== undefined || body.mask_url !== undefined); - if (referenceError) return { success: false, status: 400, error: referenceError }; + const validation = validateGeminiImageEdit(model, body, references, logPath); + if (validation) return { success: false, status: 400, error: validation }; const isEdit = references.length > 0 || logPath === "/v1/images/edits"; - if (isEdit && body.response_format !== undefined && body.response_format !== "b64_json") { - return { success: false, status: 400, error: "Antigravity image editing supports only b64_json response format" }; + const url = providerConfig.baseUrl; + const provider = "antigravity"; + const { credentialRecord, token, projectId } = resolveGeminiImageCredentials(credentials); + const { candidateCount, promptText, aspectRatio, imageSize, imageSizeClamped } = + resolveGeminiImageDimensions(body); + warnGeminiImageSize(log, model, imageSizeClamped); + + // Summarized request for call log. Both axes are recorded so the log never hides what the + // client asked for: `image_size` is the raw caller value (null when absent) and + // `image_size_applied` is what went upstream ("default" when the key was omitted). + const logRequestBody = summarizeGeminiImageRequest(body, references, { + promptText, + aspectRatio, + imageSize, + candidateCount, + }); + + const accountError = geminiAccountError( + token, + projectId, + model, + startTime, + logRequestBody, + logPath + ); + if (accountError) return accountError; + + const { antigravityBody, headers } = buildGeminiImageRequest({ + projectId, + model, + credentialRecord, + token, + references, + promptText, + candidateCount, + aspectRatio, + imageSize, + }); + + if (log) { + log.info( + "IMAGE", + `antigravity/${model} (gemini) | references: ${references.length} | ${aspectRatio} ${imageSize ?? "default"}` + ); + } + + try { + const response = await fetchWithTimeout(url, { + method: "POST", + headers, + body: JSON.stringify(antigravityBody), + signal, + timeoutMs: getConfiguredTimeout(), + }); + + return await finishGeminiImageResponse(response, { + model, + provider, + logPath, + log, + startTime, + logRequestBody, + promptText, + isEdit, + }); + } catch (err) { + return saveGeminiImageTransportFailure(err, { + model, + provider, + logPath, + log, + startTime, + logRequestBody, + isEdit, + }); } +} + +function validateGeminiImageEdit(model, body, references, logPath): string | null { + const referenceError = validateAntigravityReferences( + references, + body.mask !== undefined || body.mask_url !== undefined + ); + if (referenceError) return referenceError; + const isEdit = references.length > 0 || logPath === "/v1/images/edits"; + if (isEdit && body.response_format !== undefined && body.response_format !== "b64_json") + return "Antigravity image editing supports only b64_json response format"; if (references.length > 0) { const entry = getImageModelEntry(`antigravity/${model}`); - if (!entry || entry.provider !== "antigravity" || entry.model !== model) return { success: false, status: 400, error: "Unsupported Antigravity image edit model" }; + if (!entry || entry.provider !== "antigravity" || entry.model !== model) + return "Unsupported Antigravity image edit model"; } - const url = providerConfig.baseUrl; - const provider = "antigravity"; + return null; +} + +function resolveGeminiImageCredentials(credentials) { const credentialRecord = credentials || {}; const token = credentialRecord.accessToken || credentialRecord.apiKey; const providerSpecificData = credentialRecord.providerSpecificData; @@ -88,6 +195,10 @@ export async function handleGeminiImageGeneration({ model, providerConfig, body, const providerProjectId = typeof providerSpecificProjectId === "string" ? providerSpecificProjectId.trim() : ""; const projectId = credentialProjectId || providerProjectId || null; + return { credentialRecord, token, projectId }; +} + +function resolveGeminiImageDimensions(body) { const candidateCount = typeof body.n === "number" && Number.isFinite(body.n) && body.n > 0 ? Math.floor(body.n) : 1; const promptText = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); @@ -95,42 +206,28 @@ export async function handleGeminiImageGeneration({ model, providerConfig, body, const { value: imageSize, clamped: imageSizeClamped } = normalizeImageGenerationSize( body.image_size ); - if (imageSizeClamped && log && typeof log.warn === "function") { + return { candidateCount, promptText, aspectRatio, imageSize, imageSizeClamped }; +} + +function warnGeminiImageSize(log, model, imageSizeClamped) { + if (imageSizeClamped && log && typeof log.warn === "function") log.warn( "IMAGE", `antigravity/${model}: unsupported image_size — clamped to 1K (accepted: 1K|2K|4K)` ); - } - - // Summarized request for call log. Both axes are recorded so the log never hides what the - // client asked for: `image_size` is the raw caller value (null when absent) and - // `image_size_applied` is what went upstream ("default" when the key was omitted). - const logRequestBody = { - model: body.model, - prompt_chars: promptText.length, - reference_images: references.map((image) => ({ mime: image.mime, bytes: image.bytes.length })), - size: body.size || "default", - aspect_ratio: aspectRatio, - image_size: body.image_size ?? null, - image_size_applied: imageSize ?? "default", - n: candidateCount, - }; - - if (!token) return { success: false, status: 401, error: "Missing Antigravity credentials" }; - - if (!projectId || typeof projectId !== "string") { - return saveImageErrorResult({ - provider, - model, - status: 400, - startTime, - error: - "Missing Google projectId for Antigravity account. Please reconnect OAuth in Providers so OmniRoute can fetch your Cloud Code project.", - requestBody: logRequestBody, - path: logPath, - }); - } +} +function buildGeminiImageRequest({ + projectId, + model, + credentialRecord, + token, + references, + promptText, + candidateCount, + aspectRatio, + imageSize, +}) { const antigravityBody = { project: projectId, requestId: `image_gen/${Date.now()}/${randomUUID()}/0`, @@ -138,128 +235,152 @@ export async function handleGeminiImageGeneration({ model, providerConfig, body, contents: [ { role: "user", - parts: [{ text: promptText }, ...references.map((image) => ({ inlineData: { mimeType: image.mime.trim().toLowerCase(), data: image.bytes.toString("base64") } }))], + parts: [ + { text: promptText }, + ...references.map((image) => ({ + inlineData: { + mimeType: image.mime.trim().toLowerCase(), + data: image.bytes.toString("base64"), + }, + })), + ], }, ], generationConfig: { candidateCount, - imageConfig: { - aspectRatio, - ...(imageSize ? { imageSize } : {}), - }, + imageConfig: { aspectRatio, ...(imageSize ? { imageSize } : {}) }, }, }, model, userAgent: getAntigravityEnvelopeUserAgent(credentialRecord), requestType: "image_gen", }; - - const headers = { + const headers: Record = { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }; applyAntigravityClientProfileHeaders(headers, credentialRecord, antigravityBody); delete headers["x-goog-user-project"]; + return { antigravityBody, headers }; +} - if (log) { - log.info( - "IMAGE", - `antigravity/${model} (gemini) | references: ${references.length} | ${aspectRatio} ${imageSize ?? "default"}` - ); - } - - try { - const response = await fetchWithTimeout(url, { - method: "POST", - headers, - body: JSON.stringify(antigravityBody), - signal, - timeoutMs: getConfiguredTimeout(), - }); - - if (!response.ok) { - const errorText = await response.text(); - const safeError = sanitizeImageProviderError(errorText); - if (log) { - log.error("IMAGE", `antigravity error ${response.status}`); - } - - saveCallLog({ - method: "POST", - path: logPath, - status: response.status, - model: `antigravity/${model}`, - provider, - duration: Date.now() - startTime, - error: `Antigravity upstream error (${response.status})`, - requestBody: logRequestBody, - }).catch(() => {}); - - return { success: false, status: response.status, error: safeError, ...(isEdit ? { retryable: false } : {}) }; - } +async function finishGeminiImageResponse(response: Response, context) { + const { model, provider, logPath, startTime, logRequestBody, promptText, isEdit } = context; + if (!response.ok) return saveGeminiImageResponseFailure(response, context); + const data = await response.json(); + const images = extractGeminiImagePayload(data.response || data, promptText); + if (images.length === 0) + return { + success: false, + status: 502, + error: "Antigravity completed without an image payload", + ...(isEdit ? { retryable: false } : {}), + }; + saveCallLog({ + method: "POST", + path: logPath, + status: 200, + model: `antigravity/${model}`, + provider, + duration: Date.now() - startTime, + tokens: { prompt_tokens: 0, completion_tokens: 0 }, + requestBody: logRequestBody, + responseBody: { images_count: images.length }, + }).catch(() => {}); + return { success: true, data: { created: Math.floor(Date.now() / 1000), data: images } }; +} - const data = await response.json(); - const responseBody = data.response || data; +async function saveGeminiImageResponseFailure(response: Response, context) { + const { model, provider, logPath, log, startTime, logRequestBody, isEdit } = context; + const safeError = sanitizeImageProviderError(await response.text()); + if (log) log.error("IMAGE", `antigravity error ${response.status}`); + saveCallLog({ + method: "POST", + path: logPath, + status: response.status, + model: `antigravity/${model}`, + provider, + duration: Date.now() - startTime, + error: `Antigravity upstream error (${response.status})`, + requestBody: logRequestBody, + }).catch(() => {}); + return { + success: false, + status: response.status, + error: safeError, + ...(isEdit ? { retryable: false } : {}), + }; +} - // Extract image data from Antigravity's wrapped Gemini response. - const images = []; - const candidates = responseBody.candidates || []; - for (const candidate of candidates) { - const parts = candidate.content?.parts || []; - for (const part of parts) { - if (typeof part.inlineData?.data === "string" && part.inlineData.data.length > 0) { - images.push({ - b64_json: part.inlineData.data, - revised_prompt: parts.find((p) => p.text)?.text || promptText, - }); - } +function extractGeminiImagePayload(responseBody, promptText) { + const images = []; + const candidates = responseBody.candidates || []; + for (const candidate of candidates) { + const parts = candidate.content?.parts || []; + for (const part of parts) { + if (typeof part.inlineData?.data === "string" && part.inlineData.data.length > 0) { + images.push({ + b64_json: part.inlineData.data, + revised_prompt: parts.find((p) => p.text)?.text || promptText, + }); } } + } + return images; +} - if (images.length === 0) return { success: false, status: 502, error: "Antigravity completed without an image payload", ...(isEdit ? { retryable: false } : {}) }; - - saveCallLog({ - method: "POST", - path: logPath, - status: 200, - model: `antigravity/${model}`, - provider, - duration: Date.now() - startTime, - tokens: { prompt_tokens: 0, completion_tokens: 0 }, - requestBody: logRequestBody, - responseBody: { images_count: images.length }, - }).catch(() => {}); +function summarizeGeminiImageRequest( + body, + references, + { promptText, aspectRatio, imageSize, candidateCount } +) { + return { + model: body.model, + prompt_chars: promptText.length, + reference_images: references.map((image) => ({ mime: image.mime, bytes: image.bytes.length })), + size: body.size || "default", + aspect_ratio: aspectRatio, + image_size: body.image_size ?? null, + image_size_applied: imageSize ?? "default", + n: candidateCount, + }; +} - return { - success: true, - data: { - created: Math.floor(Date.now() / 1000), - data: images, - }, - }; - } catch (err) { - const status = err instanceof FetchTimeoutError ? 504 : 502; - const message = "Antigravity image request failed"; - if (log) { - log.error("IMAGE", `antigravity fetch error (${status})`); - } +function saveGeminiImageTransportFailure(err: unknown, context) { + const { model, provider, logPath, log, startTime, logRequestBody, isEdit } = context; + const status = err instanceof FetchTimeoutError ? 504 : 502; + const message = "Antigravity image request failed"; + if (log) log.error("IMAGE", `antigravity fetch error (${status})`); + saveCallLog({ + method: "POST", + path: logPath, + status, + model: `antigravity/${model}`, + provider, + duration: Date.now() - startTime, + error: message, + requestBody: logRequestBody, + }).catch(() => {}); + return { + success: false, + status, + error: `Image provider error: ${message}`, + ...(isEdit ? { retryable: false } : {}), + }; +} - saveCallLog({ - method: "POST", +function geminiAccountError(token, projectId, model, startTime, requestBody, logPath) { + if (!token) return { success: false, status: 401, error: "Missing Antigravity credentials" }; + if (!projectId || typeof projectId !== "string") + return saveImageErrorResult({ + provider: "antigravity", + model, + status: 400, + startTime, + error: + "Missing Google projectId for Antigravity account. Please reconnect OAuth in Providers so OmniRoute can fetch your Cloud Code project.", + requestBody, path: logPath, - status, - model: `antigravity/${model}`, - provider, - duration: Date.now() - startTime, - error: message, - requestBody: logRequestBody, - }).catch(() => {}); - - return { - success: false, - status, - error: `Image provider error: ${message}`, - ...(isEdit ? { retryable: false } : {}), - }; - } + }); + return null; } diff --git a/open-sse/handlers/imageGeneration/providers/blackForestLabs.ts b/open-sse/handlers/imageGeneration/providers/blackForestLabs.ts index 372aaabd1ee9..2807e3524899 100644 --- a/open-sse/handlers/imageGeneration/providers/blackForestLabs.ts +++ b/open-sse/handlers/imageGeneration/providers/blackForestLabs.ts @@ -1,7 +1,17 @@ import { sleep } from "../../../utils/sleep.ts"; import { sanitizeErrorMessage } from "../../../utils/error.ts"; import { mapImageSize } from "../../../translator/image/sizeMapper.ts"; -import { extractImageInputs, resolveImageSource, normalizeRequestedImageFormat, normalizeProviderImagePayload, saveImageSuccessResult, saveImageErrorResult, normalizePositiveNumber, parseSizeToDimensions, isHttpUrl } from "../shared.ts"; +import { + extractImageInputs, + resolveImageSource, + normalizeRequestedImageFormat, + normalizeProviderImagePayload, + saveImageSuccessResult, + saveImageErrorResult, + normalizePositiveNumber, + parseSizeToDimensions, + isHttpUrl, +} from "../shared.ts"; const BFL_MODEL_ENDPOINTS = { "flux-2-max": "/v1/flux-2-max", "flux-2-pro": "/v1/flux-2-pro", @@ -53,36 +63,8 @@ export async function handleBlackForestLabsImageGeneration({ }; try { - if (BFL_EDIT_MODELS.has(model) && imageUrl) { - upstreamBody.input_image = (await resolveImageSource(imageUrl)).base64; - } else if (imageUrl && isHttpUrl(imageUrl)) { - upstreamBody.image_url = imageUrl; - } - - if (maskUrl && (model === "flux-pro-1.0-fill" || model === "flux-kontext-pro")) { - upstreamBody.mask = (await resolveImageSource(maskUrl)).base64; - } - - if (model === "flux-kontext-pro" || model === "flux-kontext-max") { - upstreamBody.aspect_ratio = body.aspect_ratio || mapImageSize(body.size); - } else if (typeof body.size === "string" && body.size.includes("x")) { - const { width, height } = parseSizeToDimensions(body.size, 1024); - upstreamBody.width = width; - upstreamBody.height = height; - } - - if (body.seed !== undefined) upstreamBody.seed = body.seed; - if (body.n !== undefined && model.includes("ultra")) - upstreamBody.num_images = Number(body.n) || 1; - if (body.quality === "hd" && model.includes("ultra")) upstreamBody.raw = true; - if (body.left !== undefined) upstreamBody.left = body.left; - if (body.right !== undefined) upstreamBody.right = body.right; - if (body.top !== undefined) upstreamBody.top = body.top; - if (body.bottom !== undefined) upstreamBody.bottom = body.bottom; - if (body.steps !== undefined) upstreamBody.steps = body.steps; - if (body.guidance !== undefined) upstreamBody.guidance = body.guidance; - if (body.grow_mask !== undefined) upstreamBody.grow_mask = body.grow_mask; - if (body.safety_tolerance !== undefined) upstreamBody.safety_tolerance = body.safety_tolerance; + await prepareBflImageInputs(model, body, imageUrl, maskUrl, upstreamBody); + applyBflImageOptions(model, body, upstreamBody); if (log) { const promptPreview = String(body.prompt ?? "").slice(0, 60); @@ -113,15 +95,7 @@ export async function handleBlackForestLabsImageGeneration({ }); } - const initialPayload = await response.json(); - const finalPayload = initialPayload.polling_url - ? await pollBlackForestLabsResult({ - pollingUrl: initialPayload.polling_url, - token, - body, - log, - }) - : initialPayload; + const finalPayload = await parseBflGenerationResponse(response, token, body, log); const images = await normalizeProviderImagePayload(finalPayload, body, log, "url"); return saveImageSuccessResult({ @@ -184,3 +158,49 @@ async function pollBlackForestLabsResult({ pollingUrl, token, body, log }) { throw new Error(`BFL polling timed out after ${timeoutMs}ms`); } +async function prepareBflImageInputs(model, body, imageUrl, maskUrl, upstreamBody) { + if (BFL_EDIT_MODELS.has(model) && imageUrl) { + upstreamBody.input_image = (await resolveImageSource(imageUrl)).base64; + } else if (imageUrl && isHttpUrl(imageUrl)) { + upstreamBody.image_url = imageUrl; + } + + if (maskUrl && (model === "flux-pro-1.0-fill" || model === "flux-kontext-pro")) { + upstreamBody.mask = (await resolveImageSource(maskUrl)).base64; + } + + if (model === "flux-kontext-pro" || model === "flux-kontext-max") { + upstreamBody.aspect_ratio = body.aspect_ratio || mapImageSize(body.size); + } else if (typeof body.size === "string" && body.size.includes("x")) { + const { width, height } = parseSizeToDimensions(body.size, 1024); + upstreamBody.width = width; + upstreamBody.height = height; + } +} + +function applyBflImageOptions(model, body, upstreamBody) { + if (body.seed !== undefined) upstreamBody.seed = body.seed; + if (body.n !== undefined && model.includes("ultra")) + upstreamBody.num_images = Number(body.n) || 1; + if (body.quality === "hd" && model.includes("ultra")) upstreamBody.raw = true; + if (body.left !== undefined) upstreamBody.left = body.left; + if (body.right !== undefined) upstreamBody.right = body.right; + if (body.top !== undefined) upstreamBody.top = body.top; + if (body.bottom !== undefined) upstreamBody.bottom = body.bottom; + if (body.steps !== undefined) upstreamBody.steps = body.steps; + if (body.guidance !== undefined) upstreamBody.guidance = body.guidance; + if (body.grow_mask !== undefined) upstreamBody.grow_mask = body.grow_mask; + if (body.safety_tolerance !== undefined) upstreamBody.safety_tolerance = body.safety_tolerance; +} + +async function parseBflGenerationResponse(response, token, body, log) { + const initialPayload = await response.json(); + return initialPayload.polling_url + ? await pollBlackForestLabsResult({ + pollingUrl: initialPayload.polling_url, + token, + body, + log, + }) + : initialPayload; +} diff --git a/open-sse/handlers/imageGeneration/providers/codex.ts b/open-sse/handlers/imageGeneration/providers/codex.ts index 01daad971735..28141daf3cb6 100644 --- a/open-sse/handlers/imageGeneration/providers/codex.ts +++ b/open-sse/handlers/imageGeneration/providers/codex.ts @@ -61,13 +61,8 @@ export function extractImageGenerationCalls( } catch { continue; } - if (evt?.type !== "response.output_item.done") continue; - const item = evt.item as Record | undefined; - if (!item || item.type !== "image_generation_call") continue; - const result = typeof item.result === "string" ? item.result : ""; - if (!result) continue; - const revisedPrompt = typeof item.revised_prompt === "string" ? item.revised_prompt : null; - results.push({ b64: result, revisedPrompt }); + const imageCall = parseCodexImageGenerationCall(evt); + if (imageCall) results.push(imageCall); } return results; } @@ -133,6 +128,126 @@ export async function handleCodexImageGeneration({ } const token = credentials?.accessToken || credentials?.apiKey; + const credentialError = validateCodexImageCredentials({ + token, + credentials, + provider, + model, + startTime, + logPath, + }); + if (credentialError) return credentialError; + + const { upstreamBody, requestBodyForLog } = await prepareCodexImageRequest({ + model, + body, + prompt, + referenceImages, + }); + const headers = buildCodexImageHeaders(token, credentials); + + logCodexImagePrompt(log, provider, model, prompt, referenceImages); + + const fetchOneImage = () => + fetchCodexImage({ + providerConfig, + headers, + upstreamBody, + signal, + log, + provider, + model, + startTime, + requestBodyForLog, + logPath, + }); + + const imageResults = await Promise.all( + Array.from({ length: requestedCount }, () => fetchOneImage()) + ); + + return finishCodexImageResults({ + imageResults, + body, + provider, + model, + startTime, + requestBodyForLog, + logPath, + }); +} + +type CodexImageEditResult = + | { success: true; data: { created: number; data: Array> } } + | { success: false; status: number; error: unknown }; + +function logCodexImagePrompt(log, provider, model, prompt, referenceImages) { + if (!log) return; + const summary = + referenceImages.length > 0 ? `${prompt.length} chars` : `"${prompt.slice(0, 60)}..."`; + log.info("IMAGE", `${provider}/${model} (codex-responses) | prompt: ${summary}`); +} + +/** + * Run a stateless Codex reference-image edit through the native Responses hosted tool. + * This deliberately reuses the text-to-image implementation so OAuth headers, SSE parsing, + * response formatting, and error handling cannot drift between generations and edits. + */ +export async function handleCodexImageEdit({ + model, + provider, + providerConfig, + body, + referenceImages, + credentials, + log, + signal = null, +}: { + model: string; + provider: string; + providerConfig: unknown; + body: Record; + referenceImages: Array<{ bytes: Buffer; mime: string }>; + credentials: unknown; + log: { + info: (tag: string, message: string) => void; + warn: (tag: string, message: string) => void; + error: (tag: string, message: string) => void; + } | null; + signal?: AbortSignal | null; +}): Promise { + const result = await handleCodexImageGeneration({ + model, + provider, + providerConfig, + body: { ...body, n: 1 }, + credentials, + log, + referenceImages, + signal, + logPath: "/v1/images/edits", + }); + return result as CodexImageEditResult; +} + +function parseCodexImageGenerationCall(evt: Record) { + if (evt?.type !== "response.output_item.done") return null; + const item = evt.item as Record | undefined; + if (!item || item.type !== "image_generation_call") return null; + const result = typeof item.result === "string" ? item.result : ""; + if (!result) return null; + const revisedPrompt = typeof item.revised_prompt === "string" ? item.revised_prompt : null; + return { b64: result, revisedPrompt }; +} + +function validateCodexImageCredentials({ + token, + credentials, + provider, + model, + startTime, + logPath, +}) { if (!token) { return saveImageErrorResult({ provider, @@ -156,13 +271,10 @@ export async function handleCodexImageGeneration({ }); } - const workspaceId = - credentials?.providerSpecificData && - typeof credentials.providerSpecificData === "object" && - !Array.isArray(credentials.providerSpecificData) - ? (credentials.providerSpecificData as Record).workspaceId - : undefined; + return null; +} +async function prepareCodexImageRequest({ model, body, prompt, referenceImages }) { // Hosted chat orchestration uses the backend's image engine; exact image engines // are dispatched separately by handleImageGeneration through codexImages.ts. const toolConfig: Record = { type: "image_generation", output_format: "png" }; @@ -218,6 +330,17 @@ export async function handleCodexImageGeneration({ } : upstreamBody; + return { upstreamBody, requestBodyForLog }; +} + +function buildCodexImageHeaders(token, credentials) { + const workspaceId = + credentials?.providerSpecificData && + typeof credentials.providerSpecificData === "object" && + !Array.isArray(credentials.providerSpecificData) + ? (credentials.providerSpecificData as Record).workspaceId + : undefined; + const headers: Record = { "Content-Type": "application/json", Accept: "text/event-stream", @@ -231,85 +354,98 @@ export async function handleCodexImageGeneration({ headers["session_id"] = workspaceId; } - if (log) { - const promptSummary = - referenceImages.length > 0 ? `${prompt.length} chars` : `"${prompt.slice(0, 60)}..."`; - log.info("IMAGE", `${provider}/${model} (codex-responses) | prompt: ${promptSummary}`); - } - - const fetchOneImage = async () => { - let response: Response; - try { - response = await fetch(providerConfig.baseUrl, { - method: "POST", - headers, - body: JSON.stringify(upstreamBody), - signal, - }); - } catch (err) { - const message = sanitizeErrorMessage(err); - if (log) log.error("IMAGE", `${provider} fetch error: ${message}`); - return { - ok: false as const, - error: { - provider, - model, - status: 502, - startTime, - error: `Image provider error: ${message}`, - requestBody: requestBodyForLog, - path: logPath, - }, - }; - } + return headers; +} - if (!response.ok) { - const errorText = await response.text(); - const safeError = sanitizeImageProviderError(errorText); - const safeErrorLog = - typeof safeError === "string" ? safeError : JSON.stringify(safeError ?? {}); - if (log) log.error("IMAGE", `${provider} error ${response.status}: ${safeErrorLog}`); - const retryable = isCodexChatGptModelAccessError(response.status, errorText, model); - return { - ok: false as const, - error: { - provider, - model, - status: response.status, - startTime, - error: safeError, - requestBody: requestBodyForLog, - path: logPath, - ...(retryable ? { retryable: true } : {}), - }, - }; - } +async function fetchCodexImage({ + providerConfig, + headers, + upstreamBody, + signal, + log, + provider, + model, + startTime, + requestBodyForLog, + logPath, +}) { + let response: Response; + try { + response = await fetch(providerConfig.baseUrl, { + method: "POST", + headers, + body: JSON.stringify(upstreamBody), + signal, + }); + } catch (err) { + const message = sanitizeErrorMessage(err); + if (log) log.error("IMAGE", `${provider} fetch error: ${message}`); + return { + ok: false as const, + error: { + provider, + model, + status: 502, + startTime, + error: `Image provider error: ${message}`, + requestBody: requestBodyForLog, + path: logPath, + }, + }; + } - const rawSSE = await response.text(); - const items = extractImageGenerationCalls(rawSSE); - if (items.length === 0) { - return { - ok: false as const, - error: { - provider, - model, - status: 502, - startTime, - error: - "Codex completed without producing an image_generation_call — the model may have declined the tool", - requestBody: requestBodyForLog, - path: logPath, - }, - }; - } + if (!response.ok) { + const errorText = await response.text(); + const safeError = sanitizeImageProviderError(errorText); + const safeErrorLog = + typeof safeError === "string" ? safeError : JSON.stringify(safeError ?? {}); + if (log) log.error("IMAGE", `${provider} error ${response.status}: ${safeErrorLog}`); + const retryable = isCodexChatGptModelAccessError(response.status, errorText, model); + return { + ok: false as const, + error: { + provider, + model, + status: response.status, + startTime, + error: safeError, + requestBody: requestBodyForLog, + path: logPath, + ...(retryable ? { retryable: true } : {}), + }, + }; + } - return { ok: true as const, items }; - }; + const rawSSE = await response.text(); + const items = extractImageGenerationCalls(rawSSE); + if (items.length === 0) { + return { + ok: false as const, + error: { + provider, + model, + status: 502, + startTime, + error: + "Codex completed without producing an image_generation_call — the model may have declined the tool", + requestBody: requestBodyForLog, + path: logPath, + }, + }; + } - const imageResults = await Promise.all( - Array.from({ length: requestedCount }, () => fetchOneImage()) - ); + return { ok: true as const, items }; +} +function finishCodexImageResults({ + imageResults, + body, + provider, + model, + startTime, + requestBodyForLog, + logPath, +}) { const collected: Array<{ b64_json: string; revised_prompt?: string }> = []; for (const imageResult of imageResults) { if (!imageResult.ok) return saveImageErrorResult(imageResult.error); @@ -343,49 +479,3 @@ export async function handleCodexImageGeneration({ path: logPath, }); } - -type CodexImageEditResult = - | { success: true; data: { created: number; data: Array> } } - | { success: false; status: number; error: unknown }; - -/** - * Run a stateless Codex reference-image edit through the native Responses hosted tool. - * This deliberately reuses the text-to-image implementation so OAuth headers, SSE parsing, - * response formatting, and error handling cannot drift between generations and edits. - */ -export async function handleCodexImageEdit({ - model, - provider, - providerConfig, - body, - referenceImages, - credentials, - log, - signal = null, -}: { - model: string; - provider: string; - providerConfig: unknown; - body: Record; - referenceImages: Array<{ bytes: Buffer; mime: string }>; - credentials: unknown; - log: { - info: (tag: string, message: string) => void; - warn: (tag: string, message: string) => void; - error: (tag: string, message: string) => void; - } | null; - signal?: AbortSignal | null; -}): Promise { - const result = await handleCodexImageGeneration({ - model, - provider, - providerConfig, - body: { ...body, n: 1 }, - credentials, - log, - referenceImages, - signal, - logPath: "/v1/images/edits", - }); - return result as CodexImageEditResult; -} diff --git a/open-sse/handlers/imageGeneration/providers/falGeneration.ts b/open-sse/handlers/imageGeneration/providers/falGeneration.ts index ae1ee431e5eb..672ab2bc3565 100644 --- a/open-sse/handlers/imageGeneration/providers/falGeneration.ts +++ b/open-sse/handlers/imageGeneration/providers/falGeneration.ts @@ -1,6 +1,15 @@ import { sanitizeErrorMessage } from "../../../utils/error.ts"; -import { extractImageInputs, normalizeRequestedImageFormat, normalizeProviderImagePayload, saveImageSuccessResult, saveImageErrorResult, mapFalImageSize, mapFalAspectRatio, normalizeRecraftStyle } from "../shared.ts"; +import { + extractImageInputs, + normalizeRequestedImageFormat, + normalizeProviderImagePayload, + saveImageSuccessResult, + saveImageErrorResult, + mapFalImageSize, + mapFalAspectRatio, + normalizeRecraftStyle, +} from "../shared.ts"; export async function handleFalAIImageGeneration({ model, provider, @@ -12,44 +21,7 @@ export async function handleFalAIImageGeneration({ const startTime = Date.now(); const token = credentials.apiKey || credentials.accessToken; const falModel = model.startsWith("fal-ai/") ? model : `fal-ai/${model}`; - const { imageUrl, imageUrls } = extractImageInputs(body); - const upstreamBody: Record = { - prompt: body.prompt, - sync_mode: body.sync_mode ?? true, - }; - - if (body.n !== undefined) upstreamBody.num_images = Number(body.n) || 1; - if (body.negative_prompt) upstreamBody.negative_prompt = body.negative_prompt; - if (body.seed !== undefined) upstreamBody.seed = body.seed; - if (body.style) upstreamBody.style = normalizeRecraftStyle(body.style); - - const outputFormat = normalizeRequestedImageFormat(body, "png"); - if (outputFormat) upstreamBody.output_format = outputFormat; - - if (model.includes("flux-pro/v1.1") && !model.includes("ultra")) { - upstreamBody.image_size = mapFalImageSize(body.size, "landscape_4_3"); - } else if ( - model.includes("bytedance/") || - model.includes("stable-diffusion") || - model.includes("ideogram") || - model.includes("recraft/v3") - ) { - upstreamBody.image_size = mapFalImageSize(body.size, "square_hd"); - } else { - upstreamBody.aspect_ratio = body.aspect_ratio || mapFalAspectRatio(body.size, "1:1"); - } - - if (body.quality === "hd" && model.includes("ultra")) { - upstreamBody.raw = true; - } - - if (imageUrl && model.includes("flux-pro/v1.1-ultra")) { - upstreamBody.image_url = imageUrl; - } - - if (imageUrls.length > 0 && model.includes("ideogram")) { - upstreamBody.image_urls = imageUrls; - } + const upstreamBody = prepareFalImageRequest(model, body); if (log) { const promptPreview = String(body.prompt ?? "").slice(0, 60); @@ -103,3 +75,48 @@ export async function handleFalAIImageGeneration({ } } +function prepareFalImageRequest(model, body) { + const upstreamBody: Record = { + prompt: body.prompt, + sync_mode: body.sync_mode ?? true, + }; + + if (body.n !== undefined) upstreamBody.num_images = Number(body.n) || 1; + if (body.negative_prompt) upstreamBody.negative_prompt = body.negative_prompt; + if (body.seed !== undefined) upstreamBody.seed = body.seed; + if (body.style) upstreamBody.style = normalizeRecraftStyle(body.style); + + const outputFormat = normalizeRequestedImageFormat(body, "png"); + if (outputFormat) upstreamBody.output_format = outputFormat; + + applyFalImageOptions(model, body, upstreamBody); + return upstreamBody; +} + +function applyFalImageOptions(model, body, upstreamBody) { + const { imageUrl, imageUrls } = extractImageInputs(body); + if (model.includes("flux-pro/v1.1") && !model.includes("ultra")) { + upstreamBody.image_size = mapFalImageSize(body.size, "landscape_4_3"); + } else if ( + model.includes("bytedance/") || + model.includes("stable-diffusion") || + model.includes("ideogram") || + model.includes("recraft/v3") + ) { + upstreamBody.image_size = mapFalImageSize(body.size, "square_hd"); + } else { + upstreamBody.aspect_ratio = body.aspect_ratio || mapFalAspectRatio(body.size, "1:1"); + } + + if (body.quality === "hd" && model.includes("ultra")) { + upstreamBody.raw = true; + } + + if (imageUrl && model.includes("flux-pro/v1.1-ultra")) { + upstreamBody.image_url = imageUrl; + } + + if (imageUrls.length > 0 && model.includes("ideogram")) { + upstreamBody.image_urls = imageUrls; + } +} diff --git a/open-sse/handlers/imageGeneration/providers/kie.ts b/open-sse/handlers/imageGeneration/providers/kie.ts index cb30fadbb07b..882af13d4228 100644 --- a/open-sse/handlers/imageGeneration/providers/kie.ts +++ b/open-sse/handlers/imageGeneration/providers/kie.ts @@ -159,12 +159,85 @@ export async function handleKieImageGeneration({ }); } - // Check if model is a Market model (unified API) - const fullRegistry = getImageProvider(provider); - const modelEntry = fullRegistry?.models?.find((m) => m.id === model); - const isFluxKontext = Boolean(modelEntry?.kieFluxKontextModel); - const isMarket = !isFluxKontext && (modelEntry?.isMarket || model.includes("/")); + const { modelEntry, isFluxKontext, isMarket } = classifyKieImageModel(provider, model); + + const { baseUrl, payload } = prepareKieImageRequest({ + model, + providerConfig, + body, + prompt, + size, + modelEntry, + isFluxKontext, + isMarket, + }); + + logKieImageRequest(log, body, provider, model, isFluxKontext, isMarket); + try { + const createData = await createKieImageTask({ + isFluxKontext, + isMarket, + baseUrl, + providerConfig, + token, + payload, + }); + const taskId = getKieTaskId(createData); + if (!taskId) { + return saveKieCreateTaskError({ createData, provider, model, startTime, payload, log }); + } + const statusUrl = resolveKieImageStatusUrl({ + isFluxKontext, + isMarket, + providerConfig, + baseUrl, + }); + + const { data: recordData, state } = await kieExecutor.pollTask({ + statusUrl, + taskId: String(taskId), + token, + timeoutMs, + pollIntervalMs, + }); + + return finishKieImageTask({ + recordData, + state, + taskId, + prompt, + provider, + model, + startTime, + payload, + log, + }); + } catch (err: unknown) { + return saveKieImageFailure(err, provider, model, startTime); + } +} + +function saveKieImageFailure(err: unknown, provider: string, model: string, startTime: number) { + return saveImageErrorResult({ + provider, + model, + status: getKieErrorStatus(err, 502), + startTime, + error: `Image provider error: ${getKieErrorMessage(err, "KIE image generation failed")}`, + }); +} + +function prepareKieImageRequest({ + model, + providerConfig, + body, + prompt, + size, + modelEntry, + isFluxKontext, + isMarket, +}) { const { imageUrl } = extractImageInputs(body); let baseUrl = ""; let payload: Record = {}; @@ -206,112 +279,127 @@ export async function handleKieImageGeneration({ }; } - if (log) { - const promptPreview = String(body.prompt ?? "").slice(0, 60); - log.info( - "IMAGE", - `${provider}/${model} (${isFluxKontext ? "flux-kontext" : isMarket ? "market" : "direct"}) | prompt: "${promptPreview}..."` - ); - } - - try { - const endpoint = isFluxKontext - ? "/api/v1/flux/kontext/generate" - : isMarket - ? "/api/v1/jobs/createTask" - : new URL(baseUrl).pathname; - const createBaseUrl = - isFluxKontext || isMarket ? providerConfig.baseUrl : baseUrl.replace(endpoint, ""); - const createData = await kieExecutor.createTask({ - baseUrl: createBaseUrl, - token, - payload, - endpoint, - }); - const taskId = getKieTaskId(createData); - - if (!taskId) { - const errorMessage = - createData?.msg || - createData?.message || - createData?.error || - "KIE image generation did not return taskId"; - if (log) { - log.error("IMAGE", `KIE createTask failed: ${JSON.stringify(createData)}`); - } - return saveImageErrorResult({ - provider, - model, - status: 502, - startTime, - error: errorMessage, - requestBody: payload, - }); - } - - // Use statusUrl from providerConfig if available, fallback to dynamic derivation - const statusUrl = isFluxKontext - ? `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/flux/kontext/record-info` - : isMarket - ? `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/jobs/recordInfo` - : providerConfig.statusUrl && !providerConfig.statusUrl.includes("jobs/recordInfo") - ? providerConfig.statusUrl - : baseUrl.replace(/\/generate$/, "/record-info"); + return { baseUrl, payload }; +} - const { data: recordData, state } = await kieExecutor.pollTask({ - statusUrl, - taskId: String(taskId), - token, - timeoutMs, - pollIntervalMs, - }); +async function createKieImageTask({ + isFluxKontext, + isMarket, + baseUrl, + providerConfig, + token, + payload, +}) { + const endpoint = isFluxKontext + ? "/api/v1/flux/kontext/generate" + : isMarket + ? "/api/v1/jobs/createTask" + : new URL(baseUrl).pathname; + const createBaseUrl = + isFluxKontext || isMarket ? providerConfig.baseUrl : baseUrl.replace(endpoint, ""); + return kieExecutor.createTask({ + baseUrl: createBaseUrl, + token, + payload, + endpoint, + }); +} - const kieUrls = state === "success" ? normalizeKieImageResult(recordData) : []; - // #14335 LEDGER-13: a "success" state with zero usable urls falls through to - // the failure branch below instead of a fake HTTP-200 success with data:[]. - if (kieUrls.length > 0) { - if (log) { - log.info("IMAGE", `KIE poll success for task ${taskId}`); - } - const images = kieUrls.map((url: string) => ({ url, revised_prompt: prompt })); - - return saveImageSuccessResult({ - provider, - model, - startTime, - requestBody: payload, - responseBody: { images_count: images.length }, - images, - }); - } +function saveKieCreateTaskError({ createData, provider, model, startTime, payload, log }) { + const errorMessage = + createData?.msg || + createData?.message || + createData?.error || + "KIE image generation did not return taskId"; + if (log) { + log.error("IMAGE", `KIE createTask failed: ${JSON.stringify(createData)}`); + } + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: errorMessage, + requestBody: payload, + }); +} - const record = isJsonObject(recordData) ? recordData : {}; - const recordDataBody = isJsonObject(record.data) ? record.data : {}; - const errorMessage = - recordDataBody.errorMessage || - recordDataBody.failMsg || - record.msg || - "KIE image task failed"; +function resolveKieImageStatusUrl({ isFluxKontext, isMarket, providerConfig, baseUrl }) { + // Use statusUrl from providerConfig if available, fallback to dynamic derivation + return isFluxKontext + ? `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/flux/kontext/record-info` + : isMarket + ? `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/jobs/recordInfo` + : providerConfig.statusUrl && !providerConfig.statusUrl.includes("jobs/recordInfo") + ? providerConfig.statusUrl + : baseUrl.replace(/\/generate$/, "/record-info"); +} +function finishKieImageTask({ + recordData, + state, + taskId, + prompt, + provider, + model, + startTime, + payload, + log, +}) { + const kieUrls = state === "success" ? normalizeKieImageResult(recordData) : []; + // #14335 LEDGER-13: a "success" state with zero usable urls falls through to + // the failure branch below instead of a fake HTTP-200 success with data:[]. + if (kieUrls.length > 0) { if (log) { - log.error("IMAGE", `KIE poll failed for task ${taskId}: ${JSON.stringify(recordData)}`); + log.info("IMAGE", `KIE poll success for task ${taskId}`); } + const images = kieUrls.map((url: string) => ({ url, revised_prompt: prompt })); - return saveImageErrorResult({ + return saveImageSuccessResult({ provider, model, - status: 502, startTime, - error: String(errorMessage), requestBody: payload, + responseBody: { images_count: images.length }, + images, }); - } catch (err: unknown) { - return saveImageErrorResult({ - provider, - model, - status: getKieErrorStatus(err, 502), - startTime, - error: `Image provider error: ${getKieErrorMessage(err, "KIE image generation failed")}`, - }); + } + + const record = isJsonObject(recordData) ? recordData : {}; + const recordDataBody = isJsonObject(record.data) ? record.data : {}; + const errorMessage = + recordDataBody.errorMessage || recordDataBody.failMsg || record.msg || "KIE image task failed"; + + if (log) { + log.error("IMAGE", `KIE poll failed for task ${taskId}: ${JSON.stringify(recordData)}`); + } + + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: String(errorMessage), + requestBody: payload, + }); +} + +function classifyKieImageModel(provider, model) { + // Check if model is a Market model (unified API) + const fullRegistry = getImageProvider(provider); + const modelEntry = fullRegistry?.models?.find((m) => m.id === model); + const isFluxKontext = Boolean(modelEntry?.kieFluxKontextModel); + const isMarket = !isFluxKontext && (modelEntry?.isMarket || model.includes("/")); + + return { modelEntry, isFluxKontext, isMarket }; +} + +function logKieImageRequest(log, body, provider, model, isFluxKontext, isMarket) { + if (log) { + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log.info( + "IMAGE", + `${provider}/${model} (${isFluxKontext ? "flux-kontext" : isMarket ? "market" : "direct"}) | prompt: "${promptPreview}..."` + ); } } diff --git a/open-sse/handlers/imageGeneration/providers/nanoBanana.ts b/open-sse/handlers/imageGeneration/providers/nanoBanana.ts index b1aea35b8520..f34a5549cf46 100644 --- a/open-sse/handlers/imageGeneration/providers/nanoBanana.ts +++ b/open-sse/handlers/imageGeneration/providers/nanoBanana.ts @@ -24,38 +24,7 @@ export async function handleNanoBananaImageGeneration({ const submitUrl = isPro && providerConfig.proUrl ? providerConfig.proUrl : providerConfig.baseUrl; const statusUrl = providerConfig.statusUrl; - const aspectRatio = - typeof body.aspectRatio === "string" - ? body.aspectRatio - : typeof body.aspect_ratio === "string" - ? body.aspect_ratio - : mapImageSize(body.size); - - let resolution = - typeof body.resolution === "string" - ? body.resolution - : inferResolutionFromSize(body.size) || "1K"; - if (body.quality === "hd" && resolution === "1K") { - resolution = "2K"; - } - - const upstreamBody = isPro - ? { - prompt: body.prompt, - resolution, - aspectRatio, - ...(Array.isArray(body.imageUrls) ? { imageUrls: body.imageUrls } : {}), - } - : { - prompt: body.prompt, - type: - Array.isArray(body.imageUrls) && body.imageUrls.length > 0 - ? "IMAGETOIAMGE" - : "TEXTTOIAMGE", - numImages: Number.isFinite(body.n) ? Math.max(1, Number(body.n)) : 1, - image_size: aspectRatio, - ...(Array.isArray(body.imageUrls) ? { imageUrls: body.imageUrls } : {}), - }; + const upstreamBody = prepareNanoBananaImageRequest(body, isPro); if (log) { const promptPreview = String(body.prompt ?? "").slice(0, 60); @@ -76,173 +45,32 @@ export async function handleNanoBananaImageGeneration({ }); if (!submitResp.ok) { - const errorText = await submitResp.text(); - if (log) { - log.error( - "IMAGE", - `${provider} submit error ${submitResp.status}: ${errorText.slice(0, 200)}` - ); - } - - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: submitResp.status, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText.slice(0, 500), - }).catch(() => {}); - - return { success: false, status: submitResp.status, error: errorText }; + return await parseNanoBananaSubmitError(submitResp, log, provider, model, startTime); } const submitData = await submitResp.json(); - // Backward compatibility: handle providers returning image payload synchronously - const hasSyncPayload = - Boolean(submitData?.image) || - Array.isArray(submitData?.images) || - Array.isArray(submitData?.data) || - Boolean(submitData?.data?.[0]?.url) || - Boolean(submitData?.data?.[0]?.b64_json); - - if (hasSyncPayload) { - const syncResult = normalizeNanoBananaSyncPayload(submitData, body.prompt); - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - responseBody: { images_count: syncResult.data?.length || 0, mode: "sync" }, - }).catch(() => {}); - return { - success: true, - data: { created: Math.floor(Date.now() / 1000), data: syncResult.data }, - }; - } - - const taskId = submitData?.data?.taskId || submitData?.taskId; - if (!taskId) { - const errorText = `NanoBanana submit did not return taskId: ${JSON.stringify(submitData).slice(0, 400)}`; - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText, - }).catch(() => {}); - return { success: false, status: 502, error: errorText }; - } - - if (!statusUrl) { - const errorText = "NanoBanana statusUrl is not configured"; - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 500, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText, - }).catch(() => {}); - return { success: false, status: 500, error: errorText }; - } - - const timeoutMs = normalizePositiveNumber( - body.timeout_ms, - normalizePositiveNumber(process.env.NANOBANANA_POLL_TIMEOUT_MS, 120000) - ); - const pollIntervalMs = normalizePositiveNumber( - body.poll_interval_ms, - normalizePositiveNumber(process.env.NANOBANANA_POLL_INTERVAL_MS, 2500) - ); - - let lastTaskData = null; - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - const pollResp = await fetch(`${statusUrl}?taskId=${encodeURIComponent(taskId)}`, { - method: "GET", - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!pollResp.ok) { - const errorText = await pollResp.text(); - if (log) { - log.error( - "IMAGE", - `${provider} poll error ${pollResp.status}: ${errorText.slice(0, 200)}` - ); - } - return { success: false, status: pollResp.status, error: errorText }; - } - - const pollData = await pollResp.json(); - const taskData = pollData?.data || pollData; - lastTaskData = taskData; - - const successFlag = Number(taskData?.successFlag); - if (successFlag === 1) { - const normalized = await normalizeNanoBananaTaskResult(taskData, body, log); - - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - responseBody: { images_count: normalized.length, mode: "async", taskId }, - }).catch(() => {}); - - return { - success: true, - data: { - created: Math.floor(Date.now() / 1000), - data: normalized, - }, - }; - } - - if (successFlag === 2 || successFlag === 3) { - const errorText = - taskData?.errorMessage || `NanoBanana task failed (successFlag=${String(successFlag)})`; - - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText.slice(0, 500), - responseBody: { taskId, successFlag, errorCode: taskData?.errorCode ?? null }, - }).catch(() => {}); - - return { success: false, status: 502, error: errorText }; - } - - await sleep(pollIntervalMs); - } - - const timeoutError = `NanoBanana task timeout after ${timeoutMs}ms (taskId=${taskId}, successFlag=${String(lastTaskData?.successFlag ?? "unknown")})`; - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 504, - model: `${provider}/${model}`, + const submitResult = parseNanoBananaSubmission({ + submitData, + body, provider, - duration: Date.now() - startTime, - error: timeoutError, - responseBody: { taskId, lastSuccessFlag: lastTaskData?.successFlag ?? null }, - }).catch(() => {}); - - return { success: false, status: 504, error: timeoutError }; + model, + startTime, + statusUrl, + }); + if (submitResult.result) return submitResult.result; + const taskId = submitResult.taskId; + + return await pollNanoBananaImageTask({ + body, + statusUrl, + taskId, + token, + log, + provider, + model, + startTime, + }); } catch (err) { if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); saveCallLog({ @@ -287,31 +115,8 @@ function normalizeNanoBananaSyncPayload(data, prompt) { export async function normalizeNanoBananaTaskResult(taskData, body, log) { const response = taskData?.response || {}; - const urlCandidates = [ - response?.resultImageUrl, - response?.originImageUrl, - taskData?.resultImageUrl, - taskData?.originImageUrl, - ].filter((v) => typeof v === "string" && v.length > 0); - - if (Array.isArray(response?.resultImageUrls)) { - for (const u of response.resultImageUrls) { - if (typeof u === "string" && u.length > 0) urlCandidates.push(u); - } - } - - const b64Candidates = [ - response?.resultImageBase64, - response?.resultImage, - taskData?.resultImageBase64, - taskData?.resultImage, - ].filter((v) => typeof v === "string" && v.length > 0); - - if (Array.isArray(response?.resultImageBase64List)) { - for (const b64 of response.resultImageBase64List) { - if (typeof b64 === "string" && b64.length > 0) b64Candidates.push(b64); - } - } + const urlCandidates = collectNanoBananaResultUrls(taskData, response); + const b64Candidates = collectNanoBananaResultBase64(taskData, response); const wantsBase64 = body.response_format === "b64_json"; @@ -360,3 +165,289 @@ function inferResolutionFromSize(size) { if (longestSide <= 2048) return "2K"; return "4K"; } + +function prepareNanoBananaImageRequest(body, isPro) { + const aspectRatio = + typeof body.aspectRatio === "string" + ? body.aspectRatio + : typeof body.aspect_ratio === "string" + ? body.aspect_ratio + : mapImageSize(body.size); + + const resolution = resolveNanoBananaResolution(body); + + const upstreamBody = isPro + ? { + prompt: body.prompt, + resolution, + aspectRatio, + ...(Array.isArray(body.imageUrls) ? { imageUrls: body.imageUrls } : {}), + } + : { + prompt: body.prompt, + type: + Array.isArray(body.imageUrls) && body.imageUrls.length > 0 + ? "IMAGETOIAMGE" + : "TEXTTOIAMGE", + numImages: Number.isFinite(body.n) ? Math.max(1, Number(body.n)) : 1, + image_size: aspectRatio, + ...(Array.isArray(body.imageUrls) ? { imageUrls: body.imageUrls } : {}), + }; + + return upstreamBody; +} + +function parseNanoBananaSubmission({ submitData, body, provider, model, startTime, statusUrl }) { + const hasSyncPayload = hasNanoBananaSyncPayload(submitData); + + if (hasSyncPayload) { + const syncResult = normalizeNanoBananaSyncPayload(submitData, body.prompt); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { images_count: syncResult.data?.length || 0, mode: "sync" }, + }).catch(() => {}); + return { + result: { + success: true, + data: { created: Math.floor(Date.now() / 1000), data: syncResult.data }, + }, + taskId: null, + }; + } + + const taskId = submitData?.data?.taskId || submitData?.taskId; + if (!taskId) { + const errorText = `NanoBanana submit did not return taskId: ${JSON.stringify(submitData).slice(0, 400)}`; + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText, + }).catch(() => {}); + return { result: { success: false, status: 502, error: errorText }, taskId: null }; + } + + if (!statusUrl) { + const errorText = "NanoBanana statusUrl is not configured"; + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 500, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText, + }).catch(() => {}); + return { result: { success: false, status: 500, error: errorText }, taskId: null }; + } + + return { result: null, taskId }; +} + +async function pollNanoBananaImageTask({ + body, + statusUrl, + taskId, + token, + log, + provider, + model, + startTime, +}) { + const timeoutMs = normalizePositiveNumber( + body.timeout_ms, + normalizePositiveNumber(process.env.NANOBANANA_POLL_TIMEOUT_MS, 120000) + ); + const pollIntervalMs = normalizePositiveNumber( + body.poll_interval_ms, + normalizePositiveNumber(process.env.NANOBANANA_POLL_INTERVAL_MS, 2500) + ); + + let lastTaskData = null; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const pollResp = await fetch(`${statusUrl}?taskId=${encodeURIComponent(taskId)}`, { + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!pollResp.ok) { + const errorText = await pollResp.text(); + if (log) { + log.error("IMAGE", `${provider} poll error ${pollResp.status}: ${errorText.slice(0, 200)}`); + } + return { success: false, status: pollResp.status, error: errorText }; + } + + const pollData = await pollResp.json(); + const taskData = pollData?.data || pollData; + lastTaskData = taskData; + + const successFlag = Number(taskData?.successFlag); + const result = await parseNanoBananaPollResult({ + taskData, + successFlag, + body, + log, + provider, + model, + startTime, + taskId, + }); + if (result) return result; + + await sleep(pollIntervalMs); + } + + const timeoutError = `NanoBanana task timeout after ${timeoutMs}ms (taskId=${taskId}, successFlag=${String(lastTaskData?.successFlag ?? "unknown")})`; + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 504, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: timeoutError, + responseBody: { taskId, lastSuccessFlag: lastTaskData?.successFlag ?? null }, + }).catch(() => {}); + + return { success: false, status: 504, error: timeoutError }; +} + +async function parseNanoBananaPollResult({ + taskData, + successFlag, + body, + log, + provider, + model, + startTime, + taskId, +}) { + if (successFlag === 1) { + const normalized = await normalizeNanoBananaTaskResult(taskData, body, log); + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { images_count: normalized.length, mode: "async", taskId }, + }).catch(() => {}); + + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: normalized, + }, + }; + } + + if (successFlag === 2 || successFlag === 3) { + const errorText = + taskData?.errorMessage || `NanoBanana task failed (successFlag=${String(successFlag)})`; + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + responseBody: { taskId, successFlag, errorCode: taskData?.errorCode ?? null }, + }).catch(() => {}); + + return { success: false, status: 502, error: errorText }; + } + + return null; +} + +function collectNanoBananaResultUrls(taskData, response) { + const urlCandidates = [ + response?.resultImageUrl, + response?.originImageUrl, + taskData?.resultImageUrl, + taskData?.originImageUrl, + ].filter((v) => typeof v === "string" && v.length > 0); + + if (Array.isArray(response?.resultImageUrls)) { + for (const u of response.resultImageUrls) { + if (typeof u === "string" && u.length > 0) urlCandidates.push(u); + } + } + + return urlCandidates; +} + +function collectNanoBananaResultBase64(taskData, response) { + const b64Candidates = [ + response?.resultImageBase64, + response?.resultImage, + taskData?.resultImageBase64, + taskData?.resultImage, + ].filter((v) => typeof v === "string" && v.length > 0); + + if (Array.isArray(response?.resultImageBase64List)) { + for (const b64 of response.resultImageBase64List) { + if (typeof b64 === "string" && b64.length > 0) b64Candidates.push(b64); + } + } + + return b64Candidates; +} + +async function parseNanoBananaSubmitError(submitResp, log, provider, model, startTime) { + const errorText = await submitResp.text(); + if (log) { + log.error("IMAGE", `${provider} submit error ${submitResp.status}: ${errorText.slice(0, 200)}`); + } + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: submitResp.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + }).catch(() => {}); + + return { success: false, status: submitResp.status, error: errorText }; +} + +function resolveNanoBananaResolution(body) { + let resolution = + typeof body.resolution === "string" + ? body.resolution + : inferResolutionFromSize(body.size) || "1K"; + if (body.quality === "hd" && resolution === "1K") { + resolution = "2K"; + } + + return resolution; +} + +function hasNanoBananaSyncPayload(submitData) { + // Backward compatibility: handle providers returning image payload synchronously + return ( + Boolean(submitData?.image) || + Array.isArray(submitData?.images) || + Array.isArray(submitData?.data) || + Boolean(submitData?.data?.[0]?.url) || + Boolean(submitData?.data?.[0]?.b64_json) + ); +} diff --git a/open-sse/handlers/imageGeneration/providers/openAI.ts b/open-sse/handlers/imageGeneration/providers/openAI.ts index bfbb1adc710f..adbfd6aa0e60 100644 --- a/open-sse/handlers/imageGeneration/providers/openAI.ts +++ b/open-sse/handlers/imageGeneration/providers/openAI.ts @@ -62,56 +62,13 @@ export async function handleOpenAIImageGeneration({ }) { const startTime = Date.now(); - // Summarized request for call log - const logRequestBody = { - model: body.model, - prompt: - typeof body.prompt === "string" - ? body.prompt.slice(0, 200) - : String(body.prompt ?? "").slice(0, 200), - size: body.size || "default", - n: body.n || 1, - quality: body.quality || undefined, - }; - - // Build upstream request (OpenAI-compatible format) - const upstreamBody: Record = - providerConfig.format === "agnes-image" - ? buildAgnesImageRequestBody(model, body) - : { - model, - prompt: body.prompt, - }; - - if (providerConfig.format === "xai-image") { - const request = buildXaiImageRequest(model, body); - if ("error" in request) return { success: false, status: 400, error: request.error }; - Object.assign(upstreamBody, request.body); - } else if (providerConfig.format !== "agnes-image") { - // Pass optional parameters for ordinary OpenAI-compatible providers. - if (body.n !== undefined) upstreamBody.n = body.n; - if (body.size !== undefined) upstreamBody.size = body.size; - if (body.quality !== undefined) upstreamBody.quality = body.quality; - if (body.response_format !== undefined) upstreamBody.response_format = body.response_format; - if (body.style !== undefined) upstreamBody.style = body.style; - - const { imageUrl } = extractImageInputs(body); - if (imageUrl && OPENAI_IMAGE_TO_IMAGE_MODELS.has(model)) { - upstreamBody.image_url = imageUrl; - } - } - - // Build headers - let headers: Record = { - "Content-Type": "application/json", - }; + const logRequestBody = summarizeOpenAIImageRequest(body); + const request = prepareOpenAIImageRequest(model, providerConfig, body); + if ("error" in request) return { success: false, status: 400, error: request.error }; + const upstreamBody = request.body; const token = credentials.apiKey || credentials.accessToken; - if (token && providerConfig.authHeader === "bearer") { - headers["Authorization"] = `Bearer ${token}`; - } else if (token && providerConfig.authHeader === "x-api-key") { - headers["x-api-key"] = token; - } + let headers = buildOpenAIImageHeaders(token, providerConfig); // #8085 — keyless Pollinations image requests (the common free case) get // no Authorization header above. Mirror the chat executor's anonymous @@ -171,22 +128,14 @@ export async function handleOpenAIImageGeneration({ } // Save call log after result is determined - saveCallLog({ - method: "POST", + saveOpenAIImageCallLog({ path: "/v1/images/generations", - status: result.status || (result.success ? 200 : 502), - model: `${provider}/${model}`, + result, provider, - duration: Date.now() - startTime, - tokens: { prompt_tokens: 0, completion_tokens: 0 }, - error: result.success - ? null - : typeof result.error === "string" - ? result.error.slice(0, 500) - : null, + model, + startTime, requestBody: logRequestBody, - responseBody: result.success ? { images_count: result.data?.data?.length || 0 } : null, - }).catch(() => {}); + }); return result; } @@ -237,36 +186,15 @@ export async function handleOpenAIImageEdit({ "edits" ); - // Build the multipart body as a Buffer with an explicit boundary instead of a global - // `FormData`. In production `globalThis.fetch` is patched with node_modules/undici's fetch, - // whose `FormData` class differs from `globalThis.FormData` — passing a native FormData - // makes undici serialize it as the string "[object FormData]" (text/plain), dropping every - // field (including `model`, which reaches the upstream empty). A Buffer body is accepted - // verbatim by any fetch implementation. (#3273) - const boundary = `----OmniRouteImageEdit${randomUUID().replace(/-/g, "")}`; - const CRLF = "\r\n"; - const partBuffers: Buffer[] = []; - const appendField = (name: string, value: string) => { - partBuffers.push( - Buffer.from( - `--${boundary}${CRLF}Content-Disposition: form-data; name="${name}"${CRLF}${CRLF}${value}${CRLF}` - ) - ); - }; - appendField("model", model); - appendField("prompt", prompt); - if (size) appendField("size", size); - if (responseFormat) appendField("response_format", responseFormat); - appendField("n", String(n || 1)); - partBuffers.push( - Buffer.from( - `--${boundary}${CRLF}Content-Disposition: form-data; name="image"; filename="image.png"${CRLF}` + - `Content-Type: ${imageMime || "image/png"}${CRLF}${CRLF}` - ) - ); - partBuffers.push(imageBytes); - partBuffers.push(Buffer.from(`${CRLF}--${boundary}--${CRLF}`)); - const multipartBody = Buffer.concat(partBuffers); + const { boundary, multipartBody } = buildOpenAIImageEditBody({ + model, + prompt, + imageBytes, + imageMime, + size, + responseFormat, + n, + }); const headers: Record = { "Content-Type": `multipart/form-data; boundary=${boundary}`, @@ -289,22 +217,14 @@ export async function handleOpenAIImageEdit({ log ); - saveCallLog({ - method: "POST", + saveOpenAIImageCallLog({ path: "/v1/images/edits", - status: result.status || (result.success ? 200 : 502), - model: `${provider}/${model}`, + result, provider, - duration: Date.now() - startTime, - tokens: { prompt_tokens: 0, completion_tokens: 0 }, - error: result.success - ? null - : typeof result.error === "string" - ? result.error.slice(0, 500) - : null, + model, + startTime, requestBody: { model, prompt: prompt.slice(0, 200), size: size || "default", n: n || 1 }, - responseBody: result.success ? { images_count: result.data?.data?.length || 0 } : null, - }).catch(() => {}); + }); return result; } @@ -347,13 +267,7 @@ export async function handleOpenRouterImageEdit({ log?: { info: (tag: string, message: string) => void } | null; }) { const startTime = Date.now(); - let url = baseUrl.trim(); - while (url.endsWith("/")) url = url.slice(0, -1); - if (url.endsWith("/images/generations")) { - url = url.slice(0, -"/images/generations".length) + "/images"; - } else if (!url.endsWith("/images")) { - url += "/images"; - } + const url = resolveOpenRouterImageEditUrl(baseUrl); const mime = imageMime || "image/png"; const upstreamBody: Record = { @@ -390,22 +304,156 @@ export async function handleOpenRouterImageEdit({ log ); + saveOpenAIImageCallLog({ + path: "/v1/images/edits", + result, + provider, + model, + startTime, + requestBody: { model, prompt: prompt.slice(0, 200), size: size || "default", n: n || 1 }, + }); + + return result; +} + +function prepareOpenAIImageRequest(model, providerConfig, body) { + // Build upstream request (OpenAI-compatible format) + const upstreamBody: Record = + providerConfig.format === "agnes-image" + ? buildAgnesImageRequestBody(model, body) + : { + model, + prompt: body.prompt, + }; + + if (providerConfig.format === "xai-image") { + const request = buildXaiImageRequest(model, body); + if ("error" in request) return request; + Object.assign(upstreamBody, request.body); + } else if (providerConfig.format !== "agnes-image") { + applyOpenAIImageOptions(model, body, upstreamBody); + } + + return { body: upstreamBody }; +} + +function buildOpenAIImageHeaders(token, providerConfig) { + // Build headers + const headers: Record = { + "Content-Type": "application/json", + }; + + if (token && providerConfig.authHeader === "bearer") { + headers["Authorization"] = `Bearer ${token}`; + } else if (token && providerConfig.authHeader === "x-api-key") { + headers["x-api-key"] = token; + } + + return headers; +} + +function buildOpenAIImageEditBody({ + model, + prompt, + imageBytes, + imageMime, + size, + responseFormat, + n, +}) { + // Build the multipart body as a Buffer with an explicit boundary instead of a global + // `FormData`. In production `globalThis.fetch` is patched with node_modules/undici's fetch, + // whose `FormData` class differs from `globalThis.FormData` — passing a native FormData + // makes undici serialize it as the string "[object FormData]" (text/plain), dropping every + // field (including `model`, which reaches the upstream empty). A Buffer body is accepted + // verbatim by any fetch implementation. (#3273) + const boundary = `----OmniRouteImageEdit${randomUUID().replace(/-/g, "")}`; + const CRLF = "\r\n"; + const partBuffers: Buffer[] = []; + const appendField = (name: string, value: string) => { + partBuffers.push( + Buffer.from( + `--${boundary}${CRLF}Content-Disposition: form-data; name="${name}"${CRLF}${CRLF}${value}${CRLF}` + ) + ); + }; + appendField("model", model); + appendField("prompt", prompt); + if (size) appendField("size", size); + if (responseFormat) appendField("response_format", responseFormat); + appendField("n", String(n || 1)); + partBuffers.push( + Buffer.from( + `--${boundary}${CRLF}Content-Disposition: form-data; name="image"; filename="image.png"${CRLF}` + + `Content-Type: ${imageMime || "image/png"}${CRLF}${CRLF}` + ) + ); + partBuffers.push(imageBytes); + partBuffers.push(Buffer.from(`${CRLF}--${boundary}--${CRLF}`)); + const multipartBody = Buffer.concat(partBuffers); + + return { boundary, multipartBody }; +} + +function resolveOpenRouterImageEditUrl(baseUrl) { + let url = baseUrl.trim(); + while (url.endsWith("/")) url = url.slice(0, -1); + if (url.endsWith("/images/generations")) { + url = url.slice(0, -"/images/generations".length) + "/images"; + } else if (!url.endsWith("/images")) { + url += "/images"; + } + + return url; +} + +function getOpenAIImageLogError(result) { + return result.success + ? null + : typeof result.error === "string" + ? result.error.slice(0, 500) + : null; +} + +function summarizeOpenAIImageRequest(body) { + // Summarized request for call log + return { + model: body.model, + prompt: + typeof body.prompt === "string" + ? body.prompt.slice(0, 200) + : String(body.prompt ?? "").slice(0, 200), + size: body.size || "default", + n: body.n || 1, + quality: body.quality || undefined, + }; +} + +function saveOpenAIImageCallLog({ path, result, provider, model, startTime, requestBody }) { saveCallLog({ method: "POST", - path: "/v1/images/edits", + path, status: result.status || (result.success ? 200 : 502), model: `${provider}/${model}`, provider, duration: Date.now() - startTime, tokens: { prompt_tokens: 0, completion_tokens: 0 }, - error: result.success - ? null - : typeof result.error === "string" - ? result.error.slice(0, 500) - : null, - requestBody: { model, prompt: prompt.slice(0, 200), size: size || "default", n: n || 1 }, + error: getOpenAIImageLogError(result), + requestBody, responseBody: result.success ? { images_count: result.data?.data?.length || 0 } : null, }).catch(() => {}); +} - return result; +function applyOpenAIImageOptions(model, body, upstreamBody) { + // Pass optional parameters for ordinary OpenAI-compatible providers. + if (body.n !== undefined) upstreamBody.n = body.n; + if (body.size !== undefined) upstreamBody.size = body.size; + if (body.quality !== undefined) upstreamBody.quality = body.quality; + if (body.response_format !== undefined) upstreamBody.response_format = body.response_format; + if (body.style !== undefined) upstreamBody.style = body.style; + + const { imageUrl } = extractImageInputs(body); + if (imageUrl && OPENAI_IMAGE_TO_IMAGE_MODELS.has(model)) { + upstreamBody.image_url = imageUrl; + } } diff --git a/open-sse/handlers/imageGeneration/providers/stability.ts b/open-sse/handlers/imageGeneration/providers/stability.ts index be0053a55885..4b8745fc8b02 100644 --- a/open-sse/handlers/imageGeneration/providers/stability.ts +++ b/open-sse/handlers/imageGeneration/providers/stability.ts @@ -1,6 +1,13 @@ import { sanitizeErrorMessage } from "../../../utils/error.ts"; import { mapImageSize } from "../../../translator/image/sizeMapper.ts"; -import { extractImageInputs, resolveImageSource, normalizeRequestedImageFormat, normalizeProviderImagePayload, saveImageSuccessResult, saveImageErrorResult } from "../shared.ts"; +import { + extractImageInputs, + resolveImageSource, + normalizeRequestedImageFormat, + normalizeProviderImagePayload, + saveImageSuccessResult, + saveImageErrorResult, +} from "../shared.ts"; const STABILITY_GENERATION_ENDPOINTS = { "sd3.5-large": "/v2beta/stable-image/generate/sd3", "sd3.5-large-turbo": "/v2beta/stable-image/generate/sd3", @@ -64,114 +71,14 @@ export async function handleStabilityAIImageGeneration({ } const { imageUrl, maskUrl } = extractImageInputs(body); - const upstreamBody: Record = { - output_format: - model === "remove-background" - ? normalizeRequestedImageFormat(body, "png", ["png", "webp"]) - : normalizeRequestedImageFormat(body, "png"), - }; - const formData = new FormData(); - - appendOptionalFormValue(formData, "output_format", upstreamBody.output_format); - if (body.prompt) { - upstreamBody.prompt = body.prompt; - appendOptionalFormValue(formData, "prompt", body.prompt); - } - if (body.negative_prompt) { - upstreamBody.negative_prompt = body.negative_prompt; - appendOptionalFormValue(formData, "negative_prompt", body.negative_prompt); - } - if (body.seed !== undefined) { - upstreamBody.seed = body.seed; - appendOptionalFormValue(formData, "seed", body.seed); - } + const { upstreamBody, formData } = prepareStabilityImageForm(model, body); try { if (STABILITY_GENERATION_ENDPOINTS[model]) { - if (model.startsWith("sd3.5")) { - upstreamBody.model = model; - appendOptionalFormValue(formData, "model", model); - } - - if (imageUrl) { - const imageSource = await resolveImageSource(imageUrl); - upstreamBody.mode = "image-to-image"; - appendOptionalFormValue(formData, "mode", "image-to-image"); - upstreamBody.image = imageSource.base64; - appendImageFormValue(formData, "image", imageSource, "image"); - if (body.strength !== undefined) { - upstreamBody.strength = body.strength; - appendOptionalFormValue(formData, "strength", body.strength); - } - } else { - upstreamBody.mode = "text-to-image"; - appendOptionalFormValue(formData, "mode", "text-to-image"); - } - - if (!model.startsWith("sd3.5") || !imageUrl) { - const aspectRatio = body.aspect_ratio || mapImageSize(body.size); - upstreamBody.aspect_ratio = aspectRatio; - appendOptionalFormValue(formData, "aspect_ratio", aspectRatio); - } - - if (body.style_preset) { - upstreamBody.style_preset = body.style_preset; - appendOptionalFormValue(formData, "style_preset", body.style_preset); - } + await prepareStabilityGeneration(model, body, imageUrl, upstreamBody, formData); } else { - if (imageUrl) { - const imageSource = await resolveImageSource(imageUrl); - upstreamBody.image = imageSource.base64; - appendImageFormValue(formData, "image", imageSource, "image"); - } - - if (maskUrl && shouldIncludeStabilityMask(model)) { - const maskSource = await resolveImageSource(maskUrl); - upstreamBody.mask = maskSource.base64; - appendImageFormValue(formData, "mask", maskSource, "mask"); - } - - if (body.search_prompt) { - upstreamBody.search_prompt = body.search_prompt; - appendOptionalFormValue(formData, "search_prompt", body.search_prompt); - } - if (body.grow_mask !== undefined) { - upstreamBody.grow_mask = body.grow_mask; - appendOptionalFormValue(formData, "grow_mask", body.grow_mask); - } - if (body.control_strength !== undefined) { - upstreamBody.control_strength = body.control_strength; - appendOptionalFormValue(formData, "control_strength", body.control_strength); - } - if (body.creativity !== undefined) { - upstreamBody.creativity = body.creativity; - appendOptionalFormValue(formData, "creativity", body.creativity); - } - if (body.left !== undefined) { - upstreamBody.left = body.left; - appendOptionalFormValue(formData, "left", body.left); - } - if (body.right !== undefined) { - upstreamBody.right = body.right; - appendOptionalFormValue(formData, "right", body.right); - } - if (body.up !== undefined) { - upstreamBody.up = body.up; - appendOptionalFormValue(formData, "up", body.up); - } - if (body.down !== undefined) { - upstreamBody.down = body.down; - appendOptionalFormValue(formData, "down", body.down); - } - if (body.style_preset) { - upstreamBody.style_preset = body.style_preset; - appendOptionalFormValue(formData, "style_preset", body.style_preset); - } - - if (STABILITY_CONTROL_MODELS.has(model) && !upstreamBody.prompt) { - upstreamBody.prompt = body.prompt || ""; - appendOptionalFormValue(formData, "prompt", body.prompt || ""); - } + await prepareStabilityEditImages(model, imageUrl, maskUrl, upstreamBody, formData); + applyStabilityEditOptions(model, body, upstreamBody, formData); } if (log) { @@ -202,14 +109,7 @@ export async function handleStabilityAIImageGeneration({ }); } - const contentType = response.headers.get("content-type") || ""; - let payload; - if (contentType.includes("application/json")) { - payload = await response.json(); - } else { - const buffer = Buffer.from(await response.arrayBuffer()); - payload = { image: buffer.toString("base64") }; - } + const payload = await readStabilityImagePayload(response); const images = await normalizeProviderImagePayload(payload, body, log, "b64_json"); return saveImageSuccessResult({ @@ -233,6 +133,14 @@ export async function handleStabilityAIImageGeneration({ } } +async function readStabilityImagePayload(response: Response) { + if ((response.headers.get("content-type") || "").includes("application/json")) { + return response.json(); + } + const buffer = Buffer.from(await response.arrayBuffer()); + return { image: buffer.toString("base64") }; +} + function shouldIncludeStabilityMask(model) { return new Set([ "inpaint", @@ -243,3 +151,119 @@ function shouldIncludeStabilityMask(model) { ]).has(model); } +async function prepareStabilityGeneration(model, body, imageUrl, upstreamBody, formData) { + if (model.startsWith("sd3.5")) { + upstreamBody.model = model; + appendOptionalFormValue(formData, "model", model); + } + + if (imageUrl) { + const imageSource = await resolveImageSource(imageUrl); + upstreamBody.mode = "image-to-image"; + appendOptionalFormValue(formData, "mode", "image-to-image"); + upstreamBody.image = imageSource.base64; + appendImageFormValue(formData, "image", imageSource, "image"); + if (body.strength !== undefined) { + upstreamBody.strength = body.strength; + appendOptionalFormValue(formData, "strength", body.strength); + } + } else { + upstreamBody.mode = "text-to-image"; + appendOptionalFormValue(formData, "mode", "text-to-image"); + } + + if (!model.startsWith("sd3.5") || !imageUrl) { + const aspectRatio = body.aspect_ratio || mapImageSize(body.size); + upstreamBody.aspect_ratio = aspectRatio; + appendOptionalFormValue(formData, "aspect_ratio", aspectRatio); + } + + if (body.style_preset) { + upstreamBody.style_preset = body.style_preset; + appendOptionalFormValue(formData, "style_preset", body.style_preset); + } +} + +async function prepareStabilityEditImages(model, imageUrl, maskUrl, upstreamBody, formData) { + if (imageUrl) { + const imageSource = await resolveImageSource(imageUrl); + upstreamBody.image = imageSource.base64; + appendImageFormValue(formData, "image", imageSource, "image"); + } + + if (maskUrl && shouldIncludeStabilityMask(model)) { + const maskSource = await resolveImageSource(maskUrl); + upstreamBody.mask = maskSource.base64; + appendImageFormValue(formData, "mask", maskSource, "mask"); + } +} + +function applyStabilityEditOptions(model, body, upstreamBody, formData) { + if (body.search_prompt) { + upstreamBody.search_prompt = body.search_prompt; + appendOptionalFormValue(formData, "search_prompt", body.search_prompt); + } + if (body.grow_mask !== undefined) { + upstreamBody.grow_mask = body.grow_mask; + appendOptionalFormValue(formData, "grow_mask", body.grow_mask); + } + if (body.control_strength !== undefined) { + upstreamBody.control_strength = body.control_strength; + appendOptionalFormValue(formData, "control_strength", body.control_strength); + } + if (body.creativity !== undefined) { + upstreamBody.creativity = body.creativity; + appendOptionalFormValue(formData, "creativity", body.creativity); + } + if (body.left !== undefined) { + upstreamBody.left = body.left; + appendOptionalFormValue(formData, "left", body.left); + } + if (body.right !== undefined) { + upstreamBody.right = body.right; + appendOptionalFormValue(formData, "right", body.right); + } + if (body.up !== undefined) { + upstreamBody.up = body.up; + appendOptionalFormValue(formData, "up", body.up); + } + if (body.down !== undefined) { + upstreamBody.down = body.down; + appendOptionalFormValue(formData, "down", body.down); + } + if (body.style_preset) { + upstreamBody.style_preset = body.style_preset; + appendOptionalFormValue(formData, "style_preset", body.style_preset); + } + + if (STABILITY_CONTROL_MODELS.has(model) && !upstreamBody.prompt) { + upstreamBody.prompt = body.prompt || ""; + appendOptionalFormValue(formData, "prompt", body.prompt || ""); + } +} + +function prepareStabilityImageForm(model, body) { + const upstreamBody: Record = { + output_format: + model === "remove-background" + ? normalizeRequestedImageFormat(body, "png", ["png", "webp"]) + : normalizeRequestedImageFormat(body, "png"), + }; + const formData = new FormData(); + + appendOptionalFormValue(formData, "output_format", upstreamBody.output_format); + if (body.prompt) { + upstreamBody.prompt = body.prompt; + appendOptionalFormValue(formData, "prompt", body.prompt); + } + if (body.negative_prompt) { + upstreamBody.negative_prompt = body.negative_prompt; + appendOptionalFormValue(formData, "negative_prompt", body.negative_prompt); + } + if (body.seed !== undefined) { + upstreamBody.seed = body.seed; + appendOptionalFormValue(formData, "seed", body.seed); + } + + return { upstreamBody, formData }; +} diff --git a/open-sse/handlers/imageGeneration/shared.ts b/open-sse/handlers/imageGeneration/shared.ts index 9d6a9b2fa453..7c27ed31bae5 100644 --- a/open-sse/handlers/imageGeneration/shared.ts +++ b/open-sse/handlers/imageGeneration/shared.ts @@ -37,16 +37,7 @@ export function resolveImageBaseUrl( endpoint: "generations" | "edits" = "generations", failClosed = false ): string { - const psd = credentials?.providerSpecificData; - const psdBaseUrl = - psd && typeof psd === "object" && typeof psd.baseUrl === "string" && psd.baseUrl.trim() - ? psd.baseUrl.trim() - : null; - const topLevelBaseUrl = - typeof credentials?.baseUrl === "string" && credentials.baseUrl.trim() - ? credentials.baseUrl.trim() - : null; - const nodeBaseUrl = psdBaseUrl || topLevelBaseUrl; + const nodeBaseUrl = resolveConfiguredImageBaseUrl(credentials); if (!nodeBaseUrl) return failClosed ? "" : fallback; @@ -113,16 +104,7 @@ export function extractImageInputs(body) { for (const candidate of body.image_urls) pushCandidate(candidate); } - if (Array.isArray(body?.messages)) { - for (const msg of body.messages) { - if (!Array.isArray(msg?.content)) continue; - for (const part of msg.content) { - if (part?.type === "image_url") { - pushCandidate(part?.image_url?.url); - } - } - } - } + collectMessageImageInputs(body, pushCandidate); return { imageUrl: imageUrls[0] || null, @@ -232,21 +214,8 @@ export async function normalizeProviderImagePayload(payload, body, log, defaultF candidates.push(value); }; - if (Array.isArray(payload?.data)) { - for (const item of payload.data) pushCandidate(item); - } - - if (Array.isArray(payload?.images)) { - for (const item of payload.images) pushCandidate(item); - } - - if (payload?.image) pushCandidate({ b64_json: payload.image }); - if (payload?.url) pushCandidate({ url: payload.url }); - if (payload?.sample) pushCandidate({ url: payload.sample }); - if (payload?.result?.sample) pushCandidate({ url: payload.result.sample }); - if (Array.isArray(payload?.result?.images)) { - for (const item of payload.result.images) pushCandidate(item); - } + collectProviderImageArrays(payload, pushCandidate); + collectProviderImageFields(payload, pushCandidate); const normalized = []; for (const candidate of candidates) { @@ -266,24 +235,7 @@ export async function normalizeProviderImagePayload(payload, body, log, defaultF async function normalizeProviderImageCandidate(candidate, body, defaultFormat) { const wantsBase64 = body?.response_format === "b64_json" || defaultFormat === "b64_json"; - let url = null; - let b64 = null; - - if (typeof candidate === "string") { - const dataUriMatch = /^data:[^;]+;base64,(.+)$/i.exec(candidate); - if (dataUriMatch) { - b64 = dataUriMatch[1]; - } else if (isHttpUrl(candidate)) { - url = candidate; - } else { - b64 = candidate; - } - } else if (candidate && typeof candidate === "object") { - url = - firstString(candidate.url, candidate.image_url, candidate.sample, candidate.file_url) || null; - b64 = - firstString(candidate.b64_json, candidate.image, candidate.base64, candidate.data) || null; - } + let { url, b64 } = parseProviderImageCandidate(candidate); if (wantsBase64 && !b64 && url) { b64 = (await resolveImageSource(url)).base64; @@ -402,22 +354,8 @@ export async function fetchImageEndpoint(url, headers, body, provider, log) { timeoutMs: getConfiguredTimeout(), }); } catch (err: unknown) { - const isAbortError = - typeof err === "object" && - err !== null && - "name" in err && - (err as { name?: unknown }).name === "AbortError"; - if (err instanceof FetchTimeoutError || isAbortError) { - const message = err instanceof Error ? err.message : String(err); - if (log) { - log.error("IMAGE", `${provider} fetch error: ${message}`); - } - return { - success: false, - status: 504, - error: `Image provider error: ${sanitizeErrorMessage(message || err)}`, - }; - } + const timeoutResult = getImageFetchTimeoutResult(err, provider, log); + if (timeoutResult) return timeoutResult; throw err; } @@ -490,3 +428,92 @@ export function normalizePositiveNumber(value, fallback) { if (!Number.isFinite(n) || n <= 0) return fallback; return Math.floor(n); } + +function resolveConfiguredImageBaseUrl(credentials) { + const psd = credentials?.providerSpecificData; + const psdBaseUrl = + psd && typeof psd === "object" && typeof psd.baseUrl === "string" && psd.baseUrl.trim() + ? psd.baseUrl.trim() + : null; + const topLevelBaseUrl = + typeof credentials?.baseUrl === "string" && credentials.baseUrl.trim() + ? credentials.baseUrl.trim() + : null; + return psdBaseUrl || topLevelBaseUrl; +} + +function collectMessageImageInputs(body, pushCandidate) { + if (Array.isArray(body?.messages)) { + for (const msg of body.messages) { + if (!Array.isArray(msg?.content)) continue; + for (const part of msg.content) { + if (part?.type === "image_url") { + pushCandidate(part?.image_url?.url); + } + } + } + } +} + +function collectProviderImageArrays(payload, pushCandidate) { + if (Array.isArray(payload?.data)) { + for (const item of payload.data) pushCandidate(item); + } + + if (Array.isArray(payload?.images)) { + for (const item of payload.images) pushCandidate(item); + } +} + +function collectProviderImageFields(payload, pushCandidate) { + if (payload?.image) pushCandidate({ b64_json: payload.image }); + if (payload?.url) pushCandidate({ url: payload.url }); + if (payload?.sample) pushCandidate({ url: payload.sample }); + if (payload?.result?.sample) pushCandidate({ url: payload.result.sample }); + if (Array.isArray(payload?.result?.images)) { + for (const item of payload.result.images) pushCandidate(item); + } +} + +function parseProviderImageCandidate(candidate) { + let url = null; + let b64 = null; + + if (typeof candidate === "string") { + const dataUriMatch = /^data:[^;]+;base64,(.+)$/i.exec(candidate); + if (dataUriMatch) { + b64 = dataUriMatch[1]; + } else if (isHttpUrl(candidate)) { + url = candidate; + } else { + b64 = candidate; + } + } else if (candidate && typeof candidate === "object") { + url = + firstString(candidate.url, candidate.image_url, candidate.sample, candidate.file_url) || null; + b64 = + firstString(candidate.b64_json, candidate.image, candidate.base64, candidate.data) || null; + } + + return { url, b64 }; +} + +function getImageFetchTimeoutResult(err: unknown, provider, log) { + const isAbortError = + typeof err === "object" && + err !== null && + "name" in err && + (err as { name?: unknown }).name === "AbortError"; + if (err instanceof FetchTimeoutError || isAbortError) { + const message = err instanceof Error ? err.message : String(err); + if (log) { + log.error("IMAGE", `${provider} fetch error: ${message}`); + } + return { + success: false, + status: 504, + error: `Image provider error: ${sanitizeErrorMessage(message || err)}`, + }; + } + return null; +} diff --git a/open-sse/services/imageCombo.ts b/open-sse/services/imageCombo.ts index 91be3c7cf684..ff651e45b54d 100644 --- a/open-sse/services/imageCombo.ts +++ b/open-sse/services/imageCombo.ts @@ -136,10 +136,22 @@ export async function runImageComboTargets( } if (typeof credentials === "object" && "blockedByKeyPolicy" in credentials) { - return { outcome: "terminal", provider, status: 403, error: "Image connection is not allowed by API key policy", fallbackCount }; + return { + outcome: "terminal", + provider, + status: 403, + error: "Image connection is not allowed by API key policy", + fallbackCount, + }; } if (typeof credentials === "object" && "allExpired" in credentials) { - return { outcome: "terminal", provider, status: 401, error: "No usable image account credentials", fallbackCount }; + return { + outcome: "terminal", + provider, + status: 401, + error: "No usable image account credentials", + fallbackCount, + }; } if (isRateLimited(credentials)) { lastError = { @@ -232,34 +244,16 @@ export async function executeImageCombo( // /v1/images/edits can reuse the exact same iteration semantics (#12547). const run = await runImageComboTargets(imageTargets, { resolveProvider: (target) => parseImageModel(target.modelStr), - resolveCredentials: (provider, target) => getProviderCredentialsWithQuotaPreflight( - provider, null, auth.policy.apiKeyInfo?.allowedConnections?.length ? auth.policy.apiKeyInfo.allowedConnections : null, target.modelStr - ), - dispatch: async ({ target, provider, model, credentials }) => { - if (provider === "codex" && isCodexImagesModel(model)) { - const refreshed = await refreshSelectedCodexImageCredentials(credentials, auth.request.signal); - if (refreshed.success === false) return refreshed; - const selected = refreshed.credentials; - try { - const proxyInfo = selected.connectionId ? await resolveProxyForConnection(selected.connectionId) : null; - const result = await runWithProxyContext(proxyInfo?.proxy || null, () => handleImageGeneration({ - body: { ...body, model: target.modelStr }, credentials: selected, log, signal: auth.request.signal, - })); - return { - success: result.success, - data: "data" in result ? result.data : undefined, - status: "status" in result ? result.status : undefined, - error: "error" in result ? result.error : undefined, - retryable: "retryable" in result ? result.retryable : undefined, - }; - } catch { - return { success: false, status: 503, error: "Codex Images combo transport failed; do not resubmit an unknown outcome", retryable: false }; - } - } - return (await handleImageGeneration({ - body: { ...body, model: target.modelStr }, credentials, log, signal: auth.request?.signal || null, - })) as ImageGenerationResult; - }, + resolveCredentials: (provider, target) => + getProviderCredentialsWithQuotaPreflight( + provider, + null, + auth.policy.apiKeyInfo?.allowedConnections?.length + ? auth.policy.apiKeyInfo.allowedConnections + : null, + target.modelStr + ), + dispatch: (context) => dispatchImageComboGeneration(context, body, auth.request, log), onSuccess: async (credentials) => { await clearRecoveredProviderState(credentials as never); }, @@ -273,33 +267,7 @@ export async function executeImageCombo( // 4. Build response if (run.outcome === "success") { - const selectedProvider = run.provider; - const selectedModel = run.model; - // handleImageGeneration() already returns the public OpenAI images payload - // ({ created, data: [...] }); count the images at that level (#12268). - const payload = run.data as { created?: number; data?: unknown[] } | unknown[]; - const images = Array.isArray(payload) ? payload : payload?.data; - const n = Math.max(Number(body.n) || 1, images?.length || 0); - const costUsd = await calculateModalCost("image", selectedProvider, selectedModel, { n }); - - const headers = new Headers({ "Content-Type": "application/json" }); - attachOmniRouteMetaHeaders(headers, { - provider: selectedProvider, - model: selectedModel, - costUsd, - latencyMs: Date.now() - startTime, - requestId: generateRequestId(), - strategy: "priority", - fallbackAttempts: run.fallbackCount, - }); - - // Return the handler payload unchanged so the combo path matches the - // direct-model path byte-for-byte; re-wrap only if a handler ever yields - // a bare array (#12268). - const responseBody = Array.isArray(payload) - ? { created: Math.floor(Date.now() / 1000), data: payload } - : payload; - return new Response(JSON.stringify(responseBody), { status: 200, headers }); + return imageComboSuccessResponse(run, body, startTime); } // All targets failed — return the last error @@ -312,3 +280,86 @@ export async function executeImageCombo( headers: { "Content-Type": "application/json" }, }); } + +async function dispatchImageComboGeneration( + { + target, + provider, + model, + credentials, + }: Parameters["dispatch"]>[0], + body: Record, + request: Request, + log: typeof logger +): Promise { + if (provider !== "codex" || !isCodexImagesModel(model)) { + return (await handleImageGeneration({ + body: { ...body, model: target.modelStr }, + credentials, + log, + signal: request?.signal || null, + })) as ImageGenerationResult; + } + const refreshed = await refreshSelectedCodexImageCredentials(credentials, request.signal); + if (refreshed.success === false) return refreshed; + const selected = refreshed.credentials; + try { + const proxyInfo = selected.connectionId + ? await resolveProxyForConnection(selected.connectionId) + : null; + const result = await runWithProxyContext(proxyInfo?.proxy || null, () => + handleImageGeneration({ + body: { ...body, model: target.modelStr }, + credentials: selected, + log, + signal: request.signal, + }) + ); + return normalizeImageComboDispatchResult(result); + } catch { + return { + success: false, + status: 503, + error: "Codex Images combo transport failed; do not resubmit an unknown outcome", + retryable: false, + }; + } +} + +function normalizeImageComboDispatchResult( + result: Awaited> +): ImageComboDispatchResult { + return { + success: result.success, + data: "data" in result ? result.data : undefined, + status: "status" in result ? result.status : undefined, + error: "error" in result ? result.error : undefined, + retryable: "retryable" in result ? result.retryable : undefined, + }; +} + +async function imageComboSuccessResponse( + run: Extract, + body: Record, + startTime: number +): Promise { + const payload = run.data as { created?: number; data?: unknown[] } | unknown[]; + const images = Array.isArray(payload) ? payload : payload?.data; + const n = Math.max(Number(body.n) || 1, images?.length || 0); + const costUsd = await calculateModalCost("image", run.provider, run.model, { n }); + const headers = new Headers({ "Content-Type": "application/json" }); + attachOmniRouteMetaHeaders(headers, { + provider: run.provider, + model: run.model, + costUsd, + latencyMs: Date.now() - startTime, + requestId: generateRequestId(), + strategy: "priority", + fallbackAttempts: run.fallbackCount, + }); + // Keep the public handler payload; only a legacy bare array requires wrapping (#12268). + const responseBody = Array.isArray(payload) + ? { created: Math.floor(Date.now() / 1000), data: payload } + : payload; + return new Response(JSON.stringify(responseBody), { status: 200, headers }); +} diff --git a/src/app/api/v1/images/edits/editDispatch.ts b/src/app/api/v1/images/edits/editDispatch.ts index 4f4d089ec2a3..63a4ec49d2f5 100644 --- a/src/app/api/v1/images/edits/editDispatch.ts +++ b/src/app/api/v1/images/edits/editDispatch.ts @@ -1,7 +1,3 @@ -import { - handleGeminiImageGeneration, - validateAntigravityReferences, -} from "@omniroute/open-sse/handlers/geminiImage.ts"; import { handleAdobeFireflyImageGeneration, handleSyntxImageGeneration, @@ -9,7 +5,7 @@ import { handleOpenAIImageEdit, handleOpenRouterImageEdit, } from "@omniroute/open-sse/handlers/imageGeneration.ts"; -import { handleCodexImages, isCodexImagesModel } from "@omniroute/open-sse/handlers/codexImages.ts"; +import { isCodexImagesModel } from "@omniroute/open-sse/handlers/codexImages.ts"; import { handleFalAIImageEdit, isFalImageEditModel, @@ -45,7 +41,12 @@ import { import { resolveProxyForConnection } from "@/lib/db/settings"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { isCodexFreePlan } from "@omniroute/open-sse/config/codexPlan.ts"; -import { refreshSelectedCodexImageCredentials } from "@/sse/services/selectedCodexImageCredentials"; +import { dispatchCodexImagesEdit, dispatchAntigravityEdit } from "./editNativeDispatch"; +export { + dispatchCodexImagesEdit, + dispatchAntigravityEdit, + validateCodexImagesEditOptions, +} from "./editNativeDispatch"; function jsonResponse(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { @@ -54,6 +55,51 @@ function jsonResponse(data: unknown, status = 200): Response { }); } +async function selectImageEditCredentials(params: { + parsed: ReturnType; + allowedConnections: string[] | null; + resolvedModel: string; +}) { + const { parsed, allowedConnections, resolvedModel } = params; + const credentials = await getProviderCredentialsWithQuotaPreflight( + parsed.provider, + null, + allowedConnections, + resolvedModel + ); + if (!credentials || "allExpired" in credentials) + return { + response: errorResponse( + HTTP_STATUS.UNAUTHORIZED, + `No credentials for provider: ${parsed.provider}` + ), + }; + if ("blockedByKeyPolicy" in credentials) + return { + response: errorResponse(403, "Image provider connection is not allowed by API key policy"), + }; + if (isAllRateLimitedCredentials(credentials)) + return { + response: unavailableResponse( + HTTP_STATUS.RATE_LIMITED, + `[${parsed.provider}] All accounts rate limited`, + credentials.retryAfter, + credentials.retryAfterHuman + ), + }; + if ("allRateLimited" in credentials) + return { response: errorResponse(503, "Image provider credentials unavailable") }; + if ("leaseUnavailable" in credentials) + return { + response: errorResponse(429, "Selected image account is already leased; try again later"), + }; + if (!isUsableImageCredentials(credentials)) + return { + response: errorResponse(401, "Image credential selection did not return usable credentials"), + }; + return { credentials }; +} + /** Reduce reference images (multi + single fallback) to data-URL strings for Firefly. */ function buildAdobeFireflyEditDataUrls( images: Array<{ bytes: Buffer; mime: string }>, @@ -99,48 +145,12 @@ export async function handleAdobeFireflyEditRequest(params: { imageBytes: Buffer | null; imageMime: string | null; }): Promise { - const { - parsed, - providerConfig, - allowedConnections, - resolvedModel, - prompt, - size, - responseFormat, - images, - imageBytes, - imageMime, - } = params; - - const credentials = await getProviderCredentialsWithQuotaPreflight( - parsed.provider, - null, - allowedConnections, - resolvedModel - ); - if (!credentials || "allExpired" in credentials) { - return errorResponse( - HTTP_STATUS.UNAUTHORIZED, - `No credentials for provider: ${parsed.provider}` - ); - } - if ("blockedByKeyPolicy" in credentials) - return errorResponse(403, "Image provider connection is not allowed by API key policy"); - if (isAllRateLimitedCredentials(credentials)) { - return unavailableResponse( - HTTP_STATUS.RATE_LIMITED, - `[${parsed.provider}] All accounts rate limited`, - credentials.retryAfter, - credentials.retryAfterHuman - ); - } + const { parsed, providerConfig, prompt, size, responseFormat, images, imageBytes, imageMime } = + params; - if ("allRateLimited" in credentials) - return errorResponse(503, "Image provider credentials unavailable"); - if ("leaseUnavailable" in credentials) - return errorResponse(429, "Selected image account is already leased; try again later"); - if (!isUsableImageCredentials(credentials)) - return errorResponse(401, "Image credential selection did not return usable credentials"); + const selection = await selectImageEditCredentials(params); + if ("response" in selection) return selection.response; + const credentials = selection.credentials; // Prefer multi-image list when present; fall back to the primary imageBytes. const dataUrls = buildAdobeFireflyEditDataUrls(images, imageBytes, imageMime); if (dataUrls.length === 0) { @@ -187,48 +197,12 @@ export async function handleSyntxEditRequest(params: { imageBytes: Buffer | null; imageMime: string | null; }): Promise { - const { - parsed, - providerConfig, - allowedConnections, - resolvedModel, - prompt, - size, - responseFormat, - images, - imageBytes, - imageMime, - } = params; - - const credentials = await getProviderCredentialsWithQuotaPreflight( - parsed.provider, - null, - allowedConnections, - resolvedModel - ); - if (!credentials || "allExpired" in credentials) { - return errorResponse( - HTTP_STATUS.UNAUTHORIZED, - `No credentials for provider: ${parsed.provider}` - ); - } - if ("blockedByKeyPolicy" in credentials) - return errorResponse(403, "Image provider connection is not allowed by API key policy"); - if (isAllRateLimitedCredentials(credentials)) { - return unavailableResponse( - HTTP_STATUS.RATE_LIMITED, - `[${parsed.provider}] All accounts rate limited`, - credentials.retryAfter, - credentials.retryAfterHuman - ); - } + const { parsed, providerConfig, prompt, size, responseFormat, images, imageBytes, imageMime } = + params; - if ("allRateLimited" in credentials) - return errorResponse(503, "Image provider credentials unavailable"); - if ("leaseUnavailable" in credentials) - return errorResponse(429, "Selected image account is already leased; try again later"); - if (!isUsableImageCredentials(credentials)) - return errorResponse(401, "Image credential selection did not return usable credentials"); + const selection = await selectImageEditCredentials(params); + if ("response" in selection) return selection.response; + const credentials = selection.credentials; const dataUrls = buildAdobeFireflyEditDataUrls(images, imageBytes, imageMime); if (dataUrls.length === 0) { return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: image"); @@ -331,155 +305,187 @@ async function dispatchImageEditTarget( credentials: unknown, ctx: ImageEditContext ): Promise { - const { parsed, providerConfig, modelStr } = target; - const { prompt, size, responseFormat, images, imageBytes, imageMime, request } = ctx; - - if (providerConfig?.format === "gemini-image") + const { parsed, providerConfig } = target; + const format = providerConfig?.format; + if (format === "gemini-image") return dispatchAntigravityEdit(target, credentials, ctx) as Promise; + if (format === "codex-responses") return dispatchHostedCodexEdit(target, credentials, ctx); + if (format === "fal-ai" && isFalImageEditModel(parsed.model)) + return dispatchFalEdit(target, credentials, ctx); + if (format === "adobe-firefly-image") return dispatchFireflyEdit(target, credentials, ctx); + if (format === "syntx-image") return dispatchSyntxEdit(target, credentials, ctx); + if (providerConfig?.id === "openrouter") return dispatchOpenRouterEdit(target, credentials, ctx); + return dispatchCustomOpenAIEdit(target, credentials, ctx); +} - // Built-in Codex — native Responses hosted tool for reference-image edits. - if (providerConfig?.format === "codex-responses") { - if (isCodexImagesModel(parsed.model)) return dispatchCodexImagesEdit(target, credentials, ctx); - const modelEntry = getImageModelEntry(modelStr); - if (!modelEntry || modelEntry.provider !== "codex" || modelEntry.model !== parsed.model) { - return { - success: false, - status: HTTP_STATUS.BAD_REQUEST, - error: `Unsupported Codex image edit model: ${modelStr}`, - }; - } - const imageValidationError = validateCodexImageEditReferences(images); - if (imageValidationError) { - return { success: false, status: HTTP_STATUS.BAD_REQUEST, error: imageValidationError }; - } - const credentialDetails = credentials as { - connectionId?: unknown; - providerSpecificData?: unknown; +async function dispatchHostedCodexEdit( + target: EditComboTarget, + credentials: unknown, + ctx: ImageEditContext +): Promise { + const { parsed, providerConfig, modelStr } = target; + const { prompt, size, responseFormat, images, request } = ctx; + if (isCodexImagesModel(parsed.model)) return dispatchCodexImagesEdit(target, credentials, ctx); + const modelEntry = getImageModelEntry(modelStr); + if (!modelEntry || modelEntry.provider !== "codex" || modelEntry.model !== parsed.model) + return { + success: false, + status: HTTP_STATUS.BAD_REQUEST, + error: `Unsupported Codex image edit model: ${modelStr}`, }; - if (isCodexFreePlan(credentialDetails.providerSpecificData)) { - return { - success: false, - status: HTTP_STATUS.BAD_REQUEST, - error: "Codex image editing requires a paid ChatGPT/Codex plan", - }; - } - const connectionId = - typeof credentialDetails.connectionId === "string" ? credentialDetails.connectionId : null; - let proxyInfo = null; - if (connectionId) { - try { - proxyInfo = await resolveProxyForConnection(connectionId); - } catch { - log.debug("PROXY", `Failed to resolve proxy for image provider: ${parsed.provider}`); - } - } - const editImage = () => - handleCodexImageEdit({ - provider: parsed.provider, - model: parsed.model, - providerConfig, - body: { - prompt, - size: size ?? undefined, - response_format: responseFormat ?? undefined, - }, - referenceImages: images, - credentials: credentials as never, - log, - signal: request.signal, - }); - return (await (connectionId - ? runWithProxyContext(proxyInfo?.proxy || null, editImage).catch(() => ({ - success: false as const, - status: HTTP_STATUS.SERVICE_UNAVAILABLE, - error: "Image edit proxy error", - })) - : editImage())) as ImageComboDispatchResult; - } - - if (providerConfig?.format === "fal-ai" && isFalImageEditModel(parsed.model)) { - return (await handleFalAIImageEdit({ + const imageValidationError = validateCodexImageEditReferences(images); + if (imageValidationError) + return { success: false, status: HTTP_STATUS.BAD_REQUEST, error: imageValidationError }; + const details = credentials as { connectionId?: unknown; providerSpecificData?: unknown }; + if (isCodexFreePlan(details.providerSpecificData)) + return { + success: false, + status: HTTP_STATUS.BAD_REQUEST, + error: "Codex image editing requires a paid ChatGPT/Codex plan", + }; + const connectionId = typeof details.connectionId === "string" ? details.connectionId : null; + const proxyInfo = await resolveHostedEditProxy(connectionId, parsed.provider); + const editImage = () => + handleCodexImageEdit({ provider: parsed.provider, model: parsed.model, providerConfig, - body: { prompt, size: size ?? undefined, response_format: responseFormat ?? undefined, n: 1 }, - images, + body: { prompt, size: size ?? undefined, response_format: responseFormat ?? undefined }, + referenceImages: images, credentials: credentials as never, log, - })) as ImageComboDispatchResult; - } + signal: request.signal, + }); + return (await (connectionId + ? runWithProxyContext(proxyInfo?.proxy || null, editImage).catch(() => ({ + success: false as const, + status: HTTP_STATUS.SERVICE_UNAVAILABLE, + error: "Image edit proxy error", + })) + : editImage())) as ImageComboDispatchResult; +} - if (providerConfig?.format === "adobe-firefly-image") { - const dataUrls = buildAdobeFireflyEditDataUrls(images, imageBytes, imageMime); - if (dataUrls.length === 0) { - return { - success: false, - status: HTTP_STATUS.BAD_REQUEST, - error: "Missing required field: image", - }; - } - return (await handleAdobeFireflyImageGeneration({ - provider: parsed.provider, - model: parsed.model, - providerConfig, - body: { - prompt, - size: size ?? undefined, - response_format: responseFormat ?? undefined, - n: 1, - image_url: dataUrls[0], - image: dataUrls.length === 1 ? dataUrls[0] : dataUrls, - image_urls: dataUrls, - images: dataUrls, - }, - credentials: credentials as never, - log, - })) as ImageComboDispatchResult; +async function resolveHostedEditProxy(connectionId: string | null, provider: string) { + if (!connectionId) return null; + try { + return await resolveProxyForConnection(connectionId); + } catch { + log.debug("PROXY", `Failed to resolve proxy for image provider: ${provider}`); + return null; } +} - if (providerConfig?.format === "syntx-image") { - const dataUrls = buildAdobeFireflyEditDataUrls(images, imageBytes, imageMime); - if (dataUrls.length === 0) { - return { - success: false, - status: HTTP_STATUS.BAD_REQUEST, - error: "Missing required field: image", - }; - } - return (await handleSyntxImageGeneration({ - provider: parsed.provider, - model: parsed.model, - providerConfig, - body: { - prompt, - size: size ?? undefined, - response_format: responseFormat ?? undefined, - n: 1, - image_url: dataUrls, - image: dataUrls.length === 1 ? dataUrls[0] : dataUrls, - image_urls: dataUrls, - }, - credentials: credentials as never, - log, - })) as ImageComboDispatchResult; - } +async function dispatchFalEdit( + target: EditComboTarget, + credentials: unknown, + ctx: ImageEditContext +): Promise { + const { parsed, providerConfig } = target; + const { prompt, size, responseFormat, images } = ctx; + return (await handleFalAIImageEdit({ + provider: parsed.provider, + model: parsed.model, + providerConfig, + body: { prompt, size: size ?? undefined, response_format: responseFormat ?? undefined, n: 1 }, + images, + credentials: credentials as never, + log, + })) as ImageComboDispatchResult; +} - if (providerConfig?.id === "openrouter") { - return (await handleOpenRouterImageEdit({ - provider: parsed.provider, - model: parsed.model, - baseUrl: providerConfig.baseUrl, - credentials: credentials as never, +async function dispatchFireflyEdit( + target: EditComboTarget, + credentials: unknown, + ctx: ImageEditContext +): Promise { + const { parsed, providerConfig } = target; + const { prompt, size, responseFormat, images, imageBytes, imageMime } = ctx; + const dataUrls = buildAdobeFireflyEditDataUrls(images, imageBytes, imageMime); + if (dataUrls.length === 0) + return { + success: false, + status: HTTP_STATUS.BAD_REQUEST, + error: "Missing required field: image", + }; + return (await handleAdobeFireflyImageGeneration({ + provider: parsed.provider, + model: parsed.model, + providerConfig, + body: { prompt, - imageBytes, - imageMime, size: size ?? undefined, + response_format: responseFormat ?? undefined, n: 1, - log, - })) as ImageComboDispatchResult; - } + image_url: dataUrls[0], + image: dataUrls.length === 1 ? dataUrls[0] : dataUrls, + image_urls: dataUrls, + images: dataUrls, + }, + credentials: credentials as never, + log, + })) as ImageComboDispatchResult; +} + +async function dispatchSyntxEdit( + target: EditComboTarget, + credentials: unknown, + ctx: ImageEditContext +): Promise { + const { parsed, providerConfig } = target; + const { prompt, size, responseFormat, images, imageBytes, imageMime } = ctx; + const dataUrls = buildAdobeFireflyEditDataUrls(images, imageBytes, imageMime); + if (dataUrls.length === 0) + return { + success: false, + status: HTTP_STATUS.BAD_REQUEST, + error: "Missing required field: image", + }; + return (await handleSyntxImageGeneration({ + provider: parsed.provider, + model: parsed.model, + providerConfig, + body: { + prompt, + size: size ?? undefined, + response_format: responseFormat ?? undefined, + n: 1, + image_url: dataUrls, + image: dataUrls.length === 1 ? dataUrls[0] : dataUrls, + image_urls: dataUrls, + }, + credentials: credentials as never, + log, + })) as ImageComboDispatchResult; +} - // Custom OpenAI-compatible node: forward to {base_url}/images/edits. +async function dispatchOpenRouterEdit( + target: EditComboTarget, + credentials: unknown, + ctx: ImageEditContext +): Promise { + const { parsed, providerConfig } = target; + const { prompt, size, imageBytes, imageMime } = ctx; + return (await handleOpenRouterImageEdit({ + provider: parsed.provider, + model: parsed.model, + baseUrl: providerConfig.baseUrl, + credentials: credentials as never, + prompt, + imageBytes, + imageMime, + size: size ?? undefined, + n: 1, + log, + })) as ImageComboDispatchResult; +} + +async function dispatchCustomOpenAIEdit( + target: EditComboTarget, + credentials: unknown, + ctx: ImageEditContext +): Promise { + const { modelStr } = target; + const { prompt, size, responseFormat, imageBytes, imageMime } = ctx; const slash = modelStr.indexOf("/"); const customProviderId = slash > 0 ? modelStr.slice(0, slash) : null; const customModel = slash > 0 ? modelStr.slice(slash + 1) : null; @@ -525,28 +531,7 @@ export async function executeImageEditCombo( return errorResponse(HTTP_STATUS.BAD_REQUEST, `Combo "${comboName}" has no usable targets`); } - // Build the edit-capable target list (prefix-resolved). Non-edit-capable and - // retired targets are skipped here so the loop only iterates dispatchable ones. - const editTargets: EditComboTarget[] = []; - for (const t of targets) { - const raw = - typeof (t as { modelStr?: unknown }).modelStr === "string" - ? ((t as { modelStr: string }).modelStr as string) - : ""; - if (!raw.trim()) continue; - let resolved: string; - try { - resolved = await resolveImageModelPrefix(raw); - } catch { - // retired provider / prefix — skip this target - continue; - } - const parsed = parseImageModel(resolved); - const providerConfig = parsed.provider ? getImageProvider(parsed.provider) : null; - const capability = classifyImageEditTarget(resolved, parsed, providerConfig); - if (!capability) continue; - editTargets.push({ modelStr: resolved, parsed, providerConfig, credKey: capability.credKey }); - } + const editTargets = await resolveEditComboTargets(targets); if (editTargets.length === 0) { return errorResponse( @@ -589,116 +574,24 @@ export async function executeImageEditCombo( }); } -export function validateCodexImagesEditOptions(ctx): string | null { - if ( - ctx.hasMask || - Object.keys(ctx.nativeOptions ?? {}).some( - (key) => !["n", "quality", "background"].includes(key) - ) - ) - return "Unsupported Codex Images edit parameter; masks and output-format controls are not supported"; - const options = ctx.nativeOptions ?? {}; - if (options.n !== undefined && options.n !== 1) return "Codex Images supports n=1 only"; - if (options.quality !== undefined && !["low", "medium", "high", "auto"].includes(options.quality)) - return "Unsupported Codex Images quality"; - if ( - options.background !== undefined && - !["transparent", "opaque", "auto"].includes(options.background) - ) - return "Unsupported Codex Images background"; - if ( - ctx.responseFormat !== null && - ctx.responseFormat !== undefined && - ctx.responseFormat !== "b64_json" - ) - return "Codex Images returns original b64_json only"; - if ( - ctx.size !== null && - ctx.size !== undefined && - !/^(?:auto|[1-9]\d{0,4}x[1-9]\d{0,4})$/.test(ctx.size) - ) - return "Invalid Codex Images size; use auto or WIDTHxHEIGHT"; - return validateCodexImageEditReferences(ctx.images, 5); -} - -export async function dispatchCodexImagesEdit( - target, - credentials, - ctx -): Promise { - const { parsed, providerConfig, modelStr } = target; - const entry = getImageModelEntry(modelStr); - const fail = (status, error) => ({ success: false as const, status, error, retryable: false }); - if ( - !entry || - entry.provider !== "codex" || - entry.model !== parsed.model || - !isCodexImagesModel(parsed.model) - ) - return fail(400, "Unsupported dedicated Codex image edit model"); - const validation = validateCodexImagesEditOptions(ctx); - if (validation) return fail(400, validation); - const refreshed = await refreshSelectedCodexImageCredentials(credentials, ctx.request.signal); - if (refreshed.success === false) return refreshed; - const selected = refreshed.credentials; - try { - const proxyInfo = selected.connectionId - ? await resolveProxyForConnection(selected.connectionId) - : null; - return await runWithProxyContext(proxyInfo?.proxy || null, () => - handleCodexImages({ - provider: parsed.provider, - model: parsed.model, - providerConfig, - body: { - ...ctx.nativeOptions, - prompt: ctx.prompt, - size: ctx.size ?? undefined, - response_format: ctx.responseFormat ?? undefined, - }, - referenceImages: ctx.images, - operation: "edits", - credentials: selected, - log, - signal: ctx.request.signal, - }) - ); - } catch { - return fail( - 503, - "Codex Images edit proxy or transport failed; do not resubmit an unknown outcome" - ); +async function resolveEditComboTargets( + targets: Array<{ modelStr?: unknown }> +): Promise { + const editTargets: EditComboTarget[] = []; + for (const target of targets) { + const raw = typeof target.modelStr === "string" ? target.modelStr : ""; + if (!raw.trim()) continue; + let resolved: string; + try { + resolved = await resolveImageModelPrefix(raw); + } catch { + continue; + } + const parsed = parseImageModel(resolved); + const providerConfig = parsed.provider ? getImageProvider(parsed.provider) : null; + const capability = classifyImageEditTarget(resolved, parsed, providerConfig); + if (!capability) continue; + editTargets.push({ modelStr: resolved, parsed, providerConfig, credKey: capability.credKey }); } -} - -export async function dispatchAntigravityEdit(target, credentials, ctx) { - const { parsed, providerConfig, modelStr } = target; - const entry = getImageModelEntry(modelStr); - if (!entry || entry.provider !== "antigravity" || entry.model !== parsed.model) - return { success: false, status: 400, error: "Unsupported Antigravity image edit model" }; - const validation = validateAntigravityReferences(ctx.images, ctx.hasMask); - if (validation) return { success: false, status: 400, error: validation }; - let proxyInfo = null; - if (credentials.connectionId) - proxyInfo = await resolveProxyForConnection(credentials.connectionId); - return runWithProxyContext(proxyInfo?.proxy || null, () => - handleGeminiImageGeneration({ - model: parsed.model, - providerConfig, - credentials, - log, - signal: ctx.request.signal, - referenceImages: ctx.images, - logPath: "/v1/images/edits", - body: { - model: modelStr, - prompt: ctx.prompt, - size: ctx.size, - response_format: ctx.responseFormat ?? undefined, - aspect_ratio: ctx.aspectRatio, - image_size: ctx.imageSize, - n: 1, - }, - }) - ); + return editTargets; } diff --git a/src/app/api/v1/images/edits/editInput.ts b/src/app/api/v1/images/edits/editInput.ts index fee273a20b54..7a6296c394f1 100644 --- a/src/app/api/v1/images/edits/editInput.ts +++ b/src/app/api/v1/images/edits/editInput.ts @@ -34,18 +34,20 @@ export interface EditInput { // Keep unsupported controls too so a native transport can reject rather than drop them. function nativeEditOptions(body: Record): Record { - return Object.fromEntries(Object.entries(body).filter(([key]) => !["model", "prompt", "size", "response_format", "image", "images", "image[]"].includes(key))); + return Object.fromEntries( + Object.entries(body).filter( + ([key]) => + !["model", "prompt", "size", "response_format", "image", "images", "image[]"].includes(key) + ) + ); } async function readMultipartImage(formData: FormData): Promise { const promptRaw = formData.get("prompt"); const prompt = typeof promptRaw === "string" ? promptRaw : ""; - const modelRaw = formData.get("model"); - const model = typeof modelRaw === "string" ? modelRaw.trim() : null; - const sizeRaw = formData.get("size"); - const size = typeof sizeRaw === "string" ? sizeRaw.trim() : null; - const respRaw = formData.get("response_format"); - const responseFormat = typeof respRaw === "string" ? respRaw.trim() : null; + const model = trimmedFormString(formData, "model"); + const size = trimmedFormString(formData, "size"); + const responseFormat = trimmedFormString(formData, "response_format"); // OpenAI-style clients may repeat either `image` or `image[]`. Count every submitted // candidate so provider-specific cardinality checks cannot silently drop extras. @@ -62,13 +64,16 @@ async function readMultipartImage(formData: FormData): Promise { } const firstImage = images[0] ?? null; const nativeOptions = nativeEditOptions(Object.fromEntries(formData.entries())); - if (Object.hasOwn(nativeOptions, "n")) nativeOptions.n = nativeOptions.n === "1" ? 1 : nativeOptions.n; - const duplicateOptions = [...formData.keys()].some((key) => !["image", "image[]"].includes(key) && formData.getAll(key).length > 1); + if (Object.hasOwn(nativeOptions, "n")) + nativeOptions.n = nativeOptions.n === "1" ? 1 : nativeOptions.n; + const duplicateOptions = [...formData.keys()].some( + (key) => !["image", "image[]"].includes(key) && formData.getAll(key).length > 1 + ); if (duplicateOptions) nativeOptions.duplicate_options = true; return { nativeOptions, - aspectRatio: typeof formData.get("aspect_ratio") === "string" ? String(formData.get("aspect_ratio")) : null, - imageSize: typeof formData.get("image_size") === "string" ? String(formData.get("image_size")) : null, + aspectRatio: formString(formData, "aspect_ratio"), + imageSize: formString(formData, "image_size"), hasMask: formData.has("mask") || formData.has("mask_url"), prompt, model, @@ -81,6 +86,16 @@ async function readMultipartImage(formData: FormData): Promise { }; } +function formString(form: FormData, key: string): string | null { + const value = form.get(key); + return typeof value === "string" ? value : null; +} + +function trimmedFormString(form: FormData, key: string): string | null { + const value = formString(form, key); + return value === null ? null : value.trim(); +} + /** Read the edit input from either multipart/form-data or a JSON/data-URL body. */ export async function readEditInput(request: Request): Promise { const contentType = request.headers.get("content-type") || ""; @@ -104,26 +119,30 @@ export async function readEditInput(request: Request): Promise } } if (contentType.includes("application/json")) { - try { - const parsed = ImageEditJsonSchema.safeParse( - JSON.parse(new TextDecoder().decode(rawBody)) as unknown - ); - if (!parsed.success) { - log.warn("IMAGE", `Invalid JSON edit body shape: ${parsed.error.message}`); - return null; - } - return { - ...extractImageEditInputFromJson(parsed.data), - nativeOptions: nativeEditOptions(parsed.data), - prompt: typeof parsed.data.prompt === "string" ? parsed.data.prompt : "", - aspectRatio: typeof parsed.data.aspect_ratio === "string" ? parsed.data.aspect_ratio : null, - imageSize: typeof parsed.data.image_size === "string" ? parsed.data.image_size : null, - hasMask: Object.hasOwn(parsed.data, "mask") || Object.hasOwn(parsed.data, "mask_url"), - }; - } catch (err) { - log.warn("IMAGE", `Invalid JSON edit body: ${err instanceof Error ? err.message : err}`); + return readJsonImageEditInput(rawBody); + } + return null; +} + +function readJsonImageEditInput(rawBody: Uint8Array): EditInput | null { + try { + const parsed = ImageEditJsonSchema.safeParse( + JSON.parse(new TextDecoder().decode(rawBody)) as unknown + ); + if (!parsed.success) { + log.warn("IMAGE", `Invalid JSON edit body shape: ${parsed.error.message}`); return null; } + return { + ...extractImageEditInputFromJson(parsed.data), + nativeOptions: nativeEditOptions(parsed.data), + prompt: typeof parsed.data.prompt === "string" ? parsed.data.prompt : "", + aspectRatio: typeof parsed.data.aspect_ratio === "string" ? parsed.data.aspect_ratio : null, + imageSize: typeof parsed.data.image_size === "string" ? parsed.data.image_size : null, + hasMask: Object.hasOwn(parsed.data, "mask") || Object.hasOwn(parsed.data, "mask_url"), + }; + } catch (err) { + log.warn("IMAGE", `Invalid JSON edit body: ${err instanceof Error ? err.message : err}`); + return null; } - return null; } diff --git a/src/app/api/v1/images/edits/editNativeDispatch.ts b/src/app/api/v1/images/edits/editNativeDispatch.ts new file mode 100644 index 000000000000..9bf674426cc7 --- /dev/null +++ b/src/app/api/v1/images/edits/editNativeDispatch.ts @@ -0,0 +1,121 @@ +import { + handleGeminiImageGeneration, + validateAntigravityReferences, +} from "@omniroute/open-sse/handlers/geminiImage.ts"; +import { handleCodexImages, isCodexImagesModel } from "@omniroute/open-sse/handlers/codexImages.ts"; +import { getImageModelEntry } from "@omniroute/open-sse/config/imageRegistry.ts"; +import { validateCodexImageEditReferences } from "@/lib/images/imageRouteModel"; +import { resolveProxyForConnection } from "@/lib/db/settings"; +import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; +import { refreshSelectedCodexImageCredentials } from "@/sse/services/selectedCodexImageCredentials"; +import type { ImageComboDispatchResult } from "@omniroute/open-sse/services/imageCombo.ts"; +import * as log from "@/sse/utils/logger"; + +export function validateCodexImagesEditOptions(ctx): string | null { + if ( + ctx.hasMask || + Object.keys(ctx.nativeOptions ?? {}).some( + (key) => !["n", "quality", "background"].includes(key) + ) + ) + return "Unsupported Codex Images edit parameter; masks and output-format controls are not supported"; + const options = ctx.nativeOptions ?? {}; + if (options.n !== undefined && options.n !== 1) return "Codex Images supports n=1 only"; + if (options.quality !== undefined && !["low", "medium", "high", "auto"].includes(options.quality)) + return "Unsupported Codex Images quality"; + if ( + options.background !== undefined && + !["transparent", "opaque", "auto"].includes(options.background) + ) + return "Unsupported Codex Images background"; + if (![null, undefined, "b64_json"].includes(ctx.responseFormat)) + return "Codex Images returns original b64_json only"; + if ( + ![null, undefined].includes(ctx.size) && + !/^(?:auto|[1-9]\d{0,4}x[1-9]\d{0,4})$/.test(ctx.size) + ) + return "Invalid Codex Images size; use auto or WIDTHxHEIGHT"; + return validateCodexImageEditReferences(ctx.images, 5); +} + +export async function dispatchCodexImagesEdit( + target, + credentials, + ctx +): Promise { + const { parsed, providerConfig, modelStr } = target; + const entry = getImageModelEntry(modelStr); + const fail = (status, error) => ({ success: false as const, status, error, retryable: false }); + if ( + !entry || + entry.provider !== "codex" || + entry.model !== parsed.model || + !isCodexImagesModel(parsed.model) + ) + return fail(400, "Unsupported dedicated Codex image edit model"); + const validation = validateCodexImagesEditOptions(ctx); + if (validation) return fail(400, validation); + const refreshed = await refreshSelectedCodexImageCredentials(credentials, ctx.request.signal); + if (refreshed.success === false) return refreshed; + const selected = refreshed.credentials; + try { + const proxyInfo = selected.connectionId + ? await resolveProxyForConnection(selected.connectionId) + : null; + return await runWithProxyContext(proxyInfo?.proxy || null, () => + handleCodexImages({ + provider: parsed.provider, + model: parsed.model, + providerConfig, + body: { + ...ctx.nativeOptions, + prompt: ctx.prompt, + size: ctx.size ?? undefined, + response_format: ctx.responseFormat ?? undefined, + }, + referenceImages: ctx.images, + operation: "edits", + credentials: selected, + log, + signal: ctx.request.signal, + }) + ); + } catch { + return fail( + 503, + "Codex Images edit proxy or transport failed; do not resubmit an unknown outcome" + ); + } +} + +export async function dispatchAntigravityEdit(target, credentials, ctx) { + const { parsed, providerConfig, modelStr } = target; + const entry = getImageModelEntry(modelStr); + if (!entry || entry.provider !== "antigravity" || entry.model !== parsed.model) + return { success: false, status: 400, error: "Unsupported Antigravity image edit model" }; + const validation = validateAntigravityReferences(ctx.images, ctx.hasMask); + if (validation) return { success: false, status: 400, error: validation }; + let proxyInfo = null; + if (credentials.connectionId) + proxyInfo = await resolveProxyForConnection(credentials.connectionId); + return runWithProxyContext(proxyInfo?.proxy || null, () => + handleGeminiImageGeneration({ + model: parsed.model, + providerConfig, + credentials, + log, + signal: ctx.request.signal, + referenceImages: ctx.images, + logPath: "/v1/images/edits", + body: { + model: modelStr, + prompt: ctx.prompt, + size: ctx.size, + response_format: ctx.responseFormat ?? undefined, + aspect_ratio: ctx.aspectRatio, + image_size: ctx.imageSize, + n: 1, + }, + }) + ); +} diff --git a/src/app/api/v1/providers/[provider]/images/generations/route.ts b/src/app/api/v1/providers/[provider]/images/generations/route.ts index a1a3dcadbd23..14394bae4e88 100644 --- a/src/app/api/v1/providers/[provider]/images/generations/route.ts +++ b/src/app/api/v1/providers/[provider]/images/generations/route.ts @@ -43,17 +43,9 @@ export async function POST(request, { params }) { return errorResponse(HTTP_STATUS.BAD_REQUEST, `Unknown image provider: ${rawProvider}`); } - let rawBody; - try { - rawBody = await request.json(); - } catch { - return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); - } - const validation = validateBody(v1ImageGenerationSchema, rawBody); - if (isValidationFailure(validation)) { - return errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message); - } - const body = validation.data; + const input = await readImageProviderBody(request); + if ("rejection" in input) return input.rejection; + const body = input.body; // Ensure model has provider prefix if (!body.model.includes("/")) { @@ -117,17 +109,40 @@ export async function POST(request, { params }) { }); credentials = execution.credentials; const result = execution.result; + return imageProviderResultResponse(result, credentials); +} +async function imageProviderResultResponse( + result: Awaited>, + credentials: Parameters[0] +): Promise { if (result.success) { await clearRecoveredProviderState(credentials); - return new Response(JSON.stringify((result as any).data), { + return new Response(JSON.stringify("data" in result ? result.data : undefined), { status: 200, headers: { "Content-Type": "application/json" }, }); } - const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error"); - const message = extractErrorMessage("error" in errorPayload ? errorPayload.error : errorPayload) - ?? "Image generation provider error"; - return errorResponse((result as any).status, message); + const errorPayload = toJsonErrorPayload( + "error" in result ? result.error : undefined, + "Image generation provider error" + ); + const message = + extractErrorMessage("error" in errorPayload ? errorPayload.error : errorPayload) ?? + "Image generation provider error"; + return errorResponse("status" in result ? result.status : undefined, message); +} + +async function readImageProviderBody(request: Request) { + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return { rejection: errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body") }; + } + const validation = validateBody(v1ImageGenerationSchema, rawBody); + if (isValidationFailure(validation)) + return { rejection: errorResponse(HTTP_STATUS.BAD_REQUEST, validation.error.message) }; + return { body: validation.data }; } diff --git a/src/sse/services/selectedCodexImageCredentials.ts b/src/sse/services/selectedCodexImageCredentials.ts index b4a6d917d69f..20eb4922791a 100644 --- a/src/sse/services/selectedCodexImageCredentials.ts +++ b/src/sse/services/selectedCodexImageCredentials.ts @@ -12,10 +12,16 @@ export type SelectedCodexImageCredentials = { providerSpecificData?: Record; }; function isSelectedCredentials(value: object): value is SelectedCodexImageCredentials { - return "accessToken" in value && typeof value.accessToken === "string" && + return ( + "accessToken" in value && + typeof value.accessToken === "string" && (!("expiresAt" in value) || value.expiresAt === null || typeof value.expiresAt === "string") && (!("connectionId" in value) || typeof value.connectionId === "string") && - (!("providerSpecificData" in value) || value.providerSpecificData === null || (typeof value.providerSpecificData === "object" && !Array.isArray(value.providerSpecificData))); + (!("providerSpecificData" in value) || + value.providerSpecificData === null || + (typeof value.providerSpecificData === "object" && + !Array.isArray(value.providerSpecificData))) + ); } type SelectedResult = | { success: true; credentials: SelectedCodexImageCredentials } @@ -26,26 +32,71 @@ export async function refreshSelectedCodexImageCredentials( credentials: unknown, signal?: AbortSignal | null ): Promise { - const fail = (status: number, error: string): SelectedResult => ({ success: false, status, error, retryable: false }); - if (signal?.aborted) return fail(499, "Codex Images request cancelled before submission"); - if (!credentials || typeof credentials !== "object" || "allExpired" in credentials) return fail(401, "No usable selected Codex account credentials"); - if ("blockedByKeyPolicy" in credentials) return fail(403, "Selected image connection is not allowed by API key policy"); - if (isAllRateLimitedCredentials(credentials) || "allRateLimited" in credentials) return fail(429, "Selected Codex account is unavailable"); - if (!isSelectedCredentials(credentials)) return fail(401, "No usable selected Codex account credentials"); - if (isCodexFreePlan(credentials.providerSpecificData)) return fail(403, "Codex Images requires a paid ChatGPT/Codex plan"); + if (signal?.aborted) + return failSelectedCredentials(499, "Codex Images request cancelled before submission"); + const validation = validateSelectedCodexImageCredentials(credentials); + if (validation.success === false) return validation; + const original = validation.credentials; try { - const originalExpiry = credentials.expiresAt ? Date.parse(credentials.expiresAt) : null; - const refreshDue = originalExpiry !== null && originalExpiry - Date.now() < getRefreshLeadMs("codex", credentials.providerSpecificData); - const selected = await checkAndRefreshToken("codex", credentials); - const expiry = selected.expiresAt ? Date.parse(selected.expiresAt) : null; - const refreshFailed = refreshDue && selected.accessToken === credentials.accessToken && selected.expiresAt === credentials.expiresAt; - if (!selected.accessToken || refreshFailed || (expiry !== null && (!Number.isFinite(expiry) || expiry <= Date.now()))) { - return fail(401, "Selected Codex account refresh failed; reconnect that account"); - } - if (selected.connectionId !== credentials.connectionId || selected.providerSpecificData?.workspaceId !== credentials.providerSpecificData?.workspaceId) return fail(401, "Selected Codex account changed during refresh; submission refused"); - if (signal?.aborted) return fail(499, "Codex Images request cancelled before submission"); + const originalExpiry = original.expiresAt ? Date.parse(original.expiresAt) : null; + const refreshDue = + originalExpiry !== null && + originalExpiry - Date.now() < getRefreshLeadMs("codex", original.providerSpecificData); + const selected = await checkAndRefreshToken("codex", original); + const error = validateRefreshedCodexImageCredentials(selected, original, refreshDue); + if (error) return failSelectedCredentials(401, error); + if (signal?.aborted) + return failSelectedCredentials(499, "Codex Images request cancelled before submission"); return { success: true, credentials: selected }; } catch { - return fail(401, "Selected Codex account refresh failed; reconnect that account"); + return failSelectedCredentials( + 401, + "Selected Codex account refresh failed; reconnect that account" + ); } } + +function failSelectedCredentials(status: number, error: string): SelectedResult { + return { success: false, status, error, retryable: false }; +} + +function validateSelectedCodexImageCredentials(credentials: unknown): SelectedResult { + if (!credentials || typeof credentials !== "object" || "allExpired" in credentials) + return failSelectedCredentials(401, "No usable selected Codex account credentials"); + if ("blockedByKeyPolicy" in credentials) + return failSelectedCredentials( + 403, + "Selected image connection is not allowed by API key policy" + ); + if (isAllRateLimitedCredentials(credentials) || "allRateLimited" in credentials) + return failSelectedCredentials(429, "Selected Codex account is unavailable"); + if (!isSelectedCredentials(credentials)) + return failSelectedCredentials(401, "No usable selected Codex account credentials"); + if (isCodexFreePlan(credentials.providerSpecificData)) + return failSelectedCredentials(403, "Codex Images requires a paid ChatGPT/Codex plan"); + return { success: true, credentials }; +} + +function validateRefreshedCodexImageCredentials( + selected: SelectedCodexImageCredentials, + original: SelectedCodexImageCredentials, + refreshDue: boolean +): string | null { + const expiry = selected.expiresAt ? Date.parse(selected.expiresAt) : null; + const refreshFailed = + refreshDue && + selected.accessToken === original.accessToken && + selected.expiresAt === original.expiresAt; + if ( + !selected.accessToken || + refreshFailed || + (expiry !== null && (!Number.isFinite(expiry) || expiry <= Date.now())) + ) + return "Selected Codex account refresh failed; reconnect that account"; + if ( + selected.connectionId !== original.connectionId || + selected.providerSpecificData?.workspaceId !== original.providerSpecificData?.workspaceId + ) + return "Selected Codex account changed during refresh; submission refused"; + return null; +} diff --git a/tests/_setup/isolateDataDir.ts b/tests/_setup/isolateDataDir.ts index 8b368f44aa08..0f9a24305736 100644 --- a/tests/_setup/isolateDataDir.ts +++ b/tests/_setup/isolateDataDir.ts @@ -18,6 +18,7 @@ // so concurrent test processes never collide. Tests that set DATA_DIR explicitly keep // winning — this only fills in an isolated default when none was chosen. import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; // File logger worker threads can outlive a test's temporary DATA_DIR cleanup and then @@ -26,22 +27,8 @@ import path from "node:path"; process.env.APP_LOG_TO_FILE ||= "false"; if (!process.env.DATA_DIR) { - const workspace = path.resolve(".ci-work"); - fs.mkdirSync(workspace, { recursive: true }); - const dir = fs.mkdtempSync(path.join(workspace, "isolated-")); - for (const name of ["data", "home", "appdata", "localappdata", "temp"]) { - fs.mkdirSync(path.join(dir, name)); - } - Object.assign(process.env, { - DATA_DIR: path.join(dir, "data"), - HOME: path.join(dir, "home"), - USERPROFILE: path.join(dir, "home"), - APPDATA: path.join(dir, "appdata"), - LOCALAPPDATA: path.join(dir, "localappdata"), - TEMP: path.join(dir, "temp"), - TMP: path.join(dir, "temp"), - TMPDIR: path.join(dir, "temp"), - }); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-")); + process.env.DATA_DIR = dir; // Best-effort cleanup so a long suite run does not leak hundreds of temp DBs. process.on("exit", () => { diff --git a/tests/_setup/nativeImageState.ts b/tests/_setup/nativeImageState.ts new file mode 100644 index 000000000000..97a6df934c8e --- /dev/null +++ b/tests/_setup/nativeImageState.ts @@ -0,0 +1,39 @@ +import fs from "node:fs"; +import path from "node:path"; + +// Native image fixtures also exercise home-based credentials; isolate them before imports. +const workspace = path.resolve(".ci-work"); +const keys = ["DATA_DIR", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA"]; +const alreadyIsolated = keys.every((key) => { + const value = process.env[key]; + if (!value) return false; + const relative = path.relative(workspace, path.resolve(value)); + return relative && !relative.startsWith("..") && !path.isAbsolute(relative); +}); + +if (!alreadyIsolated) { + fs.mkdirSync(workspace, { recursive: true }); + const root = fs.mkdtempSync(path.join(workspace, "isolated-native-image-")); + const home = path.join(root, "isolated-home"); + const directories = { + DATA_DIR: path.join(root, "isolated-data"), + HOME: home, + USERPROFILE: home, + APPDATA: path.join(home, "AppData", "Roaming"), + LOCALAPPDATA: path.join(home, "AppData", "Local"), + XDG_CONFIG_HOME: path.join(home, ".config"), + XDG_CACHE_HOME: path.join(home, ".cache"), + OMNIROUTE_PLUGINS_DIR: path.join(root, "plugins"), + }; + for (const [key, value] of Object.entries(directories)) { + fs.mkdirSync(value, { recursive: true }); + process.env[key] = value; + } + process.on("exit", () => { + try { + fs.rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } catch { + // Preserve locked diagnostic state rather than interrupting the test result. + } + }); +} diff --git a/tests/unit/codex-tools-split.test.ts b/tests/unit/codex-tools-split.test.ts index e3cfc467f625..d28f73fed0a6 100644 --- a/tests/unit/codex-tools-split.test.ts +++ b/tests/unit/codex-tools-split.test.ts @@ -13,7 +13,11 @@ const EXE = join(HERE, "../../open-sse/executors"); test("leaf hosts the tool normalizers and does not import the host", () => { const src = readFileSync(join(EXE, "codex/tools.ts"), "utf8"); assert.match(src, /export function normalizeCodexTools\b/); - assert.match(src, /export function isCodexFreePlan\b/); + assert.match(src, /import \{ isCodexFreePlan \} from "\.\.\/\.\.\/config\/codexPlan\.ts"/); + assert.match(src, /export \{ isCodexFreePlan \}/); + const predicate = readFileSync(join(EXE, "../config/codexPlan.ts"), "utf8"); + assert.match(predicate, /export function isCodexFreePlan\b/); + assert.doesNotMatch(predicate, /\bimport\b/); assert.doesNotMatch(src, /from "\.\.\/codex\.ts"/); }); diff --git a/tests/unit/gemini-antigravity-image-edits.test.ts b/tests/unit/gemini-antigravity-image-edits.test.ts index 48328b68a018..954803747b31 100644 --- a/tests/unit/gemini-antigravity-image-edits.test.ts +++ b/tests/unit/gemini-antigravity-image-edits.test.ts @@ -19,7 +19,20 @@ process.on("exit", () => { (dns.promises as { lookup: unknown }).lookup = originalDnsLookup; }); +await import("../_setup/imageCallLogLifecycle.ts"); const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts"); +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", + "base64" +); + +test.after(async () => { + (dns.promises as { lookup: unknown }).lookup = originalDnsLookup; + const { resetDbInstance } = await import("../../src/lib/db/core.ts"); + resetDbInstance(); + const { rmSync } = await import("node:fs"); + rmSync(process.env.DATA_DIR!, { recursive: true, force: true }); +}); interface CapturedGeminiImageRequest { url: string; @@ -42,7 +55,7 @@ test("handleImageGeneration forwards inlineData parts for Antigravity Gemini ima const originalFetch = globalThis.fetch; let captured: CapturedGeminiImageRequest | undefined; - globalThis.fetch = async (url, options = {}) => { + globalThis.fetch = async (url, options: RequestInit = {}) => { captured = { url: String(url), headers: options.headers, @@ -72,7 +85,7 @@ test("handleImageGeneration forwards inlineData parts for Antigravity Gemini ima }; try { - const imageBuffer = Buffer.from("fake-png-binary-data"); + const imageBuffer = PNG; const result = await handleImageGeneration({ body: { model: "antigravity/gemini-3.1-flash-image-preview", @@ -90,9 +103,9 @@ test("handleImageGeneration forwards inlineData parts for Antigravity Gemini ima const parts = captured.body.request.contents[0].parts; assert.equal(parts.length, 2); - assert.equal(parts[0].inlineData.mimeType, "image/png"); - assert.equal(parts[0].inlineData.data, imageBuffer.toString("base64")); - assert.equal(parts[1].text, "add a sunset in the background"); + assert.equal(parts[1].inlineData.mimeType, "image/png"); + assert.equal(parts[1].inlineData.data, imageBuffer.toString("base64")); + assert.equal(parts[0].text, "add a sunset in the background"); } finally { globalThis.fetch = originalFetch; } @@ -102,7 +115,7 @@ test("handleImageGeneration forwards inlineData parts for Antigravity Gemini ima const originalFetch = globalThis.fetch; let captured: CapturedGeminiImageRequest | undefined; - globalThis.fetch = async (url, options = {}) => { + globalThis.fetch = async (url, options: RequestInit = {}) => { captured = { url: String(url), headers: options.headers, @@ -131,7 +144,7 @@ test("handleImageGeneration forwards inlineData parts for Antigravity Gemini ima }; try { - const dataUri = "data:image/jpeg;base64,dGVzdC1qcGVnLWRhdGE="; + const dataUri = `data:image/png;base64,${PNG.toString("base64")}`; const result = await handleImageGeneration({ body: { model: "antigravity/gemini-3.1-flash-image-preview", @@ -145,9 +158,9 @@ test("handleImageGeneration forwards inlineData parts for Antigravity Gemini ima assert.equal(result.success, true); const parts = captured.body.request.contents[0].parts; assert.equal(parts.length, 2); - assert.equal(parts[0].inlineData.mimeType, "image/jpeg"); - assert.equal(parts[0].inlineData.data, "dGVzdC1qcGVnLWRhdGE="); - assert.equal(parts[1].text, "remove person from background"); + assert.equal(parts[1].inlineData.mimeType, "image/png"); + assert.equal(parts[1].inlineData.data, PNG.toString("base64")); + assert.equal(parts[0].text, "remove person from background"); } finally { globalThis.fetch = originalFetch; } @@ -157,7 +170,7 @@ test("handleImageGeneration forwards inlineData parts for Antigravity Gemini ima const originalFetch = globalThis.fetch; let captured: CapturedGeminiImageRequest | undefined; - globalThis.fetch = async (url, options = {}) => { + globalThis.fetch = async (url, options: RequestInit = {}) => { captured = { url: String(url), headers: options.headers, @@ -186,7 +199,7 @@ test("handleImageGeneration forwards inlineData parts for Antigravity Gemini ima }; try { - const dataUri = "data:image/jpeg;base64,aW1hZ2UtZmllbGQtZGF0YQ=="; + const dataUri = `data:image/png;base64,${PNG.toString("base64")}`; const result = await handleImageGeneration({ body: { model: "antigravity/gemini-3.1-flash-image-preview", @@ -200,9 +213,9 @@ test("handleImageGeneration forwards inlineData parts for Antigravity Gemini ima assert.equal(result.success, true); const parts = captured.body.request.contents[0].parts; assert.equal(parts.length, 2); - assert.equal(parts[0].inlineData.mimeType, "image/jpeg"); - assert.equal(parts[0].inlineData.data, "aW1hZ2UtZmllbGQtZGF0YQ=="); - assert.equal(parts[1].text, "change clothing to uniform"); + assert.equal(parts[1].inlineData.mimeType, "image/png"); + assert.equal(parts[1].inlineData.data, PNG.toString("base64")); + assert.equal(parts[0].text, "change clothing to uniform"); } finally { globalThis.fetch = originalFetch; } diff --git a/tests/unit/native-antigravity-image-repair.test.ts b/tests/unit/native-antigravity-image-repair.test.ts index 744e3ea10c3b..ac290ea68a82 100644 --- a/tests/unit/native-antigravity-image-repair.test.ts +++ b/tests/unit/native-antigravity-image-repair.test.ts @@ -2,11 +2,19 @@ import assert from "node:assert/strict"; import { mkdtempSync, rmSync } from "node:fs"; import path from "node:path"; import test from "node:test"; +import "../_setup/nativeImageState.ts"; const isolatedRoot = path.resolve(process.env.DATA_DIR || ""); -assert.ok(process.env.DATA_DIR && isolatedRoot.startsWith(path.resolve(".ci-work") + path.sep), "Run with a fresh project-contained DATA_DIR"); +assert.ok( + process.env.DATA_DIR && isolatedRoot.startsWith(path.resolve(".ci-work") + path.sep), + "Run with a fresh project-contained DATA_DIR" +); for (const key of ["HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA"]) { - assert.ok(process.env[key] && path.resolve(process.env[key]!).startsWith(path.resolve(".ci-work") + path.sep), `Run with private ${key}`); + assert.ok( + process.env[key] && + path.resolve(process.env[key]!).startsWith(path.resolve(".ci-work") + path.sep), + `Run with private ${key}` + ); } const state = mkdtempSync(path.join(isolatedRoot, "native-antigravity-")); process.env.DATA_DIR = state; @@ -20,7 +28,10 @@ const providers = await import("../../src/lib/db/providers.ts"); const { POST } = await import("../../src/app/api/v1/images/edits/route.ts"); // A complete tiny PNG, not a MIME-only dummy. No real account or network is used. -const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", "base64"); +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", + "base64" +); const DATA_URL = `data:image/png;base64,${PNG.toString("base64")}`; const model = "antigravity/gemini-3.1-flash-image"; const credentials = { accessToken: "fixture-token", projectId: "fixture-project" }; @@ -43,130 +54,293 @@ test.after(async () => { rmSync(state, { recursive: true, force: true }); }); -test("Antigravity public generator forwards original reference with selected native settings", { timeout: 5000 }, async () => { - let calls = 0; - let captured: CapturedImageRequest; - globalThis.fetch = async (_url, init) => { - calls++; - captured = JSON.parse(String(init?.body)); - assert.equal(new Headers(init?.headers).get("Authorization"), "Bearer fixture-token"); - return Response.json({ response: { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }] } }] } }); - }; - const result = await handleImageGeneration({ body: { model, prompt: " رنگ آبی — café ", image: DATA_URL, aspect_ratio: "16:9", image_size: "4K" }, credentials, log: null }); - assert.equal(result.success, true); - assert.equal(calls, 1); - assert.equal(captured.model, "gemini-3.1-flash-image"); - assert.equal(captured.project, "fixture-project"); - assert.equal(captured.requestType, "image_gen"); - assert.equal(captured.userAgent, "antigravity"); - assert.deepEqual(captured.request.contents[0].parts, [ - { text: " رنگ آبی — café " }, - { inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }, - ]); - assert.deepEqual(captured.request.generationConfig.imageConfig, { aspectRatio: "16:9", imageSize: "4K" }); -}); +test( + "Antigravity public generator forwards original reference with selected native settings", + { timeout: 5000 }, + async () => { + let calls = 0; + let captured: CapturedImageRequest; + globalThis.fetch = async (_url, init) => { + calls++; + captured = JSON.parse(String(init?.body)); + assert.equal(new Headers(init?.headers).get("Authorization"), "Bearer fixture-token"); + return Response.json({ + response: { + candidates: [ + { + content: { + parts: [{ inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }], + }, + }, + ], + }, + }); + }; + const result = await handleImageGeneration({ + body: { + model, + prompt: " رنگ آبی — café ", + image: DATA_URL, + aspect_ratio: "16:9", + image_size: "4K", + }, + credentials, + log: null, + }); + assert.equal(result.success, true); + assert.equal(calls, 1); + assert.equal(captured.model, "gemini-3.1-flash-image"); + assert.equal(captured.project, "fixture-project"); + assert.equal(captured.requestType, "image_gen"); + assert.equal(captured.userAgent, "antigravity"); + assert.deepEqual(captured.request.contents[0].parts, [ + { text: " رنگ آبی — café " }, + { inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }, + ]); + assert.deepEqual(captured.request.generationConfig.imageConfig, { + aspectRatio: "16:9", + imageSize: "4K", + }); + } +); -test("Pro model aliases preserve the canonical Pro identity at registry and generator seams", { timeout: 5000 }, async () => { - const { parseImageModel, getImageModelEntry } = await import("../../open-sse/config/imageRegistry.ts"); - for (const selected of ["antigravity/gemini-3-pro-image", "antigravity/gemini-3-pro-image-preview"]) { - assert.deepEqual(parseImageModel(selected), { provider: "antigravity", model: "gemini-3-pro-image" }); - assert.equal(getImageModelEntry(selected)?.model, "gemini-3-pro-image"); - for (const editing of [false, true]) { - let submissions = 0; +test( + "Pro model aliases preserve the canonical Pro identity at registry and generator seams", + { timeout: 5000 }, + async () => { + const { parseImageModel, getImageModelEntry } = + await import("../../open-sse/config/imageRegistry.ts"); + for (const selected of [ + "antigravity/gemini-3-pro-image", + "antigravity/gemini-3-pro-image-preview", + ]) { + assert.deepEqual(parseImageModel(selected), { + provider: "antigravity", + model: "gemini-3-pro-image", + }); + assert.equal(getImageModelEntry(selected)?.model, "gemini-3-pro-image"); + for (const editing of [false, true]) { + let submissions = 0; + globalThis.fetch = async (_url, init) => { + submissions++; + const request = JSON.parse(String(init?.body)) as CapturedImageRequest; + assert.equal(request.model, "gemini-3-pro-image"); + assert.equal(request.project, "fixture-project"); + assert.deepEqual(request.request.generationConfig.imageConfig, { + aspectRatio: "1:1", + imageSize: "4K", + }); + assert.equal(request.request.contents[0].parts.length, editing ? 2 : 1); + if (editing) + assert.deepEqual(request.request.contents[0].parts[1], { + inlineData: { mimeType: "image/png", data: PNG.toString("base64") }, + }); + return Response.json({ + response: { + candidates: [ + { + content: { + parts: [ + { inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }, + ], + }, + }, + ], + }, + }); + }; + const result = await handleImageGeneration({ + body: { + model: selected, + prompt: "Pro fixture", + size: "1024x1024", + image_size: "4K", + ...(editing ? { image: DATA_URL } : {}), + }, + credentials, + log: null, + }); + assert.equal(result.success, true); + assert.equal(submissions, 1); + } + } + } +); + +test( + "Antigravity HTTP edit preserves reference, ratio and tier for JSON and multipart", + { timeout: 5000 }, + async () => { + await providers.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + name: "fixture", + accessToken: "fixture-token", + isActive: true, + testStatus: "active", + providerSpecificData: { projectId: "fixture-project" }, + }); + for (const [selected, multipart] of [ + [model, false], + [model, true], + ["antigravity/gemini-3-pro-image", false], + ["antigravity/gemini-3-pro-image-preview", true], + ] as const) { + let captured: CapturedImageRequest; globalThis.fetch = async (_url, init) => { - submissions++; - const request = JSON.parse(String(init?.body)) as CapturedImageRequest; - assert.equal(request.model, "gemini-3-pro-image"); - assert.equal(request.project, "fixture-project"); - assert.deepEqual(request.request.generationConfig.imageConfig, { aspectRatio: "1:1", imageSize: "4K" }); - assert.equal(request.request.contents[0].parts.length, editing ? 2 : 1); - if (editing) assert.deepEqual(request.request.contents[0].parts[1], { inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }); - return Response.json({ response: { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }] } }] } }); + captured = JSON.parse(String(init?.body)); + return Response.json({ + response: { + candidates: [ + { + content: { + parts: [{ inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }], + }, + }, + ], + }, + }); }; - const result = await handleImageGeneration({ body: { model: selected, prompt: "Pro fixture", size: "1024x1024", image_size: "4K", ...(editing ? { image: DATA_URL } : {}) }, credentials, log: null }); - assert.equal(result.success, true); - assert.equal(submissions, 1); + const form = new FormData(); + form.set("model", selected); + form.set("prompt", "make it blue"); + form.set("aspect_ratio", "16:9"); + form.set("image_size", "4K"); + form.set("image", new Blob([PNG], { type: "image/png" }), "reference.png"); + const request = new Request("http://localhost/api/v1/images/edits", { + method: "POST", + ...(multipart ? {} : { headers: { "content-type": "application/json" } }), + body: multipart + ? form + : JSON.stringify({ + model: selected, + prompt: "make it blue", + image: DATA_URL, + aspect_ratio: "16:9", + image_size: "4K", + }), + }); + const response = await POST(request); + assert.equal(response.status, 200, JSON.stringify(await response.clone().json())); + assert.equal( + captured.model, + selected.includes("gemini-3-pro-image") ? "gemini-3-pro-image" : "gemini-3.1-flash-image" + ); + assert.deepEqual(captured.request.contents[0].parts[1], { + inlineData: { mimeType: "image/png", data: PNG.toString("base64") }, + }); + assert.deepEqual(captured.request.generationConfig.imageConfig, { + aspectRatio: "16:9", + imageSize: "4K", + }); } } -}); +); -test("Antigravity HTTP edit preserves reference, ratio and tier for JSON and multipart", { timeout: 5000 }, async () => { - await providers.createProviderConnection({ provider: "antigravity", authType: "oauth", name: "fixture", accessToken: "fixture-token", isActive: true, testStatus: "active", providerSpecificData: { projectId: "fixture-project" } }); - for (const [selected, multipart] of [[model, false], [model, true], ["antigravity/gemini-3-pro-image", false], ["antigravity/gemini-3-pro-image-preview", true]] as const) { - let captured: CapturedImageRequest; - globalThis.fetch = async (_url, init) => { - captured = JSON.parse(String(init?.body)); - return Response.json({ response: { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }] } }] } }); +test( + "Antigravity rejects unsupported masks, invalid MIME, excess and oversized references before submission", + { timeout: 5000 }, + async () => { + let submissions = 0; + globalThis.fetch = async () => { + submissions++; + return Response.json({}, { status: 500 }); }; - const form = new FormData(); - form.set("model", selected); form.set("prompt", "make it blue"); - form.set("aspect_ratio", "16:9"); form.set("image_size", "4K"); - form.set("image", new Blob([PNG], { type: "image/png" }), "reference.png"); - const request = new Request("http://localhost/api/v1/images/edits", { - method: "POST", - ...(multipart ? {} : { headers: { "content-type": "application/json" } }), - body: multipart ? form : JSON.stringify({ model: selected, prompt: "make it blue", image: DATA_URL, aspect_ratio: "16:9", image_size: "4K" }), + for (const extra of [ + { mask: DATA_URL }, + { image: `data:image/jpeg;base64,${PNG.toString("base64")}` }, + { images: [DATA_URL, DATA_URL], image: undefined }, + { image: "data:image/svg+xml;base64,PHN2Zz4=" }, + { image: "data:image/png;base64,%%%=" }, + { model: "antigravity/not-an-image-model" }, + ]) { + const response = await POST( + new Request("http://localhost/api/v1/images/edits", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model, prompt: "make it blue", image: DATA_URL, ...extra }), + }) + ); + assert.equal(response.status, 400); + const result = await handleImageGeneration({ + body: { model, prompt: "make it blue", image: DATA_URL, ...extra }, + credentials, + log: null, + }); + assert.equal(result.success, false); + } + const oversized = Buffer.alloc(20 * 1024 * 1024 + 1); + PNG.copy(oversized); + const result = await handleImageGeneration({ + body: { + model, + prompt: "make it blue", + image: `data:image/png;base64,${oversized.toString("base64")}`, + }, + credentials, + log: null, }); - const response = await POST(request); - assert.equal(response.status, 200, JSON.stringify(await response.clone().json())); - assert.equal(captured.model, selected.includes("gemini-3-pro-image") ? "gemini-3-pro-image" : "gemini-3.1-flash-image"); - assert.deepEqual(captured.request.contents[0].parts[1], { inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }); - assert.deepEqual(captured.request.generationConfig.imageConfig, { aspectRatio: "16:9", imageSize: "4K" }); - } -}); - -test("Antigravity rejects unsupported masks, invalid MIME, excess and oversized references before submission", { timeout: 5000 }, async () => { - let submissions = 0; - globalThis.fetch = async () => { submissions++; return Response.json({}, { status: 500 }); }; - for (const extra of [ - { mask: DATA_URL }, - { image: `data:image/jpeg;base64,${PNG.toString("base64")}` }, - { images: [DATA_URL, DATA_URL], image: undefined }, - { image: "data:image/svg+xml;base64,PHN2Zz4=" }, - { image: "data:image/png;base64,%%%=" }, - { model: "antigravity/not-an-image-model" }, - ]) { - const response = await POST(new Request("http://localhost/api/v1/images/edits", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model, prompt: "make it blue", image: DATA_URL, ...extra }) })); - assert.equal(response.status, 400); - const result = await handleImageGeneration({ body: { model, prompt: "make it blue", image: DATA_URL, ...extra }, credentials, log: null }); assert.equal(result.success, false); + assert.equal(submissions, 0, "Invalid references must not submit upstream"); } - const oversized = Buffer.alloc(20 * 1024 * 1024 + 1); - PNG.copy(oversized); - const result = await handleImageGeneration({ body: { model, prompt: "make it blue", image: `data:image/png;base64,${oversized.toString("base64")}` }, credentials, log: null }); - assert.equal(result.success, false); - assert.equal(submissions, 0, "Invalid references must not submit upstream"); -}); - -test("Antigravity reference edit rejects URL response format before submission", { timeout: 5000 }, async () => { - let submissions = 0; - globalThis.fetch = async () => { - submissions++; - return Response.json({ response: { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }] } }] } }); - }; - const result = await handleImageGeneration({ body: { model, prompt: "make it blue", image: DATA_URL, response_format: "url" }, credentials, log: null }); - assert.equal(result.success, false); - assert.ok(result.success === false); - assert.ok("status" in result); - assert.equal(result.status, 400); - assert.equal(submissions, 0); -}); +); -test("Antigravity submitted reference edit failures are never retryable", { timeout: 5000 }, async () => { - for (const mode of ["upstream", "empty", "transport", "parse"]) { +test( + "Antigravity reference edit rejects URL response format before submission", + { timeout: 5000 }, + async () => { let submissions = 0; globalThis.fetch = async () => { submissions++; - if (mode === "transport") throw new Error("fixture transport failure"); - if (mode === "parse") return new Response("not json"); - if (mode === "upstream") return Response.json({ error: { message: "fixture rejection" } }, { status: 401 }); - return Response.json({ response: { candidates: [] } }); + return Response.json({ + response: { + candidates: [ + { + content: { + parts: [{ inlineData: { mimeType: "image/png", data: PNG.toString("base64") } }], + }, + }, + ], + }, + }); }; - const result = await handleImageGeneration({ body: { model, prompt: "make it blue", image: DATA_URL }, credentials, log: null }); - assert.equal(result.success, false, mode); - assert.ok(result.success === false, mode); - assert.ok("retryable" in result, mode); - assert.equal(result.retryable, false, mode); - assert.equal(submissions, 1, mode); + const result = await handleImageGeneration({ + body: { model, prompt: "make it blue", image: DATA_URL, response_format: "url" }, + credentials, + log: null, + }); + assert.equal(result.success, false); + assert.ok(result.success === false); + assert.ok("status" in result); + assert.equal(result.status, 400); + assert.equal(submissions, 0); } -}); +); + +test( + "Antigravity submitted reference edit failures are never retryable", + { timeout: 5000 }, + async () => { + for (const mode of ["upstream", "empty", "transport", "parse"]) { + let submissions = 0; + globalThis.fetch = async () => { + submissions++; + if (mode === "transport") throw new Error("fixture transport failure"); + if (mode === "parse") return new Response("not json"); + if (mode === "upstream") + return Response.json({ error: { message: "fixture rejection" } }, { status: 401 }); + return Response.json({ response: { candidates: [] } }); + }; + const result = await handleImageGeneration({ + body: { model, prompt: "make it blue", image: DATA_URL }, + credentials, + log: null, + }); + assert.equal(result.success, false, mode); + assert.ok(result.success === false, mode); + assert.ok("retryable" in result, mode); + assert.equal(result.retryable, false, mode); + assert.equal(submissions, 1, mode); + } + } +); diff --git a/tests/unit/native-codex-images-http.test.ts b/tests/unit/native-codex-images-http.test.ts index 85d3ad3344e0..2efd3a7abf8e 100644 --- a/tests/unit/native-codex-images-http.test.ts +++ b/tests/unit/native-codex-images-http.test.ts @@ -2,8 +2,9 @@ import assert from "node:assert/strict"; import { existsSync } from "node:fs"; import path from "node:path"; import test from "node:test"; +import "../_setup/nativeImageState.ts"; -// The isolated runner provisions every state/home variable before any application import. +// Native fixtures provision or reuse isolated state before any application import. for (const key of ["DATA_DIR", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA"]) { assert.ok(process.env[key]?.includes(`${path.sep}isolated-`), `${key} must be isolated`); } @@ -17,22 +18,56 @@ const { createApiKey } = await import("../../src/lib/db/apiKeys.ts"); const { POST } = await import("../../src/app/api/v1/images/edits/route.ts"); const { POST: generate } = await import("../../src/app/api/v1/images/generations/route.ts"); const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts"); -const { getImageModelEntry, parseImageModel } = await import("../../open-sse/config/imageRegistry.ts"); -const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", "base64"); +const { getImageModelEntry, parseImageModel } = + await import("../../open-sse/config/imageRegistry.ts"); +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", + "base64" +); const DATA_URL = `data:image/png;base64,${PNG.toString("base64")}`; -const payload = { created: 1778832973, data: [{ b64_json: PNG.toString("base64"), generation_id: "fixture-generation" }], size: "1024x1536", usage: { total_tokens: 3 } }; -const credentials = { accessToken: "fixture-token", providerSpecificData: { workspaceId: "fixture-account", workspacePlanType: "plus" } }; +const payload = { + created: 1778832973, + data: [{ b64_json: PNG.toString("base64"), generation_id: "fixture-generation" }], + size: "1024x1536", + usage: { total_tokens: 3 }, +}; +const credentials = { + accessToken: "fixture-token", + providerSpecificData: { workspaceId: "fixture-account", workspacePlanType: "plus" }, +}; let selectedConnectionId: string; test.before(async () => { - const connection = await providers.createProviderConnection({ provider: "codex", authType: "oauth", name: "fixture", accessToken: credentials.accessToken, isActive: true, testStatus: "active", providerSpecificData: credentials.providerSpecificData, priority: 1 }); + const connection = await providers.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: "fixture", + accessToken: credentials.accessToken, + isActive: true, + testStatus: "active", + providerSpecificData: credentials.providerSpecificData, + priority: 1, + }); selectedConnectionId = connection.id as string; }); -test.after(() => { globalThis.fetch = originalFetch; core.resetDbInstance(); }); +test.after(() => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); +}); function editRequest(extra: Record = {}, multipart = false) { - const body = { model: "cx/gpt-image-2.5-sunburst", prompt: " رنگ آبی — café ", image: DATA_URL, ...extra }; - if (!multipart) return new Request("http://localhost/api/v1/images/edits", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + const body = { + model: "cx/gpt-image-2.5-sunburst", + prompt: " رنگ آبی — café ", + image: DATA_URL, + ...extra, + }; + if (!multipart) + return new Request("http://localhost/api/v1/images/edits", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); const form = new FormData(); for (const [key, value] of Object.entries(body)) { if (key === "image") form.set(key, new Blob([PNG], { type: "image/png" }), "reference.png"); @@ -41,172 +76,363 @@ function editRequest(extra: Record = {}, multipart = false) { return new Request("http://localhost/api/v1/images/edits", { method: "POST", body: form }); } -test("public Codex engine edit sends original references and native options for JSON and multipart", { timeout: 5000 }, async () => { - let calls = 0; - globalThis.fetch = async (url, init) => { - calls++; - assert.equal(String(url), "https://chatgpt.com/backend-api/codex/images/edits"); - assert.deepEqual(JSON.parse(String(init?.body)), { model: "gpt-image-2.5-sunburst", prompt: " رنگ آبی — café ", n: 1, size: "3840x2160", quality: "high", background: "opaque", images: [{ image_url: DATA_URL }] }); - assert.equal(new Headers(init?.headers).get("authorization"), "Bearer fixture-token"); - assert.equal(new Headers(init?.headers).get("chatgpt-account-id"), "fixture-account"); - return Response.json(payload); - }; - for (const multipart of [false, true]) { - const response = await POST(editRequest({ n: 1, size: "3840x2160", quality: "high", background: "opaque", response_format: "b64_json" }, multipart)); - assert.equal(response.status, 200, JSON.stringify(await response.clone().json())); - assert.deepEqual(await response.json(), payload); +test( + "public Codex engine edit sends original references and native options for JSON and multipart", + { timeout: 5000 }, + async () => { + let calls = 0; + globalThis.fetch = async (url, init) => { + calls++; + assert.equal(String(url), "https://chatgpt.com/backend-api/codex/images/edits"); + assert.deepEqual(JSON.parse(String(init?.body)), { + model: "gpt-image-2.5-sunburst", + prompt: " رنگ آبی — café ", + n: 1, + size: "3840x2160", + quality: "high", + background: "opaque", + images: [{ image_url: DATA_URL }], + }); + assert.equal(new Headers(init?.headers).get("authorization"), "Bearer fixture-token"); + assert.equal(new Headers(init?.headers).get("chatgpt-account-id"), "fixture-account"); + return Response.json(payload); + }; + for (const multipart of [false, true]) { + const response = await POST( + editRequest( + { + n: 1, + size: "3840x2160", + quality: "high", + background: "opaque", + response_format: "b64_json", + }, + multipart + ) + ); + assert.equal(response.status, 200, JSON.stringify(await response.clone().json())); + assert.deepEqual(await response.json(), payload); + } + assert.equal(calls, 2); + assert.ok( + existsSync(path.join(process.env.DATA_DIR!, "storage.sqlite")), + "HTTP fixture uses only its isolated database" + ); } - assert.equal(calls, 2); - assert.ok(existsSync(path.join(process.env.DATA_DIR!, "storage.sqlite")), "HTTP fixture uses only its isolated database"); -}); +); -test("public dedicated edits reject unsupported options and invalid references without a POST", { timeout: 5000 }, async () => { - let calls = 0; - globalThis.fetch = async () => { calls++; return Response.json(payload); }; - const negatives: Record[] = [ - { n: 2 }, { n: 0 }, { quality: "max" }, { background: "invalid" }, - { mask: DATA_URL }, { output_format: "png" }, { output_compression: 80 }, - { response_format: "url" }, { size: "4K" }, - { image: "https://reference.invalid/image.png" }, { image: "" }, - { image: undefined, images: [{ image_url: DATA_URL }, { image_url: "https://reference.invalid/image.png" }] }, - { image: undefined, images: Array(6).fill({ image_url: DATA_URL }) }, - { image: "data:image/jpeg;base64," + PNG.toString("base64") }, - { model: "cx/gpt-image-unknown" }, - ]; - for (const multipart of [false, true]) { - for (const extra of negatives.filter((item) => !multipart || !Object.hasOwn(item, "image"))) { - const response = await POST(editRequest(extra, multipart)); - assert.equal(response.status, 400, JSON.stringify(extra)); +test( + "public dedicated edits reject unsupported options and invalid references without a POST", + { timeout: 5000 }, + async () => { + let calls = 0; + globalThis.fetch = async () => { + calls++; + return Response.json(payload); + }; + const negatives: Record[] = [ + { n: 2 }, + { n: 0 }, + { quality: "max" }, + { background: "invalid" }, + { mask: DATA_URL }, + { output_format: "png" }, + { output_compression: 80 }, + { response_format: "url" }, + { size: "4K" }, + { image: "https://reference.invalid/image.png" }, + { image: "" }, + { + image: undefined, + images: [{ image_url: DATA_URL }, { image_url: "https://reference.invalid/image.png" }], + }, + { image: undefined, images: Array(6).fill({ image_url: DATA_URL }) }, + { image: "data:image/jpeg;base64," + PNG.toString("base64") }, + { model: "cx/gpt-image-unknown" }, + ]; + for (const multipart of [false, true]) { + for (const extra of negatives.filter((item) => !multipart || !Object.hasOwn(item, "image"))) { + const response = await POST(editRequest(extra, multipart)); + assert.equal(response.status, 400, JSON.stringify(extra)); + } } + assert.equal(calls, 0); } - assert.equal(calls, 0); -}); +); -test("public image generation keeps exact engine identity and terminal failures", { timeout: 5000 }, async () => { - for (const model of ["gpt-image-2", "gpt-image-2.5-sunburst"]) { - assert.equal(getImageModelEntry(`cx/${model}`)?.model, model); - assert.deepEqual(parseImageModel(`cx/${model}`), { provider: "codex", model }); - let calls = 0; +test( + "public image generation keeps exact engine identity and terminal failures", + { timeout: 5000 }, + async () => { + for (const model of ["gpt-image-2", "gpt-image-2.5-sunburst"]) { + assert.equal(getImageModelEntry(`cx/${model}`)?.model, model); + assert.deepEqual(parseImageModel(`cx/${model}`), { provider: "codex", model }); + let calls = 0; + globalThis.fetch = async (url, init) => { + calls++; + assert.equal(String(url), "https://chatgpt.com/backend-api/codex/images/generations"); + assert.deepEqual(JSON.parse(String(init?.body)), { + model, + prompt: "fixture", + n: 1, + size: "3840x2160", + }); + return Response.json(payload); + }; + const response = await generate( + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: `cx/${model}`, + prompt: "fixture", + n: 1, + size: "3840x2160", + }), + }) + ); + assert.equal(response.status, 200, JSON.stringify(await response.clone().json())); + assert.deepEqual(await response.json(), payload); + assert.equal(calls, 1); + } + let rejectedCalls = 0; globalThis.fetch = async (url, init) => { - calls++; + rejectedCalls++; assert.equal(String(url), "https://chatgpt.com/backend-api/codex/images/generations"); - assert.deepEqual(JSON.parse(String(init?.body)), { model, prompt: "fixture", n: 1, size: "3840x2160" }); - return Response.json(payload); + assert.equal(new Headers(init?.headers).get("authorization"), "Bearer fixture-token"); + assert.equal(new Headers(init?.headers).get("chatgpt-account-id"), "fixture-account"); + assert.deepEqual(JSON.parse(String(init?.body)), { + model: "gpt-image-2", + prompt: "fixture", + n: 1, + size: "3840x2160", + }); + return Response.json({ error: { message: "provider rejected fixture" } }, { status: 401 }); }; - const response = await generate(new Request("http://localhost/api/v1/images/generations", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: `cx/${model}`, prompt: "fixture", n: 1, size: "3840x2160" }) })); - assert.equal(response.status, 200, JSON.stringify(await response.clone().json())); - assert.deepEqual(await response.json(), payload); + const rejected = await generate( + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "cx/gpt-image-2", prompt: "fixture", size: "3840x2160" }), + }) + ); + assert.equal(rejected.status, 401); + assert.equal(rejectedCalls, 1); + rejectedCalls = 0; + for (const extra of [ + { n: 2 }, + { quality: "max" }, + { background: "invalid" }, + { mask: DATA_URL }, + { output_format: "png" }, + ]) { + const invalid = await generate( + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "cx/gpt-image-2", prompt: "fixture", ...extra }), + }) + ); + assert.equal(invalid.status, 400, JSON.stringify(extra)); + } + assert.equal(rejectedCalls, 0); + let calls = 0; + globalThis.fetch = async () => { + calls++; + return Response.json({ error: { message: "provider rejected fixture" } }, { status: 401 }); + }; + const result = await handleImageGeneration({ + body: { model: "cx/gpt-image-2", prompt: "fixture" }, + credentials, + log: null, + }); + assert.equal(result.success, false); + assert.ok(result.success === false && "retryable" in result); + assert.equal(result.retryable, false); assert.equal(calls, 1); } - let rejectedCalls = 0; - globalThis.fetch = async (url, init) => { - rejectedCalls++; - assert.equal(String(url), "https://chatgpt.com/backend-api/codex/images/generations"); - assert.equal(new Headers(init?.headers).get("authorization"), "Bearer fixture-token"); - assert.equal(new Headers(init?.headers).get("chatgpt-account-id"), "fixture-account"); - assert.deepEqual(JSON.parse(String(init?.body)), { model: "gpt-image-2", prompt: "fixture", n: 1, size: "3840x2160" }); - return Response.json({ error: { message: "provider rejected fixture" } }, { status: 401 }); - }; - const rejected = await generate(new Request("http://localhost/api/v1/images/generations", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "cx/gpt-image-2", prompt: "fixture", size: "3840x2160" }) })); - assert.equal(rejected.status, 401); - assert.equal(rejectedCalls, 1); - rejectedCalls = 0; - for (const extra of [{ n: 2 }, { quality: "max" }, { background: "invalid" }, { mask: DATA_URL }, { output_format: "png" }]) { - const invalid = await generate(new Request("http://localhost/api/v1/images/generations", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "cx/gpt-image-2", prompt: "fixture", ...extra }) })); - assert.equal(invalid.status, 400, JSON.stringify(extra)); - } - assert.equal(rejectedCalls, 0); - let calls = 0; - globalThis.fetch = async () => { calls++; return Response.json({ error: { message: "provider rejected fixture" } }, { status: 401 }); }; - const result = await handleImageGeneration({ body: { model: "cx/gpt-image-2", prompt: "fixture" }, credentials, log: null }); - assert.equal(result.success, false); - assert.ok(result.success === false && "retryable" in result); - assert.equal(result.retryable, false); - assert.equal(calls, 1); -}); +); -test("Antigravity edit response format and generation cancellation reach the native guard", { timeout: 5000 }, async () => { - await providers.createProviderConnection({ provider: "antigravity", authType: "oauth", name: "fixture-ag", accessToken: "fixture-token", isActive: true, testStatus: "active", providerSpecificData: { projectId: "fixture-project" } }); - let calls = 0; - globalThis.fetch = async () => { calls++; return Response.json(payload); }; - const response = await POST(editRequest({ model: "antigravity/gemini-3.1-flash-image", response_format: "url" })); - assert.equal(response.status, 400); - const controller = new AbortController(); - controller.abort(); - let cancellationReachedFetch = false; - globalThis.fetch = async (_url, init) => { - cancellationReachedFetch = init?.signal?.aborted === true; +test( + "Antigravity edit response format and generation cancellation reach the native guard", + { timeout: 5000 }, + async () => { + await providers.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + name: "fixture-ag", + accessToken: "fixture-token", + isActive: true, + testStatus: "active", + providerSpecificData: { projectId: "fixture-project" }, + }); + let calls = 0; + globalThis.fetch = async () => { + calls++; + return Response.json(payload); + }; + const response = await POST( + editRequest({ model: "antigravity/gemini-3.1-flash-image", response_format: "url" }) + ); + assert.equal(response.status, 400); + const controller = new AbortController(); + controller.abort(); + let cancellationReachedFetch = false; + globalThis.fetch = async (_url, init) => { + cancellationReachedFetch = init?.signal?.aborted === true; + assert.equal(cancellationReachedFetch, true); + throw new DOMException("Fixture cancellation", "AbortError"); + }; + const result = await handleImageGeneration({ + body: { model: "antigravity/gemini-3.1-flash-image", prompt: "fixture" }, + credentials: { accessToken: "fixture-token", projectId: "fixture-project" }, + log: null, + signal: controller.signal, + }); + assert.equal(result.success, false); assert.equal(cancellationReachedFetch, true); - throw new DOMException("Fixture cancellation", "AbortError"); - }; - const result = await handleImageGeneration({ body: { model: "antigravity/gemini-3.1-flash-image", prompt: "fixture" }, credentials: { accessToken: "fixture-token", projectId: "fixture-project" }, log: null, signal: controller.signal }); - assert.equal(result.success, false); - assert.equal(cancellationReachedFetch, true); - assert.equal(calls, 0); -}); + assert.equal(calls, 0); + } +); -test("dedicated generation never submits an expired selected token or rotates to a sibling after refresh rejection", { timeout: 5000 }, async () => { - await providers.updateProviderConnection(selectedConnectionId, { expiresAt: "2000-01-01T00:00:00.000Z", refreshToken: "fixture-refresh-selected", priority: 1 }); - await providers.createProviderConnection({ provider: "codex", authType: "oauth", name: "fixture-sibling", accessToken: "fixture-token-sibling", isActive: true, testStatus: "active", priority: 2, providerSpecificData: { workspaceId: "fixture-sibling-account", workspacePlanType: "plus" } }); - let refreshCalls = 0; - let imageCalls = 0; - globalThis.fetch = async (url, init) => { - if (String(url) === "https://auth.openai.com/oauth/token") { - refreshCalls++; - assert.equal(new URLSearchParams(String(init?.body)).get("refresh_token"), "fixture-refresh-selected"); - return Response.json({ error: "invalid_grant" }, { status: 400 }); - } - imageCalls++; - return Response.json(payload); - }; - const response = await generate(new Request("http://localhost/api/v1/images/generations", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "cx/gpt-image-2", prompt: "fixture" }) })); - assert.equal(refreshCalls, 1); - assert.equal(imageCalls, 0, "Neither expired selected credentials nor a sibling account may submit an image"); - assert.equal(response.status, 401); -}); +test( + "dedicated generation never submits an expired selected token or rotates to a sibling after refresh rejection", + { timeout: 5000 }, + async () => { + await providers.updateProviderConnection(selectedConnectionId, { + expiresAt: "2000-01-01T00:00:00.000Z", + refreshToken: "fixture-refresh-selected", + priority: 1, + }); + await providers.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: "fixture-sibling", + accessToken: "fixture-token-sibling", + isActive: true, + testStatus: "active", + priority: 2, + providerSpecificData: { workspaceId: "fixture-sibling-account", workspacePlanType: "plus" }, + }); + let refreshCalls = 0; + let imageCalls = 0; + globalThis.fetch = async (url, init) => { + if (String(url) === "https://auth.openai.com/oauth/token") { + refreshCalls++; + assert.equal( + new URLSearchParams(String(init?.body)).get("refresh_token"), + "fixture-refresh-selected" + ); + return Response.json({ error: "invalid_grant" }, { status: 400 }); + } + imageCalls++; + return Response.json(payload); + }; + const response = await generate( + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "cx/gpt-image-2", prompt: "fixture" }), + }) + ); + assert.equal(refreshCalls, 1); + assert.equal( + imageCalls, + 0, + "Neither expired selected credentials nor a sibling account may submit an image" + ); + assert.equal(response.status, 401); + } +); -test("public image combos enforce connection allowlists and fail selected refresh without another submission", { timeout: 5000 }, async () => { - const allowed = await providers.createProviderConnection({ provider: "codex", authType: "oauth", name: "fixture-allowed", accessToken: "fixture-allowed-token", isActive: true, testStatus: "active", priority: 10, providerSpecificData: { workspaceId: "fixture-allowed-account", workspacePlanType: "plus" } }); - assert.equal(typeof allowed.id, "string"); - if (typeof allowed.id !== "string") assert.fail("Fixture connection ID missing"); - const comboName = "fixture-native-image-combo"; - await createCombo({ name: comboName, strategy: "priority", models: ["cx/gpt-image-2", "cx/gpt-image-2.5-sunburst"] }); - const key = await createApiKey("fixture-image-key", "fixture-machine", [], { allowedConnections: [allowed.id], allowedCombos: [comboName] }); - const headers = { "content-type": "application/json", authorization: `Bearer ${key.key}` }; - let calls = 0; - globalThis.fetch = async (_url, init) => { - calls++; - assert.equal(new Headers(init?.headers).get("authorization"), "Bearer fixture-allowed-token"); - assert.equal(new Headers(init?.headers).get("chatgpt-account-id"), "fixture-allowed-account"); - return Response.json(payload); - }; - const request = () => new Request("http://localhost/api/v1/images/generations", { method: "POST", headers, body: JSON.stringify({ model: comboName, prompt: "fixture" }) }); - const response = await generate(request()); - assert.equal(response.status, 200, JSON.stringify(await response.clone().json())); - assert.equal(calls, 1); - await providers.updateProviderConnection(allowed.id, { expiresAt: "2000-01-01T00:00:00.000Z", refreshToken: "fixture-combo-refresh" }); - let refreshCalls = 0; - calls = 0; - globalThis.fetch = async (url, init) => { - if (String(url) === "https://auth.openai.com/oauth/token") { - refreshCalls++; - assert.equal(new URLSearchParams(String(init?.body)).get("refresh_token"), "fixture-combo-refresh"); - return Response.json({ error: "invalid_grant" }, { status: 400 }); - } - calls++; - return Response.json(payload); - }; - const rejected = await generate(request()); - assert.equal(rejected.status, 401); - assert.equal(refreshCalls, 1); - assert.equal(calls, 0); - refreshCalls = 0; - const edit = await POST(new Request("http://localhost/api/v1/images/edits", { method: "POST", headers, body: JSON.stringify({ model: comboName, prompt: "fixture", image: DATA_URL }) })); - assert.equal(edit.status, 401); - assert.equal(refreshCalls, 1); - assert.equal(calls, 0); - await providers.updateProviderConnection(allowed.id, { isActive: false }); - const blocked = await generate(request()); - assert.equal(blocked.status, 403); - assert.equal(calls, 0); -}); +test( + "public image combos enforce connection allowlists and fail selected refresh without another submission", + { timeout: 5000 }, + async () => { + const allowed = await providers.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: "fixture-allowed", + accessToken: "fixture-allowed-token", + isActive: true, + testStatus: "active", + priority: 10, + providerSpecificData: { workspaceId: "fixture-allowed-account", workspacePlanType: "plus" }, + }); + assert.equal(typeof allowed.id, "string"); + if (typeof allowed.id !== "string") assert.fail("Fixture connection ID missing"); + const comboName = "fixture-native-image-combo"; + await createCombo({ + name: comboName, + strategy: "priority", + models: ["cx/gpt-image-2", "cx/gpt-image-2.5-sunburst"], + }); + const key = await createApiKey("fixture-image-key", "fixture-machine", [], { + allowedConnections: [allowed.id], + allowedCombos: [comboName], + }); + const headers = { "content-type": "application/json", authorization: `Bearer ${key.key}` }; + let calls = 0; + globalThis.fetch = async (_url, init) => { + calls++; + assert.equal(new Headers(init?.headers).get("authorization"), "Bearer fixture-allowed-token"); + assert.equal(new Headers(init?.headers).get("chatgpt-account-id"), "fixture-allowed-account"); + return Response.json(payload); + }; + const request = () => + new Request("http://localhost/api/v1/images/generations", { + method: "POST", + headers, + body: JSON.stringify({ model: comboName, prompt: "fixture" }), + }); + const response = await generate(request()); + assert.equal(response.status, 200, JSON.stringify(await response.clone().json())); + assert.equal(calls, 1); + await providers.updateProviderConnection(allowed.id, { + expiresAt: "2000-01-01T00:00:00.000Z", + refreshToken: "fixture-combo-refresh", + }); + let refreshCalls = 0; + calls = 0; + globalThis.fetch = async (url, init) => { + if (String(url) === "https://auth.openai.com/oauth/token") { + refreshCalls++; + assert.equal( + new URLSearchParams(String(init?.body)).get("refresh_token"), + "fixture-combo-refresh" + ); + return Response.json({ error: "invalid_grant" }, { status: 400 }); + } + calls++; + return Response.json(payload); + }; + const rejected = await generate(request()); + assert.equal(rejected.status, 401); + assert.equal(refreshCalls, 1); + assert.equal(calls, 0); + refreshCalls = 0; + const edit = await POST( + new Request("http://localhost/api/v1/images/edits", { + method: "POST", + headers, + body: JSON.stringify({ model: comboName, prompt: "fixture", image: DATA_URL }), + }) + ); + assert.equal(edit.status, 401); + assert.equal(refreshCalls, 1); + assert.equal(calls, 0); + await providers.updateProviderConnection(allowed.id, { isActive: false }); + await providers.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: "fixture-excluded", + accessToken: "fixture-excluded-token", + isActive: true, + testStatus: "active", + providerSpecificData: { workspacePlanType: "plus" }, + }); + const blocked = await generate(request()); + assert.equal(blocked.status, 403); + assert.equal(calls, 0); + } +); diff --git a/tests/unit/native-image-terminal-failure.test.ts b/tests/unit/native-image-terminal-failure.test.ts index 1e4f92a4813c..40cd02463226 100644 --- a/tests/unit/native-image-terminal-failure.test.ts +++ b/tests/unit/native-image-terminal-failure.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { resolve, relative } from "node:path"; +import "../_setup/nativeImageState.ts"; const workspace = resolve(".ci-work"); for (const key of ["DATA_DIR", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA"]) { @@ -11,33 +12,52 @@ for (const key of ["DATA_DIR", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA"] } const { runImageComboTargets } = await import("../../open-sse/services/imageCombo.ts"); const core = await import("../../src/lib/db/core.ts"); -const { executeImageWithCredentialFallback } = await import("../../src/sse/services/imageCredentialRetry.ts"); +const { executeImageWithCredentialFallback } = + await import("../../src/sse/services/imageCredentialRetry.ts"); test.after(() => core.resetDbInstance()); -test("image combos stop after a terminal submitted failure without another model", { timeout: 2000 }, async () => { - for (const status of [429, 502, 504]) { +test( + "image combos stop after a terminal submitted failure without another model", + { timeout: 2000 }, + async () => { + for (const status of [429, 502, 504]) { + let calls = 0; + const result = await runImageComboTargets([{ modelStr: "first" }, { modelStr: "second" }], { + resolveProvider: () => ({ provider: "fixture", model: "fixture-model" }), + resolveCredentials: async () => ({ connectionId: "fixture" }), + isRateLimited: () => false, + dispatch: async () => { + calls++; + return { success: false, status, error: "fixture outcome unknown", retryable: false }; + }, + }); + assert.equal(result.outcome, "terminal"); + assert.equal(calls, 1); + } + } +); + +test( + "explicit terminal image failure does not rotate the selected account", + { timeout: 2000 }, + async () => { let calls = 0; - const result = await runImageComboTargets([{ modelStr: "first" }, { modelStr: "second" }], { - resolveProvider: () => ({ provider: "fixture", model: "fixture-model" }), - resolveCredentials: async () => ({ connectionId: "fixture" }), - isRateLimited: () => false, - dispatch: async () => { calls++; return { success: false, status, error: "fixture outcome unknown", retryable: false }; }, + let selections = 0; + const result = await executeImageWithCredentialFallback({ + provider: "codex", + requestedModel: "codex/gpt-image-2", + credentials: { connectionId: "fixture-first", accessToken: "fixture-token" }, + execute: async () => { + calls++; + return { success: false, status: 401, error: "fixture failure", retryable: false }; + }, + selectNextCredentials: async () => { + selections++; + return null; + }, }); - assert.equal(result.outcome, "terminal"); + assert.equal(result.result.success, false); assert.equal(calls, 1); + assert.equal(selections, 0); } -}); - -test("explicit terminal image failure does not rotate the selected account", { timeout: 2000 }, async () => { - let calls = 0; - let selections = 0; - const result = await executeImageWithCredentialFallback({ - provider: "codex", requestedModel: "codex/gpt-image-2", - credentials: { connectionId: "fixture-first", accessToken: "fixture-token" }, - execute: async () => { calls++; return { success: false, status: 401, error: "fixture failure", retryable: false }; }, - selectNextCredentials: async () => { selections++; return null; }, - }); - assert.equal(result.result.success, false); - assert.equal(calls, 1); - assert.equal(selections, 0); -}); +); From 66855fd1c7cf44c28a34569eaaa29ec71e5821f1 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Mon, 12 Oct 2026 20:26:18 +0330 Subject: [PATCH 36/39] test: align shared image credential inventory --- tests/unit/hard-session-lease-bypass-inventory.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/unit/hard-session-lease-bypass-inventory.test.ts b/tests/unit/hard-session-lease-bypass-inventory.test.ts index 3b4b2b8f4875..64ea41d2297f 100644 --- a/tests/unit/hard-session-lease-bypass-inventory.test.ts +++ b/tests/unit/hard-session-lease-bypass-inventory.test.ts @@ -51,10 +51,9 @@ const EXPECTED: Record> = { // of the retirement-check one hoisted before enforceApiKeyPolicy) was // removed as dead redundant code, 6->5. #12653 added combo target // resolution with the same shape as imageCombo, 5->6. - // Firefly/combo selection moved to editDispatch; native Antigravity/Codex Images - // add two route calls. All eight retain the policy connection allowlist and - // preflight selector's unleased occupancy filter (class B, not managed dispatch). - "src/app/api/v1/images/edits/editDispatch.ts": 3, + // Firefly and hosted edits share selectImageEditCredentials; combo selection + // stays separate. Both retain the policy allowlist and unleased occupancy filter. + "src/app/api/v1/images/edits/editDispatch.ts": 2, "src/app/api/v1/images/edits/route.ts": 6, "src/app/api/v1/images/generations/route.ts": 3, "src/app/api/v1/images/upscale/route.ts": 1, From 8d4eaffdd5ba0946d555133bca82e999e73a783e Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Mon, 12 Oct 2026 20:36:52 +0330 Subject: [PATCH 37/39] test: distinguish inactive image account from denied scope --- tests/unit/native-codex-images-http.test.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/unit/native-codex-images-http.test.ts b/tests/unit/native-codex-images-http.test.ts index 2efd3a7abf8e..9fb09012ae90 100644 --- a/tests/unit/native-codex-images-http.test.ts +++ b/tests/unit/native-codex-images-http.test.ts @@ -431,8 +431,26 @@ test( testStatus: "active", providerSpecificData: { workspacePlanType: "plus" }, }); - const blocked = await generate(request()); + refreshCalls = 0; + const unavailable = await generate(request()); + assert.equal(unavailable.status, 400); + assert.match( + (await unavailable.json()).error.message, + /No credentials for image provider: codex/ + ); + assert.equal(calls, 0); + assert.equal(refreshCalls, 0); + const blockedKey = await createApiKey("fixture-blocked-image-key", "fixture-machine", [], { + allowedConnections: ["fixture-unconfigured-connection"], + allowedCombos: [comboName], + }); + const blocked = await generate( + new Request(request(), { + headers: { ...headers, authorization: `Bearer ${blockedKey.key}` }, + }) + ); assert.equal(blocked.status, 403); assert.equal(calls, 0); + assert.equal(refreshCalls, 0); } ); From 5f52c8c27da5eee9098918dce8ff885f6598c40f Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Mon, 12 Oct 2026 20:56:37 +0330 Subject: [PATCH 38/39] fix: isolate hosted Windows compiler cache --- scripts/check/image-runtime-package.mjs | 127 +++++++++++++++++++----- 1 file changed, 101 insertions(+), 26 deletions(-) diff --git a/scripts/check/image-runtime-package.mjs b/scripts/check/image-runtime-package.mjs index 2eb1eaa6d1fd..defaef195a96 100644 --- a/scripts/check/image-runtime-package.mjs +++ b/scripts/check/image-runtime-package.mjs @@ -2,7 +2,8 @@ import assert from "node:assert/strict"; import fs from "node:fs/promises"; import path from "node:path"; -import { spawn } from "node:child_process"; +import os from "node:os"; +import { spawn, spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { assemblePathSanitize } from "../build/assembleStandalone.mjs"; import { verifyImageArtifact } from "./image-artifact.mjs"; @@ -10,33 +11,63 @@ import { verifyImageArtifact } from "./image-artifact.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); assert.equal(process.platform, "win32", "Windows hosted packaging only"); assert.equal(process.env.GITHUB_ACTIONS, "true", "Local builds are not authorized"); +// The hosted workspace drive may fail SWC's ancestor ownership checks. +const compilerCache = await fs.mkdtemp(path.join(os.tmpdir(), "omniroute-image-swc-")); const work = path.join(root, ".ci-work/windows-runtime"); const home = path.join(work, "home"); const temp = path.join(work, "temp"); -const env = Object.fromEntries(["PATH", "Path", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT"].filter((key) => process.env[key]).map((key) => [key, process.env[key]])); +const env = Object.fromEntries( + ["PATH", "Path", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT"] + .filter((key) => process.env[key]) + .map((key) => [key, process.env[key]]) +); Object.assign(env, { - CI: "true", GITHUB_ACTIONS: "true", HOME: home, USERPROFILE: home, - APPDATA: path.join(home, "AppData/Roaming"), LOCALAPPDATA: path.join(home, "AppData/Local"), - DATA_DIR: path.join(work, "data"), TEMP: temp, TMP: temp, TMPDIR: temp, - npm_config_cache: path.join(work, "npm-cache"), HUSKY: "0", - OMNIROUTE_SKIP_POSTINSTALL: "1", OMNIROUTE_SKIP_SYSTEM_TRUST: "1", - OMNIROUTE_SKIP_DNS_WRITE: "1", ADOBE_FIREFLY_BROWSER_REFRESH: "0", - NEXT_TELEMETRY_DISABLED: "1", PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1", - OMNIROUTE_USE_TURBOPACK: "0", NEXT_DIST_DIR: ".build/next", - OMNIROUTE_BUILD_MEMORY_MB: "8192", NODE_OPTIONS: "--max-old-space-size=8192", - OMNIROUTE_DISABLE_BACKGROUND_SERVICES: "true", OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK: "true", + CI: "true", + GITHUB_ACTIONS: "true", + HOME: home, + USERPROFILE: home, + APPDATA: path.join(home, "AppData/Roaming"), + LOCALAPPDATA: path.join(home, "AppData/Local"), + DATA_DIR: path.join(work, "data"), + TEMP: temp, + TMP: temp, + TMPDIR: temp, + npm_config_cache: path.join(work, "npm-cache"), + HUSKY: "0", + OMNIROUTE_SKIP_POSTINSTALL: "1", + OMNIROUTE_SKIP_SYSTEM_TRUST: "1", + OMNIROUTE_SKIP_DNS_WRITE: "1", + ADOBE_FIREFLY_BROWSER_REFRESH: "0", + NEXT_TELEMETRY_DISABLED: "1", + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1", + OMNIROUTE_USE_TURBOPACK: "0", + NEXT_DIST_DIR: ".build/next", + OMNIROUTE_BUILD_MEMORY_MB: "8192", + NODE_OPTIONS: "--max-old-space-size=8192", + OMNIROUTE_DISABLE_BACKGROUND_SERVICES: "true", + OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK: "true", + SWC_NATIVE_BINDING_CACHE: compilerCache, }); async function run(label, args, wall, idle) { const npm = path.join(path.dirname(process.execPath), "node_modules/npm/bin/npm-cli.js"); await fs.access(npm); - const child = spawn(process.execPath, [npm, ...args], { cwd: root, env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); + const child = spawn(process.execPath, [npm, ...args], { + cwd: root, + env, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); let failure; let idleTimer; const kill = (reason) => { if (failure) return; failure = new Error(`${label}: ${reason}`); - if (child.pid) spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }); + if (child.pid) + spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { + windowsHide: true, + stdio: "ignore", + }); }; const progress = (data) => { clearTimeout(idleTimer); @@ -45,19 +76,39 @@ async function run(label, args, wall, idle) { }; const wallTimer = setTimeout(() => kill("wall timeout"), wall * 1000); idleTimer = setTimeout(() => kill("idle timeout"), idle * 1000); - child.stdout.on("data", progress); child.stderr.on("data", progress); - console.error(`[${new Date().toISOString()}] [INFO] [runtime-package] start=${label} pid=${child.pid}`); + child.stdout.on("data", progress); + child.stderr.on("data", progress); + console.error( + `[${new Date().toISOString()}] [INFO] [runtime-package] start=${label} pid=${child.pid}` + ); try { - const code = await new Promise((resolve, reject) => { child.once("error", reject); child.once("close", resolve); }); + const code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); if (failure) throw failure; assert.equal(code, 0, `${label} failed`); - } finally { clearTimeout(wallTimer); clearTimeout(idleTimer); } + } finally { + clearTimeout(wallTimer); + clearTimeout(idleTimer); + } } try { - for (const dir of [home, temp, env.APPDATA, env.LOCALAPPDATA, env.DATA_DIR]) await fs.mkdir(dir, { recursive: true }); + for (const dir of [home, temp, env.APPDATA, env.LOCALAPPDATA, env.DATA_DIR]) + await fs.mkdir(dir, { recursive: true }); Object.assign(process.env, env); await run("locked Windows install", ["ci", "--no-audit", "--no-fund"], 600, 180); + // A child releases the native DLL before the owned cache is removed on Windows. + const compilerAdmission = spawnSync( + process.execPath, + [ + "-e", + "require('@swc/core').transformSync('const nativeBinding = 1;', { jsc: { target: 'es2022' } });", + ], + { cwd: root, env, windowsHide: true, stdio: "inherit", timeout: 60000 } + ); + assert.equal(compilerAdmission.status, 0, "Native compiler cache admission failed"); await run("full build with canonical postbuild", ["run", "build"], 1800, 600); const out = path.join(root, ".build/next/standalone"); assemblePathSanitize(root, out, ".build/next"); @@ -71,21 +122,45 @@ try { await fs.writeFile(file, text, "utf8"); } await fs.writeFile(path.join(out, "BUILD_SHA"), process.env.GITHUB_SHA ?? "unknown", "utf8"); - const workers = ["src/lib/db/healthCheckWorker.js", "src/lib/usage/callLogArtifactWorker.js", "open-sse/services/compression/compressionWorker.js"]; + const workers = [ + "src/lib/db/healthCheckWorker.js", + "src/lib/usage/callLogArtifactWorker.js", + "open-sse/services/compression/compressionWorker.js", + ]; for (const worker of workers) { const file = path.join(out, worker); assert.ok((await fs.stat(file)).size > 0, `Missing worker ${worker}`); - const pkg = JSON.parse(await fs.readFile(path.join(path.dirname(file), "package.json"), "utf8")); + const pkg = JSON.parse( + await fs.readFile(path.join(path.dirname(file), "package.json"), "utf8") + ); assert.equal(pkg.type, "module", `Missing worker ESM scope ${worker}`); } - const manifest = JSON.parse(await fs.readFile(path.join(out, ".build/next/server/app-paths-manifest.json"), "utf8")); - assert.ok(Object.keys(manifest).some((route) => route.includes("dashboard") && route.endsWith("/page")), "Full dashboard absent"); - for (const file of ["server-ws.mjs", "node_modules/sql.js/dist/sql-wasm.wasm", "node_modules/tiktoken/tiktoken_bg.wasm"]) assert.ok((await fs.stat(path.join(out, file))).size > 0, `Missing runtime asset ${file}`); + const manifest = JSON.parse( + await fs.readFile(path.join(out, ".build/next/server/app-paths-manifest.json"), "utf8") + ); + assert.ok( + Object.keys(manifest).some((route) => route.includes("dashboard") && route.endsWith("/page")), + "Full dashboard absent" + ); + for (const file of [ + "server-ws.mjs", + "node_modules/sql.js/dist/sql-wasm.wasm", + "node_modules/tiktoken/tiktoken_bg.wasm", + ]) + assert.ok((await fs.stat(path.join(out, file))).size > 0, `Missing runtime asset ${file}`); const native = path.join(out, "node_modules/keytar/build/Release/keytar.node"); const bytes = await fs.readFile(native); assert.equal(bytes.subarray(0, 2).toString("ascii"), "MZ", "Windows native module is not PE"); const pkg = JSON.parse(await fs.readFile(path.join(out, "package.json"), "utf8")); assert.notEqual(pkg.type, "module", "Standalone server requires CommonJS root"); await verifyImageArtifact(root); - console.error(`[${new Date().toISOString()}] [INFO] [runtime-package] full Windows runtime verified`); -} finally { await fs.rm(work, { recursive: true, force: true }); } + console.error( + `[${new Date().toISOString()}] [INFO] [runtime-package] full Windows runtime verified` + ); +} finally { + try { + await fs.rm(work, { recursive: true, force: true }); + } finally { + await fs.rm(compilerCache, { recursive: true, force: true }); + } +} From b9853e0dae276093d13e65c02d4e53a2d44fc155 Mon Sep 17 00:00:00 2001 From: KiaroSama Date: Sat, 17 Oct 2026 22:07:54 +0330 Subject: [PATCH 39/39] fix: align streaming credential types --- src/lib/streamingCredentialTransform.ts | 18 +-- .../streaming-credential-gate-types.test.mjs | 62 ++++++++ .../streaming-credential-transform.test.ts | 140 +++++++++++++----- 3 files changed, 170 insertions(+), 50 deletions(-) create mode 100644 tests/unit/streaming-credential-gate-types.test.mjs diff --git a/src/lib/streamingCredentialTransform.ts b/src/lib/streamingCredentialTransform.ts index 723c6511e7c2..1f664469d4e9 100644 --- a/src/lib/streamingCredentialTransform.ts +++ b/src/lib/streamingCredentialTransform.ts @@ -1,5 +1,6 @@ import { createSseTextTransform, type FieldCategory } from "./sseTextTransform"; import { redactCredentials } from "./guardrails/credentialMasker"; +import type { resolveDisabledGuardrails } from "./guardrails/registry"; export interface CredentialTransformOptions { windowSize?: number; @@ -49,17 +50,12 @@ export function isCredentialTransformEnabled(gate: CredentialGate): boolean { * `resolveDisabledGuardrails` default to the production readers; tests inject * fakes. Fail-open to the historical behavior on reader errors. */ -export async function resolveCredentialGate(args: { - getSettings?: () => Promise<{ credentialRedactionEnabled?: unknown } | null>; - resolveDisabledGuardrails?: (input: { - apiKeyInfo?: unknown; - body?: unknown; - headers?: unknown; - }) => string[]; - apiKeyInfo?: unknown; - body?: unknown; - headers?: unknown; -}): Promise { +export async function resolveCredentialGate( + args: Parameters[0] & { + getSettings?: () => Promise<{ credentialRedactionEnabled?: unknown } | null>; + resolveDisabledGuardrails?: typeof resolveDisabledGuardrails; + } +): Promise { let enabled = false; try { const reader = args.getSettings ?? (await import("./db/settings")).getSettings; diff --git a/tests/unit/streaming-credential-gate-types.test.mjs b/tests/unit/streaming-credential-gate-types.test.mjs new file mode 100644 index 000000000000..38d9ccc05ab9 --- /dev/null +++ b/tests/unit/streaming-credential-gate-types.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const root = fileURLToPath(new URL("../../", import.meta.url)); + +test("streaming credential gate compiles with its production resolver", { timeout: 60000 }, () => { + const config = ts.readConfigFile(path.join(root, "tsconfig.json"), ts.sys.readFile); + assert.equal(config.error, undefined); + const options = ts.convertCompilerOptionsFromJson(config.config.compilerOptions, root); + assert.deepEqual(options.errors, []); + const gate = path.join(root, "src/lib/streamingCredentialTransform.ts"); + const registry = path.join(root, "src/lib/guardrails/registry.ts"); + const fixture = path.join(root, "tests/unit/streaming-credential-gate-contract.ts"); + const input = [ + 'import { resolveCredentialGate } from "../../src/lib/streamingCredentialTransform.ts";', + 'import { resolveDisabledGuardrails } from "../../src/lib/guardrails/registry.ts";', + "export function forward(input: Parameters[0]) {", + " return resolveDisabledGuardrails(input);", + "}", + ].join("\n"); + const compilerOptions = { + ...options.options, + types: ["node"], + noCheck: false, + noEmit: true, + incremental: false, + }; + const host = ts.createCompilerHost(compilerOptions); + const getSourceFile = host.getSourceFile.bind(host); + const sameFixture = (file) => ts.normalizePath(file) === ts.normalizePath(fixture); + host.getSourceFile = (file, languageVersion, onError, shouldCreateNewSourceFile) => + sameFixture(file) + ? ts.createSourceFile(file, input, languageVersion, true) + : getSourceFile(file, languageVersion, onError, shouldCreateNewSourceFile); + const program = ts.createProgram( + [fixture, path.join(root, "src/types/global.d.ts")], + compilerOptions, + host + ); + assert.ok(program.getSourceFile(gate), "Compiler must load the real streaming gate"); + assert.ok(program.getSourceFile(registry), "Compiler must load the production resolver"); + const source = program.getSourceFile(fixture); + assert.ok(source, "Compiler must load the caller contract"); + // A caller must be able to forward the gate's request fields to the production resolver. + const diagnostics = [ + ...program.getOptionsDiagnostics(), + ...program.getSyntacticDiagnostics(source), + ...program.getSemanticDiagnostics(source), + ]; + assert.equal( + diagnostics.length, + 0, + ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: (file) => file, + getCurrentDirectory: () => root, + getNewLine: () => "\n", + }) + ); +}); diff --git a/tests/unit/streaming-credential-transform.test.ts b/tests/unit/streaming-credential-transform.test.ts index 83aba69f0811..011c3aaec426 100644 --- a/tests/unit/streaming-credential-transform.test.ts +++ b/tests/unit/streaming-credential-transform.test.ts @@ -4,12 +4,31 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +const originalDataDir = process.env.DATA_DIR; +const originalFileLogging = process.env.APP_LOG_TO_FILE; +const originalPluginsDir = process.env.OMNIROUTE_PLUGINS_DIR; const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-streaming-credential-")); process.env.DATA_DIR = tmpDir; +process.env.APP_LOG_TO_FILE = "false"; +process.env.OMNIROUTE_PLUGINS_DIR = path.join(tmpDir, "plugins"); const { createCredentialSseTransform, findCredentialPartialTailIndex } = await import("../../src/lib/streamingCredentialTransform.ts"); -import { redactCredentials } from "../../src/lib/guardrails/credentialMasker.ts"; +const { redactCredentials } = await import("../../src/lib/guardrails/credentialMasker.ts"); +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); + +test.after(() => { + resetDbInstance(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + for (const [key, value] of [ + ["DATA_DIR", originalDataDir], + ["APP_LOG_TO_FILE", originalFileLogging], + ["OMNIROUTE_PLUGINS_DIR", originalPluginsDir], + ]) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); async function runTransform(transform: TransformStream, inputChunks: string[]): Promise { const writer = transform.writable.getWriter(); @@ -223,42 +242,85 @@ test("partial-tail probe finds a truncated fixed prefix touching the buffer end" assert.equal(findCredentialPartialTailIndex("data AKIAIOSFODNN7EX"), 5); }); -test("gate resolution honors the flag, the env fallback, and the opt-out", async () => { - const { resolveCredentialGate, isCredentialTransformEnabled } = - await import("../../src/lib/streamingCredentialTransform.ts"); - const on = await resolveCredentialGate({ - getSettings: async () => ({ credentialRedactionEnabled: true }), - resolveDisabledGuardrails: () => [], - }); - assert.equal(on.credentialRedactionEnabled, true); - assert.equal( - isCredentialTransformEnabled({ - credentialRedactionEnabled: on.credentialRedactionEnabled, - credentialDisabledGuardrails: on.credentialDisabledGuardrails, - }), - true - ); - const optedOut = await resolveCredentialGate({ - getSettings: async () => ({ credentialRedactionEnabled: true }), - resolveDisabledGuardrails: () => ["credential-masker"], - }); - assert.equal( - isCredentialTransformEnabled({ - credentialRedactionEnabled: optedOut.credentialRedactionEnabled, - credentialDisabledGuardrails: optedOut.credentialDisabledGuardrails, - }), - false - ); - const original = process.env.CREDENTIAL_REDACTION_ENABLED; - process.env.CREDENTIAL_REDACTION_ENABLED = "true"; - try { - const viaEnv = await resolveCredentialGate({ - getSettings: async () => ({ credentialRedactionEnabled: false }), - resolveDisabledGuardrails: () => [], - }); - assert.equal(viaEnv.credentialRedactionEnabled, true); - } finally { - if (original === undefined) delete process.env.CREDENTIAL_REDACTION_ENABLED; - else process.env.CREDENTIAL_REDACTION_ENABLED = original; +test( + "gate resolution honors request fields, flags, reader failures and opt-out", + { timeout: 5000 }, + async () => { + const { resolveCredentialGate, isCredentialTransformEnabled } = + await import("../../src/lib/streamingCredentialTransform.ts"); + const { resolveDisabledGuardrails } = await import("../../src/lib/guardrails/registry.ts"); + const original = process.env.CREDENTIAL_REDACTION_ENABLED; + delete process.env.CREDENTIAL_REDACTION_ENABLED; + try { + const on = await resolveCredentialGate({ + getSettings: async () => ({ credentialRedactionEnabled: true }), + resolveDisabledGuardrails: () => [], + }); + assert.equal(on.credentialRedactionEnabled, true); + assert.equal(isCredentialTransformEnabled(on), true); + + const apiKeyInfo = { disabledGuardrails: ["prompt-injection"] }; + const body = { metadata: { disabledGuardrails: "pii-masker" } }; + const headers = new Headers({ "x-omniroute-disabled-guardrails": "credential-masker" }); + const optedOut = await resolveCredentialGate({ + getSettings: async () => ({ credentialRedactionEnabled: true }), + apiKeyInfo, + body, + headers, + resolveDisabledGuardrails: (input) => { + assert.equal(input.apiKeyInfo, apiKeyInfo); + assert.equal(input.body, body); + assert.equal(input.headers, headers); + return resolveDisabledGuardrails(input); + }, + }); + assert.deepEqual(optedOut.credentialDisabledGuardrails, [ + "prompt-injection", + "pii-masker", + "credential-masker", + ]); + assert.equal(isCredentialTransformEnabled(optedOut), false); + + for (const requestHeaders of [headers, { "X-Disabled-Guardrails": "credential-masker" }]) { + const defaultResolver = await resolveCredentialGate({ + getSettings: async () => ({ credentialRedactionEnabled: true }), + headers: requestHeaders, + }); + assert.deepEqual(defaultResolver.credentialDisabledGuardrails, ["credential-masker"]); + assert.equal(isCredentialTransformEnabled(defaultResolver), false); + } + + const failedReaders = await resolveCredentialGate({ + getSettings: async () => { + throw new Error("fixture settings unavailable"); + }, + resolveDisabledGuardrails: () => { + throw new Error("fixture resolver unavailable"); + }, + }); + assert.deepEqual(failedReaders, { + credentialRedactionEnabled: false, + credentialDisabledGuardrails: [], + }); + assert.equal(isCredentialTransformEnabled(failedReaders), false); + + process.env.CREDENTIAL_REDACTION_ENABLED = "true"; + for (const getSettings of [ + async () => ({ credentialRedactionEnabled: false }), + async () => { + throw new Error("fixture settings unavailable"); + }, + ]) { + const viaEnv = await resolveCredentialGate({ + getSettings, + resolveDisabledGuardrails: () => [], + }); + assert.equal(viaEnv.credentialRedactionEnabled, true); + assert.equal(isCredentialTransformEnabled(viaEnv), true); + } + } finally { + if (original === undefined) delete process.env.CREDENTIAL_REDACTION_ENABLED; + else process.env.CREDENTIAL_REDACTION_ENABLED = original; + } } -}); +);