fix: harden custom image fallback

This commit is contained in:
FengLee
2026-05-20 16:21:24 +08:00
parent 2137a4b23f
commit b508d8df58
9 changed files with 161 additions and 10 deletions

View File

@@ -168,7 +168,7 @@ Primary SQL tables touched directly in API routes include:
`user_api_keys.manifest_path` is an optional local-storage key for an imported JSON Manifest. The storage convention is `user-api-manifests/<userId>/<keyId>.json`, so even the same user can have multiple isolated request configs. Generation must load the manifest linked to the selected model/key row instead of looking up a user-level shared config.
`system_api_configs.polling_mode` and `system_api_configs.polling_order` control admin default-model supplier fallback for image generation. `system_api_configs.video_usage_modes` controls whether a video model appears in 文生视频, 图生视频, or both creation entries. `/api/model-config` deduplicates default system rows by media type plus admin display name (`system_api_configs.name`) for clients, while `/api/generate/image` expands the selected row back into allowed supplier candidates with the same media type and display name. `model_name` stays provider-specific and is used as the upstream request model value.
`system_api_configs.polling_mode` and `system_api_configs.polling_order` control admin default-model supplier fallback for image generation. `system_api_configs.video_usage_modes` controls whether a video model appears in 文生视频, 图生视频, or both creation entries. `/api/model-config` deduplicates default system rows by media type plus admin display name (`system_api_configs.name`) for clients, while `/api/generate/image` expands the selected row back into allowed supplier candidates with the same media type and display name. System image candidates retry stream-timeout 524 responses once with `stream:false`, and shared custom API transport retries 502/503/504 once before surfacing a concise gateway error. `model_name` stays provider-specific and is used as the upstream request model value.
`redeem_codes` stores admin-generated single-use credit and membership redemption codes. Runtime code generation and redemption go through `src/lib/redeem-code-service.ts`; redemption must lock both the code row and profile row in one transaction before updating `profiles.credits_balance` for credit codes or `profiles.membership_tier`/`membership_expires_at` for membership codes. Credit-code redemptions also insert a `credit_transactions` record.

View File

@@ -158,7 +158,7 @@ User-level intelligent API imports add a fourth data artifact tied to source 2:
At generation time, `src/lib/server-api-config.ts` returns `manifestPath` for user custom keys and admin system API keys. `src/app/api/generate/image/route.ts` and `src/app/api/generate/video/route.ts` call `src/lib/user-api-manifest-executor.ts` first when that path exists. The executor handles JSON, multipart file fields, `{task_id}` polling, `*` JSON-path extraction, and media persistence handoff. Imported Manifest rows still need the user or admin to edit and save an API Key before they can generate.
Admin system intelligent API imports live in `src/components/admin/api-management-tab.tsx` and `src/app/api/admin/system-apis/smart-import/route.ts`. The `智能配置 API` section is generic Manifest import only: each imported profile/model becomes one global `system_api_configs` row with its own `manifest_path`, backed by `system-api-manifests/<systemApiId>.json`, and the visible `api_url` is resolved from the Manifest profile/provider. Incomplete configs without a resolvable relay API request URL are rejected. Optional `profile.capabilities` can constrain or hide create-page image/video parameter choices for system models. Provider-specific built-in templates such as 元界 AI are not exposed in this smart import UI; 元界 definitions remain in `src/lib/yuanjie-image-model-templates.ts` and `src/lib/yuanjie-video-model-templates.ts` for the system-default-model management path, where admins configure each model row's Key, pricing/member visibility/polling, `video_usage_modes`, and enablement before it is available to users. Admin Manifest files must remain separate from user-level files and must keep using the system pricing/credit deduction policy for the selected model. System API rows also own `is_default`, `allowed_membership_tiers`, `polling_mode`, and `polling_order`; `/api/model-config` returns only one active platform-default row per allowed media type plus admin display name so the create page shows a single default model label, and image generation expands the selected row back into all allowed supplier candidates with the same display name. The upstream `model_name` can differ between suppliers and is only used as that supplier's request model. Video model billing supports per-use count (`fixed`), per-second duration (`duration_price_per_second`), and token mode. Token billing prices shown in the admin console are credits per 1M tokens for both input and output; older storage/API field names containing `1k` remain compatibility names and must not be shown to admins as per-K pricing. If every supplier fails or returns no usable result, the user-facing error is the generic model-busy message. This polling fallback is only for admin default system models and must not be applied to user custom API keys.
Admin system intelligent API imports live in `src/components/admin/api-management-tab.tsx` and `src/app/api/admin/system-apis/smart-import/route.ts`. The `智能配置 API` section is generic Manifest import only: each imported profile/model becomes one global `system_api_configs` row with its own `manifest_path`, backed by `system-api-manifests/<systemApiId>.json`, and the visible `api_url` is resolved from the Manifest profile/provider. Incomplete configs without a resolvable relay API request URL are rejected. Optional `profile.capabilities` can constrain or hide create-page image/video parameter choices for system models. Provider-specific built-in templates such as 元界 AI are not exposed in this smart import UI; 元界 definitions remain in `src/lib/yuanjie-image-model-templates.ts` and `src/lib/yuanjie-video-model-templates.ts` for the system-default-model management path, where admins configure each model row's Key, pricing/member visibility/polling, `video_usage_modes`, and enablement before it is available to users. Admin Manifest files must remain separate from user-level files and must keep using the system pricing/credit deduction policy for the selected model. System API rows also own `is_default`, `allowed_membership_tiers`, `polling_mode`, and `polling_order`; `/api/model-config` returns only one active platform-default row per allowed media type plus admin display name so the create page shows a single default model label, and image generation expands the selected row back into all allowed supplier candidates with the same display name. The upstream `model_name` can differ between suppliers and is only used as that supplier's request model. Video model billing supports per-use count (`fixed`), per-second duration (`duration_price_per_second`), and token mode. Token billing prices shown in the admin console are credits per 1M tokens for both input and output; older storage/API field names containing `1k` remain compatibility names and must not be shown to admins as per-K pricing. If a system image supplier fails because a stream request idles until Cloudflare 524, `/api/generate/image` retries that candidate once with `stream:false`; 502/503/504 gateway responses are retried once by the shared transport. If every supplier still fails or returns no usable result, the route returns the last actionable upstream error when available, otherwise the generic model-busy message. This polling fallback is only for admin default system models and must not be applied to user custom API keys.
After production migration, app runtime tables in `public` should be owned by the app DB user from `LOCAL_DB_URL`. Runtime compatibility helpers use `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` and index creation; if restored tables remain owned by `postgres`, public routes such as `/api/model-config`, profile refresh, or generation jobs can fail with `must be owner of table ...`.

