From 7725afe680108be896a05b86b3869de0c7272e9e Mon Sep 17 00:00:00 2001 From: LJR199887 <172809968+LJR199887@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:51:08 +0800 Subject: [PATCH 01/20] add sora video extras and cookie store export --- README_EN.md | 27 +++++++ api/routes/generation.py | 111 ++++++++++++++++++++++++++ browser-cookie-exporter/README.md | 60 +++++++------- browser-cookie-exporter/manifest.json | 1 + browser-cookie-exporter/popup.css | 13 +-- browser-cookie-exporter/popup.html | 13 +-- browser-cookie-exporter/popup.js | 102 +++++++++++++++++++---- core/adobe_client.py | 27 ++++++- 8 files changed, 294 insertions(+), 60 deletions(-) diff --git a/README_EN.md b/README_EN.md index 73896db..aad41b4 100644 --- a/README_EN.md +++ b/README_EN.md @@ -214,6 +214,33 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ }' ``` +Optional Sora-only controls: + +- `locale`: overrides the default `en-US` +- `timeline_events`: adds structured timeline hints into the Sora prompt JSON +- `audio`: adds optional structured audio hints into the Sora prompt JSON + +Example: + +```bash +curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "model": "firefly-sora2-4s-16x9", + "locale": "ja-JP", + "audio": { + "sfx": "Wind howling softly", + "voice_timbre": "Natural, calm voice" + }, + "timeline_events": { + "0s-2s": "Camera holds on the snowy forest", + "2s-4s": "Drone glides forward slowly" + }, + "messages": [{"role":"user","content":"a drone shot over snowy forest"}] + }' +``` + Veo31 single-image semantics: - `firefly-veo31-*` / `firefly-veo31-fast-*`: frame mode diff --git a/api/routes/generation.py b/api/routes/generation.py index 921df09..aefdfef 100644 --- a/api/routes/generation.py +++ b/api/routes/generation.py @@ -11,6 +11,60 @@ from api.schemas import GenerateRequest +def _validate_prompt_length(prompt: str) -> None: + if len(str(prompt or "").strip()) < 3: + raise HTTPException( + status_code=400, + detail="prompt must contain at least 3 characters", + ) + + +def _normalize_upstream_request_error(exc: Exception) -> tuple[int, str, str] | None: + message = str(exc or "").strip() + lowered = message.lower() + if ("poll failed: 400" in lowered or "submit failed: 400" in lowered) and ( + "validation error" in lowered + or "字符串应至少包含 3 个字符" in message + or "string should have at least 3 characters" in lowered + ): + return ( + 400, + "invalid_request_error", + "prompt must contain at least 3 characters", + ) + return None + + +def _resolve_sora_video_extras(data: dict) -> tuple[str, dict | None, dict | None]: + locale = str( + data.get("locale") + or data.get("video_locale") + or data.get("videoLocale") + or "en-US" + ).strip() or "en-US" + if len(locale) > 32: + locale = locale[:32] + + timeline_events = ( + data.get("timeline_events") + or data.get("timelineEvents") + or data.get("video_timeline_events") + or data.get("videoTimelineEvents") + ) + if not isinstance(timeline_events, dict): + timeline_events = None + elif not timeline_events: + timeline_events = None + + audio = data.get("audio") or data.get("video_audio") or data.get("videoAudio") + if not isinstance(audio, dict): + audio = None + elif not audio: + audio = None + + return locale, timeline_events, audio + + def build_generation_router( *, store, @@ -82,6 +136,7 @@ def openai_generate(data: dict, request: Request): } }, ) + _validate_prompt_length(prompt) model_id = data.get("model") if str(model_id or "").strip() in video_model_catalog: @@ -259,6 +314,29 @@ def _image_progress_cb(update: dict): }, ) except Exception as exc: + normalized = _normalize_upstream_request_error(exc) + if normalized is not None: + status_code, err_type, message = normalized + error_code = set_request_error_detail( + request, + error=message, + status_code=status_code, + error_type=err_type, + include_traceback=False, + ) + set_request_task_progress( + request, task_status="FAILED", task_progress=0.0, error=message + ) + return JSONResponse( + status_code=status_code, + content={ + "error": { + "message": message, + "type": err_type, + "code": error_code, + } + }, + ) error_code = set_request_error_detail( request, error=exc, @@ -292,6 +370,7 @@ def create_job(data: GenerateRequest, request: Request): prompt = data.prompt.strip() if not prompt: raise HTTPException(status_code=400, detail="prompt cannot be empty") + _validate_prompt_length(prompt) ratio = data.aspect_ratio.strip() or "16:9" if ratio not in supported_ratios: @@ -430,6 +509,8 @@ def chat_completions(data: dict, request: Request): ) video_conf = video_model_catalog.get(model_id) is_video_model = video_conf is not None + if not is_video_model: + _validate_prompt_length(prompt) resolved_model_id = model_id if is_video_model else None ratio = "9:16" output_resolution = "2K" @@ -442,6 +523,9 @@ def chat_completions(data: dict, request: Request): video_engine = str(video_conf.get("engine") or "sora2") if video_conf else "" generate_audio = True negative_prompt = "" + video_locale = "en-US" + timeline_events = None + video_audio = None video_reference_mode = ( str(video_conf.get("reference_mode") or "frame") if video_conf else "frame" ) @@ -458,6 +542,7 @@ def chat_completions(data: dict, request: Request): video_reference_mode = requested_reference_mode else: generate_audio, negative_prompt = resolved_video_options + video_locale, timeline_events, video_audio = _resolve_sora_video_extras(data) else: ratio, output_resolution, resolved_model_id = resolve_ratio_and_resolution( data, model_id or None @@ -531,6 +616,9 @@ def _video_progress_cb(update: dict): timeout=max(int(client.generate_timeout), 600), negative_prompt=negative_prompt, generate_audio=generate_audio, + locale=video_locale, + timeline_events=timeline_events, + audio=video_audio, reference_mode=video_reference_mode, out_path=tmp_path, progress_cb=_video_progress_cb, @@ -736,6 +824,29 @@ def _image_progress_cb(update: dict): }, ) except Exception as exc: + normalized = _normalize_upstream_request_error(exc) + if normalized is not None: + status_code, err_type, message = normalized + error_code = set_request_error_detail( + request, + error=message, + status_code=status_code, + error_type=err_type, + include_traceback=False, + ) + set_request_task_progress( + request, task_status="FAILED", task_progress=0.0, error=message + ) + return JSONResponse( + status_code=status_code, + content={ + "error": { + "message": message, + "type": err_type, + "code": error_code, + } + }, + ) error_code = set_request_error_detail( request, error=exc, diff --git a/browser-cookie-exporter/README.md b/browser-cookie-exporter/README.md index 1e8d2c0..a2d46d2 100644 --- a/browser-cookie-exporter/README.md +++ b/browser-cookie-exporter/README.md @@ -1,16 +1,9 @@ -# Adobe Cookie Exporter 插件 +# Adobe Cookie Exporter -一个 Chrome/Edge(Manifest V3)插件,用于导出 Adobe/Firefly Cookie。 -当前改为仅导出 `adobe2api` 导入所需最小字段。 +A small Chrome or Edge extension used to export Adobe or Firefly cookies in the +minimal JSON format required by `adobe2api`. -插件界面仅保留: - -- 导出范围 -- 导出最简 JSON - -## 导出格式 - -导出的 JSON 结构如下(最简): +## Export Format ```json { @@ -18,31 +11,40 @@ } ``` -## 安装方式(开发者模式) - -1. 打开 Chrome/Edge 扩展页面:`chrome://extensions` 或 `edge://extensions` -2. 开启「开发者模式」 -3. 点击「加载已解压的扩展程序」 -4. 选择目录:`browser-cookie-exporter/` +## Install -## 使用说明 +1. Open `chrome://extensions` or `edge://extensions` +2. Enable developer mode +3. Click `Load unpacked` +4. Select the `browser-cookie-exporter/` folder -1. 先在浏览器登录 Adobe/Firefly -2. 点击插件图标 -3. 选择导出范围: - - `Adobe 全域(推荐)`:读取 `*.adobe.com` 相关 Cookie - - `当前站点`:仅读取当前标签页站点 Cookie -4. 可选填写账号标识(用于文件名和 JSON 的 `email` 字段) -5. 点击 `导出 JSON` +## Usage -## 与 adobe2api 联动 +1. Log in to Adobe or Firefly +2. Open the extension popup +3. Choose an export scope: + - `Adobe domains (recommended)` + - `Current site` +4. Click `Export Minimal JSON` -可直接把导出的 JSON 传给 `adobe2api` 的导入接口: +## Import Into adobe2api ```bash curl -X POST "http://127.0.0.1:6001/api/v1/refresh-profiles/import-cookie" \ -H "Content-Type: application/json" \ - -d '{"name":"my-account","cookie": <导出的整个JSON或cookie_header字符串>}' + -d '{"name":"my-account","cookie":"k1=v1; k2=v2"}' ``` -说明:导出文件名格式为 `cookie_YYYYMMDD_HHMMSS.json`。 +## Incognito Support + +The extension exports cookies from the cookie store used by the active tab. +If you open the popup from an incognito Adobe or Firefly tab, the exported JSON +will contain the incognito cookie jar instead of the regular browser cookie jar. + +To use it in incognito: + +1. Open `chrome://extensions` or `edge://extensions` +2. Open this extension's details page +3. Enable `Allow in Incognito` +4. Open Adobe or Firefly in an incognito window +5. Open the popup from that incognito tab and export the JSON diff --git a/browser-cookie-exporter/manifest.json b/browser-cookie-exporter/manifest.json index ea61910..6fdbca4 100644 --- a/browser-cookie-exporter/manifest.json +++ b/browser-cookie-exporter/manifest.json @@ -3,6 +3,7 @@ "name": "Adobe Cookie Exporter", "description": "Export Adobe/Firefly cookies in adobe_register-compatible JSON format.", "version": "1.0.0", + "incognito": "split", "permissions": [ "cookies", "downloads", diff --git a/browser-cookie-exporter/popup.css b/browser-cookie-exporter/popup.css index 65b85dd..dde6e82 100644 --- a/browser-cookie-exporter/popup.css +++ b/browser-cookie-exporter/popup.css @@ -9,7 +9,7 @@ body { } .app { - width: 300px; + width: 320px; padding: 14px; display: grid; gap: 10px; @@ -21,6 +21,12 @@ h1 { color: #1f2937; } +.context { + margin: 0; + font-size: 12px; + color: #475569; +} + .field { display: grid; gap: 6px; @@ -33,7 +39,6 @@ button { font: inherit; } -select, select { border: 1px solid #cbd5e1; border-radius: 8px; @@ -56,10 +61,6 @@ button { font-weight: 600; } -button:last-child { - background: #0f766e; -} - .result { display: grid; gap: 6px; diff --git a/browser-cookie-exporter/popup.html b/browser-cookie-exporter/popup.html index 81e5caf..447edb1 100644 --- a/browser-cookie-exporter/popup.html +++ b/browser-cookie-exporter/popup.html @@ -8,22 +8,23 @@
-

Adobe Cookie 导出

+

Adobe Cookie Export

+

Checking browser context...

- +
-

等待导出…

+

Ready to export.

