From 7b51ccd9e4df519dd0a76011b9721098c9591dfe Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 16 Apr 2026 20:53:35 -0300 Subject: [PATCH] feat(antigravity): add client model aliases and signature bypass modes Expose client-visible Antigravity preview model aliases while resolving them back to upstream IDs for execution and provider discovery. Refresh the Antigravity user agent from cached latest release metadata so model discovery and requests track current CLI versions more reliably. Add configurable Gemini thought signature cache modes in settings to allow validated client-provided signatures in bypass flows while preserving the existing stored-signature behavior by default. Also centralize Anthropics header/version constants, enrich image model catalog metadata with input and output modalities, add dashboard image input support for advanced image providers, and exclude task docs from Next standalone tracing to keep isolated builds stable. --- next.config.mjs | 5 + open-sse/config/anthropicHeaders.ts | 32 ++++ open-sse/config/antigravityModelAliases.ts | 48 ++++++ open-sse/config/antigravityUpstream.ts | 2 +- open-sse/config/glmProvider.ts | 6 +- open-sse/config/imageRegistry.ts | 131 ++++++++++++--- open-sse/config/providerRegistry.ts | 39 +++-- open-sse/executors/antigravity.ts | 21 ++- open-sse/handlers/chatCore.ts | 2 + open-sse/services/antigravityHeaders.ts | 14 +- open-sse/services/antigravityVersion.ts | 141 ++++++++++++++++ open-sse/services/claudeCodeCompatible.ts | 21 ++- .../services/geminiThoughtSignatureStore.ts | 120 ++++++++++++++ open-sse/services/model.ts | 3 +- .../translator/request/openai-to-gemini.ts | 21 ++- scripts/build-next-isolated.mjs | 41 ++++- .../dashboard/cache/media/MediaPageClient.tsx | 156 +++++++++++++++--- .../settings/components/RoutingTab.tsx | 69 ++++++++ src/app/api/providers/[id]/models/route.ts | 26 ++- src/app/api/settings/route.ts | 5 + src/app/api/v1/images/generations/route.ts | 5 + src/app/api/v1/models/catalog.ts | 3 + src/lib/db/settings.ts | 1 + src/lib/modelAliasSeed.ts | 1 + src/shared/constants/modelSpecs.ts | 7 +- src/shared/validation/settingsSchemas.ts | 3 + tests/unit/antigravity-model-aliases.test.ts | 50 ++++++ tests/unit/antigravity-version.test.ts | 94 +++++++++++ .../claude-code-compatible-helpers.test.ts | 4 +- tests/unit/claude-code-parity.test.ts | 18 +- tests/unit/db-settings-crud.test.ts | 2 + tests/unit/image-generation-handler.test.ts | 4 + tests/unit/model-alias-seed.test.ts | 1 + tests/unit/models-catalog-route.test.ts | 19 +++ tests/unit/provider-models-route.test.ts | 45 +++-- tests/unit/settings-api.test.ts | 14 ++ tests/unit/signature-cache-bypass.test.ts | 67 ++++++++ tests/unit/t28-model-catalog-updates.test.ts | 8 +- .../unit/t31-t33-t34-t38-model-specs.test.ts | 10 +- 39 files changed, 1129 insertions(+), 130 deletions(-) create mode 100644 open-sse/config/anthropicHeaders.ts create mode 100644 open-sse/config/antigravityModelAliases.ts create mode 100644 open-sse/services/antigravityVersion.ts create mode 100644 tests/unit/antigravity-model-aliases.test.ts create mode 100644 tests/unit/antigravity-version.test.ts create mode 100644 tests/unit/signature-cache-bypass.test.ts diff --git a/next.config.mjs b/next.config.mjs index 18a2be156d..fc6a4d2d6c 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -14,6 +14,11 @@ const nextConfig = { }, }, output: "standalone", + outputFileTracingExcludes: { + // Planning/task docs are not runtime assets and can break standalone copies + // when broad fs/path tracing pulls the whole repository into the NFT graph. + "/*": ["./_tasks/**/*"], + }, serverExternalPackages: [ "pino", "pino-pretty", diff --git a/open-sse/config/anthropicHeaders.ts b/open-sse/config/anthropicHeaders.ts new file mode 100644 index 0000000000..efb3986bf8 --- /dev/null +++ b/open-sse/config/anthropicHeaders.ts @@ -0,0 +1,32 @@ +export const ANTHROPIC_VERSION_HEADER = "2023-06-01"; + +const ANTHROPIC_BETA_BASE = Object.freeze([ + "claude-code-20250219", + "oauth-2025-04-20", + "interleaved-thinking-2025-05-14", + "context-management-2025-06-27", + "prompt-caching-scope-2026-01-05", + "advanced-tool-use-2025-11-20", + "effort-2025-11-24", + "structured-outputs-2025-12-15", + "fast-mode-2026-02-01", + "redact-thinking-2026-02-12", + "token-efficient-tools-2026-03-28", +]); + +const CLAUDE_OAUTH_EXTRA_BETAS = Object.freeze(["fine-grained-tool-streaming-2025-05-14"]); + +export const ANTHROPIC_BETA_FULL = ANTHROPIC_BETA_BASE.join(","); +export const ANTHROPIC_BETA_API_KEY = ANTHROPIC_BETA_BASE.filter( + (beta) => beta !== "oauth-2025-04-20" +).join(","); +export const ANTHROPIC_BETA_CLAUDE_OAUTH = [ + ...ANTHROPIC_BETA_BASE.slice(0, 3), + ...CLAUDE_OAUTH_EXTRA_BETAS, + ...ANTHROPIC_BETA_BASE.slice(3), +].join(","); + +export const CLAUDE_CLI_VERSION = "2.1.92"; +export const CLAUDE_CLI_USER_AGENT = `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`; +export const CLAUDE_CLI_STAINLESS_PACKAGE_VERSION = "0.80.0"; +export const CLAUDE_CLI_STAINLESS_RUNTIME_VERSION = "v24.14.0"; diff --git a/open-sse/config/antigravityModelAliases.ts b/open-sse/config/antigravityModelAliases.ts new file mode 100644 index 0000000000..4d6f748766 --- /dev/null +++ b/open-sse/config/antigravityModelAliases.ts @@ -0,0 +1,48 @@ +export const ANTIGRAVITY_MODEL_ALIASES = Object.freeze({ + "gemini-3-pro-preview": "gemini-3.1-pro-high", + "gemini-3-flash-preview": "gemini-3-flash", + "gemini-3-pro-image-preview": "gemini-3-pro-image", + "gemini-2.5-computer-use-preview-10-2025": "rev19-uic3-1p", + "gemini-claude-sonnet-4-5": "claude-sonnet-4-5", + "gemini-claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking", + "gemini-claude-opus-4-5-thinking": "claude-opus-4-5-thinking", +}); + +export const ANTIGRAVITY_REVERSE_MODEL_ALIASES = Object.freeze( + Object.entries(ANTIGRAVITY_MODEL_ALIASES).reduce>( + (acc, [alias, target]) => { + if (!acc[target]) { + acc[target] = alias; + } + return acc; + }, + {} + ) +); + +const CLIENT_VISIBLE_MODEL_NAMES = Object.freeze({ + "gemini-3-pro-preview": "Gemini 3 Pro Preview", + "gemini-3-flash-preview": "Gemini 3 Flash Preview", + "gemini-3-pro-image-preview": "Gemini 3 Pro Image Preview", + "gemini-2.5-computer-use-preview-10-2025": "Gemini 2.5 Computer Use Preview (10/2025)", + "gemini-claude-sonnet-4-5": "Claude Sonnet 4.5 (Gemini Route)", + "gemini-claude-sonnet-4-5-thinking": "Claude Sonnet 4.5 Thinking (Gemini Route)", + "gemini-claude-opus-4-5-thinking": "Claude Opus 4.5 Thinking (Gemini Route)", +}); + +export function resolveAntigravityModelId(modelId: string): string { + if (!modelId) return modelId; + return ANTIGRAVITY_MODEL_ALIASES[modelId] || modelId; +} + +export function toClientAntigravityModelId(modelId: string): string { + if (!modelId) return modelId; + return ANTIGRAVITY_REVERSE_MODEL_ALIASES[modelId] || modelId; +} + +export function getClientVisibleAntigravityModelName( + modelId: string, + fallbackName?: string +): string { + return CLIENT_VISIBLE_MODEL_NAMES[modelId] || fallbackName || modelId; +} diff --git a/open-sse/config/antigravityUpstream.ts b/open-sse/config/antigravityUpstream.ts index a4ca845301..e020723d15 100644 --- a/open-sse/config/antigravityUpstream.ts +++ b/open-sse/config/antigravityUpstream.ts @@ -1,7 +1,7 @@ export const ANTIGRAVITY_BASE_URLS = Object.freeze([ + "https://cloudcode-pa.googleapis.com", "https://daily-cloudcode-pa.googleapis.com", "https://daily-cloudcode-pa.sandbox.googleapis.com", - "https://cloudcode-pa.googleapis.com", ]); const ANTIGRAVITY_MODELS_PATH = "/v1internal:models"; diff --git a/open-sse/config/glmProvider.ts b/open-sse/config/glmProvider.ts index afef06a7e7..f74d537dbe 100644 --- a/open-sse/config/glmProvider.ts +++ b/open-sse/config/glmProvider.ts @@ -1,10 +1,12 @@ +import { ANTHROPIC_BETA_API_KEY, ANTHROPIC_VERSION_HEADER } from "./anthropicHeaders.ts"; + type JsonRecord = Record; export type GlmApiRegion = "international" | "china"; export const GLM_SHARED_HEADERS = Object.freeze({ - "Anthropic-Version": "2023-06-01", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": ANTHROPIC_VERSION_HEADER, + "Anthropic-Beta": ANTHROPIC_BETA_API_KEY, }); export const GLM_SHARED_MODELS = Object.freeze([ diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 6ac8cb6eec..f5df635ec5 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -5,36 +5,71 @@ * Each provider has its own request format and endpoint. */ -const IMAGE_MODEL_ALIASES = { +interface ImageModelEntry { + id: string; + name: string; + inputModalities?: string[]; + description?: string; +} + +interface ImageProviderConfig { + id: string; + baseUrl: string; + fallbackUrl?: string; + proUrl?: string; + statusUrl?: string; + alias?: string; + authType: string; + authHeader: string; + format: string; + models: ImageModelEntry[]; + supportedSizes: string[]; +} + +interface ImageModelAliasEntry { + provider: string; + model: string; + name: string; + listInCatalog: boolean; + inputModalities?: string[]; + description?: string; +} + +const IMAGE_MODEL_ALIASES: Record = { "flux-kontext": { provider: "pollinations", model: "flux-kontext", name: "FLUX Kontext", listInCatalog: true, + inputModalities: ["text", "image"], }, "flux-kontext-max": { provider: "together", model: "black-forest-labs/FLUX.1.1-pro", name: "FLUX Kontext Max", listInCatalog: true, + inputModalities: ["text", "image"], }, "flux-redux": { provider: "together", model: "black-forest-labs/FLUX.1-redux", name: "FLUX Redux", listInCatalog: true, + inputModalities: ["text", "image"], }, "flux-depth": { provider: "together", model: "black-forest-labs/FLUX.1-depth", name: "FLUX Depth", listInCatalog: true, + inputModalities: ["text", "image"], }, "flux-canny": { provider: "together", model: "black-forest-labs/FLUX.1-canny", name: "FLUX Canny", listInCatalog: true, + inputModalities: ["text", "image"], }, "flux-dev-lora": { provider: "together", @@ -67,7 +102,7 @@ function findImageModelConfig(providerId, modelId) { return provider.models.find((model) => model.id === modelId) || null; } -export const IMAGE_PROVIDERS = { +export const IMAGE_PROVIDERS: Record = { openai: { id: "openai", baseUrl: "https://api.openai.com/v1/images/generations", @@ -99,12 +134,32 @@ export const IMAGE_PROVIDERS = { authHeader: "bearer", format: "openai", models: [ - { id: "black-forest-labs/FLUX.1.1-pro", name: "FLUX 1.1 Pro" }, + { + id: "black-forest-labs/FLUX.1.1-pro", + name: "FLUX 1.1 Pro", + inputModalities: ["text", "image"], + description: "Advanced contextual image editing and image-to-image generation", + }, { id: "black-forest-labs/FLUX.1-schnell-Free", name: "FLUX 1 Schnell (Free)" }, { id: "stabilityai/stable-diffusion-xl-base-1.0", name: "SDXL Base 1.0" }, - { id: "black-forest-labs/FLUX.1-redux", name: "FLUX.1 Redux" }, - { id: "black-forest-labs/FLUX.1-depth", name: "FLUX.1 Depth" }, - { id: "black-forest-labs/FLUX.1-canny", name: "FLUX.1 Canny" }, + { + id: "black-forest-labs/FLUX.1-redux", + name: "FLUX.1 Redux", + inputModalities: ["text", "image"], + description: "Generate prompt-guided variations from an input image", + }, + { + id: "black-forest-labs/FLUX.1-depth", + name: "FLUX.1 Depth", + inputModalities: ["text", "image"], + description: "Depth-conditioned image generation from an input image", + }, + { + id: "black-forest-labs/FLUX.1-canny", + name: "FLUX.1 Canny", + inputModalities: ["text", "image"], + description: "Canny edge guided image generation from an input image", + }, { id: "black-forest-labs/FLUX.1-dev-lora", name: "FLUX.1 Dev LoRA" }, ], supportedSizes: ["1024x1024", "512x512"], @@ -231,8 +286,18 @@ export const IMAGE_PROVIDERS = { { id: "gptimage", name: "GPT Image 1 Mini" }, { id: "qwen-image", name: "Qwen Image Plus" }, { id: "wan-image", name: "Wan 2.7 Image" }, - { id: "flux-kontext", name: "FLUX.1 Kontext" }, - { id: "flux-kontext-max", name: "FLUX.1 Kontext Max" }, + { + id: "flux-kontext", + name: "FLUX.1 Kontext", + inputModalities: ["text", "image"], + description: "Context-aware image editing with optional source image", + }, + { + id: "flux-kontext-max", + name: "FLUX.1 Kontext Max", + inputModalities: ["text", "image"], + description: "Higher quality Kontext editing with optional source image", + }, { id: "gptimage-large", name: "GPT Image 1.5" }, ], supportedSizes: ["1024x1024", "512x512"], @@ -277,20 +342,24 @@ export const IMAGE_PROVIDERS = { { id: "sd3.5-medium", name: "sd3.5-medium" }, { id: "stable-image-ultra", name: "Stable Image Ultra" }, { id: "stable-image-core", name: "Stable Image Core" }, - { id: "inpaint", name: "Inpaint" }, - { id: "outpaint", name: "Outpaint" }, - { id: "erase", name: "Erase" }, - { id: "search-and-replace", name: "Search and Replace" }, - { id: "search-and-recolor", name: "Search and Recolor" }, - { id: "remove-background", name: "Remove Background" }, - { id: "replace-background-and-relight", name: "Replace Background and Relight" }, - { id: "fast", name: "Fast Upscale" }, - { id: "conservative", name: "Conservative Upscale" }, - { id: "creative", name: "Creative Upscale" }, - { id: "sketch", name: "Sketch Control" }, - { id: "structure", name: "Structure Control" }, - { id: "style", name: "Style Control" }, - { id: "style-transfer", name: "Style Transfer" }, + { id: "inpaint", name: "Inpaint", inputModalities: ["text", "image"] }, + { id: "outpaint", name: "Outpaint", inputModalities: ["text", "image"] }, + { id: "erase", name: "Erase", inputModalities: ["image"] }, + { id: "search-and-replace", name: "Search and Replace", inputModalities: ["text", "image"] }, + { id: "search-and-recolor", name: "Search and Recolor", inputModalities: ["text", "image"] }, + { id: "remove-background", name: "Remove Background", inputModalities: ["image"] }, + { + id: "replace-background-and-relight", + name: "Replace Background and Relight", + inputModalities: ["text", "image"], + }, + { id: "fast", name: "Fast Upscale", inputModalities: ["image"] }, + { id: "conservative", name: "Conservative Upscale", inputModalities: ["image"] }, + { id: "creative", name: "Creative Upscale", inputModalities: ["text", "image"] }, + { id: "sketch", name: "Sketch Control", inputModalities: ["text", "image"] }, + { id: "structure", name: "Structure Control", inputModalities: ["text", "image"] }, + { id: "style", name: "Style Control", inputModalities: ["text", "image"] }, + { id: "style-transfer", name: "Style Transfer", inputModalities: ["text", "image"] }, ], supportedSizes: ["1024x1024", "1024x1280", "1280x1024"], }, @@ -302,10 +371,14 @@ export const IMAGE_PROVIDERS = { authHeader: "x-key", format: "black-forest-labs", models: [ - { id: "flux-kontext-pro", name: "flux-kontext-pro" }, - { id: "flux-kontext-max", name: "flux-kontext-max" }, - { id: "flux-pro-1.0-fill", name: "flux-pro-1.0-fill" }, - { id: "flux-pro-1.0-expand", name: "flux-pro-1.0-expand" }, + { id: "flux-kontext-pro", name: "flux-kontext-pro", inputModalities: ["text", "image"] }, + { id: "flux-kontext-max", name: "flux-kontext-max", inputModalities: ["text", "image"] }, + { id: "flux-pro-1.0-fill", name: "flux-pro-1.0-fill", inputModalities: ["text", "image"] }, + { + id: "flux-pro-1.0-expand", + name: "flux-pro-1.0-expand", + inputModalities: ["text", "image"], + }, { id: "flux-pro-1.1", name: "flux-pro-1.1" }, { id: "flux-pro-1.1-ultra", name: "flux-pro-1.1-ultra" }, { id: "flux-dev", name: "flux-dev" }, @@ -333,7 +406,7 @@ export const IMAGE_PROVIDERS = { authType: "apikey", authHeader: "x-api-key", format: "topaz", - models: [{ id: "topaz-enhance", name: "topaz-enhance" }], + models: [{ id: "topaz-enhance", name: "topaz-enhance", inputModalities: ["image"] }], supportedSizes: ["1024x1024"], }, }; @@ -396,6 +469,8 @@ export function getAllImageModels() { name: model.name, provider: providerId, supportedSizes: config.supportedSizes, + inputModalities: model.inputModalities || ["text"], + description: model.description || undefined, }); } } @@ -408,6 +483,8 @@ export function getAllImageModels() { name: target.name || modelConfig?.name || alias, provider: target.provider, supportedSizes: providerConfig?.supportedSizes || [], + inputModalities: target.inputModalities || modelConfig?.inputModalities || ["text"], + description: target.description || modelConfig?.description || undefined, }); } return models; diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 02369d2eb5..162d907749 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -8,6 +8,14 @@ import { platform, arch } from "os"; import { ANTIGRAVITY_BASE_URLS } from "./antigravityUpstream.ts"; +import { + ANTHROPIC_BETA_API_KEY, + ANTHROPIC_BETA_CLAUDE_OAUTH, + ANTHROPIC_VERSION_HEADER, + CLAUDE_CLI_STAINLESS_PACKAGE_VERSION, + CLAUDE_CLI_STAINLESS_RUNTIME_VERSION, + CLAUDE_CLI_USER_AGENT, +} from "./anthropicHeaders.ts"; import { getCodexDefaultHeaders } from "./codexClient.ts"; import { GLMT_REQUEST_DEFAULTS, @@ -108,8 +116,8 @@ const KIMI_CODING_SHARED = { baseUrl: "https://api.kimi.com/coding/v1/messages", authHeader: "x-api-key", headers: { - "Anthropic-Version": "2023-06-01", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": ANTHROPIC_VERSION_HEADER, + "Anthropic-Beta": ANTHROPIC_BETA_API_KEY, }, models: [ { id: "kimi-k2.5", name: "Kimi K2.5" }, @@ -255,16 +263,15 @@ export const REGISTRY: Record = { authHeader: "x-api-key", defaultContextLength: 200000, headers: { - "Anthropic-Version": "2023-06-01", - "Anthropic-Beta": - "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05", + "Anthropic-Version": ANTHROPIC_VERSION_HEADER, + "Anthropic-Beta": ANTHROPIC_BETA_CLAUDE_OAUTH, "Anthropic-Dangerous-Direct-Browser-Access": "true", - "User-Agent": "claude-cli/2.1.63 (external, cli)", + "User-Agent": CLAUDE_CLI_USER_AGENT, "X-App": "cli", "X-Stainless-Helper-Method": "stream", "X-Stainless-Retry-Count": "0", - "X-Stainless-Runtime-Version": "v24.3.0", - "X-Stainless-Package-Version": "0.74.0", + "X-Stainless-Runtime-Version": CLAUDE_CLI_STAINLESS_RUNTIME_VERSION, + "X-Stainless-Package-Version": CLAUDE_CLI_STAINLESS_PACKAGE_VERSION, "X-Stainless-Runtime": "node", "X-Stainless-Lang": "js", "X-Stainless-Arch": mapStainlessArch(), @@ -744,8 +751,8 @@ export const REGISTRY: Record = { authType: "apikey", authHeader: "x-api-key", headers: { - "Anthropic-Version": "2023-06-01", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": ANTHROPIC_VERSION_HEADER, + "Anthropic-Beta": ANTHROPIC_BETA_API_KEY, }, models: [ { id: "qwen3.5-plus", name: "Qwen3.5 Plus" }, @@ -769,8 +776,8 @@ export const REGISTRY: Record = { authType: "apikey", authHeader: "x-api-key", headers: { - "Anthropic-Version": "2023-06-01", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": ANTHROPIC_VERSION_HEADER, + "Anthropic-Beta": ANTHROPIC_BETA_API_KEY, }, models: [ { id: "glm-5", name: "GLM 5" }, @@ -901,8 +908,8 @@ export const REGISTRY: Record = { authType: "apikey", authHeader: "bearer", headers: { - "Anthropic-Version": "2023-06-01", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": ANTHROPIC_VERSION_HEADER, + "Anthropic-Beta": ANTHROPIC_BETA_API_KEY, }, models: [ // T12/T28: MiniMax default upgraded from M2.5 to M2.7 @@ -925,8 +932,8 @@ export const REGISTRY: Record = { authType: "apikey", authHeader: "bearer", headers: { - "Anthropic-Version": "2023-06-01", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": ANTHROPIC_VERSION_HEADER, + "Anthropic-Beta": ANTHROPIC_BETA_API_KEY, }, models: [ // Keep parity with minimax to ensure model discovery works for minimax-cn connections. diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 83eeabe720..0fdb0f9a8e 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -1,8 +1,8 @@ import crypto, { randomUUID } from "crypto"; -import { BaseExecutor, mergeUpstreamExtraHeaders } from "./base.ts"; +import { BaseExecutor, mergeUpstreamExtraHeaders, type ExecuteInput } from "./base.ts"; import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS } from "../config/constants.ts"; import { scrubProxyAndFingerprintHeaders } from "../services/antigravityHeaderScrub.ts"; -import { antigravityUserAgent, googApiClientHeader } from "../services/antigravityHeaders.ts"; +import { antigravityUserAgent } from "../services/antigravityHeaders.ts"; import { classify429, decide429, type Decision } from "../services/antigravity429Engine.ts"; import { injectCreditsField, @@ -13,6 +13,8 @@ import { } from "../services/antigravityCredits.ts"; import { persistCreditBalance, getAllPersistedCreditBalances } from "@/lib/db/creditBalance"; import { obfuscateSensitiveWords } from "../services/antigravityObfuscation.ts"; +import { resolveAntigravityVersion } from "../services/antigravityVersion.ts"; +import { resolveAntigravityModelId } from "../config/antigravityModelAliases.ts"; const MAX_RETRY_AFTER_MS = 60_000; const LONG_RETRY_THRESHOLD_MS = 60_000; @@ -91,6 +93,7 @@ function markCreditsExhausted(accountId: string): void { function cleanModelName(model: string): string { if (!model) return model; let clean = model.includes("/") ? model.split("/").pop()! : model; + clean = resolveAntigravityModelId(clean); // Normalize bare Pro IDs to the Low tier (matching OpenClaw convention). // The upstream API requires an explicit tier suffix; bare IDs cause errors. if (BARE_PRO_IDS.has(clean)) { @@ -120,7 +123,6 @@ export class AntigravityExecutor extends BaseExecutor { "Content-Type": "application/json", Authorization: `Bearer ${credentials.accessToken}`, "User-Agent": antigravityUserAgent(), - "X-Goog-Api-Client": googApiClientHeader(), Accept: "text/event-stream", "X-OmniRoute-Source": "omniroute", }; @@ -442,7 +444,16 @@ export class AntigravityExecutor extends BaseExecutor { return collect(); } - async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders }) { + async execute({ + model, + body, + stream, + credentials, + signal, + log, + upstreamExtraHeaders, + }: ExecuteInput) { + await resolveAntigravityVersion(); const fallbackCount = this.getFallbackCount(); let lastError = null; let lastStatus = 0; @@ -517,7 +528,7 @@ export class AntigravityExecutor extends BaseExecutor { // signal — multi-hour Retry-After upgrades rate_limited to // quota_exhausted so the GOOGLE_ONE_AI credits retry fires). const effectiveRetryHintMs = retryMs ?? parsedRetryMs ?? null; - const category = classify429(errorMessage, effectiveRetryHintMs); + const category = classify429(errorMessage); // 3. For quota_exhausted, attempt Google One AI credits retry FIRST! // Skip if credits were already injected on the first call diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index f6185f8c27..d8cd96ce64 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -132,6 +132,7 @@ import { isClaudeCodeCompatibleProvider, resolveClaudeCodeCompatibleSessionId, } from "../services/claudeCodeCompatible.ts"; +import { setGeminiThoughtSignatureMode } from "../services/geminiThoughtSignatureStore.ts"; function extractMemoryTextFromResponse( response: Record | null | undefined @@ -858,6 +859,7 @@ export async function handleChatCore({ const stream = resolveStreamFlag(body?.stream, acceptHeader); const settings = await getCachedSettings(); + setGeminiThoughtSignatureMode(settings.antigravitySignatureCacheMode); const semanticCacheEnabled = settings.semanticCacheEnabled !== false; // Create request logger for this session: sourceFormat_targetFormat_model diff --git a/open-sse/services/antigravityHeaders.ts b/open-sse/services/antigravityHeaders.ts index 009e9a2d66..c7c0f0c6c9 100644 --- a/open-sse/services/antigravityHeaders.ts +++ b/open-sse/services/antigravityHeaders.ts @@ -1,4 +1,9 @@ import os from "node:os"; +import { + ANTIGRAVITY_FALLBACK_VERSION, + getCachedAntigravityVersion, + resolveAntigravityVersion, +} from "./antigravityVersion.ts"; /** * Antigravity and Gemini CLI header utilities. @@ -11,7 +16,7 @@ import os from "node:os"; type AntigravityHeaderProfile = "loadCodeAssist" | "fetchAvailableModels" | "models"; -const ANTIGRAVITY_VERSION = "1.21.9"; +const ANTIGRAVITY_VERSION = ANTIGRAVITY_FALLBACK_VERSION; const GEMINI_CLI_VERSION = "0.31.0"; const GEMINI_SDK_VERSION = "1.41.0"; const NODE_VERSION = "v22.19.0"; @@ -68,7 +73,12 @@ function getArch(): string { * darwin/arm64. Matches CLIProxyAPI's proven production behavior. */ export function antigravityUserAgent(): string { - return `antigravity/${ANTIGRAVITY_VERSION} darwin/arm64`; + return `antigravity/${getCachedAntigravityVersion()} darwin/arm64`; +} + +export async function resolveAntigravityUserAgent(): Promise { + const version = await resolveAntigravityVersion(); + return `antigravity/${version} darwin/arm64`; } export function getAntigravityLoadCodeAssistMetadata(): Record { diff --git a/open-sse/services/antigravityVersion.ts b/open-sse/services/antigravityVersion.ts new file mode 100644 index 0000000000..e299074941 --- /dev/null +++ b/open-sse/services/antigravityVersion.ts @@ -0,0 +1,141 @@ +const ANTIGRAVITY_RELEASE_FEED_URL = + "https://antigravity-auto-updater-974169037036.us-central1.run.app/releases"; +const ANTIGRAVITY_GITHUB_RELEASE_URL = + "https://api.github.com/repos/antigravityide/antigravity/releases/latest"; + +export const ANTIGRAVITY_VERSION_CACHE_TTL_MS = 6 * 60 * 60 * 1000; +export const ANTIGRAVITY_VERSION_FETCH_TIMEOUT_MS = 5_000; +export const ANTIGRAVITY_FALLBACK_VERSION = "1.23.2"; + +type VersionCache = { + fetchedAt: number; + version: string; +}; + +type FetchLike = typeof fetch; + +let versionCache: VersionCache | null = null; +let inFlightRequest: Promise | null = null; + +function normalizeVersion(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim().replace(/^v/i, ""); + const match = trimmed.match(/^(\d+\.\d+\.\d+)\b/); + return match ? match[1] : null; +} + +async function fetchJsonWithTimeout(fetchImpl: FetchLike, url: string): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), ANTIGRAVITY_VERSION_FETCH_TIMEOUT_MS); + + try { + const response = await fetchImpl(url, { + headers: { + Accept: "application/json", + "User-Agent": "OmniRoute-AntigravityVersion/1.0", + }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`Version source ${url} returned ${response.status}`); + } + + return response.json(); + } finally { + clearTimeout(timeoutId); + } +} + +function parseOfficialReleaseFeed(payload: unknown): string | null { + if (!Array.isArray(payload)) return null; + + for (const entry of payload) { + const version = normalizeVersion((entry as { version?: unknown })?.version); + if (version) return version; + } + + return null; +} + +function parseGitHubRelease(payload: unknown): string | null { + if (!payload || typeof payload !== "object") return null; + + const candidate = + (payload as { tag_name?: unknown }).tag_name ?? (payload as { name?: unknown }).name; + + return normalizeVersion(candidate); +} + +async function fetchLatestAntigravityVersion(fetchImpl: FetchLike): Promise { + const sources = [ + { + parse: parseOfficialReleaseFeed, + url: ANTIGRAVITY_RELEASE_FEED_URL, + }, + { + parse: parseGitHubRelease, + url: ANTIGRAVITY_GITHUB_RELEASE_URL, + }, + ]; + + for (const source of sources) { + try { + const payload = await fetchJsonWithTimeout(fetchImpl, source.url); + const version = source.parse(payload); + if (version) return version; + } catch { + // Try the next source and fall back to the last known good version if all fail. + } + } + + return null; +} + +export async function resolveAntigravityVersion(fetchImpl: FetchLike = fetch): Promise { + const now = Date.now(); + + if (versionCache && now - versionCache.fetchedAt < ANTIGRAVITY_VERSION_CACHE_TTL_MS) { + return versionCache.version; + } + + if (inFlightRequest) { + return inFlightRequest; + } + + inFlightRequest = (async () => { + const resolved = await fetchLatestAntigravityVersion(fetchImpl); + const version = resolved || versionCache?.version || ANTIGRAVITY_FALLBACK_VERSION; + + if (resolved) { + versionCache = { + fetchedAt: Date.now(), + version, + }; + } + + return version; + })(); + + try { + return await inFlightRequest; + } finally { + inFlightRequest = null; + } +} + +export function getCachedAntigravityVersion(): string { + return versionCache?.version || ANTIGRAVITY_FALLBACK_VERSION; +} + +export function seedAntigravityVersionCache(version: string, fetchedAt = Date.now()): void { + versionCache = { + fetchedAt, + version, + }; +} + +export function clearAntigravityVersionCache(): void { + versionCache = null; + inFlightRequest = null; +} diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index fa076f9de4..065840108a 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -1,6 +1,14 @@ import { createHash, randomUUID } from "node:crypto"; import { getStainlessTimeoutSeconds } from "@/shared/utils/runtimeTimeouts"; +import { + ANTHROPIC_BETA_FULL, + ANTHROPIC_VERSION_HEADER, + CLAUDE_CLI_STAINLESS_PACKAGE_VERSION, + CLAUDE_CLI_STAINLESS_RUNTIME_VERSION, + CLAUDE_CLI_USER_AGENT, + CLAUDE_CLI_VERSION, +} from "../config/anthropicHeaders.ts"; import { prepareClaudeRequest } from "../translator/helpers/claudeHelper.ts"; import { signRequestBody } from "./claudeCodeCCH.ts"; import { computeFingerprint, extractFirstUserMessageText } from "./claudeCodeFingerprint.ts"; @@ -26,11 +34,10 @@ export const CLAUDE_CODE_COMPATIBLE_PREFIX = "anthropic-compatible-cc-"; export const CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true"; export const CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH = "/models"; export const CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS = 8092; -export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_VERSION = "2023-06-01"; -export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA = - "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,effort-2025-11-24,token-efficient-tools-2025-02-19"; -export const CLAUDE_CODE_COMPATIBLE_VERSION = "2.1.87"; -export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = `claude-cli/${CLAUDE_CODE_COMPATIBLE_VERSION} (external, cli)`; +export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_VERSION = ANTHROPIC_VERSION_HEADER; +export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA = ANTHROPIC_BETA_FULL; +export const CLAUDE_CODE_COMPATIBLE_VERSION = CLAUDE_CLI_VERSION; +export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = CLAUDE_CLI_USER_AGENT; /** * Build the billing header dynamically with fingerprint and CCH placeholder. * The cch=00000 placeholder is later replaced by signRequestBody(). @@ -136,11 +143,11 @@ export function buildClaudeCodeCompatibleHeaders( "X-Stainless-Retry-Count": "0", "X-Stainless-Timeout": String(CLAUDE_CODE_COMPATIBLE_STAINLESS_TIMEOUT_SECONDS), "X-Stainless-Lang": "js", - "X-Stainless-Package-Version": "0.80.0", + "X-Stainless-Package-Version": CLAUDE_CLI_STAINLESS_PACKAGE_VERSION, "X-Stainless-OS": "MacOS", "X-Stainless-Arch": "arm64", "X-Stainless-Runtime": "node", - "X-Stainless-Runtime-Version": "v24.3.0", + "X-Stainless-Runtime-Version": CLAUDE_CLI_STAINLESS_RUNTIME_VERSION, "accept-language": "*", "sec-fetch-mode": "cors", "accept-encoding": "identity", diff --git a/open-sse/services/geminiThoughtSignatureStore.ts b/open-sse/services/geminiThoughtSignatureStore.ts index f52d7a8821..4f47b7e39a 100644 --- a/open-sse/services/geminiThoughtSignatureStore.ts +++ b/open-sse/services/geminiThoughtSignatureStore.ts @@ -1,12 +1,15 @@ const MAX_SIGNATURES = 1000; const TTL_MS = 1000 * 60 * 60; +export type SignatureCacheMode = "enabled" | "bypass" | "bypass-strict"; + type Entry = { signature: string; expiresAt: number; }; const signatures = new Map(); +let signatureCacheMode: SignatureCacheMode = "enabled"; function pruneExpired() { const now = Date.now(); @@ -42,3 +45,120 @@ export function getGeminiThoughtSignature(toolCallId: unknown) { if (!entry) return null; return entry.signature; } + +export function normalizeSignatureCacheMode(value: unknown): SignatureCacheMode { + return value === "bypass" || value === "bypass-strict" ? value : "enabled"; +} + +export function setGeminiThoughtSignatureMode(mode: unknown) { + signatureCacheMode = normalizeSignatureCacheMode(mode); +} + +export function getGeminiThoughtSignatureMode(): SignatureCacheMode { + return signatureCacheMode; +} + +function decodeSignature(signature: string): Buffer | null { + if (!signature || (signature[0] !== "R" && signature[0] !== "E")) return null; + + const payload = signature.slice(1); + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(payload) || payload.length % 4 === 1) { + return null; + } + + try { + const decoded = Buffer.from(payload, "base64"); + if (decoded.length === 0) return null; + + const canonical = decoded.toString("base64").replace(/=+$/g, ""); + if (canonical !== payload.replace(/=+$/g, "")) { + return null; + } + + return decoded; + } catch { + return null; + } +} + +function readVarint( + buffer: Buffer, + startOffset: number +): { nextOffset: number; value: number } | null { + let offset = startOffset; + let result = 0; + let shift = 0; + + while (offset < buffer.length && shift < 35) { + const byte = buffer[offset]; + result |= (byte & 0x7f) << shift; + offset += 1; + + if ((byte & 0x80) === 0) { + return { nextOffset: offset, value: result }; + } + + shift += 7; + } + + return null; +} + +export function isValidBasicGeminiThoughtSignature(signature: unknown): boolean { + if (typeof signature !== "string") return false; + const decoded = decodeSignature(signature); + return Boolean(decoded && decoded[0] === 0x12); +} + +export function isValidFullGeminiThoughtSignature(signature: unknown): boolean { + if (typeof signature !== "string") return false; + const decoded = decodeSignature(signature); + if (!decoded || decoded[0] !== 0x12) return false; + + const outerLength = readVarint(decoded, 1); + if (!outerLength) return false; + + const outerEnd = outerLength.nextOffset + outerLength.value; + if (outerEnd !== decoded.length) return false; + + const inner = decoded.subarray(outerLength.nextOffset, outerEnd); + if (inner.length === 0 || inner[0] !== 0x0a) return false; + + const innerLength = readVarint(inner, 1); + if (!innerLength) return false; + + return innerLength.nextOffset + innerLength.value === inner.length; +} + +export function resolveGeminiThoughtSignature( + toolCallId: unknown, + clientSignature?: unknown +): string | null { + const persisted = getGeminiThoughtSignature(toolCallId); + if (typeof clientSignature !== "string" || clientSignature.length === 0) { + return persisted; + } + + if (signatureCacheMode === "enabled") { + return persisted; + } + + const isValid = + signatureCacheMode === "bypass-strict" + ? isValidFullGeminiThoughtSignature(clientSignature) + : isValidBasicGeminiThoughtSignature(clientSignature); + + if (isValid) { + return clientSignature; + } + + console.warn( + `[signature-cache] ${signatureCacheMode}: invalid client thought signature, falling back` + ); + return persisted; +} + +export function clearGeminiThoughtSignatures() { + signatures.clear(); + signatureCacheMode = "enabled"; +} diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index 138281a3de..e68e06f1dc 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -1,4 +1,5 @@ import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.ts"; +import { ANTIGRAVITY_MODEL_ALIASES } from "../config/antigravityModelAliases.ts"; import { resolveWildcardAlias } from "./wildcardRouter.ts"; // Derive alias→provider mapping from the single source of truth (PROVIDER_ID_TO_ALIAS) @@ -36,7 +37,7 @@ const PROVIDER_MODEL_ALIASES = { "gpt-oss-120b": "openai/gpt-oss-120b", "nvidia/gpt-oss-120b": "openai/gpt-oss-120b", }, - antigravity: {}, + antigravity: ANTIGRAVITY_MODEL_ALIASES, }; const CROSS_PROXY_MODEL_ALIASES = { diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 26efbe6d12..f71a578c7a 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -2,7 +2,7 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/constants.ts"; -import { getGeminiThoughtSignature } from "../../services/geminiThoughtSignatureStore.ts"; +import { resolveGeminiThoughtSignature } from "../../services/geminiThoughtSignatureStore.ts"; import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.ts"; import { capMaxOutputTokens, @@ -98,6 +98,18 @@ function buildChangedToolNameMap(toolNameMap: Map): Map 0 ? new Map(changedEntries) : null; } +function extractClientThoughtSignature(toolCall) { + if (!toolCall || typeof toolCall !== "object") return null; + + return ( + toolCall.thoughtSignature || + toolCall.thought_signature || + toolCall.function?.thoughtSignature || + toolCall.function?.thought_signature || + null + ); +} + // Core: Convert OpenAI request to Gemini format (base for all variants) function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolNameOptions = {}) { const result: GeminiRequest = { @@ -210,7 +222,7 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName if (msg.tool_calls && Array.isArray(msg.tool_calls)) { const toolCallIds = []; const firstPersistedSignature = msg.tool_calls - .map((tc) => getGeminiThoughtSignature(tc.id)) + .map((tc) => resolveGeminiThoughtSignature(tc.id, extractClientThoughtSignature(tc))) .find((signature) => typeof signature === "string" && signature.length > 0); const shouldUseEmbeddedSignature = !parts.some((p) => p.thoughtSignature); @@ -219,7 +231,10 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName if (tc.type !== "function") continue; const args = tryParseJSON(tc.function?.arguments || "{}"); - const signatureForToolCall = getGeminiThoughtSignature(tc.id); + const signatureForToolCall = resolveGeminiThoughtSignature( + tc.id, + extractClientThoughtSignature(tc) + ); const embeddedThoughtSignature = shouldUseEmbeddedSignature ? firstPersistedSignature || signatureForToolCall || DEFAULT_THINKING_GEMINI_SIGNATURE : undefined; diff --git a/scripts/build-next-isolated.mjs b/scripts/build-next-isolated.mjs index 2cc7194036..52fa1dcaeb 100644 --- a/scripts/build-next-isolated.mjs +++ b/scripts/build-next-isolated.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { spawn } from "node:child_process"; import { pathToFileURL } from "node:url"; @@ -13,8 +14,19 @@ import { pathToFileURL } from "node:url"; */ const projectRoot = process.cwd(); -const legacyAppDir = path.join(projectRoot, "app"); -const backupDir = path.join(projectRoot, `.app-build-backup-${process.pid}-${Date.now()}`); +const backupRoot = path.join(os.tmpdir(), `omniroute-build-isolated-${process.pid}-${Date.now()}`); +const transientBuildPaths = [ + { + label: "legacy app snapshot", + sourcePath: path.join(projectRoot, "app"), + backupPath: path.join(backupRoot, "app"), + }, + { + label: "task planning workspace", + sourcePath: path.join(projectRoot, "_tasks"), + backupPath: path.join(backupRoot, "_tasks"), + }, +]; async function exists(targetPath) { try { @@ -26,6 +38,8 @@ async function exists(targetPath) { } export async function movePath(sourcePath, destinationPath, fsImpl = fs) { + await fsImpl.mkdir(path.dirname(destinationPath), { recursive: true }); + try { await fsImpl.rename(sourcePath, destinationPath); } catch (error) { @@ -82,12 +96,13 @@ export function resolveNextBuildEnv(baseEnv = process.env) { } export async function main() { - let moved = false; + const movedPaths = []; try { - if (await exists(legacyAppDir)) { - await movePath(legacyAppDir, backupDir); - moved = true; + for (const entry of transientBuildPaths) { + if (!(await exists(entry.sourcePath))) continue; + await movePath(entry.sourcePath, entry.backupPath); + movedPaths.push(entry); } const result = await runNextBuild(); @@ -113,17 +128,25 @@ export async function main() { console.error("[build-next-isolated] Build failed:", error); process.exitCode = 1; } finally { - if (moved) { + while (movedPaths.length > 0) { + const entry = movedPaths.pop(); + if (!entry) continue; try { - await movePath(backupDir, legacyAppDir); + await movePath(entry.backupPath, entry.sourcePath); } catch (restoreError) { console.error( - `[build-next-isolated] Failed to restore legacy app dir from ${backupDir}:`, + `[build-next-isolated] Failed to restore ${entry.label} from ${entry.backupPath}:`, restoreError ); process.exitCode = 1; } } + + try { + await fs.rm(backupRoot, { recursive: true, force: true }); + } catch (cleanupError) { + console.warn("[build-next-isolated] Failed to clean temporary backup root:", cleanupError); + } } } diff --git a/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx b/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx index f06b7ad2fd..333fa67186 100644 --- a/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx +++ b/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx @@ -311,6 +311,21 @@ function formatFileSize(bytes: number): string { return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } +function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") { + resolve(reader.result); + return; + } + reject(new Error("Failed to read file")); + }; + reader.onerror = () => reject(reader.error || new Error("Failed to read file")); + reader.readAsDataURL(file); + }); +} + /** Render image result thumbnails */ function ImageResults({ data }: { data: any }) { const images: Array<{ url?: string; b64_json?: string; revised_prompt?: string }> = @@ -383,6 +398,8 @@ export default function MediaPageClient() { const MAX_TRANSCRIPTION_FILE_SIZE = 4 * 1024 * 1024 * 1024; // 4 GB const [audioFile, setAudioFile] = useState(null); const [fileSizeError, setFileSizeError] = useState(null); + const [imageInputFile, setImageInputFile] = useState(null); + const [imageMaskFile, setImageMaskFile] = useState(null); // Fix #390: Track which local providers (sdwebui, comfyui) are actually configured // so we can hide them when they haven't been set up in the providers page @@ -433,6 +450,8 @@ export default function MediaPageClient() { setError(null); setIsCredentialsError(false); setAudioFile(null); + setImageInputFile(null); + setImageMaskFile(null); // Pick first provider and first model automatically const providers = PROVIDER_MODELS[tab] ?? []; const firstProvider = providers[0]; @@ -473,9 +492,10 @@ export default function MediaPageClient() { try { const config = MODALITY_CONFIG[activeTab]; const modelId = selectedModel; + const promptValue = prompt.trim(); if (activeTab === "speech") { - if (!prompt.trim()) { + if (!promptValue) { setError("Please enter text to synthesize."); setLoading(false); return; @@ -485,7 +505,7 @@ export default function MediaPageClient() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: modelId, - input: prompt.trim(), + input: promptValue, voice: speechVoice, response_format: speechFormat, }), @@ -549,19 +569,44 @@ export default function MediaPageClient() { return; } - if (!prompt.trim()) { - setError("Please enter a prompt."); + if (activeTab === "image" && selectedProvider === "topaz" && !imageInputFile) { + setError("Topaz requires an input image."); setLoading(false); return; } + + if (!prompt.trim()) { + if (activeTab !== "image" || selectedProvider !== "topaz") { + setError("Please enter a prompt."); + setLoading(false); + return; + } + } + + const payload: Record = { + model: modelId, + prompt: + promptValue || + (activeTab === "image" && selectedProvider === "topaz" ? "Enhance this image" : ""), + ...(activeTab === "image" ? { size: "1024x1024", n: 1 } : {}), + }; + + if (activeTab === "image" && imageInputFile) { + const imageDataUrl = await fileToDataUrl(imageInputFile); + payload.image_url = imageDataUrl; + payload.imageUrls = [imageDataUrl]; + } + + if (activeTab === "image" && imageMaskFile) { + const maskDataUrl = await fileToDataUrl(imageMaskFile); + payload.mask = maskDataUrl; + payload.mask_url = maskDataUrl; + } + const res = await fetch(config.endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: modelId, - prompt: prompt.trim(), - ...(activeTab === "image" ? { size: "1024x1024", n: 1 } : {}), - }), + body: JSON.stringify(payload), }); if (!res.ok) { const raw = await res.json().catch(() => ({})); @@ -579,6 +624,16 @@ export default function MediaPageClient() { const config = MODALITY_CONFIG[activeTab]; const voiceList = getVoiceList(selectedProvider); + const isTopazImageFlow = activeTab === "image" && selectedProvider === "topaz"; + const isGenerateDisabled = + loading || + (activeTab === "transcription" + ? !audioFile + : activeTab === "image" + ? isTopazImageFlow + ? !imageInputFile + : !prompt.trim() + : !prompt.trim()); return (
@@ -735,27 +790,80 @@ export default function MediaPageClient() {

) : ( - /* Prompt / Text */ -
- -