View File

@@ -49,7 +49,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
| Earlier completed image tasks disappear while later tasks are still running | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/generation-task-list.tsx` | The results column must not be a single `generating ? taskList : results` branch. Render active task cards and completed result cards together, and append each task's images as soon as that task succeeds instead of waiting for all submitted tasks to settle. |
| Job remains queued | `src/app/api/generation-jobs/route.ts`, `src/lib/generation-job-worker.ts`, `src/lib/generation-job-runner.ts` | `processNextGenerationJob()` invoked, stale job handling, DB locks/status, internal base URL. |
| Job remains running forever | `src/app/api/generation-jobs/[id]/route.ts`, `src/lib/generation-job-worker.ts`, `src/lib/generation-job-estimates.ts` | Stale timeout updates, `updated_at`, worker exceptions swallowed into error field. |
| Image generation returns upstream error | `src/app/api/generate/image/route.ts`, `src/lib/custom-api-fetch.ts`, `src/lib/server-api-config.ts` | Resolved custom/system API credentials, endpoint URL, New API normalization, timeout, stream/progress parser. |
| Image generation returns upstream error | `src/app/api/generate/image/route.ts`, `src/lib/custom-api-fetch.ts`, `src/lib/custom-image-fallback.ts`, `src/lib/server-api-config.ts` | Resolved custom/system API credentials, endpoint URL, New API normalization, timeout, stream/progress parser, and system-default stream timeout fallback. Gateway 502/503/504 errors are retried once; system default model failures should return the last actionable upstream timeout/gateway message instead of hiding everything behind the generic busy message. |
| Video generation returns upstream error | `src/app/api/generate/video/route.ts`, `src/lib/custom-api-fetch.ts`, `src/lib/server-api-config.ts` | Reference image upload/compression, endpoint URL, response parser, persistence timeout. |
| Wrong image size, aspect ratio, or custom API says returned resolution is lower than requested | `src/lib/model-config.ts`, `src/app/api/generate/image/route.ts` | `resolveImageSize`, `resolveCustomApiImageSize`, New API/DALL-E size normalization, prompt aspect hint, and custom API result qualification. Exact or larger generated images pass normally; lower-resolution images with matching aspect ratio and at least 60% of the requested dimensions are accepted as degraded upstream output instead of failing the job, while wrong-ratio or much smaller images are still rejected. |
| Text-to-image or image-to-image says `请在提示词中写明画面比例` even after selecting a Yuanjie resolution such as `4K 竖版 (3:4)` | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/lib/yuanjie-image-model-templates.ts` | Some Yuanjie image templates set `supportsAspectRatio: false` and encode orientation in `resolution`/`size` options. Generation validation must derive the ratio from the selected resolution label or dimensions instead of requiring a separate aspect-ratio control. Image-to-image should also default count to `1` rather than requiring prompt inference for `生成数量`. |

