fix(combo): restore hard capability filtering

Restore the shared media detector and the hard-reason set lost by the maintainer cherry-pick. Re-document the two live low-memory controls and cover nested case-insensitive image indicators.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Will Gordon
2026-08-09 15:39:22 -03:00
committed by Diego Rodrigues de Sa e Souza
13 changed files with 155 additions and 83 deletions

3
.gitignore vendored
View File

@@ -275,3 +275,6 @@ docker-compose.yml.bak
# ignora um SYMLINK chamado _tasks; /_tasks (ancorado) cobre arquivo/symlink/dir na raiz
# e impede que um git add -A recapture o symlink (incidente 2026-08-08).
/_tasks
# CLI local cache/state
.playwright-cli

View File

@@ -1 +1 @@
- **sse:** New-API / One-API / Sub2API aggregator balance detection for compatible nodes — with the "Aggregator Gateway" toggle on, OmniRoute queries the aggregator's `/api/user/self` to read the account balance, shows it as a dashboard badge and lets quota-preflight routing skip exhausted accounts. Gated by the `NEWAPI_AGGREGATOR_BALANCE` feature flag (default off), with a `quotaPerUnit` override for aggregators that do not use the default 500000 units/$1 rate ([#9415](https://github.com/diegosouzapw/OmniRoute/issues/9415))
- **feat(sse):** New-API/One-API/Sub2API aggregator balance detection for compatible provider nodes — when the "Aggregator Gateway" toggle is enabled, OmniRoute queries the aggregator's `/api/user/self` endpoint to detect the account balance; the dashboard shows a balance badge and quota-preflight routing skips exhausted accounts. Gated by the `NEWAPI_AGGREGATOR_BALANCE` feature flag (default: off), with a custom `quotaPerUnit` override for aggregators that use a different rate than the default 500000 units/$1 ([#9415](https://github.com/diegosouzapw/OmniRoute/issues/9415))

View File

