Files
OmniRoute/open-sse/services/imageCombo.ts
Diego Rodrigues de Sa e Souza f6ccd3cf9f fix(quality): green release/v3.8.50 base-reds round 2 (#9985) (#10131)
* fix(quality): green release/v3.8.50 base-reds round 2 — gateways/conol/deepai corruption, migrations, docs, ratchets, dashboard-typecheck

Base-red fix for issue #9985 after the 2026-08-11 merge storm (99 PRs).

Real defects fixed:
- gateways.ts: close regolo entry (was swallowing naga-ac + chatanywhere from #9421), drop stale duplicate chatanywhere entry (#9594)
- conol-web + deepai registry: correct ../shared import depth + deepai executor:default
- modelSelectModalHelpers: close isProviderModelHidden (#9011)
- driverFactory.test.ts: restore eaten test-closing brace (#9173)
- usageTracking: remove duplicate cache_* props
- modelCapability{Overrides,ResolutionSnapshot,Capabilities}: max_token -> max_output_tokens (#9199 vs #8908) + test align
- videoGeneration: drop duplicate handleFalVideoGeneration import (mediaGeneration/fal canonical, #9982)
- responseSanitizer: cast input_tokens_details before .cached_tokens access
- EditConnectionModal: missing alibaba code fields, hoist validationPsd, providerPageHelpers Badge variant union
- FreeBudgetCard: t() -> labels.noApiKey
- peerRouting + cliRuntime: ProcessEnv typing
- image-combo.test.ts: type any -> unknown
- fal.test.ts: moved to tests/unit/services (collected path) 14 tests green
- remove duplicate 143_job_registry.sql (146 canonical), KNOWN_GAPS fix

Docs/ratchets (owner-authorized rebaselines, annotated):
- CHANGELOG 3.8.50 living section restored + 42 i18n mirrors
- MCP-SERVER.md 104->105 tools + i18n
- ENVIRONMENT.md/.env.example: ADOBE_FIREFLY_CHROME_HEADED + DEBUG_CLAUDE_NONSTREAM
- fabricated-docs allowlist: TELEGRAM proposal env vars
- file-size: 5 grown files + proxyFetch 1207->1220
- dead-code 230->248, codeql 2->9 (drift from merged PRs, not this PR)
- untrack _tasks symlink; agent-skills-sync --apply (config-codex-cli)

* fix(changelog): reformat two feature fragments to the bullet convention (#9239, #9490)

* fix(quality): prune stale ESLint suppressions (base-red)

* fix(quality): resolve open-sse type errors + catalog/build regressions (base-red round 3)

Storm-merge splices repaired in the base-fix PR #10131:
- doctor.ts: AppConfig missing brokerSocketPath
- conol-web.ts: Buffer not assignable to BodyInit (Uint8Array)
- tinycms.ts: TinyCmsExecutor.execute return matches BaseExecutor (response/url/transformedBody)
- tinycmsSigner.ts: encodeInto never-narrowing guard + dead wasm URL fallback (Turbopack)
- virtualFactory.ts: options slot for resolutionSnapshot
- bottleneckPatch.ts: insufficient-overlap casts (as unknown as)
- imageCombo.ts: narrow handleImageGeneration union result
- browser-worker.ts: AppConfig + turn.capabilities splice
- conolDiscovery.ts: getProviderOutboundGuard from Policy module
- catalog.ts: drop removed SWR hooks (getCatalogStaleWhileRevalidateMs + accessors), CatalogCachePolicy -> inline settings, resolve 4-arg call
- catalogCache.ts: remove dead inFlight/promise refs
- chat.ts: add isProviderBreakerFailureStatus import
- model-catalog-cache-swr-8728.test.ts: align to #9199 new API (policy injection removed)

* fix(quality): align UI test fixtures to current component contracts (base-red vitest)

- setup-wizard: provide required serverState prop (component gained it in a merged PR)
- grok-device-oauth-modal: next-intl stub resolves grok flow keys to EN labels
- provider-quota-widget: label now inline (PR #8916 removed AutoRefreshButtonLabel extraction) — test the widget
- use-provider-connections-cursor-refresh + phase1f: match /api/providers?provider=<id> query form; hoist heavy dynamic imports to module scope (timeout flake)
- home-topology: mock next/navigation useRouter (component added node-click navigation)
- cooling/lobe/AutoComboCatalog: raise cold-import describe timeouts to 30-60s
- request-logger-*: align to current detail-view contract

* fix(search): guard params.token undefined in serper headers (typecheck base-red)

* fix(search): guard token headers + non-null providerConfig (typecheck base-red)

* fix(changelog): restore base CHANGELOGs eaten by merge auto-resolve (43 files)

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: backryun <bakryun0718@proton.me>
2026-08-12 16:09:34 -03:00

208 lines
6.9 KiB
TypeScript

/**
* Image Combo Strategy Execution
*
* Executes a full Combo strategy for image generation requests. Expands combo
* targets via resolveComboTargets(), filters to images-capable targets, runs
* each target via handleImageGeneration() using a priority strategy, provides
* per-credential resolution, and returns the first success or last failure.
*
* #9239
*/
import { getComboByName, getCombos } from "@/lib/db/combos";
import { resolveComboTargets } from "@omniroute/open-sse/services/combo.ts";
import { getImageModelEntry, parseImageModel } from "@omniroute/open-sse/config/imageRegistry.ts";
import {
getProviderCredentialsWithQuotaPreflight,
clearRecoveredProviderState,
} from "@/sse/services/auth";
import { isAllRateLimitedCredentials } from "@/app/api/v1/_shared/rateLimit";
import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts";
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
import { generateRequestId } from "@/shared/utils/requestId";
import { calculateModalCost } from "@/lib/usage/costCalculator";
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import * as logger from "@/sse/utils/logger";
/**
* Caller-facing shape of handleImageGeneration(). The handler is untyped and
* returns a wide inferred union across providers, so we narrow it to the two
* discriminated arms this strategy actually consumes.
*/
type ImageGenerationResult =
| { success: true; data?: unknown; status?: number; error?: string }
| { success: false; data?: unknown; status?: number; error?: string };
/**
* Execute a full combo strategy for an image generation request.
*
* 1. Resolve combo targets via resolveComboTargets.
* 2. Filter to images-capable targets (those with an entry in the image registry).
* 3. Iterate targets in priority order; for each target, resolve credentials and
* call handleImageGeneration. Return the first success or the last failure.
* 4. Attach combo name, selected target, and fallback count to response headers.
*/
export async function executeImageCombo(
comboName: string,
body: Record<string, unknown>,
auth: {
request: Request;
policy: { apiKeyInfo?: { id?: string; name?: string } | null };
},
startTime: number,
log: typeof logger
): Promise<Response> {
// 1. Resolve combo targets
const combo = await getComboByName(comboName);
if (!combo) {
// Model name is not a combo; the caller should handle this as a direct model
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`Combo not found: ${comboName}`
);
}
const allCombos = await getCombos();
const targets = resolveComboTargets(combo as never, allCombos as never);
if (!targets || targets.length === 0) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`Combo "${comboName}" has no usable targets`
);
}
// 2. Filter to images-capable targets
const imageTargets = targets.filter((t) => {
if (!t.modelStr) return false;
const entry = getImageModelEntry(t.modelStr);
return entry !== null;
});
if (imageTargets.length === 0) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`No images-capable targets in combo "${comboName}"`
);
}
// 3. Iterate targets in priority order (first healthy target wins)
let lastError: { status: number; error: string } | null = null;
let successResult: { data: unknown; provider: string; model: string } | null = null;
let fallbackCount = 0;
let selectedProvider = "";
let selectedModel = "";
for (const target of imageTargets) {
const { provider: targetProvider, model: targetModel } = parseImageModel(target.modelStr);
if (!targetProvider) {
lastError = { status: 400, error: `Invalid image model: ${target.modelStr}` };
fallbackCount += 1;
continue;
}
// Resolve provider credentials
let credentials = null;
try {
credentials = await getProviderCredentialsWithQuotaPreflight(targetProvider);
} catch {
// DB unavailable — skip this target
lastError = { status: 502, error: `Failed to resolve credentials for ${targetProvider}` };
fallbackCount += 1;
continue;
}
if (!credentials) {
lastError = { status: 400, error: `No credentials for image provider: ${targetProvider}` };
fallbackCount += 1;
continue;
}
if (isAllRateLimitedCredentials(credentials)) {
lastError = {
status: 429,
error: `[${targetProvider}] All accounts rate limited`,
};
fallbackCount += 1;
continue;
}
// Execute image generation for this target
const result = (await handleImageGeneration({
body: { ...body, model: target.modelStr },
credentials,
log,
signal: auth.request?.signal || null,
})) as ImageGenerationResult;
if (result.success) {
await clearRecoveredProviderState(credentials);
selectedProvider = targetProvider;
selectedModel = target.modelStr;
successResult = {
data: result.data,
provider: targetProvider,
model: target.modelStr,
};
break;
}
// Classify the failure
const status = result.status || 500;
const error = typeof result.error === "string" ? result.error : "Image generation failed";
// Terminal failures (400 bad model, 403 banned, etc.) — stop iterating
// Non-terminal failures (429, 5xx) — try next target
if (status === 400 || status === 403 || status === 401) {
return errorResponse(
status,
`[${targetProvider}] ${error}`
);
}
lastError = { status, error: `[${targetProvider}] ${error}` };
fallbackCount += 1;
}
// 4. Build response
if (successResult) {
const n = Math.max(
Number(body.n) || 1,
(
successResult.data as { data?: { data?: unknown[] } }
).data?.data?.length || 0
);
const costUsd = await calculateModalCost(
"image",
selectedProvider,
selectedModel,
{ n }
);
const headers = new Headers({ "Content-Type": "application/json" });
attachOmniRouteMetaHeaders(headers, {
provider: selectedProvider,
model: selectedModel,
costUsd,
latencyMs: Date.now() - startTime,
requestId: generateRequestId(),
strategy: "priority",
fallbackAttempts: fallbackCount,
});
return new Response(
JSON.stringify((successResult.data as { data: unknown }).data),
{ status: 200, headers }
);
}
// All targets failed — return the last error
const errorPayload = toJsonErrorPayload(
lastError?.error || "All combo targets failed",
"Image combo targets all failed"
);
return new Response(JSON.stringify(errorPayload), {
status: lastError?.status || 502,
headers: { "Content-Type": "application/json" },
});
}