View File

@@ -73,9 +73,9 @@ Use this document to jump directly to code before broad searching.
| Worker loop | `src/lib/generation-job-worker.ts` | Picks and processes queued jobs. After successful system default generation, it calls `src/lib/generation-credit-service.ts` to deduct credits from `profiles.credits_balance`, insert `credit_transactions`, and add `creditsCost`/`creditsBalance` to the job result for frontend display. |
| Internal runner | `src/lib/generation-job-runner.ts` | Calls `/api/generate/image` or `/api/generate/video` with internal headers. |
| ETA/progress | `src/lib/generation-job-estimates.ts` | Runtime schema, ETA samples, progress payload. |
| Image route | `src/app/api/generate/image/route.ts` | SDK + custom/system API + New API image compatibility, persistence. New image originals persist through `src/lib/media-storage.ts` into object storage, while local WEBP thumbnails are returned as `thumbnails`/`thumbnailUrls` for preview rendering. For admin default system models, image generation resolves all same-type/same-display-name default API candidates and silently retries them by the configured polling mode before returning the generic busy message. User custom APIs remain single-config and do not use this polling fallback. |
| Image route | `src/app/api/generate/image/route.ts` | SDK + custom/system API + New API image compatibility, persistence. New image originals persist through `src/lib/media-storage.ts` into object storage, while local WEBP thumbnails are returned as `thumbnails`/`thumbnailUrls` for preview rendering. For admin default system models, image generation resolves all same-type/same-display-name default API candidates, automatically retries stream-timeout failures once with `stream:false`, and returns actionable upstream timeout/gateway messages when all candidates fail. User custom APIs remain single-config and do not use this polling fallback. |
| Video route | `src/app/api/generate/video/route.ts` | SDK + custom/system API video, persistence. |
| Custom API transport | `src/lib/custom-api-fetch.ts` | Headers, retries, progress JSON parsing, upstream error parsing. |
| Custom API transport | `src/lib/custom-api-fetch.ts`, `src/lib/custom-image-fallback.ts` | Headers, one retry for 502/503/504 gateway failures, progress JSON parsing, upstream error parsing, stream-to-sync fallback policy for system image APIs. |
| Server API resolution | `src/lib/server-api-config.ts` | Resolves user custom API and admin system API IDs into decrypted credentials, enforces system API default visibility plus membership-tier allowlists before generation, and builds default-model polling candidates by media type plus admin display name (`system_api_configs.name`). The upstream `model_name` remains the per-provider request model only. |
| User API smart import | `src/components/profile/api-key-manager.tsx`, `src/app/api/user-api-keys/smart-import/route.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/model-capabilities.ts`, `src/lib/model-display.ts` | The profile API settings page has an `智能配置 API` button next to `添加 API 密钥`. It opens a wide viewport-capped Manifest editor, can copy the LLM prompt, shows guidance under the prompt button explaining the copy-to-chat-AI and paste-and-import flow, can paste clipboard JSON without importing, and can paste-and-import in one action. The prompt instructs the LLM to stop and ask the user for the relay API Base URL when the docs do not contain it. Imports create each profile/model as an independent `user_api_keys` row plus a separate `user-api-manifests/<userId>/<keyId>.json` file and reject incomplete configs without a resolvable request URL. Imported rows should store a human-readable provider name in the editable provider/supplier fields and resolve the visible API request URL from `profile.baseUrl + submit.path` for synchronous endpoints. Generic placeholder notes such as `导入的 API Key` must not be used as model labels; creation/profile UI should prefer a real note plus model, or provider plus model. Optional `profile.capabilities` filters or hides create-page aspect ratio, resolution, image format, and quality controls for the selected model. Polling Manifest query values can include `{task_id}` so task IDs are sent as real query parameters rather than being embedded into pathname strings. Generation routes must use the selected model key's `manifest_path`; do not merge different request configs under one user-level file. |
| Admin system API smart import | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/smart-import/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/model-capabilities.ts` | The console API management page has a separate `智能配置 API` section for admins, but this section is generic Manifest import only. It supports copy-to-chat-AI and paste-and-import Manifest flow, then creates one independent system API row and `system-api-manifests/<systemApiId>.json` file per imported profile/model. Imported rows resolve the visible API request URL from the Manifest profile/provider before save, and optional `profile.capabilities` can constrain or hide create-page image/video parameter choices for the selected system model. Provider-specific built-in template management, including 元界 AI, belongs in the `系统默认模型` management flow and should not be exposed in the smart import UI. |

View File

@@ -15,6 +15,7 @@
"lint": "eslint",
"start": "bash ./scripts/start.sh",
"test:admin-gallery-prompt": "node --no-warnings ./scripts/test-admin-gallery-prompt-service.mjs",
"test:custom-image-fallback": "tsx ./scripts/test-custom-image-fallback.mjs",
"test:gallery-response": "node --no-warnings ./scripts/test-gallery-response.mjs",
"test:ops-hardening": "node --no-warnings ./scripts/test-ops-hardening.mjs",
"pm2:restart": "pm2 startOrReload ecosystem.config.cjs --update-env",

View File

@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const {
parseCustomApiError,
} = await import('../src/lib/custom-api-fetch.ts');
const {
buildSynchronousImageRequestBody,
getSystemPollingFailureMessage,
shouldRetryImageRequestWithoutStream,
STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX,
} = await import('../src/lib/custom-image-fallback.ts');
const repoRoot = path.resolve(import.meta.dirname, '..');
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
await runTest('detects stream timeout confirmation errors for synchronous fallback', () => {
assert.equal(
shouldRetryImageRequestWithoutStream(
{ model: 'gpt-image-2', prompt: 'test', stream: true },
`${STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX}上游流式生图没有持续返回数据`,
),
true,
);
assert.equal(
shouldRetryImageRequestWithoutStream(
{ model: 'gpt-image-2', prompt: 'test', stream: false },
`${STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX}上游流式生图没有持续返回数据`,
),
false,
);
});
await runTest('builds a synchronous retry body without mutating the original request', () => {
const original = { model: 'gpt-image-2', prompt: 'test', n: 1, stream: true };
const next = buildSynchronousImageRequestBody(original);
assert.deepEqual(next, { model: 'gpt-image-2', prompt: 'test', n: 1, stream: false });
assert.equal(original.stream, true);
});
await runTest('system polling exposes actionable upstream errors instead of generic busy message', () => {
assert.equal(
getSystemPollingFailureMessage('上游 API 同步生图请求超时Cloudflare 524。请降低分辨率后重试。'),
'上游 API 同步生图请求超时Cloudflare 524。请降低分辨率后重试。',
);
assert.equal(
getSystemPollingFailureMessage(`${STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX}上游流式生图没有持续返回数据`),
'上游流式生图没有持续返回数据',
);
assert.equal(
getSystemPollingFailureMessage(''),
'因使用人数较多,模型繁忙,请稍后再试',
);
});
await runTest('Cloudflare gateway errors are shown as concise retryable upstream messages', () => {
const message = parseCustomApiError(502, '<!DOCTYPE html><title>mozhevip.top | 502: Bad gateway</title>');
assert.equal(message.includes('<!DOCTYPE html>'), false);
assert.match(message, /上游网关/);
assert.match(message, /502/);
});
await runTest('text-to-image custom fetch enables one retry for 502 503 504 gateway failures', () => {
const source = read('src/app/api/generate/image/route.ts');
assert.match(
source,
/fetchWithRetry\(\s*endpoint,\s*\{ method: 'POST', headers: buildCustomApiHeaders\(apiKey\), body: JSON\.stringify\(requestBody\) \},\s*GENERATION_TIMEOUT,\s*1,\s*\)/s,
);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -8,6 +8,11 @@ import {
parseCustomApiError,
parseCustomApiJsonWithProgress,
} from '@/lib/custom-api-fetch';
import {
buildSynchronousImageRequestBody,
getSystemPollingFailureMessage,
shouldRetryImageRequestWithoutStream,
} from '@/lib/custom-image-fallback';
import {
getAspectRatioPromptHint,
inferImageParamsFromPrompt,
@@ -243,7 +248,7 @@ async function fetchCustomImageGeneration(
endpoint,
{ method: 'POST', headers: buildCustomApiHeaders(apiKey), body: JSON.stringify(requestBody) },
GENERATION_TIMEOUT,
0,
1,
);
if (!response.ok) {
@@ -271,6 +276,7 @@ async function requestQualifiedCustomImages(
targetCount: number,
targetSize: TargetImageSize | null,
onProgress?: (progress: Record<string, unknown>) => void | Promise<void>,
options: { autoRetryWithoutStream?: boolean } = {},
): Promise<{ images: string[]; thumbnails: Record<string, string>; rejected: string[]; upstreamError?: { status: number; text: string } }> {
const accepted: string[] = [];
const thumbnails: Record<string, string> = {};
@@ -282,12 +288,26 @@ async function requestQualifiedCustomImages(
const requestCount = attempt === 1
? Math.max(remaining, Number(requestBody.n) || 1)
: 1;
const response = await fetchCustomImageGeneration(
const attemptBody = { ...requestBody, n: requestCount };
let response = await fetchCustomImageGeneration(
endpoint,
apiKey,
{ ...requestBody, n: requestCount },
attemptBody,
onProgress,
);
if (
!response.ok
&& options.autoRetryWithoutStream
&& shouldRetryImageRequestWithoutStream(attemptBody, response.errorText)
) {
console.warn('[Custom API Image] Stream request timed out; retrying once without stream:', endpoint);
response = await fetchCustomImageGeneration(
endpoint,
apiKey,
buildSynchronousImageRequestBody(attemptBody),
onProgress,
);
}
if (!response.ok) {
return {
@@ -1067,6 +1087,7 @@ export async function POST(request: NextRequest) {
n,
customTargetSize,
handleUpstreamProgress,
{ autoRetryWithoutStream: !!resolvedCustomApiConfig.systemApiId },
);
} catch (fetchError: unknown) {
if (fetchError instanceof DOMException && fetchError.name === 'AbortError') {
@@ -1131,7 +1152,7 @@ export async function POST(request: NextRequest) {
console.warn('[System API Polling] Candidate failed:', candidate.provider, candidate.modelName, candidate.systemApiId, lastError);
}
console.error('[System API Polling] All candidates failed:', customApiConfig.modelName || model, lastError);
return NextResponse.json({ error: '因使用人数较多,模型繁忙,请稍后再试' }, { status: 503 });
return NextResponse.json({ error: getSystemPollingFailureMessage(lastError) }, { status: 503 });
}
// ---- Custom API mode ----

View File

@@ -8,6 +8,7 @@
* 4. AbortController timeout for all requests
*/
import { fetchPublicHttpUrl } from '@/lib/remote-fetch';
import { STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX } from '@/lib/custom-image-fallback';
type UpstreamProgress = Record<string, unknown> & {
percent?: number;
@@ -18,7 +19,7 @@ type UpstreamProgress = Record<string, unknown> & {
const STREAM_EVENTS_FIELD = '__streamEvents';
const STREAM_TEXT_FIELD = '__streamText';
export const STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX = 'MIAOJING_STREAM_UNSUPPORTED_SYNC_CONFIRM:';
export { STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX };
/**
* Default headers that mimic a browser-like HTTP client.
@@ -295,5 +296,11 @@ export function parseCustomApiError(status: number, rawBody: string): string {
if (status === 524 || /cloudflare|error code 524|a timeout occurred|origin web server timed out/i.test(trimmed)) {
return '上游 API 同步生图请求超时Cloudflare 524。请确认该供应商已开启流式生图或异步任务接口高分辨率生图不要走会长时间无响应的同步接口。';
}
if (
[502, 503, 504].includes(status)
|| /bad gateway|service unavailable|gateway timeout/i.test(trimmed)
) {
return `上游网关暂时不可用HTTP ${status})。平台已自动重试一次仍失败,请稍后再试。`;
}
return trimmed || `HTTP ${status}`;
}

View File

@@ -0,0 +1,34 @@
export const STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX = 'MIAOJING_STREAM_UNSUPPORTED_SYNC_CONFIRM:';
export const SYSTEM_API_BUSY_MESSAGE = '因使用人数较多,模型繁忙,请稍后再试';
function stripStreamFallbackPrefix(message: string): string {
const trimmed = message.trim();
if (!trimmed.startsWith(STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX)) return trimmed;
return trimmed.slice(STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX.length).trim();
}
export function shouldRetryImageRequestWithoutStream(
requestBody: Record<string, unknown>,
upstreamErrorText: string,
): boolean {
return requestBody.stream !== false
&& upstreamErrorText.trim().startsWith(STREAM_UNSUPPORTED_SYNC_CONFIRM_PREFIX);
}
export function buildSynchronousImageRequestBody(
requestBody: Record<string, unknown>,
): Record<string, unknown> {
return {
...requestBody,
stream: false,
};
}
export function getSystemPollingFailureMessage(lastError: string): string {
const stripped = stripStreamFallbackPrefix(lastError);
if (!stripped) return SYSTEM_API_BUSY_MESSAGE;
if (/^\s*<!doctype html/i.test(stripped) || /^\s*<html[\s>]/i.test(stripped)) {
return SYSTEM_API_BUSY_MESSAGE;
}
return stripped;
}