@@ -1,5 +1,4 @@
{
"_rebaseline_2026_08_07_9619_reconcile_onto_tip": "PR #9619 (fix/basered-changelog-integrity-fabricated-docs) rebase-onto-tip reconciliation. 10 files + 1 test file grew via already-merged release/v3.8.50 PRs since this branch's creation, none touched by this PR's own diff: open-sse/mcp-server/server.ts 1411->1444, open-sse/services/accountFallback.ts 1972->1978, src/app/(dashboard)/dashboard/combos/page.tsx 4647->4703, src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx 1316->1324, src/app/api/providers/[id]/models/route.ts 2250->2304, src/app/api/v1/models/catalog.ts 1549->1556, src/lib/tokenHealthCheck.ts 1021->1053, src/lib/db/core.ts 1637->1639, src/sse/handlers/chat.ts 1877->1878, tests/unit/translator-openai-to-gemini.test.ts 1619->1622. open-sse/mcp-server/schemas/tools.ts 1505->1553 is new growth not previously tracked. Same root cause as every other entry in this chain: fast-gates PR->release does not run check:file-size. No offending branch left to fix.",
"_rebaseline_2026_08_09_9296_adobe_media_capabilities": "PR #9296 (artickc, fix/adobe-firefly-model-capabilities) own growth: src/app/api/v1/models/catalog.ts 1590->1597 (+7). The image and video catalog serializers now expose the already-normalized Adobe Firefly discovery capability data (media_capabilities, plus the existing video modality/size fields) at their only response-emission chokepoints. The discovery parser and capability normalization remain in open-sse/services/adobeFireflyModels.ts; extracting these seven serialization fields would obscure the catalog contract. Covered by tests/unit/adobe-firefly.test.ts and tests/unit/image-upscale.test.ts.",
"_rebaseline_2026_08_08_v3850_base_drift_batch_9757": "Base drift on release/v3.8.50, not own growth: the 08-06..08-08 merge batches grew 12 already-frozen (or newly-landed) files without carrying their rebaselines — the dedicated rebaseline PR #9616 was closed as 'superseded' but its file-size entries never actually reached the base, and later merges (#8894 combos page, #9539 EditConnectionModal, #8895 models route, #9294/#9293 catalog, #9541 db/core, #8970 tokenHealthCheck, #8925 mcp schemas+server, #8890 accountFallback, #9467 chat.ts, #8931 openai-to-kiro, ProxyRegistryManager) kept growing them. All 12 values re-measured on THIS branch's tree (= pure tip + this PR's 1-line chat.ts fix, which adds zero lines). This PR's own source changes (chat.ts identifier restore, stream.ts format carve-out) do not grow any frozen file past these values.",
"_rebaseline_2026_08_08_migration_135_collision": "fix(db): resolve migration version 135 numbering collision — #9449's 135_connection_runtime_state.sql and #8908's 135_migrate_model_capability_max_token.sql both claimed version 135 (#9449 branched before #8908 merged and never got renumbered before landing on release/v3.8.50), which threw 'Migration version collision detected' the moment ANY code touched the database — a fresh install/deploy from this tip cannot even boot. Renumbered the later-landing file to 140 (next free slot) and added the matching isSchemaAlreadyApplied('140') retroactive guard, matching the established pattern already used for the prior 135/136 -> 137/138 renumber in the same file. Own growth: src/lib/db/migrationRunner.ts 1084->1094 (+10, the new case block) — irreducible, matches the existing per-case guard pattern exactly. Covered by tests/unit/migration-135-numbering-collision.test.ts (2/2), confirmed failing (reproducing the exact live crash) against the pre-fix colliding filenames, passing after.",

View File

@@ -1524,7 +1524,7 @@ async function handleFalAIImageGeneration({
}
const payload = await response.json();
const images = await normalizeProviderImagePayload(payload, body, log);
const images = await normalizeProviderImagePayload(payload, body, log, "b64_json");
return saveImageSuccessResult({
provider,
model,
@@ -2200,7 +2200,7 @@ function shouldIncludeStabilityMask(model) {
]).has(model);
}
async function normalizeProviderImagePayload(payload, body, log) {
async function normalizeProviderImagePayload(payload, body, log, defaultFormat) {
const candidates = [];
const pushCandidate = (value) => {
@@ -2226,7 +2226,7 @@ async function normalizeProviderImagePayload(payload, body, log) {
const normalized = [];
for (const candidate of candidates) {
const item = await normalizeProviderImageCandidate(candidate, body);
const item = await normalizeProviderImageCandidate(candidate, body, defaultFormat);
if (item) normalized.push(item);
}
@@ -2240,8 +2240,8 @@ async function normalizeProviderImagePayload(payload, body, log) {
return normalized;
}
async function normalizeProviderImageCandidate(candidate, body) {
const wantsBase64 = body?.response_format === "b64_json";
async function normalizeProviderImageCandidate(candidate, body, defaultFormat) {
const wantsBase64 = body?.response_format === "b64_json" || defaultFormat === "b64_json";
let url = null;
let b64 = null;

View File

@@ -54,21 +54,8 @@ export function buildAccountSemaphoreKey({
return `${String(provider)}:${String(accountKey)}`;
}
/**
* Effective positive cap, or null when the semaphore is bypassed (unset/<=0).
*
* Narrowing companion of {@link isBypassed}: that one returns a plain boolean, so
* TypeScript cannot narrow `number | null` to `number` in its else-branch (a
* `x is null | undefined` predicate would be unsound — 0 bypasses too). Callers
* that need the VALUE after the guard go through here instead of casting.
*/
function resolveActiveCap(maxConcurrency?: number | null): number | null {
if (maxConcurrency == null || maxConcurrency <= 0) return null;
return maxConcurrency;
}
function isBypassed(maxConcurrency?: number | null): boolean {
return resolveActiveCap(maxConcurrency) === null;
return maxConcurrency == null || maxConcurrency <= 0;
}
function createNoopReleaseFn(): () => void {
@@ -205,8 +192,7 @@ export function acquire(
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
}: AcquireAccountSemaphoreOptions = {}
): Promise<() => void> {
const activeCap = resolveActiveCap(maxConcurrency);
if (activeCap === null) {
if (isBypassed(maxConcurrency)) {
return Promise.resolve(createNoopReleaseFn());
}
@@ -214,7 +200,9 @@ export function acquire(
return Promise.reject(makeAbortError(signal));
}
const gate = ensureGate(semaphoreKey, activeCap);
// isBypassed() above already excluded null/<=0 — ensureGate requires a plain
// number, but a boolean-returning helper isn't a type predicate TS can narrow on.
const gate = ensureGate(semaphoreKey, maxConcurrency as number);
clearCleanupTimer(gate);
if (gate.running < gate.maxConcurrency && !isBlocked(gate)) {

View File

@@ -13,16 +13,27 @@ import { persistDiscoveredAntigravityProjectId } from "./antigravityProjectPersi
export { persistDiscoveredAntigravityProjectId };
/**
* Return only the Antigravity connections that already have a stored projectId.
* Prefer Antigravity connections with a discovered/stored `projectId` for
* reset-aware quota routing.
*
* A connection whose projectId has been discovered and persisted can be used
* immediately; a connection without one would need to go through the Code Assist
* bootstrap first, which is handled by the calling strategy's fallback path.
* This is a preference, not a hard requirement: when no candidate has a stored
* projectId, retain the full pool rather than making freshly-added accounts unusable.
*/
export function preferAntigravityConnectionsWithStoredProject(
connections: Array<Record<string, unknown>>
): Array<Record<string, unknown>> {
return connections.filter(
(conn) => conn != null && typeof conn.projectId === "string" && conn.projectId.trim().length > 0
);
function hasStoredProjectId(connection: Record<string, unknown>): boolean {
if (typeof connection.projectId === "string" && connection.projectId.trim().length > 0) {
return true;
}
const providerSpecificData = connection.providerSpecificData;
if (providerSpecificData && typeof providerSpecificData === "object") {
const nested = (providerSpecificData as Record<string, unknown>).projectId;
if (typeof nested === "string" && nested.trim().length > 0) return true;
}
return false;
}
export function preferAntigravityConnectionsWithStoredProject<T extends Record<string, unknown>>(
connections: T[]
): T[] {
const withStoredProject = connections.filter(hasStoredProjectId);
return withStoredProject.length > 0 ? withStoredProject : connections;
}

View File

@@ -138,9 +138,7 @@ function normalizeRuntimeStep(
: {}),
weight,
label,
// `prompt` is a per-step pipeline input and only exists on a model step —
// #8894 widened the union with ComboProviderWildcardStep, which has no prompt.
prompt: (step.kind === "model" ? step.prompt : null) || null,
prompt: step.kind === "model" ? step.prompt || null : null,
} satisfies ResolvedComboTarget;
}
@@ -485,13 +483,6 @@ function estimateRequestInputTokens(body: Record<string, unknown>): number {
}
function valueContainsImagePart(value: unknown): boolean {
// Delegates to the unified media detector (open-sse/utils/mediaParts.ts) —
// single source of truth shared with the vision-bridge guardrail. The
// detector keeps this filter's legacy permissive matches (image-ish `type`
// in any casing, bare `image_url`/`input_image` keys, source.media_type
// image/*, bare data:image strings, recursion capped at depth 8) via
// "image_indicator" parts. containsMediaKind short-circuits on the first
// hit — this runs on every request, so no full-part collection here.
return containsMediaKind([{ content: [value] }], "image");
}
@@ -615,12 +606,10 @@ export type CompatFilterOptions = {
failOpen?: boolean;
};
function hasHardCapabilityFailure(reasons: string[]): boolean {
export function hasHardCapabilityFailure(reasons: string[]): boolean {
return reasons.some((reason) => HARD_COMPAT_REASONS.has(reason));
}
/**
* Summarize a capability-filter exhaustion for a 400-class combo error (#8488).
* Returns null when the empty pool is not attributable to hard requirements.
@@ -724,9 +713,7 @@ export function filterTargetsByRequestCompatibility(
if (compatible.length === targets.length) return targets;
if (compatible.length === 0) {
const hardRejected = rejected.some((entry) =>
entry.reasons.some((r) => HARD_COMPAT_REASONS.has(r))
);
const hardRejected = rejected.some((entry) => hasHardCapabilityFailure(entry.reasons));
const failOpen = options?.failOpen === true;
log.debug?.(

View File

@@ -51,9 +51,9 @@ export function extractFusionPanelSpec(
panel.push(step.comboName);
return;
}
// #8894 widened ComboStep with ComboProviderWildcardStep, which carries a
// modelPattern instead of a model. getComboModelString() already resolves any
// step shape (and returns null for the ones with no concrete model id).
// Provider-wildcard steps have no concrete model to dispatch — fusion is a
// fixed-size panel of literal models/combo-refs, not a wildcard-expanding
// strategy (see file header). Skip rather than push an undefined model.
const modelStr = getComboModelString(step);
if (modelStr) panel.push(modelStr);
});

View File

@@ -292,8 +292,10 @@ function rehydrateEntry(hash: string, principalId: string, now: number): CcrEntr
// Re-admit through the same budgets a fresh store would face. If the block no longer
// fits, it stays on disk and is served straight from the row instead of being cached.
const { principalId: owner, bytes } = entry;
if (enforcePrincipalBudget(owner, bytes) && enforceGlobalBudget(owner, bytes)) {
if (
enforcePrincipalBudget(entry.principalId, entry.bytes) &&
enforceGlobalBudget(entry.principalId, entry.bytes)
) {
const key = buildStoreKey(hash, principalId === ANON ? undefined : principalId);
ccrStore.set(key, entry);
ccrTotalBytes += entry.bytes;

View File

@@ -110,8 +110,7 @@ export function getFirecrawlBaseUrl(connection?: Record<string, unknown>): strin
return envBase.replace(/\/+$/, "");
}
const providerData = toRecord(connection?.providerSpecificData);
const connBase =
typeof connection?.baseUrl === "string" ? connection.baseUrl : providerData?.baseUrl;
const connBase = typeof connection?.baseUrl === "string" ? connection.baseUrl : providerData?.baseUrl;
if (typeof connBase === "string" && connBase.trim() && !connBase.includes("api.firecrawl.dev")) {
return connBase.trim().replace(/\/+$/, "");
}
@@ -121,10 +120,6 @@ export function getFirecrawlBaseUrl(connection?: Record<string, unknown>): strin
export async function fetchFirecrawlQuota(
connectionId: string,
connection?: Record<string, unknown>
// FirecrawlQuota, not the base QuotaInfo: every return here is a full credit
// breakdown (remainingCredits / planCredits / extraCreditsInferred / overPlan),
// and the narrower annotation made the custom-base literal below an excess-
// property error. FirecrawlQuota extends QuotaInfo, so callers are unaffected.
): Promise<FirecrawlQuota | null> {
const cached = quotaCache.get(connectionId);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {

View File

@@ -1094,8 +1094,6 @@
"costsFreeTiersSubtitle": "Hạn mức token miễn phí hàng tháng",
"freeProviderRankings": "Xếp hạng nhà cung cấp miễn phí",
"freeProviderRankingsSubtitle": "Các nhà cung cấp miễn phí tốt nhất được xếp hạng theo điểm ELO của mô hình",
"radar": "Danh mục Radar",
"radarSubtitle": "Danh mục mô hình miễn phí được làm phong phú bởi cộng đồng",
"costsQuotaShare": "Chia sẻ hạn mức",
"costsPricing": "Định giá",
"logsProxy": "Nhật ký proxy",
@@ -1121,7 +1119,6 @@
"runtime": "Thời gian chạy",
"consoleLogs": "Nhật ký bảng điều khiển",
"logsTimeline": "Timeline",
"logsTimelineSubtitle": "Dòng thời gian yêu cầu trực quan",
"globalRouting": "Định tuyến toàn cục",
"mitmProxy": "MITM Proxy",
"oneProxy": "1Proxy",
@@ -1167,6 +1164,7 @@
"logsSubtitle": "Nhật ký ứng dụng",
"logsProxySubtitle": "Nhật ký lưu lượng proxy",
"consoleLogsSubtitle": "Đầu ra bảng điều khiển",
"logsTimelineSubtitle": "Dòng thời gian yêu cầu trực quan",
"logsActivitySubtitle": "Nhật ký hoạt động người dùng",
"healthSubtitle": "Kiểm tra tình trạng hệ thống",
"costsPricingSubtitle": "Quy tắc định giá theo từng mô hình",
@@ -1234,7 +1232,9 @@
"discovery": "Khám phá",
"discoverySubtitle": "Quét các nhà cung cấp để tìm quyền truy cập miễn phí",
"resilienceConnections": "Khả Năng Phục Hồi Kết Nối",
"resilienceConnectionsSubtitle": "Cooldown, cầu dao, trạng thái khóa"
"resilienceConnectionsSubtitle": "Cooldown, cầu dao, trạng thái khóa",
"radar": "Danh mục Radar",
"radarSubtitle": "Danh mục mô hình miễn phí được làm phong phú bởi cộng đồng"
},
"webhooks": {
"title": "Webhook",
@@ -2979,7 +2979,15 @@
"copilotPasteInto": "Dán vào:",
"copilotReloadInstruction": "Sau đó tải lại VS Code và nhập khóa API khi được yêu cầu.",
"wireApiChatCompletions": "Chat Completions (/chat/completions)",
"wireApiResponses": "Responses API (/responses)"
"wireApiResponses": "Responses API (/responses)",
"ccDiscoveryInfoButton": "Cách bật khám phá mô hình trong Claude Code",
"ccDiscoveryInfoTooltip": "Công bố các mô hình không phải Claude dưới dạng id phản chiếu claude/&lt;provider&gt;/&lt;model&gt; để tính năng khám phá mô hình qua gateway của Claude Code có thể liệt kê chúng. Khi bật ở phạm vi toàn cục, số mục trong danh mục tăng gấp đôi với mọi client.",
"ccDiscoveryInfoLink": "Mở Feature Flags",
"ccOnboardingTitle": "settings.json cho việc khám phá mô hình qua gateway",
"ccOnboardingCopy": "Sao chép",
"ccOnboardingCopied": "Đã sao chép",
"ccOnboardingKeyPlaceholder": "<khóa API OmniRoute của bạn>",
"ccOnboardingWindowNote": "Claude Code mặc định coi mọi id mô hình mà nó không nhận ra là có cửa sổ ngữ cảnh 200K. Với mô hình có cửa sổ thực tế khác, hãy thêm CLAUDE_CODE_AUTO_COMPACT_WINDOW ngay bên dưới để việc nén ngữ cảnh tự động không kích hoạt quá sớm."
},
"combos": {
"title": "Combo",
@@ -5073,8 +5081,6 @@
"noNewModelsAddedExisting": "Không có mô hình mới nào được thêm (tất cả đã tồn tại).",
"importDoneCount": "✓ Hoàn tất! {count, plural, one {Đã nhập # mô hình.} other {Đã nhập # mô hình.}}",
"unexpectedErrorOccurred": "Đã xảy ra lỗi không mong muốn",
"getApiKey": "Lấy khóa API",
"getApiKeyDescription": "Đăng ký hoặc tạo tài khoản để nhận khóa API",
"connectionCountLabel": "{count, plural, one {# kết nối} other {# kết nối}}",
"messagesPath": "messages",
"responsesPath": "responses",
@@ -5492,12 +5498,12 @@
"newApiUserIdPlaceholder": "vd. 12345",
"newApiUserIdHint": "Giá trị tiêu đề New-Api-User của AgentRouter, dùng cùng với khóa API console để lấy số dư hạn mức.",
"newApiAggregatorToggleLabel": "Cổng tổng hợp",
"newApiAggregatorToggleHint": "Bật phát hiện số dư cho các node tổng hợp New-API / One-API / Sub2API. Bảng điều khiển sẽ hiển thị huy hiệu số dư và định tuyến quota-preflight sẽ bỏ qua các tài khoản đã cạn.",
"newApiAggregatorConsoleApiKeyHint": "System Access Token cho endpoint /api/user/self của bộ tổng hợp. Không phải khóa API định tuyến.",
"newApiAggregatorUserIdHint": "Giá trị header New-Api-User dùng để lấy số dư quota của người dùng bộ tổng hợp.",
"newApiAggregatorQuotaPerUnitLabel": "Quota mỗi đơn vị",
"newApiAggregatorQuotaPerUnitHint": "Số đơn vị tín dụng New-API cho mỗi 1 USD (mặc định: 500000). Ghi đè nếu bộ tổng hợp của bạn ng tỷ lệ khác.",
"featureFlagNewApiAggregatorBalanceDescription": "Bật phát hiện số dư cho các node tương thích New-API / One-API / Sub2API",
"newApiAggregatorToggleHint": "Bật tính năng phát hiện số dư cho các node tổng hợp New-API / One-API / Sub2API. Bảng điều khiển sẽ hiển thị huy hiệu số dư và định tuyến kiểm tra hạn mức trước sẽ bỏ qua các tài khoản đã hết hạn mức.",
"newApiAggregatorConsoleApiKeyHint": "Token truy cập hệ thống cho endpoint /api/user/self của bộ tổng hợp. Không phải khóa API định tuyến.",
"newApiAggregatorUserIdHint": "Giá trị tiêu đề New-Api-User dùng để lấy số dư hạn mức của người dùng bộ tổng hợp.",
"newApiAggregatorQuotaPerUnitLabel": "Hạn mức trên mỗi đơn vị",
"newApiAggregatorQuotaPerUnitHint": "Số đơn vị tín dụng New-API trên mỗi $1 (mặc định: 500000). Ghi đè nếu bộ tổng hợp của bạn sử dụng tỷ lệ khác.",
"featureFlagNewApiAggregatorBalanceDescription": "Bật tính năng phát hiện số dư cho các node tương thích với bộ tổng hợp New-API / One-API / Sub2API",
"cpaModeDisabledTitle": "Chế độ tương thích CLIProxyAPI đã bị tắt",
"cpaModeEnabledTitle": "Chế độ tương thích CLIProxyAPI đã được bật",
"customUserAgentHint": "Gợi ý User Agent tùy chỉnh",
@@ -5613,7 +5619,7 @@
"tagGroupPlaceholder": "Nhập nhóm thẻ...",
"testModel": "Kiểm tra mô hình",
"testingModel": "Đang kiểm tra mô hình",
"modelTestQuotaTooltip": "Đã hết quota — sẽ đặt lại vào ngày mai hoặc cần nạp thêm",
"modelTestQuotaTooltip": "Đã hết hạn mức — sẽ được đặt lại vào ngày mai hoặc cần nạp thêm",
"toggleOffShort": "Tắt",
"toggleOnShort": "Bật",
"tokenExpiredBadge": "Nhãn token đã hết hạn",
@@ -5783,7 +5789,6 @@
"onboardingProviderDescriptions": {
"360ai": "Lấy khóa API tại ai.360.cn",
"agentrouter": "Nhận 200 USD tín dụng miễn phí tại https://agentrouter.org/register — không cần thẻ tín dụng.",
"unorouter": "Tạo khóa API tại https://unorouter.ai, sau đó dán vào đây dưới dạng Bearer token.",
"agnes": "Lấy khóa API tại agnes-ai.com",
"aimlapi": "Gói miễn phí đã tạm dừng (2026) — AI/ML API hiện chỉ tính phí theo mức sử dụng (nạp tối thiểu 20 USD); không còn tín dụng miễn phí định kỳ.",
"ai21": "10 USD tín dụng dùng thử khi đăng ký (có hiệu lực 3 tháng), không cần thẻ tín dụng",
@@ -5980,7 +5985,8 @@
"kimi-coding": "Kết nối Kimi Coding bằng luồng OAuth hiện có.",
"kiro": "Gói miễn phí: 50 tín dụng/tháng (khoảng 25100 nghìn token). ⚠️ Điều khoản Kiro cấm sử dụng proxy/harness của bên thứ ba.",
"codex": "Kết nối OpenAI Codex bằng luồng OAuth hiện có.",
"qwen": "Kết nối Qwen Code bằng luồng OAuth hiện có."
"qwen": "Kết nối Qwen Code bằng luồng OAuth hiện có.",
"unorouter": "Tạo khóa API tại https://unorouter.ai, sau đó dán vào đây dưới dạng Bearer token."
},
"passthroughModelsDescription": "{provider} chấp nhận ID mô hình gốc của nhà cung cấp. Nhập từ /models hoặc thêm ID tùy chỉnh để định tuyến.",
"bedrockModelsDescription": "Các mô hình Amazon Bedrock được giới hạn theo vùng AWS. Nhập từ /models hoặc thêm ID mô hình Bedrock được bật trong vùng đã chọn.",
@@ -6032,7 +6038,9 @@
"kimiOfficialSupporterTooltip": "Kimi (Moonshot AI) là người bạn mã nguồn mở sáng lập của OmniRoute",
"cheaperInferenceSupporterBadge": "Người bạn mã nguồn mở",
"cheaperInferenceSupporterTooltip": "Cheaper Inference hỗ trợ OmniRoute với tư cách là người bạn mã nguồn mở",
"kimiPartnerLinkNote": "Partner link — supports OmniRoute at no extra cost to you"
"kimiPartnerLinkNote": "Partner link — supports OmniRoute at no extra cost to you",
"getApiKey": "Lấy khóa API",
"getApiKeyDescription": "Đăng ký hoặc tạo tài khoản để nhận khóa API"
},
"settings": {
"title": "Cài đặt",
@@ -10798,8 +10806,6 @@
"sourceModel": "Mô hình nguồn (agent gốc)",
"targetModel": "Mô hình mục tiêu (OmniRoute)",
"noMappings": "Chưa cấu hình ánh xạ mô hình. Hãy chạy trình hướng dẫn thiết lập để tự động phát hiện mô hình.",
"noMappingsDesc": "Chưa cấu hình ánh xạ mô hình nào. Hãy thêm ánh xạ để định tuyến yêu cầu của agent qua OmniRoute.",
"addMapping": "Thêm ánh xạ",
"selectModel": "Chọn…",
"saveMappings": "Lưu ánh xạ",
"setupWizard": "Trình hướng dẫn thiết lập",
@@ -10871,7 +10877,9 @@
"goNow": "Đi ngay",
"message": "MITM Proxy hiện nằm trong AgentBridge.",
"title": "Trang này đã được di chuyển"
}
},
"noMappingsDesc": "Chưa cấu hình ánh xạ mô hình nào. Hãy thêm ánh xạ để định tuyến yêu cầu của agent qua OmniRoute.",
"addMapping": "Thêm ánh xạ"
},
"providerStats": {
"unknownError": "Lỗi không xác định",
@@ -11741,7 +11749,6 @@
"danger": "Nguy hiểm",
"requiresRestart": "Cần khởi động lại",
"source": "Nguồn",
"ccDiscoveryAliasesEnvWarning": "Đang bật qua biến môi trường (EXPOSE_CC_DISCOVERY_ALIASES) — giá trị này ghi đè mọi công tắc trong bảng điều khiển bên dưới.",
"resetFlag": "Đặt lại {label} về mặc định",
"reset": "Đặt lại",
"loadFailed": "Không thể tải cờ tính năng",
@@ -11898,7 +11905,8 @@
"SKILLS_SANDBOX_NETWORK_ENABLED": {
"description": "Cho phép môi trường sandbox của kỹ năng truy cập mạng."
}
}
},
"ccDiscoveryAliasesEnvWarning": "Đang bật qua biến môi trường (EXPOSE_CC_DISCOVERY_ALIASES) — giá trị này ghi đè mọi công tắc trong bảng điều khiển bên dưới."
},
"comboControl": {
"title": "Trung tâm điều khiển combo",

View File

@@ -37,6 +37,8 @@ process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const { getResolvedModelCapabilities } = await import("../../src/lib/modelCapabilities.ts");
const { filterTargetsByRequestCompatibility } = await import("../../open-sse/services/combo.ts");
const { deriveRequestCompatibilityRequirements, hasHardCapabilityFailure } =
await import("../../open-sse/services/combo/comboStructure.ts");
test.after(() => {
core.resetDbInstance();
@@ -100,6 +102,28 @@ test("image request: combo drops the non-vision target, keeps the vision target"
assert.ok(!ids.includes("mistral/ministral-14b-latest"), "non-vision target must be dropped");
});
test("nested case-insensitive image indicators still enforce hard vision compatibility", () => {
const requirements = deriveRequestCompatibilityRequirements({
messages: [
{
role: "user",
content: [
{
payload: {
type: "IMAGE_URL",
image_url: { url: "data:image/png;base64,iVBOR" },
},
},
],
},
],
});
assert.equal(requirements.requiresVision, true);
assert.equal(hasHardCapabilityFailure(["vision"]), true);
assert.equal(hasHardCapabilityFailure(["context_window"]), false);
});
test(
"image request with NO confirmed-vision target: strip all (#8332 — never dispatch " +
"an image body to a confirmed-non-vision target, even as a last resort)",

View File

@@ -0,0 +1,55 @@
import test from "node:test";
import assert from "node:assert/strict";
import dns from "node:dns";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-fal-images-"));
const originalDnsLookup = dns.promises.lookup;
(dns.promises as { lookup: unknown }).lookup = (async (
_hostname: string,
options?: { all?: boolean }
) => {
const record = { address: "203.0.113.1", family: 4 };
return options?.all ? [record] : record;
}) as typeof dns.promises.lookup;
process.on("exit", () => {
(dns.promises as { lookup: unknown }).lookup = originalDnsLookup;
});
const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts");
test("handleImageGeneration returns Fal images as base64 when response_format is omitted", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
const stringUrl = String(url);
if (stringUrl === "https://fal.run/fal-ai/flux-2-flex") {
return new Response(
JSON.stringify({ images: [{ url: "https://cdn.example.com/fal-flex.png" }] }),
{ status: 200, headers: { "content-type": "application/json" } }
);
}
if (stringUrl === "https://cdn.example.com/fal-flex.png") {
return new Response(new Uint8Array([8, 9, 10]), {
status: 200,
headers: { "content-type": "image/png" },
});
}
throw new Error(`Unexpected URL: ${stringUrl}`);
};
try {
const result = await handleImageGeneration({
body: { model: "fal-ai/fal-ai/flux-2-flex", prompt: "red apple" },
credentials: { apiKey: "fal-key" },
log: null,
});
assert.equal(result.success, true);
assert.equal(result.data.data[0].b64_json, "CAkK");
assert.equal(result.data.data[0].url, undefined);
} finally {
globalThis.fetch = originalFetch;
}
});