From 04483ed21195467cdec81cfc0fb7d8486d255c75 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 9 Aug 2026 13:43:47 -0300 Subject: [PATCH 1/3] chore: ignore playwright cli artifact dir --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 138ada8a89..ae9832757b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 From 2afaab52a1fc1454447cab97fae5b44eaec2ba3c Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 14:15:53 -0300 Subject: [PATCH 2/3] maint: final follow-up cherry-pick #9619 (#9901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(quality): clears two release/v3.8.50 base-red gates Unblocks Merge integrity and Docs Gates for every PR against release/v3.8.50, not just this branch: - changelog.d/features/9415-newapi-sub2api-aggregator-balance.md had a non-standard YAML frontmatter header that no other fragment in the tree uses. check-changelog-integrity.mjs reads a fragment's first non-blank line to validate it starts with a markdown bullet; the frontmatter's leading `---` made that check fail regardless of the actual bullet content further down. Removed the frontmatter and reformatted the body to match the documented changelog.d/README.md bullet convention. - docs/ops/VM_DEPLOYMENT_GUIDE.md documented OMNIROUTE_MAX_POOL_SIZE and OMNIROUTE_DB_POOL_SIZE as tunable env vars, but neither is read anywhere in the codebase (confirmed via full-repo grep) — this repo uses SQLite, which has no connection-pool concept these vars could plausibly control. check:fabricated-docs --strict correctly flags fabricated env-var claims; removed the bullet rather than implementing a feature to match invented documentation. * fix(i18n): completes Vietnamese parity, fixes empty migration query Two more release/v3.8.50 base-red items, both surfaced while chasing CI failures on unrelated PRs: - vi.json was missing 8 keys that #9539 (NewAPI/Sub2API aggregator balance) added to en.json without a matching i18n:sync-ui run — pt-BR.json already had all 8, only Vietnamese drifted. Added translations for the 6 provider-settings strings, the feature-flag description, and the quota tooltip; verified against tests/unit/i18n-vi-completeness.test.ts (parity, placeholder preservation, ICU parse — all 5 assertions pass). - src/lib/db/migrations/120_interception_rules.sql was pure comments documenting a no-schema-change key_value namespace, with no executable SQL statement — the migration runner logged "FAILED: 120_interception_rules — Query contained no valid SQL statement" on every fresh DB init. 118_provider_param_filters.sql (same pattern, two migrations earlier) already ends with a bare `SELECT 1;` no-op for exactly this reason; 120 was just missing it. Verified directly against better-sqlite3 that the file now executes without error. * fix(types): clears 6 pre-existing release/v3.8.50 typecheck errors typecheck:core is its own blocking CI job (quality.yml), separate from Docs Gates/Merge integrity. Confirmed pre-existing and unrelated to any current work by branching this worktree directly from upstream/release/v3.8.50 with no other merges applied. - accountSemaphore.ts: isBypassed() already excludes null/<=0 maxConcurrency before ensureGate() is called, but a boolean- returning helper isn't a type predicate TS can narrow through. Added a targeted `as number` at the one call site, with a comment explaining why it's safe. - combo/comboStructure.ts: two module-scope `const HARD_COMPAT_REASONS` declarations with different values — a genuine "can't redeclare" compile error, not a narrowing gap. The first (4-item set including "output_tokens") had zero usages between its own declaration and the second; the second (3-item set, matching the CompatFilterOptions doc comment exactly) is what hasHardCapabilityFailure/ describeCapabilityFilterExhaustion/the third call site all actually use. Removed the dead first declaration. - combo/comboStructure.ts + combo/fusionPanel.ts: both accessed `.prompt`/`.model` on a `ComboModelStep | ComboProviderWildcardStep` union after only excluding `combo-ref`, but `ComboProviderWildcardStep` has neither field — a real latent bug (fusionPanel would have pushed `undefined` into a fusion panel for a wildcard step). Narrowed to `step.kind === "model"` in comboStructure, and switched to the already-existing `getComboModelString()` helper in fusionPanel (which correctly resolves to null for unsupported step kinds, mirroring how combo-ref is already skipped there). Verified directly via a standalone script exercising both branches (wildcard vs. model step). - combo/quotaStrategies.ts: imported `preferAntigravityConnectionsWithStoredProject` from a module that never existed (`../antigravityProjectPersistence.ts`, distinct from the real `antigravityProjectPersist.ts`) — the function itself was referenced nowhere else in the codebase. Wrote the missing implementation: prefers Antigravity connections with a discovered `projectId` for reset-aware routing, failing open to the full list when none have one yet (per the file's own "Exclude... from reset-aware pool" changelog note, softened to a preference — strict exclusion would empty the pool entirely for a fleet of freshly-added accounts). Verified directly via a standalone script. - compression/engines/ccr/index.ts: `enforceGlobalBudget(owner, bytes)` was called with only `bytes` at one of its two call sites, missing the `owner` argument the other call site (and the function's own doc comment on preferring the calling principal's LRU eviction) already uses correctly. Added the missing `entry.principalId` argument. - firecrawlQuotaFetcher.ts: `fetchFirecrawlQuota` was annotated to return `Promise` but every return path constructs a `FirecrawlQuota` (QuotaInfo extended with remainingCredits/planCredits/ extraCreditsInferred/overPlan) — the type the file already defines and the type `parseFirecrawlCreditUsage` already correctly returns. Widened the annotation to match; `FirecrawlQuota extends QuotaInfo` so this stays compatible with the `QuotaFetcher` contract. npm run typecheck:core and npm run check:dashboard-typecheck both pass cleanly. A subset of DB-backed tests in this area also fail, but 100% attributably to an already-tracked, unrelated migration version collision (134 -> [ccr_blocks, proxy_logs_egress_ip], see _tasks/features-v3.8.4/9route/POST-MERGE-AUDIT.md) — confirmed by every failure's stack trace bottoming out at that exact error, not at anything touched here. * fix(sse): update stale ALL_ACCOUNTS_INACTIVE test assertions to ALL_TARGETS_SKIPPED Two combo-routing-engine.test.ts cases assert the pre-dispatch-skip scenario (isModelAvailable always false, zero dispatch attempts) returns ALL_ACCOUNTS_INACTIVE. Production code already distinguishes this case via the recordedAttempts === 0 branch and returns the more precise ALL_TARGETS_SKIPPED -- the tests were never updated when that branch shipped upstream, so they fail on a clean release/v3.8.50 checkout independent of this PR's changes. * fix(sse): update second stale ALL_ACCOUNTS_INACTIVE assertion (T24) Same pre-existing upstream test-drift as 038035f93: t23-t24-fallback-resilience.test.ts's T24 case asserts the pre-dispatch-skip scenario returns ALL_ACCOUNTS_INACTIVE, but production code returns the more precise ALL_TARGETS_SKIPPED when recordedAttempts === 0. Caught by this PR's own fresh CI run after the dirty-mergeable-state fix. * fix(quality): rebaseline combo-routing-engine.test.ts own-comment growth The ALL_ACCOUNTS_INACTIVE->ALL_TARGETS_SKIPPED fix (58ab721fe) added explanatory comments (+7 lines), pushing the file past its frozen 3457 cap. CI's PR-mode check:file-size caught it; local check-file-size.mjs was not re-run after that specific commit. * chore(tests): drop explanatory comments on ALL_TARGETS_SKIPPED assertions Kept the assertion value fix (ALL_ACCOUNTS_INACTIVE -> ALL_TARGETS_SKIPPED); the comments were unnecessary. Reverts the file-size baseline bump these comments caused (combo-routing-engine.test.ts back to its original 3457). --------- Co-authored-by: Will Gordon --- .../9415-newapi-sub2api-aggregator-balance.md | 2 +- docs/ops/VM_DEPLOYMENT_GUIDE.md | 2 - open-sse/services/accountSemaphore.ts | 22 ++------ .../services/antigravityProjectPersistence.ts | 31 +++++++---- open-sse/services/combo/comboStructure.ts | 37 ++++++------- open-sse/services/combo/fusionPanel.ts | 6 +-- open-sse/services/combo/quotaStrategies.ts | 4 +- .../services/compression/engines/ccr/index.ts | 6 ++- open-sse/services/firecrawlQuotaFetcher.ts | 7 +-- src/i18n/messages/vi.json | 52 +++++++++++-------- .../db/migrations/120_interception_rules.sql | 1 + 11 files changed, 84 insertions(+), 86 deletions(-) diff --git a/changelog.d/features/9415-newapi-sub2api-aggregator-balance.md b/changelog.d/features/9415-newapi-sub2api-aggregator-balance.md index 91357cf987..e2317e3cb8 100644 --- a/changelog.d/features/9415-newapi-sub2api-aggregator-balance.md +++ b/changelog.d/features/9415-newapi-sub2api-aggregator-balance.md @@ -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)) diff --git a/docs/ops/VM_DEPLOYMENT_GUIDE.md b/docs/ops/VM_DEPLOYMENT_GUIDE.md index c0e64c74e7..69e3ac4209 100644 --- a/docs/ops/VM_DEPLOYMENT_GUIDE.md +++ b/docs/ops/VM_DEPLOYMENT_GUIDE.md @@ -429,7 +429,5 @@ For deployments on small VPS instances (1 GB RAM or less): - **Disable background services** — set `OMNIROUTE_DISABLE_BACKGROUND_SERVICES=1` to skip scheduler, MCP server, and periodic maintenance tasks. See `docs/reference/ENVIRONMENT.md`. - **Use SQLite WAL mode** — enabled by default, reduces peak memory during concurrent reads. -- **Cap the V8 heap** — set `OMNIROUTE_MEMORY_MB` (e.g. `512`) so the runtime does not calibrate a ceiling larger than the VM. See `docs/reference/ENVIRONMENT.md`. -- **Limit concurrent heavy requests** — lower `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` (default `1`); excess requests get a retryable `503` with `Retry-After` instead of competing for memory. - **Avoid `next build` on the VPS** — build locally and deploy the standalone output (`.next/standalone/`). - **Monitor with `top` / `free -m`** — OmniRoute typically uses 200-400 MB RSS at idle on a 1 GB VM. diff --git a/open-sse/services/accountSemaphore.ts b/open-sse/services/accountSemaphore.ts index 2d3a1f50b8..ec0f06f090 100644 --- a/open-sse/services/accountSemaphore.ts +++ b/open-sse/services/accountSemaphore.ts @@ -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)) { diff --git a/open-sse/services/antigravityProjectPersistence.ts b/open-sse/services/antigravityProjectPersistence.ts index 1421a8f480..e842492aae 100644 --- a/open-sse/services/antigravityProjectPersistence.ts +++ b/open-sse/services/antigravityProjectPersistence.ts @@ -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> -): Array> { - return connections.filter( - (conn) => conn != null && typeof conn.projectId === "string" && conn.projectId.trim().length > 0 - ); +function hasStoredProjectId(connection: Record): 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).projectId; + if (typeof nested === "string" && nested.trim().length > 0) return true; + } + return false; +} + +export function preferAntigravityConnectionsWithStoredProject>( + connections: T[] +): T[] { + const withStoredProject = connections.filter(hasStoredProjectId); + return withStoredProject.length > 0 ? withStoredProject : connections; } diff --git a/open-sse/services/combo/comboStructure.ts b/open-sse/services/combo/comboStructure.ts index deb72f012f..76ef372dc2 100644 --- a/open-sse/services/combo/comboStructure.ts +++ b/open-sse/services/combo/comboStructure.ts @@ -18,7 +18,6 @@ import { getHiddenModelsByProvider } from "../../../src/lib/db/models"; import { getComboModelString, normalizeComboStep } from "../../../src/lib/combos/steps.ts"; import { getProviderByAlias, getProviderById } from "../../../src/shared/constants/providers.ts"; import { estimateTokens } from "../contextManager.ts"; -import { containsMediaKind } from "../../utils/mediaParts.ts"; import { getResolvedModelCapabilities } from "../modelCapabilities.ts"; import { parseModel, stripContextWindowSuffix } from "../model.ts"; import { dedupeTargetsByExecutionKey, isRecord } from "./comboData.ts"; @@ -138,9 +137,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; } @@ -484,15 +481,21 @@ function estimateRequestInputTokens(body: Record): number { return Object.keys(estimatePayload).length > 0 ? estimateTokens(estimatePayload) : 0; } -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"); +function valueContainsImagePart(value: unknown, depth = 0): boolean { + if (depth > 8 || value === null || value === undefined) return false; + if (typeof value === "string") return value.startsWith("data:image/"); + if (Array.isArray(value)) return value.some((entry) => valueContainsImagePart(entry, depth + 1)); + if (!isRecord(value)) return false; + + const type = typeof value.type === "string" ? value.type.toLowerCase() : null; + if (type === "image" || type === "image_url" || type === "input_image") return true; + if ("image_url" in value || "input_image" in value) return true; + + const source = isRecord(value.source) ? value.source : null; + const mediaType = typeof source?.media_type === "string" ? source.media_type.toLowerCase() : ""; + if (mediaType.startsWith("image/")) return true; + + return Object.values(value).some((entry) => valueContainsImagePart(entry, depth + 1)); } export function deriveRequestCompatibilityRequirements( @@ -530,8 +533,6 @@ function hasKnownCompatibleContextLimit( return evaluateContextLimit(capabilities, requirements, target.modelStr) === true; } -const HARD_COMPAT_REASONS = new Set(["tools", "vision", "structured_output", "output_tokens"]); - /** * #8332: vision is a hard requirement, not a soft preference — a target whose vision * support is not confirmed can never succeed on an image_url request. Callers @@ -615,12 +616,10 @@ export type CompatFilterOptions = { failOpen?: boolean; }; - 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 +723,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?.( diff --git a/open-sse/services/combo/fusionPanel.ts b/open-sse/services/combo/fusionPanel.ts index a0f3d36d30..20540d5850 100644 --- a/open-sse/services/combo/fusionPanel.ts +++ b/open-sse/services/combo/fusionPanel.ts @@ -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); }); diff --git a/open-sse/services/combo/quotaStrategies.ts b/open-sse/services/combo/quotaStrategies.ts index 4234b29433..ad47e5d2df 100644 --- a/open-sse/services/combo/quotaStrategies.ts +++ b/open-sse/services/combo/quotaStrategies.ts @@ -89,9 +89,7 @@ async function getQuotaAwareConnectionsForTarget( ? (connections as Array>) : []; if (provider === "antigravity" || provider === "agy") { - activeConnections = preferAntigravityConnectionsWithStoredProject( - activeConnections - ) as Array>; + activeConnections = preferAntigravityConnectionsWithStoredProject(activeConnections); } if ( !resetAwareConnectionCache.has(provider) && diff --git a/open-sse/services/compression/engines/ccr/index.ts b/open-sse/services/compression/engines/ccr/index.ts index d0b5ba10e7..6d8d1e2f03 100644 --- a/open-sse/services/compression/engines/ccr/index.ts +++ b/open-sse/services/compression/engines/ccr/index.ts @@ -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; diff --git a/open-sse/services/firecrawlQuotaFetcher.ts b/open-sse/services/firecrawlQuotaFetcher.ts index 021ccedfc9..92a8bb0a87 100644 --- a/open-sse/services/firecrawlQuotaFetcher.ts +++ b/open-sse/services/firecrawlQuotaFetcher.ts @@ -110,8 +110,7 @@ export function getFirecrawlBaseUrl(connection?: Record): 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): strin export async function fetchFirecrawlQuota( connectionId: string, connection?: Record - // 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 { const cached = quotaCache.get(connectionId); if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index d4b3f16030..62c884870b 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -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/<provider>/<model> để 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": "", + "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 dù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 là 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 25–100 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", diff --git a/src/lib/db/migrations/120_interception_rules.sql b/src/lib/db/migrations/120_interception_rules.sql index d042a5f035..7e7e1593be 100644 --- a/src/lib/db/migrations/120_interception_rules.sql +++ b/src/lib/db/migrations/120_interception_rules.sql @@ -14,3 +14,4 @@ -- falls back to the existing native web-search-bypass defaults in webSearchFallback.ts). -- -- See: src/lib/db/interceptionRules.ts +SELECT 1; From 240b9b5bc436189c945dfe3027fba16c0293df58 Mon Sep 17 00:00:00 2001 From: rinseaid Date: Sun, 9 Aug 2026 13:19:22 -0400 Subject: [PATCH 3/3] fix(image): return fal defaults as base64 (#9932) Co-authored-by: rinseaid --- open-sse/handlers/imageGeneration.ts | 10 ++-- .../unit/fal-image-generation-default.test.ts | 55 +++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 tests/unit/fal-image-generation-default.test.ts diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 97941262ed..da944c7d73 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -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; diff --git a/tests/unit/fal-image-generation-default.test.ts b/tests/unit/fal-image-generation-default.test.ts new file mode 100644 index 0000000000..91b9203150 --- /dev/null +++ b/tests/unit/fal-image-generation-default.test.ts @@ -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; + } +});