diff --git a/browser-cookie-exporter/popup.js b/browser-cookie-exporter/popup.js index f84c9ed..736c0b9 100644 --- a/browser-cookie-exporter/popup.js +++ b/browser-cookie-exporter/popup.js @@ -1,4 +1,5 @@ const statusText = document.getElementById("statusText"); +const contextText = document.getElementById("contextText"); const scopeSelect = document.getElementById("scopeSelect"); const exportJsonBtn = document.getElementById("exportJsonBtn"); @@ -30,9 +31,46 @@ function getCurrentTab() { }); } -function getCookies(filter) { +function getAllCookieStores() { return new Promise((resolve, reject) => { - chrome.cookies.getAll(filter, (cookies) => { + chrome.cookies.getAllCookieStores((stores) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + resolve(Array.isArray(stores) ? stores : []); + }); + }); +} + +async function getCurrentContext() { + const tab = await getCurrentTab(); + if (!tab || typeof tab.id !== "number") { + throw new Error("Unable to find the active tab for cookie export."); + } + + const stores = await getAllCookieStores(); + const matchedStore = stores.find((store) => + Array.isArray(store.tabIds) && store.tabIds.includes(tab.id) + ); + if (!matchedStore || !matchedStore.id) { + throw new Error("Unable to resolve the cookie store for the active tab."); + } + + return { + tab, + storeId: matchedStore.id, + incognito: Boolean(tab.incognito || chrome.extension.inIncognitoContext) + }; +} + +function getCookies(filter, storeId) { + return new Promise((resolve, reject) => { + const nextFilter = { ...(filter || {}) }; + if (storeId) { + nextFilter.storeId = storeId; + } + chrome.cookies.getAll(nextFilter, (cookies) => { if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); return; @@ -43,20 +81,22 @@ function getCookies(filter) { } async function collectCookiesByScope(scope) { + const context = await getCurrentContext(); + const { tab, storeId, incognito } = context; + if (scope === "current") { - const tab = await getCurrentTab(); const url = tab && tab.url ? tab.url : ""; if (!url.startsWith("http://") && !url.startsWith("https://")) { - throw new Error("当前标签页不是网页,无法按当前站点读取 Cookie"); + throw new Error("The current tab is not a regular web page."); } - const cookies = await getCookies({ url }); - return { cookies, sourceUrl: url }; + const cookies = await getCookies({ url }, storeId); + return { cookies, sourceUrl: url, storeId, incognito }; } const domains = [".adobe.com", "firefly.adobe.com", "account.adobe.com"]; const all = []; for (const domain of domains) { - const cookies = await getCookies({ domain }); + const cookies = await getCookies({ domain }, storeId); all.push(...cookies); } @@ -68,7 +108,13 @@ async function collectCookiesByScope(scope) { seen.add(key); unique.push(item); } - return { cookies: unique, sourceUrl: "https://firefly.adobe.com/" }; + + return { + cookies: unique, + sourceUrl: "https://firefly.adobe.com/", + storeId, + incognito + }; } function toPlaywrightLikeCookies(cookies) { @@ -107,34 +153,58 @@ function downloadJson(filename, data) { async function generatePayload() { const scope = scopeSelect.value; - const { cookies } = await collectCookiesByScope(scope); + const { cookies, incognito, storeId } = await collectCookiesByScope(scope); const normalizedCookies = toPlaywrightLikeCookies(cookies); const cookieHeader = buildCookieHeader(normalizedCookies); const now = new Date(); const fileTs = toTimestampParts(now); const payload = { cookie: cookieHeader }; - const fileName = `cookie_${fileTs}.json`; return { payload, fileName, cookieCount: normalizedCookies.length, - cookieHeader + incognito, + storeId }; } +function renderContext(context) { + const modeText = context.incognito ? "Incognito" : "Regular"; + contextText.textContent = `Browser context: ${modeText} window | store: ${context.storeId}`; + if (context.incognito) { + setStatus("Incognito cookie store detected. Export will use the isolated incognito cookie jar."); + } else { + setStatus("Regular browser context detected."); + } +} + +async function initContext() { + try { + const context = await getCurrentContext(); + renderContext(context); + } catch (error) { + contextText.textContent = "Browser context: unavailable"; + setStatus(`Unable to detect the cookie store: ${error.message || error}`); + exportJsonBtn.disabled = true; + } +} + exportJsonBtn.addEventListener("click", async () => { try { - setStatus("正在读取 Cookie..."); - const { payload, fileName, cookieCount, cookieHeader } = await generatePayload(); + setStatus("Reading cookies..."); + const { payload, fileName, cookieCount, incognito } = await generatePayload(); if (!cookieCount) { - setStatus("未读取到 Cookie,请先登录 Adobe/Firefly 后重试"); + setStatus("No cookies were found. Log in to Adobe or Firefly first."); return; } downloadJson(fileName, payload); - setStatus(`导出成功:${cookieCount} 条 Cookie`); + const modeText = incognito ? "incognito" : "regular"; + setStatus(`Exported ${cookieCount} cookies from the ${modeText} browser store.`); } catch (error) { - setStatus(`导出失败:${error.message || error}`); + setStatus(`Export failed: ${error.message || error}`); } }); + +initContext(); diff --git a/core/adobe_client.py b/core/adobe_client.py index 27fb1df..d42b0b0 100644 --- a/core/adobe_client.py +++ b/core/adobe_client.py @@ -623,7 +623,11 @@ def _extract_job_id(raw_url: str) -> str: @staticmethod def _build_video_prompt_json( - prompt: str, duration: int, negative_prompt: str = "" + prompt: str, + duration: int, + negative_prompt: str = "", + timeline_events: Optional[dict] = None, + audio: Optional[dict] = None, ) -> str: payload = { "id": 1, @@ -632,6 +636,10 @@ def _build_video_prompt_json( } if negative_prompt: payload["negative_prompt"] = negative_prompt + if isinstance(timeline_events, dict) and timeline_events: + payload["timeline_events"] = timeline_events + if isinstance(audio, dict) and audio: + payload["audio"] = audio return json.dumps(payload, ensure_ascii=False) def _build_video_payload( @@ -643,6 +651,9 @@ def _build_video_payload( source_image_ids: Optional[list[str]] = None, negative_prompt: str = "", generate_audio: bool = True, + locale: str = "en-US", + timeline_events: Optional[dict] = None, + audio: Optional[dict] = None, reference_mode: str = "frame", ) -> dict: seed_val = int(time.time()) % 999999 @@ -703,7 +714,11 @@ def _build_video_payload( "duration": int(duration), "fps": 24, "prompt": self._build_video_prompt_json( - prompt=prompt, duration=duration, negative_prompt=negative_prompt + prompt=prompt, + duration=duration, + negative_prompt=negative_prompt, + timeline_events=timeline_events, + audio=audio, ), "generationMetadata": {"module": "text2video"}, "model": upstream_model, @@ -711,7 +726,7 @@ def _build_video_payload( "generateLoop": False, "transparentBackground": False, "seed": str(seed_val), - "locale": "en-US", + "locale": str(locale or "en-US").strip() or "en-US", "camera": { "angle": "none", "shotSize": "none", @@ -756,6 +771,9 @@ def generate_video( timeout: int = 600, negative_prompt: str = "", generate_audio: bool = True, + locale: str = "en-US", + timeline_events: Optional[dict] = None, + audio: Optional[dict] = None, reference_mode: str = "frame", out_path: Optional[Path] = None, progress_cb: Optional[Callable[[dict], None]] = None, @@ -768,6 +786,9 @@ def generate_video( source_image_ids=source_image_ids, negative_prompt=negative_prompt, generate_audio=generate_audio, + locale=locale, + timeline_events=timeline_events, + audio=audio, reference_mode=reference_mode, ) submit_resp = self._post_json( From bb498e9ce8f02b455a4a5af94bf4dfe8e9af0ab5 Mon Sep 17 00:00:00 2001 From: LJR199887 <172809968+LJR199887@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:03:00 +0800 Subject: [PATCH 02/20] simplify video model names --- README_EN.md | 84 +++++++-------- api/routes/generation.py | 169 +++++++++++++++++++++++++++--- core/models/catalog.py | 219 +++++++++++++++++++++++++++------------ 3 files changed, 344 insertions(+), 128 deletions(-) diff --git a/README_EN.md b/README_EN.md index aad41b4..d9ca29d 100644 --- a/README_EN.md +++ b/README_EN.md @@ -71,11 +71,10 @@ Current supported model families are: - `firefly-nano-banana-*` (image, maps to upstream `nano-banana-2`) - `firefly-nano-banana2-*` (image, maps to upstream `nano-banana-3`) - `firefly-nano-banana-pro-*` (image) -- `firefly-sora2-*` (video) -- `firefly-sora2-pro-*` (video) -- `firefly-veo31-*` (video) -- `firefly-veo31-ref-*` (video, reference-image mode) -- `firefly-veo31-fast-*` (video) +- `firefly-sora2` (video) +- `firefly-sora2-pro` (video) +- `firefly-veo31` (video) +- `firefly-veo31-fast` (video) Nano Banana image models (`nano-banana-2`): @@ -106,62 +105,51 @@ Nano Banana Pro image models (legacy-compatible): Sora2 video models: -- Pattern: `firefly-sora2-{duration}-{ratio}` -- Duration: `4s` / `8s` / `12s` -- Ratio: `9x16` / `16x9` +- Pattern: `model=firefly-sora2` with separate request fields +- Duration: pass `duration` as `4` / `8` / `12` +- Ratio: pass `aspect_ratio` as `9:16` / `16:9` - Examples: - - `firefly-sora2-4s-16x9` - - `firefly-sora2-8s-9x16` + - `model=firefly-sora2, duration=4, aspect_ratio=16:9` + - `model=firefly-sora2, duration=8, aspect_ratio=9:16` Sora2 Pro video models: -- Pattern: `firefly-sora2-pro-{duration}-{ratio}` -- Duration: `4s` / `8s` / `12s` -- Ratio: `9x16` / `16x9` +- Pattern: `model=firefly-sora2-pro` with separate request fields +- Duration: pass `duration` as `4` / `8` / `12` +- Ratio: pass `aspect_ratio` as `9:16` / `16:9` - Examples: - - `firefly-sora2-pro-4s-16x9` - - `firefly-sora2-pro-8s-9x16` + - `model=firefly-sora2-pro, duration=4, aspect_ratio=16:9` + - `model=firefly-sora2-pro, duration=8, aspect_ratio=9:16` Veo31 video models: -- Pattern: `firefly-veo31-{duration}-{ratio}-{resolution}` -- Duration: `4s` / `6s` / `8s` -- Ratio: `16x9` / `9x16` -- Resolution: `1080p` / `720p` +- Pattern: `model=firefly-veo31` with separate request fields +- Duration: pass `duration` as `4` / `6` / `8` +- Ratio: pass `aspect_ratio` as `16:9` / `9:16` +- Resolution: pass `resolution` as `1080p` / `720p` +- Reference mode: pass `reference_mode` as `frame` or `image` - Supports up to 2 reference images: - 1 image: first-frame reference - 2 images: first-frame + last-frame reference +- In `reference_mode=image`, supports up to 3 reference images - Audio defaults to enabled - Examples: - - `firefly-veo31-4s-16x9-1080p` - - `firefly-veo31-6s-9x16-720p` - -Veo31 Ref video models (reference-image mode): - -- Pattern: `firefly-veo31-ref-{duration}-{ratio}-{resolution}` -- Duration: `4s` / `6s` / `8s` -- Ratio: `16x9` / `9x16` -- Resolution: `1080p` / `720p` -- Always uses reference image mode (not first/last frame mode) -- Supports up to 3 reference images (mapped to upstream `referenceBlobs[].usage="asset"`) -- Examples: - - `firefly-veo31-ref-4s-9x16-720p` - - `firefly-veo31-ref-6s-16x9-1080p` - - `firefly-veo31-ref-8s-9x16-1080p` + - `model=firefly-veo31, duration=4, aspect_ratio=16:9, resolution=1080p` + - `model=firefly-veo31, duration=6, aspect_ratio=9:16, resolution=720p, reference_mode=image` Veo31 Fast video models: -- Pattern: `firefly-veo31-fast-{duration}-{ratio}-{resolution}` -- Duration: `4s` / `6s` / `8s` -- Ratio: `16x9` / `9x16` -- Resolution: `1080p` / `720p` +- Pattern: `model=firefly-veo31-fast` with separate request fields +- Duration: pass `duration` as `4` / `6` / `8` +- Ratio: pass `aspect_ratio` as `16:9` / `9:16` +- Resolution: pass `resolution` as `1080p` / `720p` - Supports up to 2 reference images: - 1 image: first-frame reference - 2 images: first-frame + last-frame reference - Audio defaults to enabled - Examples: - - `firefly-veo31-fast-4s-16x9-1080p` - - `firefly-veo31-fast-6s-9x16-720p` + - `model=firefly-veo31-fast, duration=4, aspect_ratio=16:9, resolution=1080p` + - `model=firefly-veo31-fast, duration=6, aspect_ratio=9:16, resolution=720p` ### 3.1 List models @@ -209,7 +197,9 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-sora2-4s-16x9", + "model": "firefly-sora2", + "duration": 4, + "aspect_ratio": "16:9", "messages": [{"role":"user","content":"a drone shot over snowy forest"}] }' ``` @@ -227,7 +217,9 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-sora2-4s-16x9", + "model": "firefly-sora2", + "duration": 4, + "aspect_ratio": "16:9", "locale": "ja-JP", "audio": { "sfx": "Wind howling softly", @@ -243,10 +235,10 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ Veo31 single-image semantics: -- `firefly-veo31-*` / `firefly-veo31-fast-*`: frame mode +- `firefly-veo31` / `firefly-veo31-fast` with `reference_mode=frame`: frame mode - 1 image => first frame - 2 images => first frame + last frame -- `firefly-veo31-ref-*`: reference-image mode +- `firefly-veo31` with `reference_mode=image`: reference-image mode - 1~3 images => reference images Image-to-video: @@ -256,7 +248,9 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-sora2-8s-9x16", + "model": "firefly-sora2", + "duration": 8, + "aspect_ratio": "9:16", "messages": [{ "role":"user", "content":[ diff --git a/api/routes/generation.py b/api/routes/generation.py index aefdfef..c61adee 100644 --- a/api/routes/generation.py +++ b/api/routes/generation.py @@ -65,6 +65,114 @@ def _resolve_sora_video_extras(data: dict) -> tuple[str, dict | None, dict | Non return locale, timeline_events, audio +def _coerce_video_duration(value: Any, allowed: list[int], default: int) -> int: + if value is None or str(value).strip() == "": + return default + try: + parsed = int(str(value).strip().rstrip("sS")) + except Exception: + raise HTTPException(status_code=400, detail="unsupported duration") + if parsed not in allowed: + raise HTTPException(status_code=400, detail="unsupported duration") + return parsed + + +def _coerce_video_resolution( + value: Any, allowed: list[str], default: str | None +) -> str | None: + if not allowed: + return default + if value is None or str(value).strip() == "": + return default + normalized = str(value).strip().lower() + resolution_aliases = { + "720": "720p", + "720p": "720p", + "1080": "1080p", + "1080p": "1080p", + "fhd": "1080p", + "fullhd": "1080p", + } + resolved = resolution_aliases.get(normalized, normalized) + if resolved not in allowed: + raise HTTPException(status_code=400, detail="unsupported resolution") + return resolved + + +def _resolve_video_request_config(model_id: str, data: dict, video_conf: dict) -> dict: + resolved = dict(video_conf or {}) + allow_request_overrides = bool(resolved.get("allow_request_overrides")) + + if not allow_request_overrides: + resolved["resolved_model_id"] = str(resolved.get("canonical_model") or model_id) + return resolved + + duration_options = [ + int(item) + for item in (resolved.get("duration_options") or []) + if str(item).strip() + ] + aspect_ratio_options = [ + str(item).strip() + for item in (resolved.get("aspect_ratio_options") or []) + if str(item).strip() + ] + resolution_options = [ + str(item).strip().lower() + for item in (resolved.get("resolution_options") or []) + if str(item).strip() + ] + reference_mode_options = [ + str(item).strip().lower() + for item in (resolved.get("reference_mode_options") or []) + if str(item).strip() + ] + + default_duration = int(resolved.get("duration") or (duration_options[0] if duration_options else 8)) + default_ratio = str( + resolved.get("aspect_ratio") or (aspect_ratio_options[0] if aspect_ratio_options else "16:9") + ).strip() + default_resolution = ( + str(resolved.get("resolution") or (resolution_options[0] if resolution_options else "")).strip().lower() + or None + ) + default_reference_mode = str( + resolved.get("reference_mode") or (reference_mode_options[0] if reference_mode_options else "frame") + ).strip().lower() + + requested_ratio = str(data.get("aspect_ratio") or "").strip() + if not requested_ratio and aspect_ratio_options: + requested_ratio = default_ratio + if requested_ratio and aspect_ratio_options and requested_ratio not in aspect_ratio_options: + raise HTTPException(status_code=400, detail="unsupported aspect_ratio") + + requested_resolution = ( + data.get("resolution") + or data.get("video_resolution") + or data.get("output_resolution") + ) + requested_reference_mode = str( + data.get("reference_mode") or data.get("video_reference_mode") or default_reference_mode + ).strip().lower() or default_reference_mode + if reference_mode_options and requested_reference_mode not in reference_mode_options: + raise HTTPException(status_code=400, detail="unsupported reference_mode") + + resolved["duration"] = _coerce_video_duration( + data.get("duration") or data.get("video_duration"), + duration_options, + default_duration, + ) + resolved["aspect_ratio"] = requested_ratio or default_ratio + resolved["resolution"] = _coerce_video_resolution( + requested_resolution, + resolution_options, + default_resolution, + ) + resolved["reference_mode"] = requested_reference_mode + resolved["resolved_model_id"] = model_id + return resolved + + def build_generation_router( *, store, @@ -111,13 +219,27 @@ def list_models(request: Request): } ) for model_id, conf in video_model_catalog.items(): + if conf.get("hidden"): + continue + item = { + "id": model_id, + "object": "model", + "owned_by": "adobe2api", + "description": conf["description"], + } + parameters = {} + if conf.get("duration_options"): + parameters["duration"] = conf["duration_options"] + if conf.get("aspect_ratio_options"): + parameters["aspect_ratio"] = conf["aspect_ratio_options"] + if conf.get("resolution_options"): + parameters["resolution"] = conf["resolution_options"] + if conf.get("reference_mode_options"): + parameters["reference_mode"] = conf["reference_mode_options"] + if parameters: + item["parameters"] = parameters data.append( - { - "id": model_id, - "object": "model", - "owned_by": "adobe2api", - "description": conf["description"], - } + item ) return {"object": "list", "data": data} @@ -502,7 +624,7 @@ def chat_completions(data: dict, request: Request): status_code=400, content={ "error": { - "message": "Invalid video model. Use /v1/models to get supported firefly-sora2-*, firefly-veo31-* or firefly-veo31-fast-* models", + "message": "Invalid video model. Use /v1/models to get supported firefly-sora2, firefly-sora2-pro, firefly-veo31 or firefly-veo31-fast models, then pass duration/aspect_ratio/resolution/reference_mode in the request body.", "type": "invalid_request_error", } }, @@ -511,23 +633,38 @@ def chat_completions(data: dict, request: Request): is_video_model = video_conf is not None if not is_video_model: _validate_prompt_length(prompt) - resolved_model_id = model_id if is_video_model else None + resolved_video_conf = ( + _resolve_video_request_config(model_id, data, video_conf or {}) + if is_video_model + else {} + ) + resolved_model_id = ( + str(resolved_video_conf.get("resolved_model_id") or model_id) + if is_video_model + else None + ) ratio = "9:16" output_resolution = "2K" - duration = int(video_conf["duration"]) if video_conf else 12 + duration = int(resolved_video_conf["duration"]) if is_video_model else 12 video_resolution = ( - str(video_conf.get("resolution") or "720p") if video_conf else "720p" + str(resolved_video_conf.get("resolution") or "720p") + if is_video_model + else "720p" + ) + if is_video_model: + ratio = str(resolved_video_conf.get("aspect_ratio") or ratio) + video_engine = ( + str(resolved_video_conf.get("engine") or "sora2") if is_video_model else "" ) - if video_conf: - ratio = str(video_conf.get("aspect_ratio") or ratio) - video_engine = str(video_conf.get("engine") or "sora2") if video_conf else "" generate_audio = True negative_prompt = "" video_locale = "en-US" timeline_events = None video_audio = None video_reference_mode = ( - str(video_conf.get("reference_mode") or "frame") if video_conf else "frame" + str(resolved_video_conf.get("reference_mode") or "frame") + if is_video_model + else "frame" ) if is_video_model: resolved_video_options = resolve_video_options(data) @@ -606,9 +743,9 @@ def _video_progress_cb(update: dict): except Exception: old_size = 0 - video_bytes, video_meta = client.generate_video( + video_bytes, video_meta = client.generate_video( token=token, - video_conf=video_conf or {}, + video_conf=resolved_video_conf or {}, prompt=prompt, aspect_ratio=ratio, duration=duration, diff --git a/core/models/catalog.py b/core/models/catalog.py index e5ec04a..1f9b42b 100644 --- a/core/models/catalog.py +++ b/core/models/catalog.py @@ -53,85 +53,170 @@ def _register_nano_banana_family( DEFAULT_MODEL_ID = "firefly-nano-banana-pro-2k-16x9" -VIDEO_MODEL_CATALOG: dict[str, dict] = { - "firefly-sora2-4s-9x16": { - "duration": 4, - "aspect_ratio": "9:16", - "description": "Firefly Sora2 video model (4s 9:16)", - }, - "firefly-sora2-4s-16x9": { - "duration": 4, - "aspect_ratio": "16:9", - "description": "Firefly Sora2 video model (4s 16:9)", - }, - "firefly-sora2-8s-9x16": { - "duration": 8, - "aspect_ratio": "9:16", - "description": "Firefly Sora2 video model (8s 9:16)", - }, - "firefly-sora2-8s-16x9": { - "duration": 8, - "aspect_ratio": "16:9", - "description": "Firefly Sora2 video model (8s 16:9)", - }, - "firefly-sora2-12s-9x16": { - "duration": 12, - "aspect_ratio": "9:16", - "description": "Firefly Sora2 video model (12s 9:16)", - }, - "firefly-sora2-12s-16x9": { - "duration": 12, - "aspect_ratio": "16:9", - "description": "Firefly Sora2 video model (12s 16:9)", - }, -} +VIDEO_MODEL_CATALOG: dict[str, dict] = {} + + +def _register_video_model( + model_id: str, + *, + description: str, + engine: str = "sora2", + upstream_model: str | None = None, + duration: int = 8, + duration_options: tuple[int, ...] = (), + aspect_ratio: str = "16:9", + aspect_ratio_options: tuple[str, ...] = (), + resolution: str | None = None, + resolution_options: tuple[str, ...] = (), + reference_mode: str = "frame", + reference_mode_options: tuple[str, ...] = (), +) -> None: + VIDEO_MODEL_CATALOG[model_id] = { + "description": description, + "engine": engine, + "upstream_model": upstream_model, + "duration": duration, + "duration_options": list(duration_options or (duration,)), + "aspect_ratio": aspect_ratio, + "aspect_ratio_options": list(aspect_ratio_options or (aspect_ratio,)), + "resolution": resolution, + "resolution_options": list(resolution_options), + "reference_mode": reference_mode, + "reference_mode_options": list(reference_mode_options or (reference_mode,)), + "allow_request_overrides": True, + } + + +def _register_video_alias( + alias_id: str, + *, + canonical_model: str, + duration: int, + aspect_ratio: str, + resolution: str | None = None, + reference_mode: str = "frame", + description: str, +) -> None: + base = dict(VIDEO_MODEL_CATALOG[canonical_model]) + base.update( + { + "canonical_model": canonical_model, + "description": description, + "duration": duration, + "aspect_ratio": aspect_ratio, + "resolution": resolution, + "reference_mode": reference_mode, + "hidden": True, + "allow_request_overrides": False, + } + ) + VIDEO_MODEL_CATALOG[alias_id] = base + + +_register_video_model( + "firefly-sora2", + description="Firefly Sora2 video model (set duration/aspect_ratio in request)", + engine="sora2", + upstream_model="openai:firefly:colligo:sora2", + duration=8, + duration_options=(4, 8, 12), + aspect_ratio="16:9", + aspect_ratio_options=("16:9", "9:16"), +) + +_register_video_model( + "firefly-sora2-pro", + description="Firefly Sora2 Pro video model (set duration/aspect_ratio in request)", + engine="sora2", + upstream_model="openai:firefly:colligo:sora2-pro", + duration=8, + duration_options=(4, 8, 12), + aspect_ratio="16:9", + aspect_ratio_options=("16:9", "9:16"), +) + +_register_video_model( + "firefly-veo31", + description="Firefly Veo31 video model (set duration/aspect_ratio/resolution/reference_mode in request)", + engine="veo31-standard", + upstream_model="google:firefly:colligo:veo31", + duration=4, + duration_options=(4, 6, 8), + aspect_ratio="16:9", + aspect_ratio_options=("16:9", "9:16"), + resolution="720p", + resolution_options=("720p", "1080p"), + reference_mode="frame", + reference_mode_options=("frame", "image"), +) + +_register_video_model( + "firefly-veo31-fast", + description="Firefly Veo31 Fast video model (set duration/aspect_ratio/resolution in request)", + engine="veo31-fast", + upstream_model="google:firefly:colligo:veo31-fast", + duration=4, + duration_options=(4, 6, 8), + aspect_ratio="16:9", + aspect_ratio_options=("16:9", "9:16"), + resolution="720p", + resolution_options=("720p", "1080p"), + reference_mode="frame", +) for dur in (4, 8, 12): for ratio in ("9:16", "16:9"): - model_id = f"firefly-sora2-pro-{dur}s-{RATIO_SUFFIX_MAP[ratio]}" - VIDEO_MODEL_CATALOG[model_id] = { - "duration": dur, - "aspect_ratio": ratio, - "upstream_model": "openai:firefly:colligo:sora2-pro", - "description": f"Firefly Sora2 Pro video model ({dur}s {ratio})", - } + _register_video_alias( + f"firefly-sora2-{dur}s-{RATIO_SUFFIX_MAP[ratio]}", + canonical_model="firefly-sora2", + duration=dur, + aspect_ratio=ratio, + description=f"Firefly Sora2 video model ({dur}s {ratio})", + ) + +for dur in (4, 8, 12): + for ratio in ("9:16", "16:9"): + _register_video_alias( + f"firefly-sora2-pro-{dur}s-{RATIO_SUFFIX_MAP[ratio]}", + canonical_model="firefly-sora2-pro", + duration=dur, + aspect_ratio=ratio, + description=f"Firefly Sora2 Pro video model ({dur}s {ratio})", + ) for dur in (4, 6, 8): for ratio in ("16:9", "9:16"): for res in ("1080p", "720p"): - model_id = f"firefly-veo31-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}" - VIDEO_MODEL_CATALOG[model_id] = { - "engine": "veo31-standard", - "upstream_model": "google:firefly:colligo:veo31", - "duration": dur, - "aspect_ratio": ratio, - "resolution": res, - "description": f"Firefly Veo31 video model ({dur}s {ratio} {res})", - } + _register_video_alias( + f"firefly-veo31-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}", + canonical_model="firefly-veo31", + duration=dur, + aspect_ratio=ratio, + resolution=res, + description=f"Firefly Veo31 video model ({dur}s {ratio} {res})", + ) for dur in (4, 6, 8): for ratio in ("16:9", "9:16"): for res in ("1080p", "720p"): - model_id = f"firefly-veo31-ref-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}" - VIDEO_MODEL_CATALOG[model_id] = { - "engine": "veo31-standard", - "upstream_model": "google:firefly:colligo:veo31", - "duration": dur, - "aspect_ratio": ratio, - "resolution": res, - "reference_mode": "image", - "description": f"Firefly Veo31 Ref video model ({dur}s {ratio} {res})", - } + _register_video_alias( + f"firefly-veo31-ref-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}", + canonical_model="firefly-veo31", + duration=dur, + aspect_ratio=ratio, + resolution=res, + reference_mode="image", + description=f"Firefly Veo31 Ref video model ({dur}s {ratio} {res})", + ) for dur in (4, 6, 8): for ratio in ("16:9", "9:16"): for res in ("1080p", "720p"): - model_id = f"firefly-veo31-fast-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}" - VIDEO_MODEL_CATALOG[model_id] = { - "engine": "veo31-fast", - "upstream_model": "google:firefly:colligo:veo31-fast", - "duration": dur, - "aspect_ratio": ratio, - "resolution": res, - "description": f"Firefly Veo31 Fast video model ({dur}s {ratio} {res})", - } + _register_video_alias( + f"firefly-veo31-fast-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}", + canonical_model="firefly-veo31-fast", + duration=dur, + aspect_ratio=ratio, + resolution=res, + description=f"Firefly Veo31 Fast video model ({dur}s {ratio} {res})", + ) From 5c2129ae0397ef9e676efdceff67652ec4c1a938 Mon Sep 17 00:00:00 2001 From: LJR199887 <172809968+LJR199887@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:10:13 +0800 Subject: [PATCH 03/20] unify image model names --- README_EN.md | 63 ++++++++++++++++++++++++++-------------- api/routes/generation.py | 24 ++++++++++----- core/models/catalog.py | 48 ++++++++++++++++++++++++------ core/models/resolver.py | 43 ++++++++++++++++++++------- 4 files changed, 130 insertions(+), 48 deletions(-) diff --git a/README_EN.md b/README_EN.md index d9ca29d..12d22c7 100644 --- a/README_EN.md +++ b/README_EN.md @@ -68,40 +68,41 @@ Admin UI and admin APIs require login session cookie via `/api/v1/auth/login`. Current supported model families are: -- `firefly-nano-banana-*` (image, maps to upstream `nano-banana-2`) -- `firefly-nano-banana2-*` (image, maps to upstream `nano-banana-3`) -- `firefly-nano-banana-pro-*` (image) +- `firefly-nano-banana` (image, maps to upstream `nano-banana-2`) +- `firefly-nano-banana2` (image, maps to upstream `nano-banana-3`) +- `firefly-nano-banana-pro` (image) - `firefly-sora2` (video) - `firefly-sora2-pro` (video) - `firefly-veo31` (video) +- `firefly-veo31-ref` (video, reference-image mode) - `firefly-veo31-fast` (video) Nano Banana image models (`nano-banana-2`): -- Pattern: `firefly-nano-banana-{resolution}-{ratio}` -- Resolution: `1k` / `2k` / `4k` -- Ratio suffix: `1x1` / `16x9` / `9x16` / `4x3` / `3x4` +- Pattern: `model=firefly-nano-banana` with separate request fields +- Resolution: pass `output_resolution` as `1K` / `2K` / `4K` +- Ratio: pass `aspect_ratio` as `1:1` / `16:9` / `9:16` / `4:3` / `3:4` - Examples: - - `firefly-nano-banana-2k-16x9` - - `firefly-nano-banana-4k-1x1` + - `model=firefly-nano-banana, output_resolution=2K, aspect_ratio=16:9` + - `model=firefly-nano-banana, output_resolution=4K, aspect_ratio=1:1` Nano Banana 2 image models (`nano-banana-3`): -- Pattern: `firefly-nano-banana2-{resolution}-{ratio}` -- Resolution: `1k` / `2k` / `4k` -- Ratio suffix: `1x1` / `16x9` / `9x16` / `4x3` / `3x4` +- Pattern: `model=firefly-nano-banana2` with separate request fields +- Resolution: pass `output_resolution` as `1K` / `2K` / `4K` +- Ratio: pass `aspect_ratio` as `1:1` / `16:9` / `9:16` / `4:3` / `3:4` - Examples: - - `firefly-nano-banana2-2k-16x9` - - `firefly-nano-banana2-4k-1x1` + - `model=firefly-nano-banana2, output_resolution=2K, aspect_ratio=16:9` + - `model=firefly-nano-banana2, output_resolution=4K, aspect_ratio=1:1` Nano Banana Pro image models (legacy-compatible): -- Pattern: `firefly-nano-banana-pro-{resolution}-{ratio}` -- Resolution: `1k` / `2k` / `4k` -- Ratio suffix: `1x1` / `16x9` / `9x16` / `4x3` / `3x4` +- Pattern: `model=firefly-nano-banana-pro` with separate request fields +- Resolution: pass `output_resolution` as `1K` / `2K` / `4K` +- Ratio: pass `aspect_ratio` as `1:1` / `16:9` / `9:16` / `4:3` / `3:4` - Examples: - - `firefly-nano-banana-pro-2k-16x9` - - `firefly-nano-banana-pro-4k-1x1` + - `model=firefly-nano-banana-pro, output_resolution=2K, aspect_ratio=16:9` + - `model=firefly-nano-banana-pro, output_resolution=4K, aspect_ratio=1:1` Sora2 video models: @@ -137,6 +138,18 @@ Veo31 video models: - `model=firefly-veo31, duration=4, aspect_ratio=16:9, resolution=1080p` - `model=firefly-veo31, duration=6, aspect_ratio=9:16, resolution=720p, reference_mode=image` +Veo31 Ref video models: + +- Pattern: `model=firefly-veo31-ref` with separate request fields +- Duration: pass `duration` as `4` / `6` / `8` +- Ratio: pass `aspect_ratio` as `16:9` / `9:16` +- Resolution: pass `resolution` as `1080p` / `720p` +- Always uses reference image mode +- Supports up to 3 reference images +- Examples: + - `model=firefly-veo31-ref, duration=4, aspect_ratio=9:16, resolution=720p` + - `model=firefly-veo31-ref, duration=6, aspect_ratio=16:9, resolution=1080p` + Veo31 Fast video models: - Pattern: `model=firefly-veo31-fast` with separate request fields @@ -167,7 +180,9 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-nano-banana-pro-2k-16x9", + "model": "firefly-nano-banana-pro", + "output_resolution": "2K", + "aspect_ratio": "16:9", "messages": [{"role":"user","content":"a cinematic mountain sunrise"}] }' ``` @@ -179,7 +194,9 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-nano-banana-pro-2k-16x9", + "model": "firefly-nano-banana-pro", + "output_resolution": "2K", + "aspect_ratio": "16:9", "messages": [{ "role":"user", "content":[ @@ -238,7 +255,7 @@ Veo31 single-image semantics: - `firefly-veo31` / `firefly-veo31-fast` with `reference_mode=frame`: frame mode - 1 image => first frame - 2 images => first frame + last frame -- `firefly-veo31` with `reference_mode=image`: reference-image mode +- `firefly-veo31-ref` or `firefly-veo31` with `reference_mode=image`: reference-image mode - 1~3 images => reference images Image-to-video: @@ -268,7 +285,9 @@ curl -X POST "http://127.0.0.1:6001/v1/images/generations" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-nano-banana-pro-4k-16x9", + "model": "firefly-nano-banana-pro", + "output_resolution": "4K", + "aspect_ratio": "16:9", "prompt": "futuristic city skyline at dusk" }' ``` diff --git a/api/routes/generation.py b/api/routes/generation.py index c61adee..5a0911b 100644 --- a/api/routes/generation.py +++ b/api/routes/generation.py @@ -210,13 +210,23 @@ def list_models(request: Request): require_service_api_key(request) data = [] for model_id, conf in model_catalog.items(): + if conf.get("hidden"): + continue + item = { + "id": model_id, + "object": "model", + "owned_by": "adobe2api", + "description": conf["description"], + } + parameters = {} + if conf.get("output_resolution_options"): + parameters["output_resolution"] = conf["output_resolution_options"] + if conf.get("aspect_ratio_options"): + parameters["aspect_ratio"] = conf["aspect_ratio_options"] + if parameters: + item["parameters"] = parameters data.append( - { - "id": model_id, - "object": "model", - "owned_by": "adobe2api", - "description": conf["description"], - } + item ) for model_id, conf in video_model_catalog.items(): if conf.get("hidden"): @@ -624,7 +634,7 @@ def chat_completions(data: dict, request: Request): status_code=400, content={ "error": { - "message": "Invalid video model. Use /v1/models to get supported firefly-sora2, firefly-sora2-pro, firefly-veo31 or firefly-veo31-fast models, then pass duration/aspect_ratio/resolution/reference_mode in the request body.", + "message": "Invalid video model. Use /v1/models to get supported firefly-sora2, firefly-sora2-pro, firefly-veo31, firefly-veo31-ref or firefly-veo31-fast models, then pass duration/aspect_ratio/resolution/reference_mode in the request body.", "type": "invalid_request_error", } }, diff --git a/core/models/catalog.py b/core/models/catalog.py index 1f9b42b..9057f1c 100644 --- a/core/models/catalog.py +++ b/core/models/catalog.py @@ -12,46 +12,61 @@ MODEL_CATALOG: dict[str, dict] = {} -def _register_nano_banana_family( - prefix: str, +def _register_image_model( + model_id: str, *, upstream_model_id: str, upstream_model_version: str, family_label: str, ) -> None: + MODEL_CATALOG[model_id] = { + "upstream_model": "google:firefly:colligo:nano-banana-pro", + "upstream_model_id": upstream_model_id, + "upstream_model_version": upstream_model_version, + "output_resolution": "2K", + "output_resolution_options": ["1K", "2K", "4K"], + "aspect_ratio": "16:9", + "aspect_ratio_options": ["1:1", "16:9", "9:16", "4:3", "3:4"], + "description": f"{family_label} image model (set output_resolution/aspect_ratio in request)", + "allow_request_overrides": True, + } + for res in ("1k", "2k", "4k"): for ratio, suffix in RATIO_SUFFIX_MAP.items(): - model_id = f"{prefix}-{res}-{suffix}" - MODEL_CATALOG[model_id] = { + alias_id = f"{model_id}-{res}-{suffix}" + MODEL_CATALOG[alias_id] = { "upstream_model": "google:firefly:colligo:nano-banana-pro", "upstream_model_id": upstream_model_id, "upstream_model_version": upstream_model_version, "output_resolution": res.upper(), "aspect_ratio": ratio, "description": f"{family_label} ({res.upper()} {ratio})", + "canonical_model": model_id, + "hidden": True, + "allow_request_overrides": False, } -_register_nano_banana_family( +_register_image_model( "firefly-nano-banana-pro", upstream_model_id="gemini-flash", upstream_model_version="nano-banana-2", family_label="Firefly Nano Banana Pro", ) -_register_nano_banana_family( +_register_image_model( "firefly-nano-banana", upstream_model_id="gemini-flash", upstream_model_version="nano-banana-2", family_label="Firefly Nano Banana", ) -_register_nano_banana_family( +_register_image_model( "firefly-nano-banana2", upstream_model_id="gemini-flash", upstream_model_version="nano-banana-3", family_label="Firefly Nano Banana 2", ) -DEFAULT_MODEL_ID = "firefly-nano-banana-pro-2k-16x9" +DEFAULT_MODEL_ID = "firefly-nano-banana-pro" VIDEO_MODEL_CATALOG: dict[str, dict] = {} @@ -150,6 +165,21 @@ def _register_video_alias( reference_mode_options=("frame", "image"), ) +_register_video_model( + "firefly-veo31-ref", + description="Firefly Veo31 Ref video model (set duration/aspect_ratio/resolution in request)", + engine="veo31-standard", + upstream_model="google:firefly:colligo:veo31", + duration=4, + duration_options=(4, 6, 8), + aspect_ratio="16:9", + aspect_ratio_options=("16:9", "9:16"), + resolution="720p", + resolution_options=("720p", "1080p"), + reference_mode="image", + reference_mode_options=("image",), +) + _register_video_model( "firefly-veo31-fast", description="Firefly Veo31 Fast video model (set duration/aspect_ratio/resolution in request)", @@ -201,7 +231,7 @@ def _register_video_alias( for res in ("1080p", "720p"): _register_video_alias( f"firefly-veo31-ref-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}", - canonical_model="firefly-veo31", + canonical_model="firefly-veo31-ref", duration=dur, aspect_ratio=ratio, resolution=res, diff --git a/core/models/resolver.py b/core/models/resolver.py index 5d0f6d7..b3ed774 100644 --- a/core/models/resolver.py +++ b/core/models/resolver.py @@ -30,21 +30,40 @@ def ratio_from_size(size: str) -> str: return mapping.get(str(size or "").strip(), "1:1") +def _normalize_output_resolution(value: str) -> str: + normalized = str(value or "").strip().upper() + aliases = { + "1K": "1K", + "HD": "2K", + "2K": "2K", + "4K": "4K", + "ULTRA": "4K", + } + return aliases.get(normalized, normalized or "2K") + + def resolve_ratio_and_resolution( data: dict, model_id: Optional[str] ) -> tuple[str, str, str]: - ratio = str(data.get("aspect_ratio") or "").strip() or ratio_from_size( - data.get("size", "1024x1024") - ) - if ratio not in SUPPORTED_RATIOS: - ratio = "1:1" - resolved_model_id = model_id or DEFAULT_MODEL_ID if resolved_model_id not in MODEL_CATALOG: resolved_model_id = DEFAULT_MODEL_ID model_conf = MODEL_CATALOG[resolved_model_id] - output_resolution = model_conf["output_resolution"] + if not model_conf.get("allow_request_overrides"): + ratio = str(model_conf.get("aspect_ratio") or "1:1").strip() + output_resolution = str(model_conf.get("output_resolution") or "2K").upper() + return ratio, output_resolution, resolved_model_id + + ratio = str(data.get("aspect_ratio") or "").strip() or ratio_from_size( + data.get("size", "1024x1024") + ) + if ratio not in SUPPORTED_RATIOS: + ratio = str(model_conf.get("aspect_ratio") or "1:1").strip() + + output_resolution = _normalize_output_resolution( + data.get("output_resolution") or model_conf.get("output_resolution") or "2K" + ) if not model_id: quality = str(data.get("quality", "2k")).lower() if quality in ("4k", "ultra"): @@ -54,8 +73,12 @@ def resolve_ratio_and_resolution( else: output_resolution = "1K" - model_ratio = model_conf.get("aspect_ratio") - if model_ratio: - ratio = model_ratio + allowed_resolutions = [ + str(item).strip().upper() + for item in (model_conf.get("output_resolution_options") or []) + if str(item).strip() + ] + if allowed_resolutions and output_resolution not in allowed_resolutions: + output_resolution = str(model_conf.get("output_resolution") or "2K").upper() return ratio, output_resolution, resolved_model_id From 033acc0cb644712e60716df6f7f6c786e0837a6b Mon Sep 17 00:00:00 2001 From: LJR199887 <172809968+LJR199887@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:13:18 +0800 Subject: [PATCH 04/20] sync chinese README model naming --- README.md | 130 +++++++++++++++++++++++++++++------------------------- 1 file changed, 71 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index bc6e939..28688c9 100644 --- a/README.md +++ b/README.md @@ -68,100 +68,102 @@ docker compose up -d --build 当前支持如下模型族: -- `firefly-nano-banana-*`(图像,对应上游 `nano-banana-2`) -- `firefly-nano-banana2-*`(图像,对应上游 `nano-banana-3`) -- `firefly-nano-banana-pro-*`(图像) -- `firefly-sora2-*`(视频) -- `firefly-sora2-pro-*`(视频) -- `firefly-veo31-*`(视频) -- `firefly-veo31-ref-*`(视频,参考图模式) -- `firefly-veo31-fast-*`(视频) +- `firefly-nano-banana`(图像,对应上游 `nano-banana-2`) +- `firefly-nano-banana2`(图像,对应上游 `nano-banana-3`) +- `firefly-nano-banana-pro`(图像) +- `firefly-sora2`(视频) +- `firefly-sora2-pro`(视频) +- `firefly-veo31`(视频) +- `firefly-veo31-ref`(视频,参考图模式) +- `firefly-veo31-fast`(视频) Nano Banana 图像模型(`nano-banana-2`): -- 命名:`firefly-nano-banana-{resolution}-{ratio}` -- 分辨率:`1k` / `2k` / `4k` -- 比例后缀:`1x1` / `16x9` / `9x16` / `4x3` / `3x4` +- 命名:`model=firefly-nano-banana`,尺寸参数单独传 +- 分辨率:通过 `output_resolution` 传 `1K` / `2K` / `4K` +- 比例:通过 `aspect_ratio` 传 `1:1` / `16:9` / `9:16` / `4:3` / `3:4` - 示例: - - `firefly-nano-banana-2k-16x9` - - `firefly-nano-banana-4k-1x1` + - `model=firefly-nano-banana, output_resolution=2K, aspect_ratio=16:9` + - `model=firefly-nano-banana, output_resolution=4K, aspect_ratio=1:1` Nano Banana 2 图像模型(`nano-banana-3`): -- 命名:`firefly-nano-banana2-{resolution}-{ratio}` -- 分辨率:`1k` / `2k` / `4k` -- 比例后缀:`1x1` / `16x9` / `9x16` / `4x3` / `3x4` +- 命名:`model=firefly-nano-banana2`,尺寸参数单独传 +- 分辨率:通过 `output_resolution` 传 `1K` / `2K` / `4K` +- 比例:通过 `aspect_ratio` 传 `1:1` / `16:9` / `9:16` / `4:3` / `3:4` - 示例: - - `firefly-nano-banana2-2k-16x9` - - `firefly-nano-banana2-4k-1x1` + - `model=firefly-nano-banana2, output_resolution=2K, aspect_ratio=16:9` + - `model=firefly-nano-banana2, output_resolution=4K, aspect_ratio=1:1` Nano Banana Pro 图像模型(兼容旧命名): -- 命名:`firefly-nano-banana-pro-{resolution}-{ratio}` -- 分辨率:`1k` / `2k` / `4k` -- 比例后缀:`1x1` / `16x9` / `9x16` / `4x3` / `3x4` +- 命名:`model=firefly-nano-banana-pro`,尺寸参数单独传 +- 分辨率:通过 `output_resolution` 传 `1K` / `2K` / `4K` +- 比例:通过 `aspect_ratio` 传 `1:1` / `16:9` / `9:16` / `4:3` / `3:4` - 示例: - - `firefly-nano-banana-pro-2k-16x9` - - `firefly-nano-banana-pro-4k-1x1` + - `model=firefly-nano-banana-pro, output_resolution=2K, aspect_ratio=16:9` + - `model=firefly-nano-banana-pro, output_resolution=4K, aspect_ratio=1:1` Sora2 视频模型: -- 命名:`firefly-sora2-{duration}-{ratio}` -- 时长:`4s` / `8s` / `12s` -- 比例:`9x16` / `16x9` +- 命名:`model=firefly-sora2`,参数单独传 +- 时长:通过 `duration` 传 `4` / `8` / `12` +- 比例:通过 `aspect_ratio` 传 `9:16` / `16:9` - 示例: - - `firefly-sora2-4s-16x9` - - `firefly-sora2-8s-9x16` + - `model=firefly-sora2, duration=4, aspect_ratio=16:9` + - `model=firefly-sora2, duration=8, aspect_ratio=9:16` Sora2 Pro 视频模型: -- 命名:`firefly-sora2-pro-{duration}-{ratio}` -- 时长:`4s` / `8s` / `12s` -- 比例:`9x16` / `16x9` +- 命名:`model=firefly-sora2-pro`,参数单独传 +- 时长:通过 `duration` 传 `4` / `8` / `12` +- 比例:通过 `aspect_ratio` 传 `9:16` / `16:9` - 示例: - - `firefly-sora2-pro-4s-16x9` - - `firefly-sora2-pro-8s-9x16` + - `model=firefly-sora2-pro, duration=4, aspect_ratio=16:9` + - `model=firefly-sora2-pro, duration=8, aspect_ratio=9:16` Veo31 视频模型: -- 命名:`firefly-veo31-{duration}-{ratio}-{resolution}` -- 时长:`4s` / `6s` / `8s` -- 比例:`16x9` / `9x16` -- 分辨率:`1080p` / `720p` +- 命名:`model=firefly-veo31`,参数单独传 +- 时长:通过 `duration` 传 `4` / `6` / `8` +- 比例:通过 `aspect_ratio` 传 `16:9` / `9:16` +- 分辨率:通过 `resolution` 传 `1080p` / `720p` +- 参考模式:通过 `reference_mode` 传 `frame` 或 `image` - 最多支持 2 张参考图: - 1 张:首帧参考 - 2 张:首帧 + 尾帧参考 +- 当 `reference_mode=image` 时,最多支持 3 张参考图 - 音频默认开启 - 示例: - - `firefly-veo31-4s-16x9-1080p` - - `firefly-veo31-6s-9x16-720p` + - `model=firefly-veo31, duration=4, aspect_ratio=16:9, resolution=1080p` + - `model=firefly-veo31, duration=6, aspect_ratio=9:16, resolution=720p, reference_mode=image` Veo31 Ref 视频模型(参考图模式): -- 命名:`firefly-veo31-ref-{duration}-{ratio}-{resolution}` -- 时长:`4s` / `6s` / `8s` -- 比例:`16x9` / `9x16` -- 分辨率:`1080p` / `720p` +- 命名:`model=firefly-veo31-ref`,参数单独传 +- 时长:通过 `duration` 传 `4` / `6` / `8` +- 比例:通过 `aspect_ratio` 传 `16:9` / `9:16` +- 分辨率:通过 `resolution` 传 `1080p` / `720p` - 始终使用参考图模式(不是首尾帧模式) - 最多支持 3 张参考图(映射到上游 `referenceBlobs[].usage="asset"`) - 示例: - - `firefly-veo31-ref-4s-9x16-720p` - - `firefly-veo31-ref-6s-16x9-1080p` - - `firefly-veo31-ref-8s-9x16-1080p` + - `model=firefly-veo31-ref, duration=4, aspect_ratio=9:16, resolution=720p` + - `model=firefly-veo31-ref, duration=6, aspect_ratio=16:9, resolution=1080p` + - `model=firefly-veo31-ref, duration=8, aspect_ratio=9:16, resolution=1080p` Veo31 Fast 视频模型: -- 命名:`firefly-veo31-fast-{duration}-{ratio}-{resolution}` -- 时长:`4s` / `6s` / `8s` -- 比例:`16x9` / `9x16` -- 分辨率:`1080p` / `720p` +- 命名:`model=firefly-veo31-fast`,参数单独传 +- 时长:通过 `duration` 传 `4` / `6` / `8` +- 比例:通过 `aspect_ratio` 传 `16:9` / `9:16` +- 分辨率:通过 `resolution` 传 `1080p` / `720p` - 最多支持 2 张参考图: - 1 张:首帧参考 - 2 张:首帧 + 尾帧参考 - 音频默认开启 - 示例: - - `firefly-veo31-fast-4s-16x9-1080p` - - `firefly-veo31-fast-6s-9x16-720p` + - `model=firefly-veo31-fast, duration=4, aspect_ratio=16:9, resolution=1080p` + - `model=firefly-veo31-fast, duration=6, aspect_ratio=9:16, resolution=720p` ### 3.1 获取模型列表 @@ -179,7 +181,9 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-nano-banana-pro-2k-16x9", + "model": "firefly-nano-banana-pro", + "output_resolution": "2K", + "aspect_ratio": "16:9", "messages": [{"role":"user","content":"a cinematic mountain sunrise"}] }' ``` @@ -191,7 +195,9 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-nano-banana-pro-2k-16x9", + "model": "firefly-nano-banana-pro", + "output_resolution": "2K", + "aspect_ratio": "16:9", "messages": [{ "role":"user", "content":[ @@ -209,17 +215,19 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-sora2-4s-16x9", + "model": "firefly-sora2", + "duration": 4, + "aspect_ratio": "16:9", "messages": [{"role":"user","content":"a drone shot over snowy forest"}] }' ``` Veo31 单图语义说明: -- `firefly-veo31-*` / `firefly-veo31-fast-*`:帧模式 +- `firefly-veo31` / `firefly-veo31-fast` 且 `reference_mode=frame`:帧模式 - 1 张图 => 首帧 - 2 张图 => 首帧 + 尾帧 -- `firefly-veo31-ref-*`:参考图模式 +- `firefly-veo31-ref`,或 `firefly-veo31` 且 `reference_mode=image`:参考图模式 - 1~3 张图 => 参考图 图生视频: @@ -229,7 +237,9 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-sora2-8s-9x16", + "model": "firefly-sora2", + "duration": 8, + "aspect_ratio": "9:16", "messages": [{ "role":"user", "content":[ @@ -247,7 +257,9 @@ curl -X POST "http://127.0.0.1:6001/v1/images/generations" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-nano-banana-pro-4k-16x9", + "model": "firefly-nano-banana-pro", + "output_resolution": "4K", + "aspect_ratio": "16:9", "prompt": "futuristic city skyline at dusk" }' ``` From 8ed50322ef0050ab871f2b1b73c654ad2ece29f5 Mon Sep 17 00:00:00 2001 From: LJR199887 <172809968+LJR199887@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:44:05 +0800 Subject: [PATCH 05/20] drop firefly prefix from model ids --- README.md | 114 ++++++++++++-------- api/routes/generation.py | 9 +- core/models/catalog.py | 220 +++++++++++++++++++++++++++------------ core/models/resolver.py | 8 +- 4 files changed, 236 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index 28688c9..f9a551d 100644 --- a/README.md +++ b/README.md @@ -68,63 +68,90 @@ docker compose up -d --build 当前支持如下模型族: -- `firefly-nano-banana`(图像,对应上游 `nano-banana-2`) -- `firefly-nano-banana2`(图像,对应上游 `nano-banana-3`) -- `firefly-nano-banana-pro`(图像) -- `firefly-sora2`(视频) -- `firefly-sora2-pro`(视频) -- `firefly-veo31`(视频) -- `firefly-veo31-ref`(视频,参考图模式) -- `firefly-veo31-fast`(视频) +- `nano-banana`(图像,对应上游 `nano-banana-2`) +- `nano-banana-4k`(图像,固定 4K,对应上游 `nano-banana-2`) +- `nano-banana2`(图像,对应上游 `nano-banana-3`) +- `nano-banana2-4k`(图像,固定 4K,对应上游 `nano-banana-3`) +- `nano-banana-pro`(图像) +- `nano-banana-pro-4k`(图像,固定 4K) +- `sora2`(视频) +- `sora2-pro`(视频) +- `veo31`(视频) +- `veo31-ref`(视频,参考图模式) +- `veo31-fast`(视频) Nano Banana 图像模型(`nano-banana-2`): -- 命名:`model=firefly-nano-banana`,尺寸参数单独传 -- 分辨率:通过 `output_resolution` 传 `1K` / `2K` / `4K` +- 命名:`model=nano-banana`,尺寸参数单独传 +- 分辨率:通过 `output_resolution` 传 `1K` / `2K` - 比例:通过 `aspect_ratio` 传 `1:1` / `16:9` / `9:16` / `4:3` / `3:4` - 示例: - - `model=firefly-nano-banana, output_resolution=2K, aspect_ratio=16:9` - - `model=firefly-nano-banana, output_resolution=4K, aspect_ratio=1:1` + - `model=nano-banana, output_resolution=2K, aspect_ratio=16:9` + - `model=nano-banana, output_resolution=1K, aspect_ratio=1:1` + +Nano Banana 4K 图像模型(`nano-banana-2`): + +- 命名:`model=nano-banana-4k` +- 分辨率固定为 `4K` +- 比例:通过 `aspect_ratio` 传 `1:1` / `16:9` / `9:16` / `4:3` / `3:4` +- 示例: + - `model=nano-banana-4k, aspect_ratio=16:9` Nano Banana 2 图像模型(`nano-banana-3`): -- 命名:`model=firefly-nano-banana2`,尺寸参数单独传 -- 分辨率:通过 `output_resolution` 传 `1K` / `2K` / `4K` +- 命名:`model=nano-banana2`,尺寸参数单独传 +- 分辨率:通过 `output_resolution` 传 `1K` / `2K` +- 比例:通过 `aspect_ratio` 传 `1:1` / `16:9` / `9:16` / `4:3` / `3:4` +- 示例: + - `model=nano-banana2, output_resolution=2K, aspect_ratio=16:9` + - `model=nano-banana2, output_resolution=1K, aspect_ratio=1:1` + +Nano Banana 2 4K 图像模型(`nano-banana-3`): + +- 命名:`model=nano-banana2-4k` +- 分辨率固定为 `4K` - 比例:通过 `aspect_ratio` 传 `1:1` / `16:9` / `9:16` / `4:3` / `3:4` - 示例: - - `model=firefly-nano-banana2, output_resolution=2K, aspect_ratio=16:9` - - `model=firefly-nano-banana2, output_resolution=4K, aspect_ratio=1:1` + - `model=nano-banana2-4k, aspect_ratio=16:9` Nano Banana Pro 图像模型(兼容旧命名): -- 命名:`model=firefly-nano-banana-pro`,尺寸参数单独传 -- 分辨率:通过 `output_resolution` 传 `1K` / `2K` / `4K` +- 命名:`model=nano-banana-pro`,尺寸参数单独传 +- 分辨率:通过 `output_resolution` 传 `1K` / `2K` +- 比例:通过 `aspect_ratio` 传 `1:1` / `16:9` / `9:16` / `4:3` / `3:4` +- 示例: + - `model=nano-banana-pro, output_resolution=2K, aspect_ratio=16:9` + - `model=nano-banana-pro, output_resolution=1K, aspect_ratio=1:1` + +Nano Banana Pro 4K 图像模型: + +- 命名:`model=nano-banana-pro-4k` +- 分辨率固定为 `4K` - 比例:通过 `aspect_ratio` 传 `1:1` / `16:9` / `9:16` / `4:3` / `3:4` - 示例: - - `model=firefly-nano-banana-pro, output_resolution=2K, aspect_ratio=16:9` - - `model=firefly-nano-banana-pro, output_resolution=4K, aspect_ratio=1:1` + - `model=nano-banana-pro-4k, aspect_ratio=16:9` Sora2 视频模型: -- 命名:`model=firefly-sora2`,参数单独传 +- 命名:`model=sora2`,参数单独传 - 时长:通过 `duration` 传 `4` / `8` / `12` - 比例:通过 `aspect_ratio` 传 `9:16` / `16:9` - 示例: - - `model=firefly-sora2, duration=4, aspect_ratio=16:9` - - `model=firefly-sora2, duration=8, aspect_ratio=9:16` + - `model=sora2, duration=4, aspect_ratio=16:9` + - `model=sora2, duration=8, aspect_ratio=9:16` Sora2 Pro 视频模型: -- 命名:`model=firefly-sora2-pro`,参数单独传 +- 命名:`model=sora2-pro`,参数单独传 - 时长:通过 `duration` 传 `4` / `8` / `12` - 比例:通过 `aspect_ratio` 传 `9:16` / `16:9` - 示例: - - `model=firefly-sora2-pro, duration=4, aspect_ratio=16:9` - - `model=firefly-sora2-pro, duration=8, aspect_ratio=9:16` + - `model=sora2-pro, duration=4, aspect_ratio=16:9` + - `model=sora2-pro, duration=8, aspect_ratio=9:16` Veo31 视频模型: -- 命名:`model=firefly-veo31`,参数单独传 +- 命名:`model=veo31`,参数单独传 - 时长:通过 `duration` 传 `4` / `6` / `8` - 比例:通过 `aspect_ratio` 传 `16:9` / `9:16` - 分辨率:通过 `resolution` 传 `1080p` / `720p` @@ -135,25 +162,25 @@ Veo31 视频模型: - 当 `reference_mode=image` 时,最多支持 3 张参考图 - 音频默认开启 - 示例: - - `model=firefly-veo31, duration=4, aspect_ratio=16:9, resolution=1080p` - - `model=firefly-veo31, duration=6, aspect_ratio=9:16, resolution=720p, reference_mode=image` + - `model=veo31, duration=4, aspect_ratio=16:9, resolution=1080p` + - `model=veo31, duration=6, aspect_ratio=9:16, resolution=720p, reference_mode=image` Veo31 Ref 视频模型(参考图模式): -- 命名:`model=firefly-veo31-ref`,参数单独传 +- 命名:`model=veo31-ref`,参数单独传 - 时长:通过 `duration` 传 `4` / `6` / `8` - 比例:通过 `aspect_ratio` 传 `16:9` / `9:16` - 分辨率:通过 `resolution` 传 `1080p` / `720p` - 始终使用参考图模式(不是首尾帧模式) - 最多支持 3 张参考图(映射到上游 `referenceBlobs[].usage="asset"`) - 示例: - - `model=firefly-veo31-ref, duration=4, aspect_ratio=9:16, resolution=720p` - - `model=firefly-veo31-ref, duration=6, aspect_ratio=16:9, resolution=1080p` - - `model=firefly-veo31-ref, duration=8, aspect_ratio=9:16, resolution=1080p` + - `model=veo31-ref, duration=4, aspect_ratio=9:16, resolution=720p` + - `model=veo31-ref, duration=6, aspect_ratio=16:9, resolution=1080p` + - `model=veo31-ref, duration=8, aspect_ratio=9:16, resolution=1080p` Veo31 Fast 视频模型: -- 命名:`model=firefly-veo31-fast`,参数单独传 +- 命名:`model=veo31-fast`,参数单独传 - 时长:通过 `duration` 传 `4` / `6` / `8` - 比例:通过 `aspect_ratio` 传 `16:9` / `9:16` - 分辨率:通过 `resolution` 传 `1080p` / `720p` @@ -162,8 +189,8 @@ Veo31 Fast 视频模型: - 2 张:首帧 + 尾帧参考 - 音频默认开启 - 示例: - - `model=firefly-veo31-fast, duration=4, aspect_ratio=16:9, resolution=1080p` - - `model=firefly-veo31-fast, duration=6, aspect_ratio=9:16, resolution=720p` + - `model=veo31-fast, duration=4, aspect_ratio=16:9, resolution=1080p` + - `model=veo31-fast, duration=6, aspect_ratio=9:16, resolution=720p` ### 3.1 获取模型列表 @@ -181,7 +208,7 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-nano-banana-pro", + "model": "nano-banana-pro", "output_resolution": "2K", "aspect_ratio": "16:9", "messages": [{"role":"user","content":"a cinematic mountain sunrise"}] @@ -195,7 +222,7 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-nano-banana-pro", + "model": "nano-banana-pro", "output_resolution": "2K", "aspect_ratio": "16:9", "messages": [{ @@ -215,7 +242,7 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-sora2", + "model": "sora2", "duration": 4, "aspect_ratio": "16:9", "messages": [{"role":"user","content":"a drone shot over snowy forest"}] @@ -224,10 +251,10 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ Veo31 单图语义说明: -- `firefly-veo31` / `firefly-veo31-fast` 且 `reference_mode=frame`:帧模式 +- `veo31` / `veo31-fast` 且 `reference_mode=frame`:帧模式 - 1 张图 => 首帧 - 2 张图 => 首帧 + 尾帧 -- `firefly-veo31-ref`,或 `firefly-veo31` 且 `reference_mode=image`:参考图模式 +- `veo31-ref`,或 `veo31` 且 `reference_mode=image`:参考图模式 - 1~3 张图 => 参考图 图生视频: @@ -237,7 +264,7 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-sora2", + "model": "sora2", "duration": 8, "aspect_ratio": "9:16", "messages": [{ @@ -257,8 +284,7 @@ curl -X POST "http://127.0.0.1:6001/v1/images/generations" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-nano-banana-pro", - "output_resolution": "4K", + "model": "nano-banana-pro-4k", "aspect_ratio": "16:9", "prompt": "futuristic city skyline at dusk" }' diff --git a/api/routes/generation.py b/api/routes/generation.py index 5a0911b..d2d0169 100644 --- a/api/routes/generation.py +++ b/api/routes/generation.py @@ -169,7 +169,7 @@ def _resolve_video_request_config(model_id: str, data: dict, video_conf: dict) - default_resolution, ) resolved["reference_mode"] = requested_reference_mode - resolved["resolved_model_id"] = model_id + resolved["resolved_model_id"] = str(resolved.get("canonical_model") or model_id) return resolved @@ -626,7 +626,10 @@ def chat_completions(data: dict, request: Request): model_id = str(data.get("model") or "").strip() if ( - model_id.startswith("firefly-sora2") + model_id.startswith("sora2") + or model_id.startswith("veo31-fast") + or model_id.startswith("veo31-") + or model_id.startswith("firefly-sora2") or model_id.startswith("firefly-veo31-fast") or model_id.startswith("firefly-veo31-") ) and model_id not in video_model_catalog: @@ -634,7 +637,7 @@ def chat_completions(data: dict, request: Request): status_code=400, content={ "error": { - "message": "Invalid video model. Use /v1/models to get supported firefly-sora2, firefly-sora2-pro, firefly-veo31, firefly-veo31-ref or firefly-veo31-fast models, then pass duration/aspect_ratio/resolution/reference_mode in the request body.", + "message": "Invalid video model. Use /v1/models to get supported sora2, sora2-pro, veo31, veo31-ref or veo31-fast models, then pass duration/aspect_ratio/resolution/reference_mode in the request body.", "type": "invalid_request_error", } }, diff --git a/core/models/catalog.py b/core/models/catalog.py index 9057f1c..ad9125d 100644 --- a/core/models/catalog.py +++ b/core/models/catalog.py @@ -18,55 +18,108 @@ def _register_image_model( upstream_model_id: str, upstream_model_version: str, family_label: str, + fixed_output_resolution: str | None = None, ) -> None: + resolution_options = ( + [fixed_output_resolution] + if fixed_output_resolution + else ["1K", "2K"] + ) MODEL_CATALOG[model_id] = { "upstream_model": "google:firefly:colligo:nano-banana-pro", "upstream_model_id": upstream_model_id, "upstream_model_version": upstream_model_version, - "output_resolution": "2K", - "output_resolution_options": ["1K", "2K", "4K"], + "output_resolution": fixed_output_resolution or "2K", + "output_resolution_options": resolution_options, "aspect_ratio": "16:9", "aspect_ratio_options": ["1:1", "16:9", "9:16", "4:3", "3:4"], - "description": f"{family_label} image model (set output_resolution/aspect_ratio in request)", + "description": ( + f"{family_label} 4K image model (set aspect_ratio in request)" + if fixed_output_resolution == "4K" + else f"{family_label} image model (set output_resolution/aspect_ratio in request)" + ), "allow_request_overrides": True, } for res in ("1k", "2k", "4k"): for ratio, suffix in RATIO_SUFFIX_MAP.items(): - alias_id = f"{model_id}-{res}-{suffix}" - MODEL_CATALOG[alias_id] = { - "upstream_model": "google:firefly:colligo:nano-banana-pro", - "upstream_model_id": upstream_model_id, - "upstream_model_version": upstream_model_version, - "output_resolution": res.upper(), - "aspect_ratio": ratio, - "description": f"{family_label} ({res.upper()} {ratio})", - "canonical_model": model_id, - "hidden": True, - "allow_request_overrides": False, - } + for alias_id in (f"{model_id}-{res}-{suffix}", f"firefly-{model_id}-{res}-{suffix}"): + MODEL_CATALOG[alias_id] = { + "upstream_model": "google:firefly:colligo:nano-banana-pro", + "upstream_model_id": upstream_model_id, + "upstream_model_version": upstream_model_version, + "output_resolution": res.upper(), + "aspect_ratio": ratio, + "description": f"{family_label} ({res.upper()} {ratio})", + "canonical_model": model_id, + "hidden": True, + "allow_request_overrides": False, + } + + +def _register_image_family_alias(alias_id: str, canonical_model: str) -> None: + base = dict(MODEL_CATALOG[canonical_model]) + base.update( + { + "canonical_model": canonical_model, + "hidden": True, + "allow_request_overrides": True, + } + ) + MODEL_CATALOG[alias_id] = base _register_image_model( - "firefly-nano-banana-pro", + "nano-banana-pro", upstream_model_id="gemini-flash", upstream_model_version="nano-banana-2", - family_label="Firefly Nano Banana Pro", + family_label="Nano Banana Pro", ) _register_image_model( - "firefly-nano-banana", + "nano-banana-pro-4k", upstream_model_id="gemini-flash", upstream_model_version="nano-banana-2", - family_label="Firefly Nano Banana", + family_label="Nano Banana Pro", + fixed_output_resolution="4K", ) _register_image_model( - "firefly-nano-banana2", + "nano-banana", + upstream_model_id="gemini-flash", + upstream_model_version="nano-banana-2", + family_label="Nano Banana", +) +_register_image_model( + "nano-banana-4k", + upstream_model_id="gemini-flash", + upstream_model_version="nano-banana-2", + family_label="Nano Banana", + fixed_output_resolution="4K", +) +_register_image_model( + "nano-banana2", + upstream_model_id="gemini-flash", + upstream_model_version="nano-banana-3", + family_label="Nano Banana 2", +) +_register_image_model( + "nano-banana2-4k", upstream_model_id="gemini-flash", upstream_model_version="nano-banana-3", - family_label="Firefly Nano Banana 2", + family_label="Nano Banana 2", + fixed_output_resolution="4K", ) -DEFAULT_MODEL_ID = "firefly-nano-banana-pro" +for canonical_id in ( + "nano-banana", + "nano-banana-4k", + "nano-banana-pro", + "nano-banana-pro-4k", + "nano-banana2", + "nano-banana2-4k", +): + _register_image_family_alias(f"firefly-{canonical_id}", canonical_id) + +DEFAULT_MODEL_ID = "nano-banana-pro" VIDEO_MODEL_CATALOG: dict[str, dict] = {} @@ -102,6 +155,18 @@ def _register_video_model( } +def _register_video_family_alias(alias_id: str, canonical_model: str) -> None: + base = dict(VIDEO_MODEL_CATALOG[canonical_model]) + base.update( + { + "canonical_model": canonical_model, + "hidden": True, + "allow_request_overrides": True, + } + ) + VIDEO_MODEL_CATALOG[alias_id] = base + + def _register_video_alias( alias_id: str, *, @@ -129,8 +194,8 @@ def _register_video_alias( _register_video_model( - "firefly-sora2", - description="Firefly Sora2 video model (set duration/aspect_ratio in request)", + "sora2", + description="Sora2 video model (set duration/aspect_ratio in request)", engine="sora2", upstream_model="openai:firefly:colligo:sora2", duration=8, @@ -140,8 +205,8 @@ def _register_video_alias( ) _register_video_model( - "firefly-sora2-pro", - description="Firefly Sora2 Pro video model (set duration/aspect_ratio in request)", + "sora2-pro", + description="Sora2 Pro video model (set duration/aspect_ratio in request)", engine="sora2", upstream_model="openai:firefly:colligo:sora2-pro", duration=8, @@ -151,8 +216,8 @@ def _register_video_alias( ) _register_video_model( - "firefly-veo31", - description="Firefly Veo31 video model (set duration/aspect_ratio/resolution/reference_mode in request)", + "veo31", + description="Veo31 video model (set duration/aspect_ratio/resolution/reference_mode in request)", engine="veo31-standard", upstream_model="google:firefly:colligo:veo31", duration=4, @@ -166,8 +231,8 @@ def _register_video_alias( ) _register_video_model( - "firefly-veo31-ref", - description="Firefly Veo31 Ref video model (set duration/aspect_ratio/resolution in request)", + "veo31-ref", + description="Veo31 Ref video model (set duration/aspect_ratio/resolution in request)", engine="veo31-standard", upstream_model="google:firefly:colligo:veo31", duration=4, @@ -181,8 +246,8 @@ def _register_video_alias( ) _register_video_model( - "firefly-veo31-fast", - description="Firefly Veo31 Fast video model (set duration/aspect_ratio/resolution in request)", + "veo31-fast", + description="Veo31 Fast video model (set duration/aspect_ratio/resolution in request)", engine="veo31-fast", upstream_model="google:firefly:colligo:veo31-fast", duration=4, @@ -194,59 +259,82 @@ def _register_video_alias( reference_mode="frame", ) +for canonical_id in ("sora2", "sora2-pro", "veo31", "veo31-ref", "veo31-fast"): + _register_video_family_alias(f"firefly-{canonical_id}", canonical_id) + for dur in (4, 8, 12): for ratio in ("9:16", "16:9"): - _register_video_alias( + for alias_id in ( + f"sora2-{dur}s-{RATIO_SUFFIX_MAP[ratio]}", f"firefly-sora2-{dur}s-{RATIO_SUFFIX_MAP[ratio]}", - canonical_model="firefly-sora2", - duration=dur, - aspect_ratio=ratio, - description=f"Firefly Sora2 video model ({dur}s {ratio})", - ) + ): + _register_video_alias( + alias_id, + canonical_model="sora2", + duration=dur, + aspect_ratio=ratio, + description=f"Sora2 video model ({dur}s {ratio})", + ) for dur in (4, 8, 12): for ratio in ("9:16", "16:9"): - _register_video_alias( + for alias_id in ( + f"sora2-pro-{dur}s-{RATIO_SUFFIX_MAP[ratio]}", f"firefly-sora2-pro-{dur}s-{RATIO_SUFFIX_MAP[ratio]}", - canonical_model="firefly-sora2-pro", - duration=dur, - aspect_ratio=ratio, - description=f"Firefly Sora2 Pro video model ({dur}s {ratio})", - ) + ): + _register_video_alias( + alias_id, + canonical_model="sora2-pro", + duration=dur, + aspect_ratio=ratio, + description=f"Sora2 Pro video model ({dur}s {ratio})", + ) for dur in (4, 6, 8): for ratio in ("16:9", "9:16"): for res in ("1080p", "720p"): - _register_video_alias( + for alias_id in ( + f"veo31-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}", f"firefly-veo31-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}", - canonical_model="firefly-veo31", - duration=dur, - aspect_ratio=ratio, - resolution=res, - description=f"Firefly Veo31 video model ({dur}s {ratio} {res})", - ) + ): + _register_video_alias( + alias_id, + canonical_model="veo31", + duration=dur, + aspect_ratio=ratio, + resolution=res, + description=f"Veo31 video model ({dur}s {ratio} {res})", + ) for dur in (4, 6, 8): for ratio in ("16:9", "9:16"): for res in ("1080p", "720p"): - _register_video_alias( + for alias_id in ( + f"veo31-ref-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}", f"firefly-veo31-ref-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}", - canonical_model="firefly-veo31-ref", - duration=dur, - aspect_ratio=ratio, - resolution=res, - reference_mode="image", - description=f"Firefly Veo31 Ref video model ({dur}s {ratio} {res})", - ) + ): + _register_video_alias( + alias_id, + canonical_model="veo31-ref", + duration=dur, + aspect_ratio=ratio, + resolution=res, + reference_mode="image", + description=f"Veo31 Ref video model ({dur}s {ratio} {res})", + ) for dur in (4, 6, 8): for ratio in ("16:9", "9:16"): for res in ("1080p", "720p"): - _register_video_alias( + for alias_id in ( + f"veo31-fast-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}", f"firefly-veo31-fast-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}", - canonical_model="firefly-veo31-fast", - duration=dur, - aspect_ratio=ratio, - resolution=res, - description=f"Firefly Veo31 Fast video model ({dur}s {ratio} {res})", - ) + ): + _register_video_alias( + alias_id, + canonical_model="veo31-fast", + duration=dur, + aspect_ratio=ratio, + resolution=res, + description=f"Veo31 Fast video model ({dur}s {ratio} {res})", + ) diff --git a/core/models/resolver.py b/core/models/resolver.py index b3ed774..542a618 100644 --- a/core/models/resolver.py +++ b/core/models/resolver.py @@ -53,7 +53,11 @@ def resolve_ratio_and_resolution( if not model_conf.get("allow_request_overrides"): ratio = str(model_conf.get("aspect_ratio") or "1:1").strip() output_resolution = str(model_conf.get("output_resolution") or "2K").upper() - return ratio, output_resolution, resolved_model_id + return ( + ratio, + output_resolution, + str(model_conf.get("canonical_model") or resolved_model_id), + ) ratio = str(data.get("aspect_ratio") or "").strip() or ratio_from_size( data.get("size", "1024x1024") @@ -81,4 +85,4 @@ def resolve_ratio_and_resolution( if allowed_resolutions and output_resolution not in allowed_resolutions: output_resolution = str(model_conf.get("output_resolution") or "2K").upper() - return ratio, output_resolution, resolved_model_id + return ratio, output_resolution, str(model_conf.get("canonical_model") or resolved_model_id) From a512ea6419dc34d546ea6dfe4be8a8c17c9bdd32 Mon Sep 17 00:00:00 2001 From: LJR199887 <172809968+LJR199887@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:37:54 +0800 Subject: [PATCH 06/20] document banana size mapping --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index f9a551d..3ddd953 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,35 @@ Nano Banana Pro 4K 图像模型: - 示例: - `model=nano-banana-pro-4k, aspect_ratio=16:9` +Banana 图像尺寸映射规则: + +- 这类模型最终不会直接使用你传入的像素宽高,而是根据 `output_resolution` + `aspect_ratio` 自动换算成固定尺寸 +- 如果没有传 `aspect_ratio`,但传了 `size`,服务会先根据 `size` 自动反推出比例,再套用下表 + +`1K` + +- `1:1` -> `1024 x 1024` +- `16:9` -> `1360 x 768` +- `9:16` -> `768 x 1360` +- `4:3` -> `1152 x 864` +- `3:4` -> `864 x 1152` + +`2K` + +- `1:1` -> `2048 x 2048` +- `16:9` -> `2752 x 1536` +- `9:16` -> `1536 x 2752` +- `4:3` -> `2048 x 1536` +- `3:4` -> `1536 x 2048` + +`4K` + +- `1:1` -> `4096 x 4096` +- `16:9` -> `5504 x 3072` +- `9:16` -> `3072 x 5504` +- `4:3` -> `4096 x 3072` +- `3:4` -> `3072 x 4096` + Sora2 视频模型: - 命名:`model=sora2`,参数单独传 From 79e8e349b016d8f259e377331a69a27742ba8ce6 Mon Sep 17 00:00:00 2001 From: LJR199887 <172809968+LJR199887@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:58:44 +0800 Subject: [PATCH 07/20] fix veo31 video_meta assignment --- api/routes/generation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/routes/generation.py b/api/routes/generation.py index d2d0169..b22ad75 100644 --- a/api/routes/generation.py +++ b/api/routes/generation.py @@ -756,7 +756,7 @@ def _video_progress_cb(update: dict): except Exception: old_size = 0 - video_bytes, video_meta = client.generate_video( + video_bytes, video_meta = client.generate_video( token=token, video_conf=resolved_video_conf or {}, prompt=prompt, From ad7ea5e7a8603b7c5d75ba39734b1378ca7ba984 Mon Sep 17 00:00:00 2001 From: LJR199887 <172809968+LJR199887@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:23:40 +0800 Subject: [PATCH 08/20] avoid duplicate upstream task resubmission --- app.py | 19 ++- core/adobe_client.py | 353 ++++++++++++++++++++++++++----------------- 2 files changed, 227 insertions(+), 145 deletions(-) diff --git a/app.py b/app.py index d581f0f..5eafd31 100644 --- a/app.py +++ b/app.py @@ -614,7 +614,10 @@ def _run_with_token_retries( except QuotaExhaustedError as exc: token_manager.report_exhausted(token) last_exc = exc - retryable = attempt < max_attempts + upstream_job_created = bool( + str(getattr(request.state, "log_upstream_job_id", "") or "").strip() + ) + retryable = attempt < max_attempts and not upstream_job_created retry_reason = "quota_exhausted" err_code = report_error( request, @@ -637,7 +640,10 @@ def _run_with_token_retries( except AuthError as exc: token_manager.report_invalid(token) last_exc = exc - retryable = attempt < max_attempts + upstream_job_created = bool( + str(getattr(request.state, "log_upstream_job_id", "") or "").strip() + ) + retryable = attempt < max_attempts and not upstream_job_created retry_reason = "auth" err_code = report_error( request, @@ -659,8 +665,13 @@ def _run_with_token_retries( ) except UpstreamTemporaryError as exc: last_exc = exc - retryable = attempt < max_attempts and client.should_retry_temporary_error( - exc + upstream_job_created = bool( + str(getattr(request.state, "log_upstream_job_id", "") or "").strip() + ) + retryable = ( + attempt < max_attempts + and client.should_retry_temporary_error(exc) + and not upstream_job_created ) status_part = f"status={exc.status_code}" if exc.status_code else "status=?" type_part = f"type={exc.error_type or 'temporary'}" diff --git a/core/adobe_client.py b/core/adobe_client.py index d42b0b0..55517e8 100644 --- a/core/adobe_client.py +++ b/core/adobe_client.py @@ -835,94 +835,132 @@ def generate_video( pass start = time.time() + last_progress = 0.0 + poll_retry_attempt = 0 while True: - poll_resp = self._get( - poll_url, headers=self._poll_headers(token), timeout=60 - ) - if poll_resp.status_code in (401, 403): - raise AuthError("Token invalid or expired") - if poll_resp.status_code != 200: - if poll_resp.status_code in (429, 451) or poll_resp.status_code >= 500: - raise UpstreamTemporaryError( - f"video poll failed: {poll_resp.status_code} {poll_resp.text[:300]}", - status_code=poll_resp.status_code, - error_type="status", - ) - raise AdobeRequestError( - f"video poll failed: {poll_resp.status_code} {poll_resp.text[:300]}" + try: + poll_resp = self._get( + poll_url, headers=self._poll_headers(token), timeout=60 ) + if poll_resp.status_code in (401, 403): + raise AuthError("Token invalid or expired") + if poll_resp.status_code != 200: + if poll_resp.status_code in (429, 451) or poll_resp.status_code >= 500: + raise UpstreamTemporaryError( + f"video poll failed: {poll_resp.status_code} {poll_resp.text[:300]}", + status_code=poll_resp.status_code, + error_type="status", + ) + raise AdobeRequestError( + f"video poll failed: {poll_resp.status_code} {poll_resp.text[:300]}" + ) - latest = poll_resp.json() - status_header = str(poll_resp.headers.get("x-task-status") or "").upper() - status_val = str(latest.get("status") or "").upper() or status_header - progress_val = self._extract_progress_percent(latest, poll_resp) + latest = poll_resp.json() + status_header = str(poll_resp.headers.get("x-task-status") or "").upper() + status_val = str(latest.get("status") or "").upper() or status_header + progress_val = self._extract_progress_percent(latest, poll_resp) + if progress_val is not None: + last_progress = progress_val + poll_retry_attempt = 0 - if progress_cb and self._is_in_progress_status(status_val): - try: - progress_cb( - { - "task_status": "IN_PROGRESS", - "task_progress": progress_val - if progress_val is not None - else 0.0, - "upstream_job_id": upstream_job_id, - "retry_after": int( - poll_resp.headers.get("retry-after") or 0 - ) - or None, - } - ) - except Exception: - pass - - outputs = latest.get("outputs") or [] - if outputs: - video_url = ((outputs[0] or {}).get("video") or {}).get("presignedUrl") - if not video_url: - raise AdobeRequestError("video job finished without video url") - if out_path is not None: - self._download_to_file( - video_url, - headers={"accept": "*/*"}, - out_path=out_path, - timeout=60, - ) - video_bytes = None - else: - video_resp = self._get(video_url, headers={"accept": "*/*"}, timeout=60) - video_resp.raise_for_status() - video_bytes = video_resp.content - if progress_cb: + if progress_cb and self._is_in_progress_status(status_val): try: progress_cb( { - "task_status": "COMPLETED", - "task_progress": 100.0, + "task_status": "IN_PROGRESS", + "task_progress": progress_val + if progress_val is not None + else 0.0, "upstream_job_id": upstream_job_id, - "retry_after": None, + "retry_after": int( + poll_resp.headers.get("retry-after") or 0 + ) + or None, } ) except Exception: pass - return video_bytes, latest - if status_val in {"FAILED", "CANCELLED", "ERROR"}: + outputs = latest.get("outputs") or [] + if outputs: + video_url = ((outputs[0] or {}).get("video") or {}).get("presignedUrl") + if not video_url: + raise AdobeRequestError("video job finished without video url") + if out_path is not None: + self._download_to_file( + video_url, + headers={"accept": "*/*"}, + out_path=out_path, + timeout=60, + ) + video_bytes = None + else: + video_resp = self._get( + video_url, headers={"accept": "*/*"}, timeout=60 + ) + video_resp.raise_for_status() + video_bytes = video_resp.content + if progress_cb: + try: + progress_cb( + { + "task_status": "COMPLETED", + "task_progress": 100.0, + "upstream_job_id": upstream_job_id, + "retry_after": None, + } + ) + except Exception: + pass + return video_bytes, latest + + if status_val in {"FAILED", "CANCELLED", "ERROR"}: + if progress_cb: + try: + progress_cb( + { + "task_status": "FAILED", + "task_progress": progress_val + if progress_val is not None + else 0.0, + "upstream_job_id": upstream_job_id, + "retry_after": None, + "error": f"video job failed: {latest}", + } + ) + except Exception: + pass + raise AdobeRequestError(f"video job failed: {latest}") + except UpstreamTemporaryError as exc: + can_retry_same_job = self.should_retry_temporary_error(exc) and ( + time.time() - start < timeout + ) + if not can_retry_same_job: + raise + poll_retry_attempt += 1 + retry_delay = max(1.0, self._retry_delay_for_attempt(poll_retry_attempt)) + logger.warning( + "video poll temporary error; retrying same upstream job id=%s attempt=%s delay=%.2fs error=%s", + upstream_job_id, + poll_retry_attempt, + retry_delay, + str(exc), + ) if progress_cb: try: progress_cb( { - "task_status": "FAILED", - "task_progress": progress_val - if progress_val is not None - else 0.0, + "task_status": "IN_PROGRESS", + "task_progress": last_progress, "upstream_job_id": upstream_job_id, - "retry_after": None, - "error": f"video job failed: {latest}", + "retry_after": int(retry_delay), + "error": f"poll retry {poll_retry_attempt}: {str(exc)[:160]}", } ) except Exception: pass - raise AdobeRequestError(f"video job failed: {latest}") + time.sleep(retry_delay) + continue if time.time() - start > timeout: if progress_cb: @@ -930,10 +968,7 @@ def generate_video( progress_cb( { "task_status": "FAILED", - "task_progress": progress_val - if "progress_val" in locals() - and progress_val is not None - else 0.0, + "task_progress": last_progress, "upstream_job_id": upstream_job_id, "retry_after": None, "error": "video generation timed out", @@ -1038,97 +1073,135 @@ def generate( start = time.time() latest = {} sleep_time = 3.0 + last_progress = 0.0 + poll_retry_attempt = 0 while True: - poll_resp = self._get( - poll_url, headers=self._poll_headers(token), timeout=60 - ) - if poll_resp.status_code != 200: - logger.error( - "poll failed status=%s body=%s", - poll_resp.status_code, - poll_resp.text[:500], + try: + poll_resp = self._get( + poll_url, headers=self._poll_headers(token), timeout=60 ) - if poll_resp.status_code in (429, 451) or poll_resp.status_code >= 500: - raise UpstreamTemporaryError( - f"poll failed: {poll_resp.status_code} {poll_resp.text[:300]}", - status_code=poll_resp.status_code, - error_type="status", + if poll_resp.status_code != 200: + logger.error( + "poll failed status=%s body=%s", + poll_resp.status_code, + poll_resp.text[:500], + ) + if poll_resp.status_code in (429, 451) or poll_resp.status_code >= 500: + raise UpstreamTemporaryError( + f"poll failed: {poll_resp.status_code} {poll_resp.text[:300]}", + status_code=poll_resp.status_code, + error_type="status", + ) + raise AdobeRequestError( + f"poll failed: {poll_resp.status_code} {poll_resp.text[:300]}" ) - raise AdobeRequestError( - f"poll failed: {poll_resp.status_code} {poll_resp.text[:300]}" - ) - latest = poll_resp.json() - status_header = str(poll_resp.headers.get("x-task-status") or "").upper() - status_val = str(latest.get("status") or "").upper() or status_header - progress_val = self._extract_progress_percent(latest, poll_resp) + latest = poll_resp.json() + status_header = str(poll_resp.headers.get("x-task-status") or "").upper() + status_val = str(latest.get("status") or "").upper() or status_header + progress_val = self._extract_progress_percent(latest, poll_resp) + if progress_val is not None: + last_progress = progress_val + poll_retry_attempt = 0 - if progress_cb and self._is_in_progress_status(status_val): - try: - progress_cb( - { - "task_status": "IN_PROGRESS", - "task_progress": progress_val - if progress_val is not None - else 0.0, - "upstream_job_id": upstream_job_id, - "retry_after": int( - poll_resp.headers.get("retry-after") or 0 - ) - or None, - } - ) - except Exception: - pass - - outputs = latest.get("outputs") or [] - if outputs: - image_url = ((outputs[0] or {}).get("image") or {}).get("presignedUrl") - if not image_url: - raise AdobeRequestError("job finished without image url") - if out_path is not None: - self._download_to_file( - image_url, - headers={"accept": "*/*"}, - out_path=out_path, - timeout=30, - ) - image_bytes = None - else: - img_resp = self._get(image_url, headers={"accept": "*/*"}, timeout=30) - img_resp.raise_for_status() - image_bytes = img_resp.content - if progress_cb: + if progress_cb and self._is_in_progress_status(status_val): try: progress_cb( { - "task_status": "COMPLETED", - "task_progress": 100.0, + "task_status": "IN_PROGRESS", + "task_progress": progress_val + if progress_val is not None + else 0.0, "upstream_job_id": upstream_job_id, - "retry_after": None, + "retry_after": int( + poll_resp.headers.get("retry-after") or 0 + ) + or None, } ) except Exception: pass - return image_bytes, latest - if status_val in {"FAILED", "CANCELLED", "ERROR"}: + outputs = latest.get("outputs") or [] + if outputs: + image_url = ((outputs[0] or {}).get("image") or {}).get("presignedUrl") + if not image_url: + raise AdobeRequestError("job finished without image url") + if out_path is not None: + self._download_to_file( + image_url, + headers={"accept": "*/*"}, + out_path=out_path, + timeout=30, + ) + image_bytes = None + else: + img_resp = self._get( + image_url, headers={"accept": "*/*"}, timeout=30 + ) + img_resp.raise_for_status() + image_bytes = img_resp.content + if progress_cb: + try: + progress_cb( + { + "task_status": "COMPLETED", + "task_progress": 100.0, + "upstream_job_id": upstream_job_id, + "retry_after": None, + } + ) + except Exception: + pass + return image_bytes, latest + + if status_val in {"FAILED", "CANCELLED", "ERROR"}: + if progress_cb: + try: + progress_cb( + { + "task_status": "FAILED", + "task_progress": progress_val + if progress_val is not None + else 0.0, + "upstream_job_id": upstream_job_id, + "retry_after": None, + "error": f"image job failed: {latest}", + } + ) + except Exception: + pass + raise AdobeRequestError(f"image job failed: {latest}") + except UpstreamTemporaryError as exc: + can_retry_same_job = self.should_retry_temporary_error(exc) and ( + time.time() - start < timeout + ) + if not can_retry_same_job: + raise + poll_retry_attempt += 1 + retry_delay = max(1.0, self._retry_delay_for_attempt(poll_retry_attempt)) + logger.warning( + "image poll temporary error; retrying same upstream job id=%s attempt=%s delay=%.2fs error=%s", + upstream_job_id, + poll_retry_attempt, + retry_delay, + str(exc), + ) if progress_cb: try: progress_cb( { - "task_status": "FAILED", - "task_progress": progress_val - if progress_val is not None - else 0.0, + "task_status": "IN_PROGRESS", + "task_progress": last_progress, "upstream_job_id": upstream_job_id, - "retry_after": None, - "error": f"image job failed: {latest}", + "retry_after": int(retry_delay), + "error": f"poll retry {poll_retry_attempt}: {str(exc)[:160]}", } ) except Exception: pass - raise AdobeRequestError(f"image job failed: {latest}") + time.sleep(retry_delay) + continue if time.time() - start > timeout: if progress_cb: @@ -1136,9 +1209,7 @@ def generate( progress_cb( { "task_status": "FAILED", - "task_progress": progress_val - if progress_val is not None - else 0.0, + "task_progress": last_progress, "upstream_job_id": upstream_job_id, "retry_after": None, "error": "image generation timed out", From dfff8ea5accc7e26cc74a3a5ec3cd666aaa5bce9 Mon Sep 17 00:00:00 2001 From: LJR199887 <172809968+LJR199887@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:03:58 +0800 Subject: [PATCH 09/20] Add request polling progress API --- README.md | 64 +++++++++++ README_EN.md | 64 +++++++++++ api/routes/generation.py | 157 ++++++++++++++++++++++---- app.py | 48 +++++++- core/stores.py | 46 ++++++++ tests/test_request_progress.py | 197 +++++++++++++++++++++++++++++++++ 6 files changed, 550 insertions(+), 26 deletions(-) create mode 100644 tests/test_request_progress.py diff --git a/README.md b/README.md index 3ddd953..f64eabb 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,70 @@ curl -X POST "http://127.0.0.1:6001/v1/images/generations" \ }' ``` +### 3.4 `request_id` 进度查询 + +普通外部 API 调用现在支持按 `request_id` 轮询任务状态与进度。 + +- 可用于:`/v1/chat/completions` 和 `/v1/images/generations` +- 建议用法:客户端自己生成一个 `request_id`,并随请求一起传入 +- 查询接口:`GET /v1/requests/{request_id}` +- 认证方式:与生成接口一致,使用 `Authorization: Bearer ` 或 `X-API-Key` +- 服务会在响应头中回写 `X-Request-Id`,同时在 JSON 响应体中也会包含 `request_id` + +说明: + +- 如果你想在请求还未返回时就开始轮询,请务必自己传入 `request_id` +- 如果不传,服务会自动生成一个 ID,但只能在响应完成后从响应头或响应体里拿到 + +示例:提交生成请求 + +```bash +curl -X POST "http://127.0.0.1:6001/v1/images/generations" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "request_id": "demo-req-001", + "model": "nano-banana-pro", + "output_resolution": "2K", + "aspect_ratio": "16:9", + "prompt": "a cinematic mountain sunrise" + }' +``` + +示例:轮询任务进度 + +```bash +curl -X GET "http://127.0.0.1:6001/v1/requests/demo-req-001" \ + -H "Authorization: Bearer " +``` + +返回示例: + +```json +{ + "request_id": "demo-req-001", + "task_status": "IN_PROGRESS", + "task_progress": 42.0, + "upstream_job_id": "upstream-job-id", + "retry_after": null, + "preview_url": null, + "preview_kind": null, + "error": null, + "error_code": null, + "operation": "images.generations", + "model": "nano-banana-pro", + "prompt_preview": "a cinematic mountain sunrise", + "status_code": 102, + "source": "live", + "done": false +} +``` + +- `task_status` 可能为 `IN_PROGRESS` / `COMPLETED` / `FAILED` +- `task_progress` 范围为 `0` 到 `100` +- `source=live` 表示来自运行中的内存状态,`source=log` 表示任务已结束,数据来自最终日志 +- 任务完成后,如果有预览地址,会在 `preview_url` 中返回 + ## 4)Cookie 导入 ### 第一步:使用浏览器插件导出(推荐) diff --git a/README_EN.md b/README_EN.md index 12d22c7..12487ce 100644 --- a/README_EN.md +++ b/README_EN.md @@ -292,6 +292,70 @@ curl -X POST "http://127.0.0.1:6001/v1/images/generations" \ }' ``` +### 3.4 `request_id` Progress Polling + +Regular external API calls now support polling task status and progress by `request_id`. + +- Works with: `/v1/chat/completions` and `/v1/images/generations` +- Recommended usage: generate a client-side `request_id` and send it with the request +- Polling endpoint: `GET /v1/requests/{request_id}` +- Authentication: same as generation endpoints, using `Authorization: Bearer ` or `X-API-Key` +- The service also echoes `X-Request-Id` in the response headers and includes `request_id` in the JSON response body + +Notes: + +- If you want to start polling before the generation request returns, you must provide your own `request_id` +- If you omit it, the service will generate one for you, but you can only read it after the response finishes + +Example: submit a generation request + +```bash +curl -X POST "http://127.0.0.1:6001/v1/images/generations" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "request_id": "demo-req-001", + "model": "firefly-nano-banana-pro", + "output_resolution": "2K", + "aspect_ratio": "16:9", + "prompt": "a cinematic mountain sunrise" + }' +``` + +Example: poll progress + +```bash +curl -X GET "http://127.0.0.1:6001/v1/requests/demo-req-001" \ + -H "Authorization: Bearer " +``` + +Response example: + +```json +{ + "request_id": "demo-req-001", + "task_status": "IN_PROGRESS", + "task_progress": 42.0, + "upstream_job_id": "upstream-job-id", + "retry_after": null, + "preview_url": null, + "preview_kind": null, + "error": null, + "error_code": null, + "operation": "images.generations", + "model": "firefly-nano-banana-pro", + "prompt_preview": "a cinematic mountain sunrise", + "status_code": 102, + "source": "live", + "done": false +} +``` + +- `task_status` can be `IN_PROGRESS`, `COMPLETED`, or `FAILED` +- `task_progress` ranges from `0` to `100` +- `source=live` means the payload comes from in-memory live state; `source=log` means the task already finished and the data comes from final logs +- Once the task completes, `preview_url` will be populated when a preview is available + ## 4) Cookie Import ### Step 1: Export using the Browser Extension (Recommended) diff --git a/api/routes/generation.py b/api/routes/generation.py index b22ad75..6d5b270 100644 --- a/api/routes/generation.py +++ b/api/routes/generation.py @@ -176,6 +176,8 @@ def _resolve_video_request_config(model_id: str, data: dict, video_conf: dict) - def build_generation_router( *, store, + request_log_store, + live_request_store, token_manager, client, generated_dir: Path, @@ -205,6 +207,79 @@ def build_generation_router( ) -> APIRouter: router = APIRouter() + def _current_request_id(request: Request) -> str: + return str(getattr(request.state, "log_id", "") or "").strip() + + def _attach_request_id(payload: dict, request: Request) -> dict: + content = dict(payload or {}) + request_id = _current_request_id(request) + if request_id: + content["request_id"] = request_id + return content + + def _json_response(status_code: int, content: dict, request: Request) -> JSONResponse: + return JSONResponse( + status_code=status_code, + content=_attach_request_id(content, request), + ) + + def _build_request_status_payload( + request_id: str, item: dict, source: str + ) -> dict: + task_status = str(item.get("task_status") or "").upper() or None + preview_url = str(item.get("preview_url") or "").strip() or None + preview_kind = str(item.get("preview_kind") or "").strip() or None + error_text = str(item.get("error") or "").strip() or None + error_code = str(item.get("error_code") or "").strip() or None + operation = str(item.get("operation") or "").strip() or None + model = str(item.get("model") or "").strip() or None + prompt_preview = str(item.get("prompt_preview") or "").strip() or None + upstream_job_id = str(item.get("upstream_job_id") or "").strip() or None + attempt_id = str(item.get("id") or "").strip() or None + if attempt_id == request_id: + attempt_id = None + retry_after = item.get("retry_after") + status_code = item.get("status_code") + try: + task_progress = ( + round(float(item.get("task_progress")), 2) + if item.get("task_progress") is not None + else None + ) + except Exception: + task_progress = None + try: + status_code = int(status_code) if status_code is not None else None + except Exception: + status_code = None + try: + retry_after = int(retry_after) if retry_after is not None else None + except Exception: + retry_after = None + done = task_status in {"COMPLETED", "FAILED"} or bool( + status_code is not None and status_code >= 400 + ) + payload = { + "request_id": request_id, + "task_status": task_status, + "task_progress": task_progress, + "upstream_job_id": upstream_job_id, + "retry_after": retry_after, + "preview_url": preview_url, + "preview_kind": preview_kind, + "error": error_text, + "error_code": error_code, + "operation": operation, + "model": model, + "prompt_preview": prompt_preview, + "status_code": status_code, + "source": source, + "done": done, + } + if attempt_id: + payload["attempt_id"] = attempt_id + return payload + @router.get("/v1/models") def list_models(request: Request): require_service_api_key(request) @@ -253,13 +328,39 @@ def list_models(request: Request): ) return {"object": "list", "data": data} + @router.get("/v1/requests/{request_id}") + def get_request_status(request_id: str, request: Request): + require_service_api_key(request) + + normalized_id = str(request_id or "").strip() + if not normalized_id: + raise HTTPException(status_code=400, detail="request_id is required") + + live_item = live_request_store.get(normalized_id) + if isinstance(live_item, dict): + return _build_request_status_payload( + normalized_id, + live_item, + source="live", + ) + + log_item = request_log_store.get(normalized_id) + if isinstance(log_item, dict): + return _build_request_status_payload( + normalized_id, + log_item, + source="log", + ) + + raise HTTPException(status_code=404, detail="request not found") + @router.post("/v1/images/generations") def openai_generate(data: dict, request: Request): require_service_api_key(request) prompt = data.get("prompt", "").strip() if not prompt: - return JSONResponse( + return _json_response( status_code=400, content={ "error": { @@ -267,12 +368,13 @@ def openai_generate(data: dict, request: Request): "type": "invalid_request_error", } }, + request=request, ) _validate_prompt_length(prompt) model_id = data.get("model") if str(model_id or "").strip() in video_model_catalog: - return JSONResponse( + return _json_response( status_code=400, content={ "error": { @@ -280,6 +382,7 @@ def openai_generate(data: dict, request: Request): "type": "invalid_request_error", } }, + request=request, ) ratio, output_resolution, resolved_model_id = resolve_ratio_and_resolution( data, model_id @@ -332,11 +435,11 @@ def _image_progress_cb(update: dict): on_generated_file_written(out_path, old_size, new_size) image_url = public_image_url(request, job_id) set_request_preview(request, image_url, kind="image") - return { + return _attach_request_id({ "created": int(time.time()), "model": resolved_model_id, "data": [{"url": image_url}], - } + }, request) return run_with_token_retries( request=request, @@ -360,7 +463,7 @@ def _image_progress_cb(update: dict): task_progress=0.0, error="Token quota exhausted", ) - return JSONResponse( + return _json_response( status_code=429, content={ "error": { @@ -369,6 +472,7 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) except auth_error_cls: error_code = str( @@ -386,7 +490,7 @@ def _image_progress_cb(update: dict): task_progress=0.0, error="Token invalid or expired", ) - return JSONResponse( + return _json_response( status_code=401, content={ "error": { @@ -395,6 +499,7 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) except upstream_temp_error_cls as exc: error_code = str( @@ -409,7 +514,7 @@ def _image_progress_cb(update: dict): set_request_task_progress( request, task_status="FAILED", task_progress=0.0, error=str(exc) ) - return JSONResponse( + return _json_response( status_code=503, content={ "error": { @@ -418,6 +523,7 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) except HTTPException as exc: err_type = ( @@ -435,7 +541,7 @@ def _image_progress_cb(update: dict): set_request_task_progress( request, task_status="FAILED", task_progress=0.0, error=str(exc.detail) ) - return JSONResponse( + return _json_response( status_code=exc.status_code, content={ "error": { @@ -444,6 +550,7 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) except Exception as exc: normalized = _normalize_upstream_request_error(exc) @@ -459,7 +566,7 @@ def _image_progress_cb(update: dict): set_request_task_progress( request, task_status="FAILED", task_progress=0.0, error=message ) - return JSONResponse( + return _json_response( status_code=status_code, content={ "error": { @@ -468,6 +575,7 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) error_code = set_request_error_detail( request, @@ -484,7 +592,7 @@ def _image_progress_cb(update: dict): set_request_task_progress( request, task_status="FAILED", task_progress=0.0, error=str(exc) ) - return JSONResponse( + return _json_response( status_code=500, content={ "error": { @@ -493,6 +601,7 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) @router.post("/api/v1/generate") @@ -595,7 +704,7 @@ def runner(job_id: str): threading.Thread(target=runner, args=(job.id,), daemon=True).start() - return {"task_id": job.id, "status": job.status} + return _attach_request_id({"task_id": job.id, "status": job.status}, request) @router.get("/api/v1/generate/{task_id}") def get_job(task_id: str, request: Request): @@ -614,7 +723,7 @@ def chat_completions(data: dict, request: Request): if not prompt: prompt = str(data.get("prompt") or "").strip() if not prompt: - return JSONResponse( + return _json_response( status_code=400, content={ "error": { @@ -622,6 +731,7 @@ def chat_completions(data: dict, request: Request): "type": "invalid_request_error", } }, + request=request, ) model_id = str(data.get("model") or "").strip() @@ -633,7 +743,7 @@ def chat_completions(data: dict, request: Request): or model_id.startswith("firefly-veo31-fast") or model_id.startswith("firefly-veo31-") ) and model_id not in video_model_catalog: - return JSONResponse( + return _json_response( status_code=400, content={ "error": { @@ -641,6 +751,7 @@ def chat_completions(data: dict, request: Request): "type": "invalid_request_error", } }, + request=request, ) video_conf = video_model_catalog.get(model_id) is_video_model = video_conf is not None @@ -865,7 +976,7 @@ def _image_progress_cb(update: dict): sse_chat_stream(response_payload), media_type="text/event-stream", ) - return response_payload + return _attach_request_id(response_payload, request) return run_with_token_retries( request=request, @@ -888,7 +999,7 @@ def _image_progress_cb(update: dict): task_progress=0.0, error="Token quota exhausted", ) - return JSONResponse( + return _json_response( status_code=429, content={ "error": { @@ -897,6 +1008,7 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) except auth_error_cls: error_code = str( @@ -914,7 +1026,7 @@ def _image_progress_cb(update: dict): task_progress=0.0, error="Token invalid or expired", ) - return JSONResponse( + return _json_response( status_code=401, content={ "error": { @@ -923,6 +1035,7 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) except upstream_temp_error_cls as exc: error_code = str( @@ -937,7 +1050,7 @@ def _image_progress_cb(update: dict): set_request_task_progress( request, task_status="FAILED", task_progress=0.0, error=str(exc) ) - return JSONResponse( + return _json_response( status_code=503, content={ "error": { @@ -946,6 +1059,7 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) except HTTPException as exc: err_type = ( @@ -963,7 +1077,7 @@ def _image_progress_cb(update: dict): set_request_task_progress( request, task_status="FAILED", task_progress=0.0, error=str(exc.detail) ) - return JSONResponse( + return _json_response( status_code=exc.status_code, content={ "error": { @@ -972,6 +1086,7 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) except Exception as exc: normalized = _normalize_upstream_request_error(exc) @@ -987,7 +1102,7 @@ def _image_progress_cb(update: dict): set_request_task_progress( request, task_status="FAILED", task_progress=0.0, error=message ) - return JSONResponse( + return _json_response( status_code=status_code, content={ "error": { @@ -996,6 +1111,7 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) error_code = set_request_error_detail( request, @@ -1014,7 +1130,7 @@ def _image_progress_cb(update: dict): set_request_task_progress( request, task_status="FAILED", task_progress=0.0, error=str(exc) ) - return JSONResponse( + return _json_response( status_code=500, content={ "error": { @@ -1023,6 +1139,7 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) return router diff --git a/app.py b/app.py index 5eafd31..2a2a502 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,7 @@ import os import json import logging +import re import time import uuid import threading @@ -69,6 +70,8 @@ _generated_usage_bytes = 0 _generated_file_count = 0 _generated_last_reconcile_ts = 0.0 +_REQUEST_ID_MAX_LEN = 120 +_REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$") def _drop_generated_file_cache(file_path: Path) -> None: @@ -128,13 +131,13 @@ def serve_generated_file(filename: str): def _extract_logging_fields(raw_body: bytes) -> dict[str, Optional[str]]: if not raw_body: - return {"model": None, "prompt_preview": None} + return {"model": None, "prompt_preview": None, "request_id": None} try: import json data: Any = json.loads(raw_body.decode("utf-8")) if not isinstance(data, dict): - return {"model": None, "prompt_preview": None} + return {"model": None, "prompt_preview": None, "request_id": None} model = str(data.get("model") or "").strip() or None prompt = str(data.get("prompt") or "").strip() @@ -143,9 +146,27 @@ def _extract_logging_fields(raw_body: bytes) -> dict[str, Optional[str]]: if prompt: prompt = prompt.replace("\r", " ").replace("\n", " ").strip() prompt = prompt[:180] - return {"model": model, "prompt_preview": prompt or None} + request_id = _normalize_request_id( + data.get("request_id") or data.get("requestId") + ) + return { + "model": model, + "prompt_preview": prompt or None, + "request_id": request_id or None, + } except Exception: - return {"model": None, "prompt_preview": None} + return {"model": None, "prompt_preview": None, "request_id": None} + + +def _normalize_request_id(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + if len(text) > _REQUEST_ID_MAX_LEN: + text = text[:_REQUEST_ID_MAX_LEN] + if not _REQUEST_ID_PATTERN.fullmatch(text): + return "" + return text def _upsert_live_request(request: Request, patch: dict) -> None: @@ -373,6 +394,7 @@ def _append_attempt_log( status_code=int(status_code), duration_sec=duration_sec, operation=operation, + request_id=root_log_id, preview_url=preview_url, preview_kind=preview_kind, model=model, @@ -412,7 +434,7 @@ async def request_logger(request: Request, call_next): preview_url = None preview_kind = None raw_body = b"" - body_meta = {"model": None, "prompt_preview": None} + body_meta = {"model": None, "prompt_preview": None, "request_id": None} error_text = None status_code = 500 @@ -435,13 +457,21 @@ async def request_logger(request: Request, call_next): body_meta = _extract_logging_fields(raw_body) request.state.log_model = body_meta.get("model") request.state.log_prompt_preview = body_meta.get("prompt_preview") - request.state.log_id = uuid.uuid4().hex[:12] + header_request_id = _normalize_request_id( + request.headers.get("x-request-id") + ) + request.state.log_id = ( + header_request_id + or str(body_meta.get("request_id") or "").strip() + or uuid.uuid4().hex[:12] + ) log_id = str(getattr(request.state, "log_id", "") or "") if log_id: live_log_store.upsert( log_id, { "id": log_id, + "request_id": log_id, "ts": time.time(), "method": method, "path": path, @@ -461,6 +491,9 @@ async def request_logger(request: Request, call_next): try: response = await call_next(request) status_code = response.status_code + log_id = str(getattr(request.state, "log_id", "") or "").strip() + if response is not None and log_id: + response.headers["X-Request-Id"] = log_id except Exception as exc: _set_request_error_detail( request, @@ -539,6 +572,7 @@ async def request_logger(request: Request, call_next): status_code=status_code, duration_sec=duration_sec, operation=operation, + request_id=log_id, preview_url=preview_url, preview_kind=preview_kind, model=body_meta.get("model"), @@ -1221,6 +1255,8 @@ def _sse_chat_stream(payload: dict): app.include_router( build_generation_router( store=store, + request_log_store=log_store, + live_request_store=live_log_store, token_manager=token_manager, client=client, generated_dir=GENERATED_DIR, diff --git a/core/stores.py b/core/stores.py index 4563651..6b744ce 100644 --- a/core/stores.py +++ b/core/stores.py @@ -70,6 +70,7 @@ class RequestLogRecord: status_code: int duration_sec: int operation: str + request_id: Optional[str] = None preview_url: Optional[str] = None preview_kind: Optional[str] = None model: Optional[str] = None @@ -174,6 +175,41 @@ def list(self, limit: int = 20, page: int = 1) -> tuple[list[dict], int]: continue return data, total + def get(self, request_id: str) -> Optional[dict]: + target = str(request_id or "").strip() + if not target: + return None + with self._lock: + with self._file_path.open("r", encoding="utf-8") as f: + lines = f.readlines() + + fallback = None + attempt_prefix = f"{target}-a" + for line in reversed(lines): + raw = line.strip() + if not raw: + continue + try: + item = json.loads(raw) + except Exception: + continue + if not isinstance(item, dict): + continue + item_id = str(item.get("id") or "").strip() + item_request_id = str(item.get("request_id") or "").strip() + if item_id == target: + payload = dict(item) + payload.setdefault("request_id", target) + return payload + if item_request_id == target: + return dict(item) + if fallback is None and item_id.startswith(attempt_prefix): + payload = dict(item) + payload.setdefault("request_id", target) + payload.setdefault("attempt_id", item_id) + fallback = payload + return fallback + def stats( self, start_ts: Optional[float] = None, @@ -348,6 +384,16 @@ def remove(self, item_id: str) -> None: with self._lock: self._items.pop(iid, None) + def get(self, item_id: str) -> Optional[dict]: + iid = str(item_id or "").strip() + if not iid: + return None + with self._lock: + item = self._items.get(iid) + if not isinstance(item, dict): + return None + return dict(item) + def list(self, limit: int = 200) -> list[dict]: safe_limit = min(max(int(limit or 200), 1), 1000) with self._lock: diff --git a/tests/test_request_progress.py b/tests/test_request_progress.py new file mode 100644 index 0000000..c41a88b --- /dev/null +++ b/tests/test_request_progress.py @@ -0,0 +1,197 @@ +import tempfile +import threading +import unittest +from pathlib import Path +from unittest.mock import patch +import socket + +import requests +import uvicorn + +import app as app_module + + +class RequestProgressApiTests(unittest.TestCase): + def setUp(self) -> None: + self.request_id = "req-progress-001" + self.api_key = "test-service-key" + self.temp_dir = tempfile.TemporaryDirectory() + self.log_path = Path(self.temp_dir.name) / "request_logs.jsonl" + self.log_path.write_text("", encoding="utf-8") + self.original_log_path = app_module.log_store._file_path + self.original_append_since_truncate = app_module.log_store._append_since_truncate + app_module.log_store._file_path = self.log_path + app_module.log_store._append_since_truncate = 0 + with app_module.live_log_store._lock: + app_module.live_log_store._items.clear() + self.generated_before = { + item.name for item in Path(app_module.GENERATED_DIR).glob("*") + } + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + self.port = sock.getsockname()[1] + self.base_url = f"http://127.0.0.1:{self.port}" + self.server = uvicorn.Server( + uvicorn.Config( + app_module.app, + host="127.0.0.1", + port=self.port, + log_level="error", + ) + ) + self.server_thread = threading.Thread(target=self.server.run, daemon=True) + self.server_thread.start() + self._wait_for_server() + + def tearDown(self) -> None: + self.server.should_exit = True + self.server_thread.join(timeout=5) + app_module.log_store._file_path = self.original_log_path + app_module.log_store._append_since_truncate = ( + self.original_append_since_truncate + ) + with app_module.live_log_store._lock: + app_module.live_log_store._items.clear() + for item in Path(app_module.GENERATED_DIR).glob("*"): + if item.name not in self.generated_before and item.is_file(): + item.unlink(missing_ok=True) + self.temp_dir.cleanup() + + def _wait_for_server(self) -> None: + last_error = None + for _ in range(50): + try: + response = requests.get( + f"{self.base_url}/api/v1/health", + timeout=0.5, + ) + if response.status_code == 200: + return + except requests.RequestException as exc: + last_error = exc + threading.Event().wait(0.1) + raise RuntimeError(f"server did not start in time: {last_error}") + + def test_public_request_progress_polling(self) -> None: + progress_started = threading.Event() + allow_finish = threading.Event() + response_holder: dict[str, object] = {} + + def fake_config_get(key: str, default=None): + if key == "api_key": + return self.api_key + if key == "public_base_url": + return "" + return default + + def fake_generate(**kwargs): + progress_cb = kwargs.get("progress_cb") + if callable(progress_cb): + progress_cb( + { + "task_status": "IN_PROGRESS", + "task_progress": 42.0, + "upstream_job_id": "up-job-123", + } + ) + progress_started.set() + if not allow_finish.wait(timeout=5): + raise RuntimeError("test timed out waiting to finish generation") + if callable(progress_cb): + progress_cb( + { + "task_status": "COMPLETED", + "task_progress": 100.0, + "upstream_job_id": "up-job-123", + } + ) + return b"fake-image-bytes", {"progress": 100.0} + + def send_request(): + response_holder["response"] = requests.post( + f"{self.base_url}/v1/images/generations", + headers={"Authorization": f"Bearer {self.api_key}"}, + json={ + "model": "nano-banana-pro", + "prompt": "a cinematic mountain sunrise", + "request_id": self.request_id, + }, + timeout=10, + ) + + with patch.object(app_module.config_manager, "get", side_effect=fake_config_get), patch.object( + app_module.token_manager, + "get_available", + return_value="token-123", + ), patch.object( + app_module.token_manager, + "get_meta_by_value", + return_value={ + "token_id": "token-123", + "token_account_name": "Test User", + "token_account_email": "test@example.com", + "token_source": "unit-test", + }, + ), patch.object( + app_module.token_manager, + "report_exhausted", + return_value=None, + ), patch.object( + app_module.token_manager, + "report_invalid", + return_value=None, + ), patch.object( + app_module.client, + "generate", + side_effect=fake_generate, + ): + worker = threading.Thread(target=send_request, daemon=True) + worker.start() + + self.assertTrue(progress_started.wait(timeout=5)) + + running = requests.get( + f"{self.base_url}/v1/requests/{self.request_id}", + headers={"Authorization": f"Bearer {self.api_key}"}, + timeout=5, + ) + self.assertEqual(running.status_code, 200, running.text) + running_data = running.json() + self.assertEqual(running_data["request_id"], self.request_id) + self.assertEqual(running_data["task_status"], "IN_PROGRESS") + self.assertEqual(running_data["task_progress"], 42.0) + self.assertEqual(running_data["upstream_job_id"], "up-job-123") + self.assertEqual(running_data["source"], "live") + self.assertFalse(running_data["done"]) + + allow_finish.set() + worker.join(timeout=5) + self.assertFalse(worker.is_alive()) + + response = response_holder.get("response") + self.assertIsNotNone(response) + response = response_holder["response"] + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(response.headers.get("X-Request-Id"), self.request_id) + payload = response.json() + self.assertEqual(payload["request_id"], self.request_id) + self.assertIn("data", payload) + + finished = requests.get( + f"{self.base_url}/v1/requests/{self.request_id}", + headers={"Authorization": f"Bearer {self.api_key}"}, + timeout=5, + ) + self.assertEqual(finished.status_code, 200, finished.text) + finished_data = finished.json() + self.assertEqual(finished_data["request_id"], self.request_id) + self.assertEqual(finished_data["task_status"], "COMPLETED") + self.assertEqual(finished_data["task_progress"], 100.0) + self.assertEqual(finished_data["upstream_job_id"], "up-job-123") + self.assertEqual(finished_data["source"], "log") + self.assertTrue(finished_data["done"]) + self.assertTrue(str(finished_data.get("preview_url") or "").endswith(".png")) + + +if __name__ == "__main__": + unittest.main() From a1de6e3f02a01c62ddb3cae9b77b9de927b4094f Mon Sep 17 00:00:00 2001 From: LJR199887 <172809968+LJR199887@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:20:43 +0800 Subject: [PATCH 10/20] Show model params in request logs --- api/routes/generation.py | 2 + app.py | 95 ++++++++++++++++++++++++++++++++-- core/stores.py | 1 + static/admin.css | 14 +++++ static/admin.html | 2 +- static/admin.js | 10 +++- tests/test_request_progress.py | 4 ++ 7 files changed, 122 insertions(+), 6 deletions(-) diff --git a/api/routes/generation.py b/api/routes/generation.py index 6d5b270..f37c9f3 100644 --- a/api/routes/generation.py +++ b/api/routes/generation.py @@ -233,6 +233,7 @@ def _build_request_status_payload( error_code = str(item.get("error_code") or "").strip() or None operation = str(item.get("operation") or "").strip() or None model = str(item.get("model") or "").strip() or None + model_params = str(item.get("model_params") or "").strip() or None prompt_preview = str(item.get("prompt_preview") or "").strip() or None upstream_job_id = str(item.get("upstream_job_id") or "").strip() or None attempt_id = str(item.get("id") or "").strip() or None @@ -271,6 +272,7 @@ def _build_request_status_payload( "error_code": error_code, "operation": operation, "model": model, + "model_params": model_params, "prompt_preview": prompt_preview, "status_code": status_code, "source": source, diff --git a/app.py b/app.py index 2a2a502..71e4d1d 100644 --- a/app.py +++ b/app.py @@ -129,15 +129,85 @@ def serve_generated_file(filename: str): refresh_manager.start() +def _extract_model_params(data: dict[str, Any]) -> Optional[str]: + if not isinstance(data, dict): + return None + + def _pick(*keys: str) -> str: + for key in keys: + value = data.get(key) + if value is None: + continue + text = str(value).strip() + if text: + return text + return "" + + parts: list[str] = [] + duration = _pick("duration", "video_duration", "videoDuration") + if duration: + duration_text = duration.rstrip("sS") + if duration_text: + parts.append(f"{duration_text}s") + + aspect_ratio = _pick("aspect_ratio", "aspectRatio") + if aspect_ratio: + parts.append(aspect_ratio) + + resolution = _pick("resolution", "video_resolution", "videoResolution") + if resolution: + parts.append(resolution) + else: + output_resolution = _pick("output_resolution", "outputResolution") + if output_resolution: + parts.append(output_resolution) + + size_val = data.get("size") + if size_val is not None: + if isinstance(size_val, str): + size_text = size_val.strip() + elif isinstance(size_val, dict): + width = str(size_val.get("width") or "").strip() + height = str(size_val.get("height") or "").strip() + size_text = f"{width}x{height}" if width and height else "" + else: + size_text = "" + if size_text and size_text not in parts: + parts.append(size_text) + + reference_mode = _pick( + "reference_mode", + "referenceMode", + "video_reference_mode", + "videoReferenceMode", + ) + if reference_mode: + parts.append(f"ref:{reference_mode}") + + if not parts: + return None + return " | ".join(parts[:5]) + + def _extract_logging_fields(raw_body: bytes) -> dict[str, Optional[str]]: if not raw_body: - return {"model": None, "prompt_preview": None, "request_id": None} + return { + "model": None, + "model_params": None, + "prompt_preview": None, + "request_id": None, + } try: import json data: Any = json.loads(raw_body.decode("utf-8")) if not isinstance(data, dict): - return {"model": None, "prompt_preview": None, "request_id": None} + return { + "model": None, + "model_params": None, + "prompt_preview": None, + "request_id": None, + } model = str(data.get("model") or "").strip() or None prompt = str(data.get("prompt") or "").strip() @@ -151,11 +221,17 @@ def _extract_logging_fields(raw_body: bytes) -> dict[str, Optional[str]]: ) return { "model": model, + "model_params": _extract_model_params(data), "prompt_preview": prompt or None, "request_id": request_id or None, } except Exception: - return {"model": None, "prompt_preview": None, "request_id": None} + return { + "model": None, + "model_params": None, + "prompt_preview": None, + "request_id": None, + } def _normalize_request_id(value: Any) -> str: @@ -321,6 +397,7 @@ def _set_request_task_progress( "error": patch.get("error"), "error_code": getattr(request.state, "log_error_code", None), "model": getattr(request.state, "log_model", None), + "model_params": getattr(request.state, "log_model_params", None), "prompt_preview": getattr(request.state, "log_prompt_preview", None), "ts": time.time(), }, @@ -374,6 +451,7 @@ def _append_attempt_log( method = str(getattr(request, "method", "POST") or "POST").upper() path = str(getattr(getattr(request, "url", None), "path", "") or "") model = getattr(request.state, "log_model", None) + model_params = getattr(request.state, "log_model_params", None) prompt_preview = getattr(request.state, "log_prompt_preview", None) preview_url = getattr(request.state, "log_preview_url", None) preview_kind = getattr(request.state, "log_preview_kind", None) @@ -398,6 +476,7 @@ def _append_attempt_log( preview_url=preview_url, preview_kind=preview_kind, model=model, + model_params=model_params, prompt_preview=prompt_preview, error=(str(error)[:240] if error else None), error_code=(str(error_code or "") or None), @@ -434,7 +513,12 @@ async def request_logger(request: Request, call_next): preview_url = None preview_kind = None raw_body = b"" - body_meta = {"model": None, "prompt_preview": None, "request_id": None} + body_meta = { + "model": None, + "model_params": None, + "prompt_preview": None, + "request_id": None, + } error_text = None status_code = 500 @@ -456,6 +540,7 @@ async def request_logger(request: Request, call_next): }: body_meta = _extract_logging_fields(raw_body) request.state.log_model = body_meta.get("model") + request.state.log_model_params = body_meta.get("model_params") request.state.log_prompt_preview = body_meta.get("prompt_preview") header_request_id = _normalize_request_id( request.headers.get("x-request-id") @@ -479,6 +564,7 @@ async def request_logger(request: Request, call_next): "duration_sec": 0, "operation": operation, "model": body_meta.get("model"), + "model_params": body_meta.get("model_params"), "prompt_preview": body_meta.get("prompt_preview"), "task_status": "IN_PROGRESS", "task_progress": 0.0, @@ -576,6 +662,7 @@ async def request_logger(request: Request, call_next): preview_url=preview_url, preview_kind=preview_kind, model=body_meta.get("model"), + model_params=body_meta.get("model_params"), prompt_preview=body_meta.get("prompt_preview"), error=error_final, error_code=error_code, diff --git a/core/stores.py b/core/stores.py index 6b744ce..4366b17 100644 --- a/core/stores.py +++ b/core/stores.py @@ -74,6 +74,7 @@ class RequestLogRecord: preview_url: Optional[str] = None preview_kind: Optional[str] = None model: Optional[str] = None + model_params: Optional[str] = None prompt_preview: Optional[str] = None error: Optional[str] = None error_code: Optional[str] = None diff --git a/static/admin.css b/static/admin.css index 4498bf8..79aee5e 100644 --- a/static/admin.css +++ b/static/admin.css @@ -539,12 +539,26 @@ table { } .log-model-cell { + display: flex; + flex-direction: column; + gap: 4px; font-family: "IBM Plex Mono", monospace; font-size: 12px; color: #8fb0d3; word-break: break-word; } +.log-model-name { + color: #8fb0d3; +} + +.log-model-meta { + color: #7f96ad; + font-size: 11px; + line-height: 1.35; + word-break: break-word; +} + .log-prompt-cell { color: #a8bfd8; line-height: 1.35; diff --git a/static/admin.html b/static/admin.html index 2c43f07..32002da 100644 --- a/static/admin.html +++ b/static/admin.html @@ -259,7 +259,7 @@

请求日志

耗时/秒 进度 账号 - 模型 + 模型/参数 提示词摘要 预览 diff --git a/static/admin.js b/static/admin.js index fc613d0..0a0615c 100644 --- a/static/admin.js +++ b/static/admin.js @@ -1321,17 +1321,25 @@ document.addEventListener("DOMContentLoaded", async () => { : `` ); const modelText = String(item.model || "-"); + const modelParamsText = String(item.model_params || "").trim(); const tokenCell = ``; const previewCell = previewUrl ? `` : `-`; + const modelTitle = escapeHtml([modelText, modelParamsText].filter(Boolean).join(" | ")); + const modelCell = ` +
+ ${escapeHtml(modelText)} + ${modelParamsText ? `${escapeHtml(modelParamsText)}` : ""} +
+ `; tr.innerHTML = ` ${dateText}${timeText} ${statusCell} ${t} ${progressCell} ${tokenCell} - ${escapeHtml(modelText)} + ${modelCell} ${item.prompt_preview || "-"} ${previewCell} `; diff --git a/tests/test_request_progress.py b/tests/test_request_progress.py index c41a88b..94b7dc0 100644 --- a/tests/test_request_progress.py +++ b/tests/test_request_progress.py @@ -113,6 +113,8 @@ def send_request(): headers={"Authorization": f"Bearer {self.api_key}"}, json={ "model": "nano-banana-pro", + "output_resolution": "2K", + "aspect_ratio": "16:9", "prompt": "a cinematic mountain sunrise", "request_id": self.request_id, }, @@ -161,6 +163,7 @@ def send_request(): self.assertEqual(running_data["task_status"], "IN_PROGRESS") self.assertEqual(running_data["task_progress"], 42.0) self.assertEqual(running_data["upstream_job_id"], "up-job-123") + self.assertEqual(running_data["model_params"], "16:9 | 2K") self.assertEqual(running_data["source"], "live") self.assertFalse(running_data["done"]) @@ -188,6 +191,7 @@ def send_request(): self.assertEqual(finished_data["task_status"], "COMPLETED") self.assertEqual(finished_data["task_progress"], 100.0) self.assertEqual(finished_data["upstream_job_id"], "up-job-123") + self.assertEqual(finished_data["model_params"], "16:9 | 2K") self.assertEqual(finished_data["source"], "log") self.assertTrue(finished_data["done"]) self.assertTrue(str(finished_data.get("preview_url") or "").endswith(".png")) From 4041a4d4f2ce7cc649585e7d94c6546445d6763d Mon Sep 17 00:00:00 2001 From: LJR199887 <172809968+LJR199887@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:32:53 +0800 Subject: [PATCH 11/20] Tighten request log model column layout --- static/admin.css | 20 +++++++++++--------- static/admin.js | 2 +- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/static/admin.css b/static/admin.css index 79aee5e..dc1538d 100644 --- a/static/admin.css +++ b/static/admin.css @@ -488,22 +488,23 @@ table { #logsTable td:nth-child(5), #runningLogsTable th:nth-child(5), #runningLogsTable td:nth-child(5) { - width: 500px; + width: 360px; } #logsTable th:nth-child(6), #logsTable td:nth-child(6), #runningLogsTable th:nth-child(6), #runningLogsTable td:nth-child(6) { - width: 190px; + width: 320px; + white-space: nowrap; } #logsTable th:nth-child(7), #logsTable td:nth-child(7), #runningLogsTable th:nth-child(7), #runningLogsTable td:nth-child(7) { - min-width: 220px; - width: 35%; + min-width: 280px; + width: 40%; } #logsTable td, @@ -539,24 +540,25 @@ table { } .log-model-cell { - display: flex; - flex-direction: column; - gap: 4px; + display: inline-flex; + align-items: center; + gap: 6px; font-family: "IBM Plex Mono", monospace; font-size: 12px; color: #8fb0d3; - word-break: break-word; + white-space: nowrap; } .log-model-name { color: #8fb0d3; + flex: 0 0 auto; } .log-model-meta { color: #7f96ad; font-size: 11px; line-height: 1.35; - word-break: break-word; + flex: 0 0 auto; } .log-prompt-cell { diff --git a/static/admin.js b/static/admin.js index 0a0615c..f91187e 100644 --- a/static/admin.js +++ b/static/admin.js @@ -1330,7 +1330,7 @@ document.addEventListener("DOMContentLoaded", async () => { const modelCell = `
${escapeHtml(modelText)} - ${modelParamsText ? `${escapeHtml(modelParamsText)}` : ""} + ${modelParamsText ? `| ${escapeHtml(modelParamsText)}` : ""}
`; tr.innerHTML = ` From 464c30239b06e99a549543908950ec0d542ac092 Mon Sep 17 00:00:00 2001 From: LJR199887 <172809968+LJR199887@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:41:03 +0800 Subject: [PATCH 12/20] Refine request log table spacing --- static/admin.css | 36 ++++++++++++++++++++++++------------ static/admin.js | 2 +- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/static/admin.css b/static/admin.css index dc1538d..611d458 100644 --- a/static/admin.css +++ b/static/admin.css @@ -485,26 +485,31 @@ table { } #logsTable th:nth-child(5), -#logsTable td:nth-child(5), #runningLogsTable th:nth-child(5), +#logsTable th:nth-child(6), +#runningLogsTable th:nth-child(6) { + white-space: nowrap; +} + +#logsTable td:nth-child(5), #runningLogsTable td:nth-child(5) { - width: 360px; + width: 440px; + min-width: 260px; } -#logsTable th:nth-child(6), #logsTable td:nth-child(6), -#runningLogsTable th:nth-child(6), #runningLogsTable td:nth-child(6) { - width: 320px; - white-space: nowrap; + width: 300px; + min-width: 220px; + white-space: normal; } #logsTable th:nth-child(7), #logsTable td:nth-child(7), #runningLogsTable th:nth-child(7), #runningLogsTable td:nth-child(7) { - min-width: 280px; - width: 40%; + min-width: 320px; + width: 42%; } #logsTable td, @@ -517,6 +522,8 @@ table { display: block; color: #a8bfd8; line-height: 1.35; + max-width: 420px; + overflow-wrap: anywhere; } .log-time-cell { @@ -540,18 +547,21 @@ table { } .log-model-cell { - display: inline-flex; - align-items: center; - gap: 6px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; font-family: "IBM Plex Mono", monospace; font-size: 12px; color: #8fb0d3; - white-space: nowrap; + white-space: normal; + line-height: 1.3; } .log-model-name { color: #8fb0d3; flex: 0 0 auto; + word-break: break-word; } .log-model-meta { @@ -559,6 +569,7 @@ table { font-size: 11px; line-height: 1.35; flex: 0 0 auto; + word-break: break-word; } .log-prompt-cell { @@ -567,6 +578,7 @@ table { white-space: normal; word-break: break-word; overflow-wrap: anywhere; + padding-left: 22px !important; } th { diff --git a/static/admin.js b/static/admin.js index f91187e..0a0615c 100644 --- a/static/admin.js +++ b/static/admin.js @@ -1330,7 +1330,7 @@ document.addEventListener("DOMContentLoaded", async () => { const modelCell = `
${escapeHtml(modelText)} - ${modelParamsText ? `| ${escapeHtml(modelParamsText)}` : ""} + ${modelParamsText ? `${escapeHtml(modelParamsText)}` : ""}
`; tr.innerHTML = ` From b55e339115d7276614c6e56cc435485b0e847865 Mon Sep 17 00:00:00 2001 From: LJR199887 <172809968+LJR199887@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:04:26 +0800 Subject: [PATCH 13/20] Refine log table readability --- static/admin.css | 64 ++++++++++++++++++++++++++++++++++++++++------- static/admin.html | 10 ++++++++ static/admin.js | 46 +++++++++++++++++++++++++++++++++- 3 files changed, 110 insertions(+), 10 deletions(-) diff --git a/static/admin.css b/static/admin.css index 611d458..237adf7 100644 --- a/static/admin.css +++ b/static/admin.css @@ -48,7 +48,7 @@ body { } .shell { - max-width: 1000px; + max-width: 1180px; margin: 40px auto; padding: 0 20px; display: flex; @@ -457,7 +457,7 @@ table { #logsTable td:nth-child(1), #runningLogsTable th:nth-child(1), #runningLogsTable td:nth-child(1) { - width: 70px; + width: 84px; } #logsTable th:nth-child(2), @@ -493,13 +493,13 @@ table { #logsTable td:nth-child(5), #runningLogsTable td:nth-child(5) { - width: 440px; - min-width: 260px; + width: 340px; + min-width: 240px; } #logsTable td:nth-child(6), #runningLogsTable td:nth-child(6) { - width: 300px; + width: 250px; min-width: 220px; white-space: normal; } @@ -508,8 +508,17 @@ table { #logsTable td:nth-child(7), #runningLogsTable th:nth-child(7), #runningLogsTable td:nth-child(7) { - min-width: 320px; - width: 42%; + min-width: 200px; + width: 26%; +} + +#logsTable th:nth-child(8), +#logsTable td:nth-child(8), +#runningLogsTable th:nth-child(8), +#runningLogsTable td:nth-child(8) { + width: 116px; + min-width: 116px; + white-space: nowrap; } #logsTable td, @@ -522,7 +531,7 @@ table { display: block; color: #a8bfd8; line-height: 1.35; - max-width: 420px; + max-width: 340px; overflow-wrap: anywhere; } @@ -578,7 +587,44 @@ table { white-space: normal; word-break: break-word; overflow-wrap: anywhere; - padding-left: 22px !important; + padding-left: 10px !important; +} + +.log-prompt-btn { + appearance: none; + border: 0; + background: transparent; + color: inherit; + font: inherit; + padding: 0; + margin: 0; + cursor: pointer; + text-align: left; + transition: color 0.18s ease, opacity 0.18s ease; +} + +.log-prompt-btn:hover { + color: #d6e7f9; +} + +.log-prompt-btn:focus-visible { + outline: 2px solid rgba(44, 199, 170, 0.55); + outline-offset: 4px; + border-radius: 6px; +} + +.prompt-detail-content { + border: 1px solid rgba(142, 181, 221, 0.25); + border-radius: 8px; + background: #091321; + padding: 14px 16px; + max-height: calc(100vh - 220px); + overflow: auto; + color: #cfe3f8; + font-size: 15px; + line-height: 1.7; + white-space: pre-wrap; + word-break: break-word; } th { diff --git a/static/admin.html b/static/admin.html index 32002da..5c0d400 100644 --- a/static/admin.html +++ b/static/admin.html @@ -348,6 +348,16 @@

错误信息

+ +
diff --git a/static/admin.js b/static/admin.js index 0a0615c..97fa230 100644 --- a/static/admin.js +++ b/static/admin.js @@ -700,6 +700,9 @@ document.addEventListener("DOMContentLoaded", async () => { const errorDetailCode = document.getElementById("errorDetailCode"); const errorDetailContent = document.getElementById("errorDetailContent"); const errorDetailCloseBtn = document.getElementById("errorDetailCloseBtn"); + const promptDetailModal = document.getElementById("promptDetailModal"); + const promptDetailContent = document.getElementById("promptDetailContent"); + const promptDetailCloseBtn = document.getElementById("promptDetailCloseBtn"); const appToast = document.getElementById("appToast"); const LOGS_PAGE_SIZE = 20; let logsCurrentPage = 1; @@ -871,6 +874,14 @@ document.addEventListener("DOMContentLoaded", async () => { .replace(/'/g, "'"); } + function buildPromptSummary(value) { + const raw = String(value || "").trim(); + if (!raw) return "-"; + const chars = Array.from(raw); + if (chars.length <= 4) return raw; + return `${chars.slice(0, 4).join("")}...`; + } + function truncateText(value, maxLen) { const text = String(value || ""); if (text.length <= maxLen) return text; @@ -1322,6 +1333,8 @@ document.addEventListener("DOMContentLoaded", async () => { ); const modelText = String(item.model || "-"); const modelParamsText = String(item.model_params || "").trim(); + const promptText = String(item.prompt_preview || "").trim(); + const promptSummary = buildPromptSummary(promptText); const tokenCell = ``; const previewCell = previewUrl ? `` @@ -1340,7 +1353,7 @@ document.addEventListener("DOMContentLoaded", async () => { ${progressCell} ${tokenCell} ${modelCell} - ${item.prompt_preview || "-"} + ${promptText ? `` : "-"} ${previewCell} `; if (isRunning) tr.classList.add("log-row-running"); @@ -1426,6 +1439,13 @@ document.addEventListener("DOMContentLoaded", async () => { errorDetailContent.innerHTML = ""; } + function closePromptDetail() { + if (!promptDetailModal || !promptDetailContent) return; + promptDetailModal.classList.remove("open"); + promptDetailModal.setAttribute("aria-hidden", "true"); + promptDetailContent.textContent = ""; + } + async function openErrorDetailByCode(code) { const errCode = String(code || "").trim(); if (!errCode || !errorDetailModal || !errorDetailCode || !errorDetailContent) return; @@ -1475,10 +1495,23 @@ document.addEventListener("DOMContentLoaded", async () => { previewModal.setAttribute("aria-hidden", "false"); } + function openPromptDetail(text) { + if (!promptDetailModal || !promptDetailContent) return; + promptDetailContent.textContent = String(text || "").trim() || "暂无提示词"; + promptDetailModal.classList.add("open"); + promptDetailModal.setAttribute("aria-hidden", "false"); + } + if (logsTbody) { logsTbody.addEventListener("click", (event) => { const target = event.target; if (!(target instanceof HTMLElement)) return; + const promptBtn = target.closest("[data-full-prompt]"); + if (promptBtn instanceof HTMLElement) { + const fullPrompt = String(promptBtn.getAttribute("data-full-prompt") || "").trim(); + openPromptDetail(decodeURIComponent(fullPrompt)); + return; + } if (target.classList.contains("preview-btn")) { const encodedUrl = target.getAttribute("data-url") || ""; const kind = (target.getAttribute("data-kind") || "").trim(); @@ -1515,10 +1548,21 @@ document.addEventListener("DOMContentLoaded", async () => { }); } + if (promptDetailCloseBtn) { + promptDetailCloseBtn.addEventListener("click", closePromptDetail); + } + + if (promptDetailModal) { + promptDetailModal.addEventListener("click", (event) => { + if (event.target === promptDetailModal) closePromptDetail(); + }); + } + document.addEventListener("keydown", (event) => { if (event.key === "Escape") { closePreview(); closeErrorDetail(); + closePromptDetail(); closeDialog(tokenModal); closeDialog(refreshModal); } From 0f2c137b9d6e35bb6bbcc4c686d4448c2a771830 Mon Sep 17 00:00:00 2001 From: LJR199887 <172809968+LJR199887@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:14:22 +0800 Subject: [PATCH 14/20] Center request log duration values --- static/admin.css | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/static/admin.css b/static/admin.css index 237adf7..8797e99 100644 --- a/static/admin.css +++ b/static/admin.css @@ -480,8 +480,18 @@ table { #logsTable td:nth-child(3), #runningLogsTable th:nth-child(3), #runningLogsTable td:nth-child(3) { - width: 62px; + width: 92px; + min-width: 92px; white-space: nowrap; + text-align: center; +} + +#logsTable th:nth-child(4), +#logsTable td:nth-child(4), +#runningLogsTable th:nth-child(4), +#runningLogsTable td:nth-child(4) { + width: 82px; + min-width: 82px; } #logsTable th:nth-child(5), @@ -493,31 +503,38 @@ table { #logsTable td:nth-child(5), #runningLogsTable td:nth-child(5) { - width: 340px; - min-width: 240px; + width: 320px; + min-width: 228px; + padding-left: 20px; } #logsTable td:nth-child(6), #runningLogsTable td:nth-child(6) { - width: 250px; - min-width: 220px; + width: 238px; + min-width: 210px; white-space: normal; + padding-left: 18px; } #logsTable th:nth-child(7), #logsTable td:nth-child(7), #runningLogsTable th:nth-child(7), #runningLogsTable td:nth-child(7) { - min-width: 200px; - width: 26%; + min-width: 190px; + width: 24%; +} + +#logsTable td:nth-child(7), +#runningLogsTable td:nth-child(7) { + padding-left: 16px; } #logsTable th:nth-child(8), #logsTable td:nth-child(8), #runningLogsTable th:nth-child(8), #runningLogsTable td:nth-child(8) { - width: 116px; - min-width: 116px; + width: 104px; + min-width: 104px; white-space: nowrap; } From b6cc7c3a0d9edca37dbef2ecad47904fc6cdd7fd Mon Sep 17 00:00:00 2001 From: LJR199887 <172809968+LJR199887@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:09:41 +0800 Subject: [PATCH 15/20] merge banana 4k model variants --- README.md | 324 ++++++++++++++--------------------------- core/models/catalog.py | 51 +++---- 2 files changed, 133 insertions(+), 242 deletions(-) diff --git a/README.md b/README.md index f64eabb..392d831 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,97 @@ # adobe2api ---- - -### ✨ 广告时间 (o゜▽゜)o☆ - -这是我个人独立搭建和长期维护的网站:[**Pixelle Labs**](https://www.pixellelabs.com/) - -主要分享我正在开发的 **AI 创意工具**、图像/视频相关小产品和各种有趣实验。欢迎大家来逛逛、免费体验、随便玩耍 (๑•̀ㅂ•́)و✧;如果你有想法或需求,也非常欢迎反馈交流!ヾ(≧▽≦*)o - ---- - -Adobe Firefly / OpenAI 兼容网关服务。 - +Adobe Firefly / OpenAI 兼容网关服务。 English README: `README_EN.md` - 当前设计: - - 对外统一入口:`/v1/chat/completions`(图像 + 视频) -- 可选图像专用接口:`/v1/images/generations` -- Token 池管理(手动 Token + 自动刷新 Token) -- 管理后台 Web UI:Token / 配置 / 日志 / 刷新配置导入 +- 图像专用入口:`/v1/images/generations` +- 支持多账号 Token 池、自动刷新、管理后台、请求日志与任务进度查询 -## 1)部署方式 +## 1. 部署方式 -### A. 本地开发/运行 - -1. **安装依赖**: +### A. 本地运行 ```bash pip install -r requirements.txt -``` - -2. **启动服务**(在 `adobe2api/` 目录下执行): - -```bash uvicorn app:app --host 0.0.0.0 --port 6001 --reload ``` -3. **访问管理后台**: - +管理后台: - 地址:`http://127.0.0.1:6001/` - 默认账号密码:`admin / admin` -- 登录后可在「系统配置」修改,或编辑 `config/config.json` -### B. Docker 部署 (推荐) - -本项目已提供 Docker 支持,推荐使用 Docker Compose 一键启动: +### B. Docker ```bash docker compose up -d --build ``` -## 2)服务鉴权 +## 2. 服务鉴权 服务 API Key 配置在 `config/config.json` 的 `api_key` 字段。 -- 若已设置,调用时可使用以下任一方式: - - `Authorization: Bearer ` - - `X-API-Key: ` +调用时可使用: +- `Authorization: Bearer ` +- `X-API-Key: ` 管理后台和管理 API 需要先通过 `/api/v1/auth/login` 登录并持有会话 Cookie。 -## 3)外部 API 使用 +## 3. 外部 API 使用 -### 3.0 支持的模型族 +### 3.0 支持的模型 -当前支持如下模型族: +当前公开模型如下: - `nano-banana`(图像,对应上游 `nano-banana-2`) -- `nano-banana-4k`(图像,固定 4K,对应上游 `nano-banana-2`) - `nano-banana2`(图像,对应上游 `nano-banana-3`) -- `nano-banana2-4k`(图像,固定 4K,对应上游 `nano-banana-3`) - `nano-banana-pro`(图像) -- `nano-banana-pro-4k`(图像,固定 4K) - `sora2`(视频) - `sora2-pro`(视频) - `veo31`(视频) - `veo31-ref`(视频,参考图模式) - `veo31-fast`(视频) -Nano Banana 图像模型(`nano-banana-2`): +说明: +- `nano-banana`、`nano-banana2`、`nano-banana-pro` 现在都统一通过 `output_resolution` 选择 `1K` / `2K` / `4K` +- 旧的 `nano-banana-4k`、`nano-banana2-4k`、`nano-banana-pro-4k` 仍保留兼容,但不会继续在 `/v1/models` 中单独展示 +- 视频模型继续通过请求参数单独传 `duration`、`aspect_ratio`、`resolution`、`reference_mode` -- 命名:`model=nano-banana`,尺寸参数单独传 -- 分辨率:通过 `output_resolution` 传 `1K` / `2K` -- 比例:通过 `aspect_ratio` 传 `1:1` / `16:9` / `9:16` / `4:3` / `3:4` +### 3.1 Banana 图像模型 + +Nano Banana(`nano-banana-2`): +- 命名:`model=nano-banana` +- 分辨率:`output_resolution=1K / 2K / 4K` +- 比例:`aspect_ratio=1:1 / 16:9 / 9:16 / 4:3 / 3:4` - 示例: - `model=nano-banana, output_resolution=2K, aspect_ratio=16:9` - `model=nano-banana, output_resolution=1K, aspect_ratio=1:1` + - `model=nano-banana, output_resolution=4K, aspect_ratio=16:9` -Nano Banana 4K 图像模型(`nano-banana-2`): - -- 命名:`model=nano-banana-4k` -- 分辨率固定为 `4K` -- 比例:通过 `aspect_ratio` 传 `1:1` / `16:9` / `9:16` / `4:3` / `3:4` -- 示例: - - `model=nano-banana-4k, aspect_ratio=16:9` - -Nano Banana 2 图像模型(`nano-banana-3`): - -- 命名:`model=nano-banana2`,尺寸参数单独传 -- 分辨率:通过 `output_resolution` 传 `1K` / `2K` -- 比例:通过 `aspect_ratio` 传 `1:1` / `16:9` / `9:16` / `4:3` / `3:4` +Nano Banana 2(`nano-banana-3`): +- 命名:`model=nano-banana2` +- 分辨率:`output_resolution=1K / 2K / 4K` +- 比例:`aspect_ratio=1:1 / 16:9 / 9:16 / 4:3 / 3:4` - 示例: - `model=nano-banana2, output_resolution=2K, aspect_ratio=16:9` - `model=nano-banana2, output_resolution=1K, aspect_ratio=1:1` + - `model=nano-banana2, output_resolution=4K, aspect_ratio=16:9` -Nano Banana 2 4K 图像模型(`nano-banana-3`): - -- 命名:`model=nano-banana2-4k` -- 分辨率固定为 `4K` -- 比例:通过 `aspect_ratio` 传 `1:1` / `16:9` / `9:16` / `4:3` / `3:4` -- 示例: - - `model=nano-banana2-4k, aspect_ratio=16:9` - -Nano Banana Pro 图像模型(兼容旧命名): - -- 命名:`model=nano-banana-pro`,尺寸参数单独传 -- 分辨率:通过 `output_resolution` 传 `1K` / `2K` -- 比例:通过 `aspect_ratio` 传 `1:1` / `16:9` / `9:16` / `4:3` / `3:4` +Nano Banana Pro: +- 命名:`model=nano-banana-pro` +- 分辨率:`output_resolution=1K / 2K / 4K` +- 比例:`aspect_ratio=1:1 / 16:9 / 9:16 / 4:3 / 3:4` - 示例: - `model=nano-banana-pro, output_resolution=2K, aspect_ratio=16:9` - `model=nano-banana-pro, output_resolution=1K, aspect_ratio=1:1` + - `model=nano-banana-pro, output_resolution=4K, aspect_ratio=16:9` -Nano Banana Pro 4K 图像模型: - -- 命名:`model=nano-banana-pro-4k` -- 分辨率固定为 `4K` -- 比例:通过 `aspect_ratio` 传 `1:1` / `16:9` / `9:16` / `4:3` / `3:4` -- 示例: - - `model=nano-banana-pro-4k, aspect_ratio=16:9` - -Banana 图像尺寸映射规则: +### 3.2 Banana 图像尺寸映射规则 -- 这类模型最终不会直接使用你传入的像素宽高,而是根据 `output_resolution` + `aspect_ratio` 自动换算成固定尺寸 -- 如果没有传 `aspect_ratio`,但传了 `size`,服务会先根据 `size` 自动反推出比例,再套用下表 +这类模型最终不会直接使用你传入的像素宽高,而是根据 `output_resolution + aspect_ratio` 自动换算成固定尺寸。 +如果没有传 `aspect_ratio`,但传了 `size`,服务会先根据 `size` 自动反推比例,再套用下表。 `1K` - - `1:1` -> `1024 x 1024` - `16:9` -> `1360 x 768` - `9:16` -> `768 x 1360` @@ -145,7 +99,6 @@ Banana 图像尺寸映射规则: - `3:4` -> `864 x 1152` `2K` - - `1:1` -> `2048 x 2048` - `16:9` -> `2752 x 1536` - `9:16` -> `1536 x 2752` @@ -153,82 +106,59 @@ Banana 图像尺寸映射规则: - `3:4` -> `1536 x 2048` `4K` - - `1:1` -> `4096 x 4096` - `16:9` -> `5504 x 3072` - `9:16` -> `3072 x 5504` - `4:3` -> `4096 x 3072` - `3:4` -> `3072 x 4096` -Sora2 视频模型: - -- 命名:`model=sora2`,参数单独传 -- 时长:通过 `duration` 传 `4` / `8` / `12` -- 比例:通过 `aspect_ratio` 传 `9:16` / `16:9` -- 示例: - - `model=sora2, duration=4, aspect_ratio=16:9` - - `model=sora2, duration=8, aspect_ratio=9:16` - -Sora2 Pro 视频模型: - -- 命名:`model=sora2-pro`,参数单独传 -- 时长:通过 `duration` 传 `4` / `8` / `12` -- 比例:通过 `aspect_ratio` 传 `9:16` / `16:9` -- 示例: - - `model=sora2-pro, duration=4, aspect_ratio=16:9` - - `model=sora2-pro, duration=8, aspect_ratio=9:16` - -Veo31 视频模型: - -- 命名:`model=veo31`,参数单独传 -- 时长:通过 `duration` 传 `4` / `6` / `8` -- 比例:通过 `aspect_ratio` 传 `16:9` / `9:16` -- 分辨率:通过 `resolution` 传 `1080p` / `720p` -- 参考模式:通过 `reference_mode` 传 `frame` 或 `image` -- 最多支持 2 张参考图: - - 1 张:首帧参考 - - 2 张:首帧 + 尾帧参考 -- 当 `reference_mode=image` 时,最多支持 3 张参考图 -- 音频默认开启 -- 示例: - - `model=veo31, duration=4, aspect_ratio=16:9, resolution=1080p` - - `model=veo31, duration=6, aspect_ratio=9:16, resolution=720p, reference_mode=image` - -Veo31 Ref 视频模型(参考图模式): - -- 命名:`model=veo31-ref`,参数单独传 -- 时长:通过 `duration` 传 `4` / `6` / `8` -- 比例:通过 `aspect_ratio` 传 `16:9` / `9:16` -- 分辨率:通过 `resolution` 传 `1080p` / `720p` -- 始终使用参考图模式(不是首尾帧模式) -- 最多支持 3 张参考图(映射到上游 `referenceBlobs[].usage="asset"`) -- 示例: - - `model=veo31-ref, duration=4, aspect_ratio=9:16, resolution=720p` - - `model=veo31-ref, duration=6, aspect_ratio=16:9, resolution=1080p` - - `model=veo31-ref, duration=8, aspect_ratio=9:16, resolution=1080p` - -Veo31 Fast 视频模型: - -- 命名:`model=veo31-fast`,参数单独传 -- 时长:通过 `duration` 传 `4` / `6` / `8` -- 比例:通过 `aspect_ratio` 传 `16:9` / `9:16` -- 分辨率:通过 `resolution` 传 `1080p` / `720p` -- 最多支持 2 张参考图: - - 1 张:首帧参考 - - 2 张:首帧 + 尾帧参考 -- 音频默认开启 -- 示例: - - `model=veo31-fast, duration=4, aspect_ratio=16:9, resolution=1080p` - - `model=veo31-fast, duration=6, aspect_ratio=9:16, resolution=720p` +### 3.3 视频模型 + +Sora2: +- 命名:`model=sora2` +- 时长:`duration=4 / 8 / 12` +- 比例:`aspect_ratio=16:9 / 9:16` + +Sora2 Pro: +- 命名:`model=sora2-pro` +- 时长:`duration=4 / 8 / 12` +- 比例:`aspect_ratio=16:9 / 9:16` + +Veo31: +- 命名:`model=veo31` +- 时长:`duration=4 / 6 / 8` +- 比例:`aspect_ratio=16:9 / 9:16` +- 分辨率:`resolution=720p / 1080p` +- 参考模式:`reference_mode=frame / image` + +Veo31 Ref: +- 命名:`model=veo31-ref` +- 时长:`duration=4 / 6 / 8` +- 比例:`aspect_ratio=16:9 / 9:16` +- 分辨率:`resolution=720p / 1080p` +- 固定参考图模式:`reference_mode=image` + +Veo31 Fast: +- 命名:`model=veo31-fast` +- 时长:`duration=4 / 6 / 8` +- 比例:`aspect_ratio=16:9 / 9:16` +- 分辨率:`resolution=720p / 1080p` + +Veo31 单图/多图语义: +- `veo31` / `veo31-fast` 且 `reference_mode=frame`:帧模式 +- 1 张图:首帧 +- 2 张图:首帧 + 尾帧 +- `veo31-ref`,或 `veo31` 且 `reference_mode=image`:参考图模式 +- 1~3 张图:参考图 -### 3.1 获取模型列表 +### 3.4 获取模型列表 ```bash curl -X GET "http://127.0.0.1:6001/v1/models" \ -H "Authorization: Bearer " ``` -### 3.2 统一入口:`/v1/chat/completions` +### 3.5 统一入口:`/v1/chat/completions` 文生图: @@ -244,7 +174,7 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ }' ``` -图生图(在最新 user 消息中传入图片): +图生图: ```bash curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ @@ -252,7 +182,7 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Content-Type: application/json" \ -d '{ "model": "nano-banana-pro", - "output_resolution": "2K", + "output_resolution": "4K", "aspect_ratio": "16:9", "messages": [{ "role":"user", @@ -278,14 +208,6 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ }' ``` -Veo31 单图语义说明: - -- `veo31` / `veo31-fast` 且 `reference_mode=frame`:帧模式 - - 1 张图 => 首帧 - - 2 张图 => 首帧 + 尾帧 -- `veo31-ref`,或 `veo31` 且 `reference_mode=image`:参考图模式 - - 1~3 张图 => 参考图 - 图生视频: ```bash @@ -293,9 +215,11 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "sora2", - "duration": 8, + "model": "veo31", + "duration": 6, "aspect_ratio": "9:16", + "resolution": "720p", + "reference_mode": "image", "messages": [{ "role":"user", "content":[ @@ -306,35 +230,29 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ }' ``` -### 3.3 图像接口:`/v1/images/generations` +### 3.6 图像接口:`/v1/images/generations` ```bash curl -X POST "http://127.0.0.1:6001/v1/images/generations" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "nano-banana-pro-4k", + "model": "nano-banana-pro", + "output_resolution": "4K", "aspect_ratio": "16:9", "prompt": "futuristic city skyline at dusk" }' ``` -### 3.4 `request_id` 进度查询 +### 3.7 `request_id` 进度查询 -普通外部 API 调用现在支持按 `request_id` 轮询任务状态与进度。 +普通外部 API 调用支持按 `request_id` 轮询任务状态与进度。 - 可用于:`/v1/chat/completions` 和 `/v1/images/generations` -- 建议用法:客户端自己生成一个 `request_id`,并随请求一起传入 - 查询接口:`GET /v1/requests/{request_id}` -- 认证方式:与生成接口一致,使用 `Authorization: Bearer ` 或 `X-API-Key` -- 服务会在响应头中回写 `X-Request-Id`,同时在 JSON 响应体中也会包含 `request_id` - -说明: - -- 如果你想在请求还未返回时就开始轮询,请务必自己传入 `request_id` -- 如果不传,服务会自动生成一个 ID,但只能在响应完成后从响应头或响应体里拿到 +- 服务会在响应头回写 `X-Request-Id`,同时在 JSON 响应体里也包含 `request_id` -示例:提交生成请求 +提交示例: ```bash curl -X POST "http://127.0.0.1:6001/v1/images/generations" \ @@ -349,7 +267,7 @@ curl -X POST "http://127.0.0.1:6001/v1/images/generations" \ }' ``` -示例:轮询任务进度 +查询示例: ```bash curl -X GET "http://127.0.0.1:6001/v1/requests/demo-req-001" \ @@ -378,58 +296,30 @@ curl -X GET "http://127.0.0.1:6001/v1/requests/demo-req-001" \ } ``` -- `task_status` 可能为 `IN_PROGRESS` / `COMPLETED` / `FAILED` -- `task_progress` 范围为 `0` 到 `100` -- `source=live` 表示来自运行中的内存状态,`source=log` 表示任务已结束,数据来自最终日志 -- 任务完成后,如果有预览地址,会在 `preview_url` 中返回 - -## 4)Cookie 导入 - -### 第一步:使用浏览器插件导出(推荐) +## 4. Cookie 导入 -本项目提供了一个配套的浏览器插件,可以方便地从 Adobe Firefly 页面导出所需的 Cookie 数据。 +项目自带浏览器插件目录:`browser-cookie-exporter/` -- 插件源码位置:`browser-cookie-exporter/` -- 可导出最简 `cookie_*.json`(仅包含 `cookie` 字段) -- 详细说明见:`browser-cookie-exporter/README.md` +推荐流程: +1. 在 Chrome / Edge 打开 `chrome://extensions` +2. 开启开发者模式 +3. 加载 `browser-cookie-exporter/` +4. 登录 [Adobe Firefly](https://firefly.adobe.com/) +5. 用插件导出 Cookie JSON +6. 在后台 `Token 管理` 页面导入 -**插件安装与使用步骤:** +支持: +- 粘贴 JSON 内容 +- 直接上传 `.json` 文件 +- 批量导入多个账号 -1. 打开 Chrome 或 Edge 浏览器的扩展管理页:`chrome://extensions` -2. 开启右上角的「开发者模式」 -3. 点击「加载已解压的扩展程序」,选择项目中的 `browser-cookie-exporter/` 目录 -4. 在浏览器中正常登录 [Adobe Firefly](https://firefly.adobe.com/) -5. 点击浏览器工具栏的插件图标,选择导出范围 -6. 点击「导出最简 JSON」并保存文件 - -### 第二步:导入到项目中 - -拿到导出的 JSON 文件后,按照以下流程导入服务: - -1. 访问并登录管理后台(默认 `http://127.0.0.1:6001/`) -2. 打开「Token 管理」页签 -3. 点击「导入 Cookie」按钮 -4. **方式 A:** 粘贴 JSON 文件内容到文本框;**方式 B:** 直接上传导出的 `.json` 文件 -5. 点击「确认导入」(服务会自动验证 Cookie 并执行一次刷新) -6. 导入成功后,Token 列表中会显示对应的 Token,且 `自动刷新` 状态为「是」 - -**批量导入:** 导入弹窗支持一次上传多个文件,或粘贴包含多个账户信息的 JSON 数组。 - -## 5)存储路径 +## 5. 存储路径 - 生成媒体文件:`data/generated/` - 请求日志:`data/request_logs.jsonl` - Token 池:`config/tokens.json` - 服务配置:`config/config.json` -- 刷新配置(本地私有):`config/refresh_profile.json` - -生成媒体保留策略: - -- `data/generated/` 下文件会保留,并通过 `/generated/*` 对外访问 -- 启用按容量阈值自动清理(最旧文件优先) - - `generated_max_size_mb`(默认 `1024`) - - `generated_prune_size_mb`(默认 `200`) -- 当总大小超过 `generated_max_size_mb` 时,服务会删除旧文件,直到至少回收 `generated_prune_size_mb`且总大小降回阈值以内 +- 刷新配置:`config/refresh_profile.json` ## Star History diff --git a/core/models/catalog.py b/core/models/catalog.py index ad9125d..3d8edc1 100644 --- a/core/models/catalog.py +++ b/core/models/catalog.py @@ -23,7 +23,7 @@ def _register_image_model( resolution_options = ( [fixed_output_resolution] if fixed_output_resolution - else ["1K", "2K"] + else ["1K", "2K", "4K"] ) MODEL_CATALOG[model_id] = { "upstream_model": "google:firefly:colligo:nano-banana-pro", @@ -69,56 +69,57 @@ def _register_image_family_alias(alias_id: str, canonical_model: str) -> None: MODEL_CATALOG[alias_id] = base +def _register_image_fixed_resolution_alias( + alias_id: str, canonical_model: str, output_resolution: str +) -> None: + base = dict(MODEL_CATALOG[canonical_model]) + base.update( + { + "canonical_model": canonical_model, + "output_resolution": output_resolution, + "output_resolution_options": [output_resolution], + "hidden": True, + "allow_request_overrides": True, + } + ) + MODEL_CATALOG[alias_id] = base + + _register_image_model( "nano-banana-pro", upstream_model_id="gemini-flash", upstream_model_version="nano-banana-2", family_label="Nano Banana Pro", ) -_register_image_model( - "nano-banana-pro-4k", - upstream_model_id="gemini-flash", - upstream_model_version="nano-banana-2", - family_label="Nano Banana Pro", - fixed_output_resolution="4K", -) _register_image_model( "nano-banana", upstream_model_id="gemini-flash", upstream_model_version="nano-banana-2", family_label="Nano Banana", ) -_register_image_model( - "nano-banana-4k", - upstream_model_id="gemini-flash", - upstream_model_version="nano-banana-2", - family_label="Nano Banana", - fixed_output_resolution="4K", -) _register_image_model( "nano-banana2", upstream_model_id="gemini-flash", upstream_model_version="nano-banana-3", family_label="Nano Banana 2", ) -_register_image_model( - "nano-banana2-4k", - upstream_model_id="gemini-flash", - upstream_model_version="nano-banana-3", - family_label="Nano Banana 2", - fixed_output_resolution="4K", -) for canonical_id in ( "nano-banana", - "nano-banana-4k", "nano-banana-pro", - "nano-banana-pro-4k", "nano-banana2", - "nano-banana2-4k", ): _register_image_family_alias(f"firefly-{canonical_id}", canonical_id) +_register_image_fixed_resolution_alias("nano-banana-4k", "nano-banana", "4K") +_register_image_fixed_resolution_alias("firefly-nano-banana-4k", "nano-banana", "4K") +_register_image_fixed_resolution_alias("nano-banana-pro-4k", "nano-banana-pro", "4K") +_register_image_fixed_resolution_alias( + "firefly-nano-banana-pro-4k", "nano-banana-pro", "4K" +) +_register_image_fixed_resolution_alias("nano-banana2-4k", "nano-banana2", "4K") +_register_image_fixed_resolution_alias("firefly-nano-banana2-4k", "nano-banana2", "4K") + DEFAULT_MODEL_ID = "nano-banana-pro" VIDEO_MODEL_CATALOG: dict[str, dict] = {} From ad53563203b2a14819151d855799f16ba00dafb9 Mon Sep 17 00:00:00 2001 From: LJR199887 <172809968+LJR199887@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:30:58 +0800 Subject: [PATCH 16/20] trigger push on b6cc7c3 From ab64a87c711b9580c02df9e078d8df4625a6c602 Mon Sep 17 00:00:00 2001 From: LJR199887 <172809968+LJR199887@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:41:02 +0800 Subject: [PATCH 17/20] Remove public request progress polling --- README.md | 52 --------- README_EN.md | 64 ----------- api/routes/generation.py | 108 +----------------- app.py | 37 +----- tests/test_request_progress.py | 201 --------------------------------- 5 files changed, 6 insertions(+), 456 deletions(-) delete mode 100644 tests/test_request_progress.py diff --git a/README.md b/README.md index 392d831..1a4b7aa 100644 --- a/README.md +++ b/README.md @@ -244,58 +244,6 @@ curl -X POST "http://127.0.0.1:6001/v1/images/generations" \ }' ``` -### 3.7 `request_id` 进度查询 - -普通外部 API 调用支持按 `request_id` 轮询任务状态与进度。 - -- 可用于:`/v1/chat/completions` 和 `/v1/images/generations` -- 查询接口:`GET /v1/requests/{request_id}` -- 服务会在响应头回写 `X-Request-Id`,同时在 JSON 响应体里也包含 `request_id` - -提交示例: - -```bash -curl -X POST "http://127.0.0.1:6001/v1/images/generations" \ - -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{ - "request_id": "demo-req-001", - "model": "nano-banana-pro", - "output_resolution": "2K", - "aspect_ratio": "16:9", - "prompt": "a cinematic mountain sunrise" - }' -``` - -查询示例: - -```bash -curl -X GET "http://127.0.0.1:6001/v1/requests/demo-req-001" \ - -H "Authorization: Bearer " -``` - -返回示例: - -```json -{ - "request_id": "demo-req-001", - "task_status": "IN_PROGRESS", - "task_progress": 42.0, - "upstream_job_id": "upstream-job-id", - "retry_after": null, - "preview_url": null, - "preview_kind": null, - "error": null, - "error_code": null, - "operation": "images.generations", - "model": "nano-banana-pro", - "prompt_preview": "a cinematic mountain sunrise", - "status_code": 102, - "source": "live", - "done": false -} -``` - ## 4. Cookie 导入 项目自带浏览器插件目录:`browser-cookie-exporter/` diff --git a/README_EN.md b/README_EN.md index 12487ce..12d22c7 100644 --- a/README_EN.md +++ b/README_EN.md @@ -292,70 +292,6 @@ curl -X POST "http://127.0.0.1:6001/v1/images/generations" \ }' ``` -### 3.4 `request_id` Progress Polling - -Regular external API calls now support polling task status and progress by `request_id`. - -- Works with: `/v1/chat/completions` and `/v1/images/generations` -- Recommended usage: generate a client-side `request_id` and send it with the request -- Polling endpoint: `GET /v1/requests/{request_id}` -- Authentication: same as generation endpoints, using `Authorization: Bearer ` or `X-API-Key` -- The service also echoes `X-Request-Id` in the response headers and includes `request_id` in the JSON response body - -Notes: - -- If you want to start polling before the generation request returns, you must provide your own `request_id` -- If you omit it, the service will generate one for you, but you can only read it after the response finishes - -Example: submit a generation request - -```bash -curl -X POST "http://127.0.0.1:6001/v1/images/generations" \ - -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{ - "request_id": "demo-req-001", - "model": "firefly-nano-banana-pro", - "output_resolution": "2K", - "aspect_ratio": "16:9", - "prompt": "a cinematic mountain sunrise" - }' -``` - -Example: poll progress - -```bash -curl -X GET "http://127.0.0.1:6001/v1/requests/demo-req-001" \ - -H "Authorization: Bearer " -``` - -Response example: - -```json -{ - "request_id": "demo-req-001", - "task_status": "IN_PROGRESS", - "task_progress": 42.0, - "upstream_job_id": "upstream-job-id", - "retry_after": null, - "preview_url": null, - "preview_kind": null, - "error": null, - "error_code": null, - "operation": "images.generations", - "model": "firefly-nano-banana-pro", - "prompt_preview": "a cinematic mountain sunrise", - "status_code": 102, - "source": "live", - "done": false -} -``` - -- `task_status` can be `IN_PROGRESS`, `COMPLETED`, or `FAILED` -- `task_progress` ranges from `0` to `100` -- `source=live` means the payload comes from in-memory live state; `source=log` means the task already finished and the data comes from final logs -- Once the task completes, `preview_url` will be populated when a preview is available - ## 4) Cookie Import ### Step 1: Export using the Browser Extension (Recommended) diff --git a/api/routes/generation.py b/api/routes/generation.py index f37c9f3..d7a7d4d 100644 --- a/api/routes/generation.py +++ b/api/routes/generation.py @@ -207,80 +207,8 @@ def build_generation_router( ) -> APIRouter: router = APIRouter() - def _current_request_id(request: Request) -> str: - return str(getattr(request.state, "log_id", "") or "").strip() - - def _attach_request_id(payload: dict, request: Request) -> dict: - content = dict(payload or {}) - request_id = _current_request_id(request) - if request_id: - content["request_id"] = request_id - return content - def _json_response(status_code: int, content: dict, request: Request) -> JSONResponse: - return JSONResponse( - status_code=status_code, - content=_attach_request_id(content, request), - ) - - def _build_request_status_payload( - request_id: str, item: dict, source: str - ) -> dict: - task_status = str(item.get("task_status") or "").upper() or None - preview_url = str(item.get("preview_url") or "").strip() or None - preview_kind = str(item.get("preview_kind") or "").strip() or None - error_text = str(item.get("error") or "").strip() or None - error_code = str(item.get("error_code") or "").strip() or None - operation = str(item.get("operation") or "").strip() or None - model = str(item.get("model") or "").strip() or None - model_params = str(item.get("model_params") or "").strip() or None - prompt_preview = str(item.get("prompt_preview") or "").strip() or None - upstream_job_id = str(item.get("upstream_job_id") or "").strip() or None - attempt_id = str(item.get("id") or "").strip() or None - if attempt_id == request_id: - attempt_id = None - retry_after = item.get("retry_after") - status_code = item.get("status_code") - try: - task_progress = ( - round(float(item.get("task_progress")), 2) - if item.get("task_progress") is not None - else None - ) - except Exception: - task_progress = None - try: - status_code = int(status_code) if status_code is not None else None - except Exception: - status_code = None - try: - retry_after = int(retry_after) if retry_after is not None else None - except Exception: - retry_after = None - done = task_status in {"COMPLETED", "FAILED"} or bool( - status_code is not None and status_code >= 400 - ) - payload = { - "request_id": request_id, - "task_status": task_status, - "task_progress": task_progress, - "upstream_job_id": upstream_job_id, - "retry_after": retry_after, - "preview_url": preview_url, - "preview_kind": preview_kind, - "error": error_text, - "error_code": error_code, - "operation": operation, - "model": model, - "model_params": model_params, - "prompt_preview": prompt_preview, - "status_code": status_code, - "source": source, - "done": done, - } - if attempt_id: - payload["attempt_id"] = attempt_id - return payload + return JSONResponse(status_code=status_code, content=content) @router.get("/v1/models") def list_models(request: Request): @@ -330,32 +258,6 @@ def list_models(request: Request): ) return {"object": "list", "data": data} - @router.get("/v1/requests/{request_id}") - def get_request_status(request_id: str, request: Request): - require_service_api_key(request) - - normalized_id = str(request_id or "").strip() - if not normalized_id: - raise HTTPException(status_code=400, detail="request_id is required") - - live_item = live_request_store.get(normalized_id) - if isinstance(live_item, dict): - return _build_request_status_payload( - normalized_id, - live_item, - source="live", - ) - - log_item = request_log_store.get(normalized_id) - if isinstance(log_item, dict): - return _build_request_status_payload( - normalized_id, - log_item, - source="log", - ) - - raise HTTPException(status_code=404, detail="request not found") - @router.post("/v1/images/generations") def openai_generate(data: dict, request: Request): require_service_api_key(request) @@ -437,11 +339,11 @@ def _image_progress_cb(update: dict): on_generated_file_written(out_path, old_size, new_size) image_url = public_image_url(request, job_id) set_request_preview(request, image_url, kind="image") - return _attach_request_id({ + return { "created": int(time.time()), "model": resolved_model_id, "data": [{"url": image_url}], - }, request) + } return run_with_token_retries( request=request, @@ -706,7 +608,7 @@ def runner(job_id: str): threading.Thread(target=runner, args=(job.id,), daemon=True).start() - return _attach_request_id({"task_id": job.id, "status": job.status}, request) + return {"task_id": job.id, "status": job.status} @router.get("/api/v1/generate/{task_id}") def get_job(task_id: str, request: Request): @@ -978,7 +880,7 @@ def _image_progress_cb(update: dict): sse_chat_stream(response_payload), media_type="text/event-stream", ) - return _attach_request_id(response_payload, request) + return response_payload return run_with_token_retries( request=request, diff --git a/app.py b/app.py index 71e4d1d..2e7d021 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,6 @@ import os import json import logging -import re import time import uuid import threading @@ -70,10 +69,6 @@ _generated_usage_bytes = 0 _generated_file_count = 0 _generated_last_reconcile_ts = 0.0 -_REQUEST_ID_MAX_LEN = 120 -_REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$") - - def _drop_generated_file_cache(file_path: Path) -> None: if not hasattr(os, "posix_fadvise"): return @@ -195,7 +190,6 @@ def _extract_logging_fields(raw_body: bytes) -> dict[str, Optional[str]]: "model": None, "model_params": None, "prompt_preview": None, - "request_id": None, } try: import json @@ -206,7 +200,6 @@ def _extract_logging_fields(raw_body: bytes) -> dict[str, Optional[str]]: "model": None, "model_params": None, "prompt_preview": None, - "request_id": None, } model = str(data.get("model") or "").strip() or None @@ -216,35 +209,19 @@ def _extract_logging_fields(raw_body: bytes) -> dict[str, Optional[str]]: if prompt: prompt = prompt.replace("\r", " ").replace("\n", " ").strip() prompt = prompt[:180] - request_id = _normalize_request_id( - data.get("request_id") or data.get("requestId") - ) return { "model": model, "model_params": _extract_model_params(data), "prompt_preview": prompt or None, - "request_id": request_id or None, } except Exception: return { "model": None, "model_params": None, "prompt_preview": None, - "request_id": None, } -def _normalize_request_id(value: Any) -> str: - text = str(value or "").strip() - if not text: - return "" - if len(text) > _REQUEST_ID_MAX_LEN: - text = text[:_REQUEST_ID_MAX_LEN] - if not _REQUEST_ID_PATTERN.fullmatch(text): - return "" - return text - - def _upsert_live_request(request: Request, patch: dict) -> None: try: log_id = str(getattr(request.state, "log_id", "") or "").strip() @@ -517,7 +494,6 @@ async def request_logger(request: Request, call_next): "model": None, "model_params": None, "prompt_preview": None, - "request_id": None, } error_text = None status_code = 500 @@ -542,21 +518,13 @@ async def request_logger(request: Request, call_next): request.state.log_model = body_meta.get("model") request.state.log_model_params = body_meta.get("model_params") request.state.log_prompt_preview = body_meta.get("prompt_preview") - header_request_id = _normalize_request_id( - request.headers.get("x-request-id") - ) - request.state.log_id = ( - header_request_id - or str(body_meta.get("request_id") or "").strip() - or uuid.uuid4().hex[:12] - ) + request.state.log_id = uuid.uuid4().hex[:12] log_id = str(getattr(request.state, "log_id", "") or "") if log_id: live_log_store.upsert( log_id, { "id": log_id, - "request_id": log_id, "ts": time.time(), "method": method, "path": path, @@ -577,9 +545,6 @@ async def request_logger(request: Request, call_next): try: response = await call_next(request) status_code = response.status_code - log_id = str(getattr(request.state, "log_id", "") or "").strip() - if response is not None and log_id: - response.headers["X-Request-Id"] = log_id except Exception as exc: _set_request_error_detail( request, diff --git a/tests/test_request_progress.py b/tests/test_request_progress.py deleted file mode 100644 index 94b7dc0..0000000 --- a/tests/test_request_progress.py +++ /dev/null @@ -1,201 +0,0 @@ -import tempfile -import threading -import unittest -from pathlib import Path -from unittest.mock import patch -import socket - -import requests -import uvicorn - -import app as app_module - - -class RequestProgressApiTests(unittest.TestCase): - def setUp(self) -> None: - self.request_id = "req-progress-001" - self.api_key = "test-service-key" - self.temp_dir = tempfile.TemporaryDirectory() - self.log_path = Path(self.temp_dir.name) / "request_logs.jsonl" - self.log_path.write_text("", encoding="utf-8") - self.original_log_path = app_module.log_store._file_path - self.original_append_since_truncate = app_module.log_store._append_since_truncate - app_module.log_store._file_path = self.log_path - app_module.log_store._append_since_truncate = 0 - with app_module.live_log_store._lock: - app_module.live_log_store._items.clear() - self.generated_before = { - item.name for item in Path(app_module.GENERATED_DIR).glob("*") - } - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.bind(("127.0.0.1", 0)) - self.port = sock.getsockname()[1] - self.base_url = f"http://127.0.0.1:{self.port}" - self.server = uvicorn.Server( - uvicorn.Config( - app_module.app, - host="127.0.0.1", - port=self.port, - log_level="error", - ) - ) - self.server_thread = threading.Thread(target=self.server.run, daemon=True) - self.server_thread.start() - self._wait_for_server() - - def tearDown(self) -> None: - self.server.should_exit = True - self.server_thread.join(timeout=5) - app_module.log_store._file_path = self.original_log_path - app_module.log_store._append_since_truncate = ( - self.original_append_since_truncate - ) - with app_module.live_log_store._lock: - app_module.live_log_store._items.clear() - for item in Path(app_module.GENERATED_DIR).glob("*"): - if item.name not in self.generated_before and item.is_file(): - item.unlink(missing_ok=True) - self.temp_dir.cleanup() - - def _wait_for_server(self) -> None: - last_error = None - for _ in range(50): - try: - response = requests.get( - f"{self.base_url}/api/v1/health", - timeout=0.5, - ) - if response.status_code == 200: - return - except requests.RequestException as exc: - last_error = exc - threading.Event().wait(0.1) - raise RuntimeError(f"server did not start in time: {last_error}") - - def test_public_request_progress_polling(self) -> None: - progress_started = threading.Event() - allow_finish = threading.Event() - response_holder: dict[str, object] = {} - - def fake_config_get(key: str, default=None): - if key == "api_key": - return self.api_key - if key == "public_base_url": - return "" - return default - - def fake_generate(**kwargs): - progress_cb = kwargs.get("progress_cb") - if callable(progress_cb): - progress_cb( - { - "task_status": "IN_PROGRESS", - "task_progress": 42.0, - "upstream_job_id": "up-job-123", - } - ) - progress_started.set() - if not allow_finish.wait(timeout=5): - raise RuntimeError("test timed out waiting to finish generation") - if callable(progress_cb): - progress_cb( - { - "task_status": "COMPLETED", - "task_progress": 100.0, - "upstream_job_id": "up-job-123", - } - ) - return b"fake-image-bytes", {"progress": 100.0} - - def send_request(): - response_holder["response"] = requests.post( - f"{self.base_url}/v1/images/generations", - headers={"Authorization": f"Bearer {self.api_key}"}, - json={ - "model": "nano-banana-pro", - "output_resolution": "2K", - "aspect_ratio": "16:9", - "prompt": "a cinematic mountain sunrise", - "request_id": self.request_id, - }, - timeout=10, - ) - - with patch.object(app_module.config_manager, "get", side_effect=fake_config_get), patch.object( - app_module.token_manager, - "get_available", - return_value="token-123", - ), patch.object( - app_module.token_manager, - "get_meta_by_value", - return_value={ - "token_id": "token-123", - "token_account_name": "Test User", - "token_account_email": "test@example.com", - "token_source": "unit-test", - }, - ), patch.object( - app_module.token_manager, - "report_exhausted", - return_value=None, - ), patch.object( - app_module.token_manager, - "report_invalid", - return_value=None, - ), patch.object( - app_module.client, - "generate", - side_effect=fake_generate, - ): - worker = threading.Thread(target=send_request, daemon=True) - worker.start() - - self.assertTrue(progress_started.wait(timeout=5)) - - running = requests.get( - f"{self.base_url}/v1/requests/{self.request_id}", - headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=5, - ) - self.assertEqual(running.status_code, 200, running.text) - running_data = running.json() - self.assertEqual(running_data["request_id"], self.request_id) - self.assertEqual(running_data["task_status"], "IN_PROGRESS") - self.assertEqual(running_data["task_progress"], 42.0) - self.assertEqual(running_data["upstream_job_id"], "up-job-123") - self.assertEqual(running_data["model_params"], "16:9 | 2K") - self.assertEqual(running_data["source"], "live") - self.assertFalse(running_data["done"]) - - allow_finish.set() - worker.join(timeout=5) - self.assertFalse(worker.is_alive()) - - response = response_holder.get("response") - self.assertIsNotNone(response) - response = response_holder["response"] - self.assertEqual(response.status_code, 200, response.text) - self.assertEqual(response.headers.get("X-Request-Id"), self.request_id) - payload = response.json() - self.assertEqual(payload["request_id"], self.request_id) - self.assertIn("data", payload) - - finished = requests.get( - f"{self.base_url}/v1/requests/{self.request_id}", - headers={"Authorization": f"Bearer {self.api_key}"}, - timeout=5, - ) - self.assertEqual(finished.status_code, 200, finished.text) - finished_data = finished.json() - self.assertEqual(finished_data["request_id"], self.request_id) - self.assertEqual(finished_data["task_status"], "COMPLETED") - self.assertEqual(finished_data["task_progress"], 100.0) - self.assertEqual(finished_data["upstream_job_id"], "up-job-123") - self.assertEqual(finished_data["model_params"], "16:9 | 2K") - self.assertEqual(finished_data["source"], "log") - self.assertTrue(finished_data["done"]) - self.assertTrue(str(finished_data.get("preview_url") or "").endswith(".png")) - - -if __name__ == "__main__": - unittest.main() From 6f5acd42979a26a0927e6d6afd2d2a126acd1ca3 Mon Sep 17 00:00:00 2001 From: LJR199887 <172809968+LJR199887@users.noreply.github.com> Date: Wed, 1 Apr 2026 00:00:26 +0800 Subject: [PATCH 18/20] Add more log stats ranges --- api/routes/admin.py | 15 +++++++++++++-- static/admin.html | 2 ++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/api/routes/admin.py b/api/routes/admin.py index 5a1a751..6a42887 100644 --- a/api/routes/admin.py +++ b/api/routes/admin.py @@ -154,15 +154,26 @@ def _resolve_logs_stats_range(range_key: str) -> tuple[str, float, float]: key = str(range_key or "today").strip().lower() if key == "today": start_dt = datetime(now_dt.year, now_dt.month, now_dt.day) + end_ts = now_ts + elif key == "yesterday": + today_start = datetime(now_dt.year, now_dt.month, now_dt.day) + start_dt = today_start - timedelta(days=1) + end_ts = today_start.timestamp() + elif key == "3d": + start_dt = now_dt - timedelta(days=3) + end_ts = now_ts elif key == "7d": start_dt = now_dt - timedelta(days=7) + end_ts = now_ts elif key == "30d": start_dt = now_dt - timedelta(days=30) + end_ts = now_ts else: raise HTTPException( - status_code=400, detail="range must be one of: today, 7d, 30d" + status_code=400, + detail="range must be one of: today, yesterday, 3d, 7d, 30d", ) - return key, start_dt.timestamp(), now_ts + return key, start_dt.timestamp(), end_ts @router.get("/api/v1/logs/stats") def logs_stats(request: Request, range: str = "today"): diff --git a/static/admin.html b/static/admin.html index 5c0d400..008fb14 100644 --- a/static/admin.html +++ b/static/admin.html @@ -222,6 +222,8 @@

请求日志

From 790da912eb23add9f44fcf7abdce76a8e135e0c3 Mon Sep 17 00:00:00 2001 From: LJR199887 <172809968+LJR199887@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:17:00 +0800 Subject: [PATCH 19/20] Support direct upstream result URLs --- api/routes/admin.py | 4 + api/routes/generation.py | 156 ++++++++++++++++++++++++++------------- api/schemas.py | 1 + app.py | 5 ++ core/adobe_client.py | 10 ++- core/config_mgr.py | 1 + static/admin.html | 10 +++ static/admin.js | 3 + 8 files changed, 137 insertions(+), 53 deletions(-) diff --git a/api/routes/admin.py b/api/routes/admin.py index 6a42887..cf67edf 100644 --- a/api/routes/admin.py +++ b/api/routes/admin.py @@ -587,6 +587,10 @@ def update_config(req: ConfigUpdateRequest, request: Request): detail="generated_prune_size_mb must be between 10 and 10240", ) update_data["generated_prune_size_mb"] = generated_prune_size_mb + if "use_upstream_result_url" in incoming: + update_data["use_upstream_result_url"] = bool( + incoming["use_upstream_result_url"] + ) effective_max = int( update_data.get( "generated_max_size_mb", diff --git a/api/routes/generation.py b/api/routes/generation.py index d7a7d4d..4abc03e 100644 --- a/api/routes/generation.py +++ b/api/routes/generation.py @@ -35,6 +35,14 @@ def _normalize_upstream_request_error(exc: Exception) -> tuple[int, str, str] | return None +def _extract_upstream_asset_url(meta: dict, asset_kind: str) -> str: + outputs = meta.get("outputs") or [] + if not outputs: + return "" + asset = (outputs[0] or {}).get(asset_kind) or {} + return str(asset.get("presignedUrl") or "").strip() + + def _resolve_sora_video_extras(data: dict) -> tuple[str, dict | None, dict | None]: locale = str( data.get("locale") @@ -193,6 +201,7 @@ def build_generation_router( set_request_preview: Callable[[Request, str, str], None], public_image_url: Callable[[Request, str], str], public_generated_url: Callable[[Request, str], str], + use_upstream_result_url: Callable[[], bool], resolve_video_options: Callable[[dict], tuple[bool, str, str]], load_input_images: Callable[[Any], list[tuple[bytes, str]]], prepare_video_source_image: Callable[[bytes, str, str], tuple[bytes, str]], @@ -309,16 +318,18 @@ def _image_progress_cb(update: dict): error=update.get("error"), ) + direct_result_url = bool(use_upstream_result_url()) job_id = uuid.uuid4().hex out_path = generated_dir / f"{job_id}.png" old_size = 0 - try: - if out_path.exists(): - old_size = int(out_path.stat().st_size) - except Exception: - old_size = 0 + if not direct_result_url: + try: + if out_path.exists(): + old_size = int(out_path.stat().st_size) + except Exception: + old_size = 0 - image_bytes, _meta = client.generate( + image_bytes, meta = client.generate( token=token, prompt=prompt, aspect_ratio=ratio, @@ -330,14 +341,23 @@ def _image_progress_cb(update: dict): model_conf.get("upstream_model_version") or "nano-banana-2" ), timeout=client.generate_timeout, - out_path=out_path, + out_path=None if direct_result_url else out_path, progress_cb=_image_progress_cb, + return_upstream_url=direct_result_url, ) - if image_bytes is not None: - out_path.write_bytes(image_bytes) - new_size = int(out_path.stat().st_size) if out_path.exists() else 0 - on_generated_file_written(out_path, old_size, new_size) - image_url = public_image_url(request, job_id) + if direct_result_url: + image_url = _extract_upstream_asset_url(meta, "image") + if not image_url: + raise HTTPException( + status_code=502, + detail="upstream result url missing", + ) + else: + if image_bytes is not None: + out_path.write_bytes(image_bytes) + new_size = int(out_path.stat().st_size) if out_path.exists() else 0 + on_generated_file_written(out_path, old_size, new_size) + image_url = public_image_url(request, job_id) set_request_preview(request, image_url, kind="image") return { "created": int(time.time()), @@ -545,13 +565,15 @@ def runner(job_id: str): break try: + direct_result_url = bool(use_upstream_result_url()) out_path = generated_dir / f"{job_id}.png" old_size = 0 - try: - if out_path.exists(): - old_size = int(out_path.stat().st_size) - except Exception: - old_size = 0 + if not direct_result_url: + try: + if out_path.exists(): + old_size = int(out_path.stat().st_size) + except Exception: + old_size = 0 image_bytes, meta = client.generate( token=token, @@ -564,14 +586,22 @@ def runner(job_id: str): upstream_model_version=str( model_conf.get("upstream_model_version") or "nano-banana-2" ), - out_path=out_path, + out_path=None if direct_result_url else out_path, + return_upstream_url=direct_result_url, ) - if image_bytes is not None: - out_path.write_bytes(image_bytes) - new_size = int(out_path.stat().st_size) if out_path.exists() else 0 - on_generated_file_written(out_path, old_size, new_size) + if direct_result_url: + image_url = _extract_upstream_asset_url(meta, "image") + if not image_url: + raise RuntimeError("upstream result url missing") + else: + if image_bytes is not None: + out_path.write_bytes(image_bytes) + new_size = ( + int(out_path.stat().st_size) if out_path.exists() else 0 + ) + on_generated_file_written(out_path, old_size, new_size) + image_url = public_image_url(request, job_id) progress = float(meta.get("progress") or 100.0) - image_url = public_image_url(request, job_id) store.update( job_id, status="succeeded", @@ -762,14 +792,16 @@ def _video_progress_cb(update: dict): error=update.get("error"), ) + direct_result_url = bool(use_upstream_result_url()) job_id = uuid.uuid4().hex tmp_path = generated_dir / f"{job_id}.video.tmp" old_size = 0 - try: - if tmp_path.exists(): - old_size = int(tmp_path.stat().st_size) - except Exception: - old_size = 0 + if not direct_result_url: + try: + if tmp_path.exists(): + old_size = int(tmp_path.stat().st_size) + except Exception: + old_size = 0 video_bytes, video_meta = client.generate_video( token=token, @@ -785,19 +817,30 @@ def _video_progress_cb(update: dict): timeline_events=timeline_events, audio=video_audio, reference_mode=video_reference_mode, - out_path=tmp_path, + out_path=None if direct_result_url else tmp_path, progress_cb=_video_progress_cb, + return_upstream_url=direct_result_url, ) - video_ext = video_ext_from_meta(video_meta) - filename = f"{job_id}.{video_ext}" - out_path = generated_dir / filename - if video_bytes is not None: - out_path.write_bytes(video_bytes) - elif tmp_path.exists(): - tmp_path.replace(out_path) - new_size = int(out_path.stat().st_size) if out_path.exists() else 0 - on_generated_file_written(out_path, old_size, new_size) - image_url = public_generated_url(request, filename) + if direct_result_url: + image_url = _extract_upstream_asset_url(video_meta, "video") + if not image_url: + raise HTTPException( + status_code=502, + detail="upstream result url missing", + ) + else: + video_ext = video_ext_from_meta(video_meta) + filename = f"{job_id}.{video_ext}" + out_path = generated_dir / filename + if video_bytes is not None: + out_path.write_bytes(video_bytes) + elif tmp_path.exists(): + tmp_path.replace(out_path) + new_size = ( + int(out_path.stat().st_size) if out_path.exists() else 0 + ) + on_generated_file_written(out_path, old_size, new_size) + image_url = public_generated_url(request, filename) set_request_preview(request, image_url, kind="video") response_content = ( f"```html\n\n```" @@ -820,16 +863,18 @@ def _image_progress_cb(update: dict): error=update.get("error"), ) + direct_result_url = bool(use_upstream_result_url()) job_id = uuid.uuid4().hex out_path = generated_dir / f"{job_id}.png" old_size = 0 - try: - if out_path.exists(): - old_size = int(out_path.stat().st_size) - except Exception: - old_size = 0 + if not direct_result_url: + try: + if out_path.exists(): + old_size = int(out_path.stat().st_size) + except Exception: + old_size = 0 - image_bytes, _meta = client.generate( + image_bytes, meta = client.generate( token=token, prompt=prompt, aspect_ratio=ratio, @@ -843,14 +888,23 @@ def _image_progress_cb(update: dict): ), source_image_ids=source_image_ids, timeout=client.generate_timeout, - out_path=out_path, + out_path=None if direct_result_url else out_path, progress_cb=_image_progress_cb, + return_upstream_url=direct_result_url, ) - if image_bytes is not None: - out_path.write_bytes(image_bytes) - new_size = int(out_path.stat().st_size) if out_path.exists() else 0 - on_generated_file_written(out_path, old_size, new_size) - image_url = public_image_url(request, job_id) + if direct_result_url: + image_url = _extract_upstream_asset_url(meta, "image") + if not image_url: + raise HTTPException( + status_code=502, + detail="upstream result url missing", + ) + else: + if image_bytes is not None: + out_path.write_bytes(image_bytes) + new_size = int(out_path.stat().st_size) if out_path.exists() else 0 + on_generated_file_written(out_path, old_size, new_size) + image_url = public_image_url(request, job_id) set_request_preview(request, image_url, kind="image") response_content = f"![Generated Image]({image_url})" diff --git a/api/schemas.py b/api/schemas.py index db624c7..6b8f274 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -44,6 +44,7 @@ class ConfigUpdateRequest(BaseModel): batch_concurrency: Optional[int] = None generated_max_size_mb: Optional[int] = None generated_prune_size_mb: Optional[int] = None + use_upstream_result_url: Optional[bool] = None class RefreshCookieImportRequest(BaseModel): diff --git a/app.py b/app.py index 2e7d021..a5cc0b4 100644 --- a/app.py +++ b/app.py @@ -1066,6 +1066,10 @@ def _public_image_url(request: Request, job_id: str) -> str: return _public_generated_url(request, f"{job_id}.png") +def _use_upstream_result_url() -> bool: + return bool(config_manager.get("use_upstream_result_url", False)) + + def _public_generated_url(request: Request, filename: str) -> str: safe_name = str(filename or "").lstrip("/") path = f"/generated/{safe_name}" @@ -1324,6 +1328,7 @@ def _sse_chat_stream(payload: dict): set_request_preview=_set_request_preview, public_image_url=_public_image_url, public_generated_url=_public_generated_url, + use_upstream_result_url=_use_upstream_result_url, resolve_video_options=_resolve_video_options, load_input_images=_load_input_images, prepare_video_source_image=_prepare_video_source_image, diff --git a/core/adobe_client.py b/core/adobe_client.py index 55517e8..445d1bf 100644 --- a/core/adobe_client.py +++ b/core/adobe_client.py @@ -777,6 +777,7 @@ def generate_video( reference_mode: str = "frame", out_path: Optional[Path] = None, progress_cb: Optional[Callable[[dict], None]] = None, + return_upstream_url: bool = False, ) -> tuple[Optional[bytes], dict]: payload = self._build_video_payload( video_conf=video_conf, @@ -886,7 +887,9 @@ def generate_video( video_url = ((outputs[0] or {}).get("video") or {}).get("presignedUrl") if not video_url: raise AdobeRequestError("video job finished without video url") - if out_path is not None: + if return_upstream_url: + video_bytes = None + elif out_path is not None: self._download_to_file( video_url, headers={"accept": "*/*"}, @@ -991,6 +994,7 @@ def generate( timeout: int = 180, out_path: Optional[Path] = None, progress_cb: Optional[Callable[[dict], None]] = None, + return_upstream_url: bool = False, ) -> tuple[Optional[bytes], dict]: submit_resp = None last_error = "" @@ -1127,7 +1131,9 @@ def generate( image_url = ((outputs[0] or {}).get("image") or {}).get("presignedUrl") if not image_url: raise AdobeRequestError("job finished without image url") - if out_path is not None: + if return_upstream_url: + image_bytes = None + elif out_path is not None: self._download_to_file( image_url, headers={"accept": "*/*"}, diff --git a/core/config_mgr.py b/core/config_mgr.py index 2367949..9e6ced8 100644 --- a/core/config_mgr.py +++ b/core/config_mgr.py @@ -33,6 +33,7 @@ def __init__(self): "batch_concurrency": 5, "generated_max_size_mb": 1024, "generated_prune_size_mb": 200, + "use_upstream_result_url": False, } self.load() diff --git a/static/admin.html b/static/admin.html index 008fb14..664b32e 100644 --- a/static/admin.html +++ b/static/admin.html @@ -205,6 +205,16 @@

网络与代理设置

超过上限后按最旧文件优先清理;最新生成文件会被保护,不会被立即删掉。

+
+
+ +

Use the upstream presignedUrl directly to save local disk and memory. Older previews may stop working after the signed URL expires.

+
+
+
diff --git a/static/admin.js b/static/admin.js index 97fa230..8fe022b 100644 --- a/static/admin.js +++ b/static/admin.js @@ -669,6 +669,7 @@ document.addEventListener("DOMContentLoaded", async () => { const confBatchConcurrency = document.getElementById("confBatchConcurrency"); const confGeneratedMaxSizeMb = document.getElementById("confGeneratedMaxSizeMb"); const confGeneratedPruneSizeMb = document.getElementById("confGeneratedPruneSizeMb"); + const confUseUpstreamResultUrl = document.getElementById("confUseUpstreamResultUrl"); const generatedUsageInfo = document.getElementById("generatedUsageInfo"); const configCatBtns = document.querySelectorAll(".config-cat-btn"); const configCatPanes = document.querySelectorAll(".config-cat-pane"); @@ -761,6 +762,7 @@ document.addEventListener("DOMContentLoaded", async () => { confBatchConcurrency.value = currentBatchConcurrency; confGeneratedMaxSizeMb.value = Number(data.generated_max_size_mb || 1024); confGeneratedPruneSizeMb.value = Number(data.generated_prune_size_mb || 200); + confUseUpstreamResultUrl.checked = Boolean(data.use_upstream_result_url || false); if (generatedUsageInfo) { const usageMb = Number(data.generated_usage_mb || 0); const fileCount = Number(data.generated_file_count || 0); @@ -804,6 +806,7 @@ document.addEventListener("DOMContentLoaded", async () => { batch_concurrency: Math.max(1, Math.min(100, Number(confBatchConcurrency.value || 5))), generated_max_size_mb: Math.max(100, Math.min(102400, Number(confGeneratedMaxSizeMb.value || 1024))), generated_prune_size_mb: Math.max(10, Math.min(10240, Number(confGeneratedPruneSizeMb.value || 200))), + use_upstream_result_url: confUseUpstreamResultUrl.checked, }; if (!payload.admin_username) { From 1cbd2edfb54fdc910764a3d43a05d257542f7d8e Mon Sep 17 00:00:00 2001 From: LJR199887 <172809968+LJR199887@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:50:18 +0800 Subject: [PATCH 20/20] Localize upstream URL setting copy --- static/admin.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/admin.html b/static/admin.html index 664b32e..c7186a6 100644 --- a/static/admin.html +++ b/static/admin.html @@ -209,9 +209,9 @@

网络与代理设置

-

Use the upstream presignedUrl directly to save local disk and memory. Older previews may stop working after the signed URL expires.

+

开启后将直接返回上游的 presignedUrl,可减少服务器磁盘占用和本地缓存压力;但该链接通常会过期,历史预览或旧结果可能失效。