mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-10 17:22:17 +03:00
Compare commits
15 Commits
feat/audio
...
fix/9981-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ea614ab12 | ||
|
|
61014aec52 | ||
|
|
0cb7410ca6 | ||
|
|
0dbc34ea56 | ||
|
|
32935b04fa | ||
|
|
9105220242 | ||
|
|
fbbef4eaaf | ||
|
|
faf382cf5a | ||
|
|
d7d98fe356 | ||
|
|
8d78e3dfd3 | ||
|
|
40f9709071 | ||
|
|
e4a7da7526 | ||
|
|
b80afbb74f | ||
|
|
2e6c151902 | ||
|
|
ee0073dc4a |
15
.env.example
15
.env.example
@@ -350,6 +350,18 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# by OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT and the heap-pressure shed instead. Set a positive
|
||||
# value only on memory-constrained deployments that need a hard ceiling.
|
||||
# OMNIROUTE_CHAT_HARD_MAX_MESSAGES=0
|
||||
# How long a heavy request waits for heavyweight capacity before a retryable 503.
|
||||
# A short bounded wait serializes agent bursts instead of an instant 503; 0 = instant.
|
||||
# Default 2000 (2s).
|
||||
# OMNIROUTE_CHAT_ADMISSION_QUEUE_MS=2000
|
||||
# Queued-bytes budget for the admission wait: bounds total buffered body bytes parked
|
||||
# per lane so the wait cannot amplify the heap (#4380). Over-budget waits 503 immediately.
|
||||
# Default 4194304 (4 MB).
|
||||
# OMNIROUTE_CHAT_ADMISSION_MAX_QUEUED_BYTES=4194304
|
||||
# Per-connection virtual admission lanes (#9654): idle-lane eviction TTL. Default 60000 (60s).
|
||||
# OMNIROUTE_CHAT_VIRTUAL_TTL_MS=60000
|
||||
# Per-connection virtual admission lanes (#9654): max concurrent sessions (lanes). Default 64.
|
||||
# OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS=64
|
||||
|
||||
# Hard cap (bytes) for a non-streaming upstream response buffered fully into memory
|
||||
# (#5152). Past this the upstream reader is cancelled and the request fails fast
|
||||
@@ -644,6 +656,9 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# Reduces risk of JA3/JA4 fingerprint-based blocking by providers (e.g., Google).
|
||||
# Used by: open-sse/executors — replaces Node.js default TLS fingerprint.
|
||||
# ENABLE_TLS_FINGERPRINT=true
|
||||
# New proxied TLS routing requires an explicit, comma-separated provider allowlist.
|
||||
# Direct TLS keeps its legacy behavior when this is unset.
|
||||
# TLS_FINGERPRINT_PROVIDERS=codex,openai
|
||||
|
||||
# Allow the Claude Turnstile Playwright browser context to ignore HTTPS certificate errors.
|
||||
# Only enable for local debugging or trusted MITM/corporate proxy environments.
|
||||
|
||||
1
changelog.d/fixes/8887-lkgp-connection-delete.md
Normal file
1
changelog.d/fixes/8887-lkgp-connection-delete.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(db): invalidate stale LKGP pins when provider connections are deleted (#8887)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(executors):** prevent intermittent Codex `upstream_empty_response` errors for tool schemas that combine `oneOf` const branches with a matching sibling `enum` by removing only the semantically redundant `oneOf`; bare, narrowing, non-matching, and type-discriminated `oneOf` schemas remain unchanged. ([#9828](https://github.com/diegosouzapw/OmniRoute/pull/9828))
|
||||
1
changelog.d/fixes/9931-codex-translated-tool-strict.md
Normal file
1
changelog.d/fixes/9931-codex-translated-tool-strict.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(executors):** preserve non-strict function-tool semantics when translating Chat Completions requests to Codex Responses, avoiding intermittent streamed failures without rewriting tool schemas or dropping branch-level descriptions and annotations. ([#9931](https://github.com/diegosouzapw/OmniRoute/pull/9931))
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(admission):** per-connection virtual admission lanes with idle TTL eviction — guards `expireEntry` null deref, adds `deleteLane()` for safe LRU eviction, and passes `sessionId` to byte-level admission (fixes #9654)
|
||||
1
changelog.d/fixes/9981-image-error-normalization.md
Normal file
1
changelog.d/fixes/9981-image-error-normalization.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(images): normalize terminal upstream errors via OpenAI-standard type/code (#9981)
|
||||
@@ -164,11 +164,11 @@
|
||||
"_rebaseline_2026_07_18_6979_codex_test": "PR #6979 own growth: executor-codex.test.ts 1340->1347 (+7 = generalized ensureThinkingBudget assertion added to the existing codex thinking-budget cases). antigravity-test bump 942->977 REVERTED here: #7408's test split dropped that file to 888, so this PR's +35 fits under the original 942 frozen cap.",
|
||||
"_rebaseline_2026_07_24_8354_logs_timeline_sidebar": "PR #8354 (hartmark, feature/scrolling-log) own growth: src/shared/constants/sidebarVisibility/sections.ts 812->820 (+8, the single new logs-timeline SidebarItemDefinition entry added to LOGS_GROUP.items for the new /dashboard/logs/timeline scrolling request-timeline page). Irreducible data-literal wiring at the existing sidebar-sections chokepoint, same shape as every other item in the file; not extractable without an ad-hoc single-item exception to the file's otherwise-uniform multi-line item style.",
|
||||
"_rebaseline_2026_08_09_v3850_post_sweep_tip": "Release-captain reconciliation of absolute file-size drift on pure tip 382449d593 after the authorized cherry-pick wave. The affected production growth already belongs to merged, tested commits: Adobe Firefly CDP/session recovery (#9881), model capability serialization (#9296), Modality Bridge request wiring (#9759), disconnect-grace/reasoning-cache chatCore wiring (#9653/#9183), stacked Lite precedence, and Responses tool-call index/argument handling (#9843 plus the release translator fixes). This repair adds only the compact migration-146 retroactive guard, covered by db-job-registry-migration-renumber-139.test.ts. Values are the exact check:file-size split-newline measurements and remain shrink-only; structural decomposition remains tracked by the existing #3501 notes.",
|
||||
"cap": 1300,
|
||||
"testCap": 1300,
|
||||
"cap": 1000,
|
||||
"testCap": 1000,
|
||||
"testFrozen": {
|
||||
"tests/unit/adobe-firefly.test.ts": 1136,
|
||||
"tests/unit/reasoning-cache.test.ts": 1035,
|
||||
"tests/unit/adobe-firefly.test.ts": 1477,
|
||||
"tests/unit/reasoning-cache.test.ts": 1346,
|
||||
"_rebaseline_2026_06_27_5193_antigravity_test": "#5193 own test growth: oauth-providers-config.test.ts 870->873 (+3: antigravity projectId assertion + 50ms tick for the now fire-and-forget onboarding, matching the no-PKCE/no-openid flow).",
|
||||
"_rebaseline_2026_07_02_5928_base_red": "web-cookie-providers-new.test.ts 845->850: #5928 (test(security) Kimi Web URL host parse, CodeQL #689) grew the file +5 lines and merged into release/v3.8.44 WITHOUT rebaselining, leaving a fast-gates base-red that blocked every subsequent PR->release. Test growth is legitimate (a security regression test); maintainer absorbs the drift here. Frozen at 850.",
|
||||
"_rebaseline_2026_07_09_6126_clinepass_dualauth": "#6126 (ClinePass dual-auth) own test growth: oauth-providers-config.test.ts 842->845 (+3: clinepass key/config/required-fields entries reusing the Cline WorkOS flow config, needed after registering clinepass in the oauth.ts PROVIDERS enum).",
|
||||
@@ -180,39 +180,39 @@
|
||||
"_rebaseline_2026_07_25_8510_adobe_firefly_reference_images_tests": "#8510 (artickc, feat/adobe-firefly-reference-images) own test growth: tests/unit/adobe-firefly.test.ts 711->871 (+159, entirely this PR's diff \u2014 new referenceBlobs upload/dispatch coverage for handleAdobeFireflyImageGeneration, resolveAdobeSourceImageIds, and the storage-upload wire contract). Route-level /v1/images/edits coverage (credentials/rate-limit/4-ref-cap branches added to route.ts) lives in the new tests/unit/8510-adobe-firefly-edits-route.test.ts instead of growing this file further.",
|
||||
"_rebaseline_basered_codebuddy_cn": "Base-red fix (#4664 CodeBuddy CN): oauth-providers-config.test.ts 867->870 (+3) to align the EXPECTED provider list/config with the codebuddy-cn provider that #4664 added to the registry without updating this test (it asserts 'exactly once').",
|
||||
"_rebaseline_pr4613_compatible_provider_groups": "Reconcile #4613 already-merged growth: providers-page-utils.test.ts 1004->1052 (+48, buildCompatibleProviderGroups partition unit test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.",
|
||||
"tests/integration/chat-pipeline.test.ts": 1598,
|
||||
"tests/integration/chatcore-compression-integration.test.ts": 1114,
|
||||
"tests/unit/account-fallback-service.test.ts": 1563,
|
||||
"tests/unit/batch_api.test.ts": 1324,
|
||||
"tests/unit/cc-compatible-provider.test.ts": 1217,
|
||||
"tests/unit/chatcore-translation-paths.test.ts": 2876,
|
||||
"tests/unit/chatgpt-web.test.ts": 3148,
|
||||
"tests/unit/combo-routing-engine.test.ts": 3457,
|
||||
"tests/unit/db-migration-runner.test.ts": 1499,
|
||||
"tests/unit/deepseek-web.test.ts": 1092,
|
||||
"tests/unit/executor-codex.test.ts": 1339,
|
||||
"tests/unit/executor-default-base.test.ts": 1519,
|
||||
"tests/unit/grok-web.test.ts": 2437,
|
||||
"tests/unit/image-generation-handler.test.ts": 2029,
|
||||
"tests/unit/model-sync-route.test.ts": 1016,
|
||||
"tests/unit/models-catalog-route.test.ts": 1636,
|
||||
"tests/unit/perplexity-web.test.ts": 1355,
|
||||
"tests/unit/provider-models-route.test.ts": 1787,
|
||||
"tests/unit/provider-validation-specialty.test.ts": 2985,
|
||||
"tests/unit/providers-page-utils.test.ts": 1106,
|
||||
"tests/unit/response-sanitizer.test.ts": 1063,
|
||||
"tests/unit/route-edge-coverage.test.ts": 1241,
|
||||
"tests/unit/search-handler-extended.test.ts": 1071,
|
||||
"tests/unit/sse-auth.test.ts": 1610,
|
||||
"tests/unit/stream-utils.test.ts": 2445,
|
||||
"tests/unit/token-refresh-service.test.ts": 1378,
|
||||
"tests/unit/translator-openai-responses-req.test.ts": 1194,
|
||||
"tests/unit/translator-openai-to-gemini.test.ts": 1622,
|
||||
"tests/unit/translator-openai-to-kiro.test.ts": 1275,
|
||||
"tests/unit/translator-resp-gemini-to-openai.test.ts": 1234,
|
||||
"tests/unit/usage-service-hardening.test.ts": 1483,
|
||||
"tests/unit/vscode-token-routes.test.ts": 1256,
|
||||
"tests/unit/executor-antigravity.test.ts": 1098
|
||||
"tests/integration/chat-pipeline.test.ts": 2077,
|
||||
"tests/integration/chatcore-compression-integration.test.ts": 1448,
|
||||
"tests/unit/account-fallback-service.test.ts": 2032,
|
||||
"tests/unit/batch_api.test.ts": 1721,
|
||||
"tests/unit/cc-compatible-provider.test.ts": 1582,
|
||||
"tests/unit/chatcore-translation-paths.test.ts": 3739,
|
||||
"tests/unit/chatgpt-web.test.ts": 4092,
|
||||
"tests/unit/combo-routing-engine.test.ts": 4494,
|
||||
"tests/unit/db-migration-runner.test.ts": 1949,
|
||||
"tests/unit/deepseek-web.test.ts": 1420,
|
||||
"tests/unit/executor-codex.test.ts": 1741,
|
||||
"tests/unit/executor-default-base.test.ts": 1975,
|
||||
"tests/unit/grok-web.test.ts": 3168,
|
||||
"tests/unit/image-generation-handler.test.ts": 2638,
|
||||
"tests/unit/model-sync-route.test.ts": 1321,
|
||||
"tests/unit/models-catalog-route.test.ts": 2127,
|
||||
"tests/unit/perplexity-web.test.ts": 1762,
|
||||
"tests/unit/provider-models-route.test.ts": 2323,
|
||||
"tests/unit/provider-validation-specialty.test.ts": 3880,
|
||||
"tests/unit/providers-page-utils.test.ts": 1438,
|
||||
"tests/unit/response-sanitizer.test.ts": 1382,
|
||||
"tests/unit/route-edge-coverage.test.ts": 1613,
|
||||
"tests/unit/search-handler-extended.test.ts": 1392,
|
||||
"tests/unit/sse-auth.test.ts": 2093,
|
||||
"tests/unit/stream-utils.test.ts": 3178,
|
||||
"tests/unit/token-refresh-service.test.ts": 1791,
|
||||
"tests/unit/translator-openai-responses-req.test.ts": 1552,
|
||||
"tests/unit/translator-openai-to-gemini.test.ts": 2109,
|
||||
"tests/unit/translator-openai-to-kiro.test.ts": 1658,
|
||||
"tests/unit/translator-resp-gemini-to-openai.test.ts": 1604,
|
||||
"tests/unit/usage-service-hardening.test.ts": 1928,
|
||||
"tests/unit/vscode-token-routes.test.ts": 1633,
|
||||
"tests/unit/executor-antigravity.test.ts": 1427
|
||||
},
|
||||
"_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores \u2014 proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.",
|
||||
"_rebaseline_2026_06_11_phase1f": "Phase 1f (#3501): ProviderDetailPageClient.tsx 4948\u21924062 (-886 LOC); 3 novos hooks extra\u00eddos. useProviderConnections.ts=954 acima do cap=800 \u2014 justificado: extra\u00e7\u00e3o direta do god-component (zero l\u00f3gica nova), pr\u00f3pria redu\u00e7\u00e3o do cliente supera o custo. useProviderSettings.ts=263 e useProviderModels.ts=154 j\u00e1 abaixo do cap.",
|
||||
@@ -349,78 +349,78 @@
|
||||
"_rebaseline_2026_07_25_adobe_firefly_reference_images": "Follow-up to #8006: storage upload + referenceBlobs for image/video and /v1/images/edits dispatch. adobeFireflyClient.ts 1958->2317 (+upload helpers, extract sources, resolve blob ids). Note: 2317 not 2316 \u2014 check-file-size.mjs counts LOC via split(\"\\n\").length (counts the trailing-newline empty element), which is 1 higher than `wc -l` on a file ending in \\n; the PR's original entry (2316) was measured with wc -l and undercounted by 1 against the actual gate.",
|
||||
"_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) \u2014 provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) \u2014 extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).",
|
||||
"_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.",
|
||||
"open-sse/executors/antigravity.ts": 1528,
|
||||
"open-sse/executors/base.ts": 1640,
|
||||
"open-sse/executors/chatgpt-web.ts": 3241,
|
||||
"open-sse/executors/codex.ts": 1562,
|
||||
"open-sse/executors/cursor.ts": 1563,
|
||||
"open-sse/executors/deepseek-web.ts": 1148,
|
||||
"open-sse/executors/grok-web.ts": 1044,
|
||||
"open-sse/executors/muse-spark-web.ts": 1405,
|
||||
"open-sse/handlers/chatCore.ts": 5061,
|
||||
"open-sse/handlers/imageGeneration.ts": 3101,
|
||||
"open-sse/handlers/responseSanitizer.ts": 1128,
|
||||
"open-sse/handlers/search.ts": 1536,
|
||||
"open-sse/handlers/videoGeneration.ts": 1063,
|
||||
"open-sse/mcp-server/schemas/tools.ts": 1553,
|
||||
"open-sse/mcp-server/server.ts": 1448,
|
||||
"open-sse/mcp-server/tools/advancedTools.ts": 1120,
|
||||
"open-sse/services/accountFallback.ts": 1978,
|
||||
"open-sse/services/adobeFireflyBrowserLogin.ts": 1362,
|
||||
"open-sse/services/adobeFireflyChromeRuntime.ts": 1201,
|
||||
"open-sse/services/adobeFireflyClient.ts": 2999,
|
||||
"open-sse/services/adobeFireflySession.ts": 1003,
|
||||
"open-sse/services/claudeCodeCompatible.ts": 1202,
|
||||
"open-sse/services/combo.ts": 3648,
|
||||
"open-sse/services/compression/strategySelector.ts": 1061,
|
||||
"open-sse/services/rateLimitManager.ts": 1167,
|
||||
"open-sse/translator/response/openai-responses.ts": 1271,
|
||||
"open-sse/utils/cursorAgentProtobuf.ts": 1505,
|
||||
"open-sse/utils/stream.ts": 2889,
|
||||
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1388,
|
||||
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1031,
|
||||
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3117,
|
||||
"src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": 1067,
|
||||
"src/app/(dashboard)/dashboard/combos/page.tsx": 4703,
|
||||
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1283,
|
||||
"src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1022,
|
||||
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2615,
|
||||
"src/app/(dashboard)/dashboard/health/page.tsx": 1165,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1324,
|
||||
"src/app/(dashboard)/dashboard/providers/page.tsx": 1944,
|
||||
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1201,
|
||||
"src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1019,
|
||||
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1470,
|
||||
"src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 1123,
|
||||
"src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1629,
|
||||
"src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1573,
|
||||
"src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1028,
|
||||
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148,
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1119,
|
||||
"src/app/api/providers/[id]/models/route.ts": 2361,
|
||||
"src/app/api/v1/models/catalog.ts": 1597,
|
||||
"src/lib/db/apiKeys.ts": 1529,
|
||||
"src/lib/db/core.ts": 1639,
|
||||
"src/lib/db/migrationRunner.ts": 1101,
|
||||
"src/lib/db/models.ts": 1097,
|
||||
"src/lib/db/providers.ts": 1034,
|
||||
"src/lib/memory/retrieval.ts": 1073,
|
||||
"src/lib/tailscaleTunnel.ts": 1202,
|
||||
"src/lib/usage/providerLimits.ts": 1013,
|
||||
"src/shared/components/OAuthModal.tsx": 1134,
|
||||
"src/shared/components/RequestLoggerV2.tsx": 1629,
|
||||
"src/shared/components/analytics/charts.tsx": 1035,
|
||||
"src/shared/services/cliRuntime.ts": 1122,
|
||||
"src/sse/handlers/chat.ts": 1918,
|
||||
"src/sse/services/auth.ts": 2508,
|
||||
"tests/unit/account-fallback-service.test.ts": 1572,
|
||||
"tests/unit/provider-validation-specialty.test.ts": 2985,
|
||||
"open-sse/executors/hyperagent.ts": 1026,
|
||||
"src/lib/tokenHealthCheck.ts": 1053,
|
||||
"open-sse/executors/default.ts": 1042,
|
||||
"open-sse/executors/kiro.ts": 1069,
|
||||
"open-sse/translator/request/openai-to-kiro.ts": 1057,
|
||||
"open-sse/utils/sseHeartbeat.ts": 149
|
||||
"open-sse/executors/antigravity.ts": 1986,
|
||||
"open-sse/executors/base.ts": 2132,
|
||||
"open-sse/executors/chatgpt-web.ts": 4213,
|
||||
"open-sse/executors/codex.ts": 2031,
|
||||
"open-sse/executors/cursor.ts": 2032,
|
||||
"open-sse/executors/deepseek-web.ts": 1492,
|
||||
"open-sse/executors/grok-web.ts": 1357,
|
||||
"open-sse/executors/muse-spark-web.ts": 1826,
|
||||
"open-sse/handlers/chatCore.ts": 6579,
|
||||
"open-sse/handlers/imageGeneration.ts": 4031,
|
||||
"open-sse/handlers/responseSanitizer.ts": 1466,
|
||||
"open-sse/handlers/search.ts": 1997,
|
||||
"open-sse/handlers/videoGeneration.ts": 1382,
|
||||
"open-sse/mcp-server/schemas/tools.ts": 2019,
|
||||
"open-sse/mcp-server/server.ts": 1882,
|
||||
"open-sse/mcp-server/tools/advancedTools.ts": 1456,
|
||||
"open-sse/services/accountFallback.ts": 2571,
|
||||
"open-sse/services/adobeFireflyBrowserLogin.ts": 1771,
|
||||
"open-sse/services/adobeFireflyChromeRuntime.ts": 1561,
|
||||
"open-sse/services/adobeFireflyClient.ts": 3899,
|
||||
"open-sse/services/adobeFireflySession.ts": 1304,
|
||||
"open-sse/services/claudeCodeCompatible.ts": 1563,
|
||||
"open-sse/services/combo.ts": 4742,
|
||||
"open-sse/services/compression/strategySelector.ts": 1379,
|
||||
"open-sse/services/rateLimitManager.ts": 1517,
|
||||
"open-sse/translator/response/openai-responses.ts": 1652,
|
||||
"open-sse/utils/cursorAgentProtobuf.ts": 1956,
|
||||
"open-sse/utils/stream.ts": 3756,
|
||||
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1804,
|
||||
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1340,
|
||||
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 4052,
|
||||
"src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": 1387,
|
||||
"src/app/(dashboard)/dashboard/combos/page.tsx": 6114,
|
||||
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1668,
|
||||
"src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1329,
|
||||
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 3400,
|
||||
"src/app/(dashboard)/dashboard/health/page.tsx": 1514,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1721,
|
||||
"src/app/(dashboard)/dashboard/providers/page.tsx": 2527,
|
||||
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1561,
|
||||
"src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1325,
|
||||
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1911,
|
||||
"src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 1460,
|
||||
"src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 2118,
|
||||
"src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 2045,
|
||||
"src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1336,
|
||||
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2792,
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1455,
|
||||
"src/app/api/providers/[id]/models/route.ts": 3069,
|
||||
"src/app/api/v1/models/catalog.ts": 2076,
|
||||
"src/lib/db/apiKeys.ts": 1988,
|
||||
"src/lib/db/core.ts": 2131,
|
||||
"src/lib/db/migrationRunner.ts": 1431,
|
||||
"src/lib/db/models.ts": 1426,
|
||||
"src/lib/db/providers.ts": 1344,
|
||||
"src/lib/memory/retrieval.ts": 1395,
|
||||
"src/lib/tailscaleTunnel.ts": 1563,
|
||||
"src/lib/usage/providerLimits.ts": 1317,
|
||||
"src/shared/components/OAuthModal.tsx": 1474,
|
||||
"src/shared/components/RequestLoggerV2.tsx": 2118,
|
||||
"src/shared/components/analytics/charts.tsx": 1346,
|
||||
"src/shared/services/cliRuntime.ts": 1459,
|
||||
"src/sse/handlers/chat.ts": 2493,
|
||||
"src/sse/services/auth.ts": 3260,
|
||||
"tests/unit/account-fallback-service.test.ts": 2044,
|
||||
"tests/unit/provider-validation-specialty.test.ts": 3880,
|
||||
"open-sse/executors/hyperagent.ts": 1334,
|
||||
"src/lib/tokenHealthCheck.ts": 1369,
|
||||
"open-sse/executors/default.ts": 1355,
|
||||
"open-sse/executors/kiro.ts": 1390,
|
||||
"open-sse/translator/request/openai-to-kiro.ts": 1374,
|
||||
"open-sse/utils/sseHeartbeat.ts": 194
|
||||
},
|
||||
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) \u2014 owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore \u2014 the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",
|
||||
"_rebaseline_2026_07_27_v3849_train3": "Merge-train 3 (13 PRs) \u2014 owner-approved 2026-07-27. Both entries are genuine irreducible growth at existing chokepoints, not new branches: src/lib/db/apiKeys.ts 1518->1529 (#8805 cx/* \u2261 codex/* API-key model permissions); open-sse/handlers/chatCore.ts 5006->5020 (#8806 real response payload into plugin onResponse hooks). Covered by tests/unit/db-apiKeys-crud.test.ts (4 new cases) and the two plugin-hook test files updated in #8806 respectively.",
|
||||
@@ -499,75 +499,75 @@
|
||||
"_rebaseline_2026_07_25_adobe_firefly_reference_images": "Follow-up to #8006: storage upload + referenceBlobs for image/video and /v1/images/edits dispatch. adobeFireflyClient.ts 1958->2317 (+upload helpers, extract sources, resolve blob ids). Note: 2317 not 2316 \u2014 check-file-size.mjs counts LOC via split(\\\"\\\\n\\\").length (counts the trailing-newline empty element), which is 1 higher than `wc -l` on a file ending in \\\\n; the PR's original entry (2316) was measured with wc -l and undercounted by 1 against the actual gate.",
|
||||
"_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) \u2014 provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) \u2014 extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).",
|
||||
"_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.",
|
||||
"open-sse/executors/antigravity.ts": "1986",
|
||||
"open-sse/executors/base.ts": "2132",
|
||||
"open-sse/executors/chatgpt-web.ts": "4213",
|
||||
"open-sse/executors/codex.ts": "2031",
|
||||
"open-sse/executors/cursor.ts": "2032",
|
||||
"open-sse/executors/deepseek-web.ts": "1492",
|
||||
"open-sse/executors/grok-web.ts": "1357",
|
||||
"open-sse/executors/muse-spark-web.ts": "1826",
|
||||
"open-sse/handlers/chatCore.ts": "6544",
|
||||
"open-sse/handlers/imageGeneration.ts": "4031",
|
||||
"open-sse/handlers/responseSanitizer.ts": "1466",
|
||||
"open-sse/handlers/search.ts": "1997",
|
||||
"open-sse/handlers/videoGeneration.ts": "1382",
|
||||
"open-sse/mcp-server/schemas/tools.ts": "2019",
|
||||
"open-sse/mcp-server/server.ts": "1882",
|
||||
"open-sse/mcp-server/tools/advancedTools.ts": "1456",
|
||||
"open-sse/services/accountFallback.ts": "2571",
|
||||
"open-sse/services/adobeFireflyClient.ts": "3100",
|
||||
"open-sse/services/claudeCodeCompatible.ts": "1563",
|
||||
"open-sse/services/combo.ts": "4742",
|
||||
"open-sse/services/compression/strategySelector.ts": "1378",
|
||||
"open-sse/services/rateLimitManager.ts": "1517",
|
||||
"open-sse/translator/response/openai-responses.ts": "1565",
|
||||
"open-sse/utils/cursorAgentProtobuf.ts": "1956",
|
||||
"open-sse/utils/stream.ts": 3790,
|
||||
"src/app/(dashboard)/dashboard/HomePageClient.tsx": "1804",
|
||||
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": "1340",
|
||||
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": "4052",
|
||||
"src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": "1387",
|
||||
"src/app/(dashboard)/dashboard/combos/page.tsx": "6114",
|
||||
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": "1668",
|
||||
"src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": "1329",
|
||||
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": "3400",
|
||||
"src/app/(dashboard)/dashboard/health/page.tsx": "1514",
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": "1721",
|
||||
"src/app/(dashboard)/dashboard/providers/page.tsx": "2527",
|
||||
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": "1561",
|
||||
"src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": "1325",
|
||||
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": "1911",
|
||||
"src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": "1460",
|
||||
"src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": "2118",
|
||||
"src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": "2045",
|
||||
"src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": "1336",
|
||||
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": "2792",
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": "1455",
|
||||
"src/app/api/providers/[id]/models/route.ts": "3069",
|
||||
"src/app/api/v1/models/catalog.ts": "2076",
|
||||
"src/lib/tokenHealthCheck.ts": "1369",
|
||||
"src/lib/db/apiKeys.ts": "1988",
|
||||
"src/lib/db/core.ts": "2131",
|
||||
"src/lib/db/migrationRunner.ts": "1425",
|
||||
"src/lib/db/models.ts": "1426",
|
||||
"src/lib/db/providers.ts": "1344",
|
||||
"src/lib/memory/retrieval.ts": "1395",
|
||||
"src/lib/tailscaleTunnel.ts": "1563",
|
||||
"src/lib/usage/providerLimits.ts": "1317",
|
||||
"src/shared/components/OAuthModal.tsx": "1474",
|
||||
"src/shared/components/RequestLoggerV2.tsx": "2118",
|
||||
"src/shared/components/analytics/charts.tsx": "1346",
|
||||
"src/shared/services/cliRuntime.ts": "1459",
|
||||
"src/sse/handlers/chat.ts": "2475",
|
||||
"src/sse/services/auth.ts": "3260",
|
||||
"tests/unit/account-fallback-service.test.ts": "2044",
|
||||
"tests/unit/provider-validation-specialty.test.ts": "3880",
|
||||
"open-sse/executors/hyperagent.ts": "1334",
|
||||
"open-sse/executors/default.ts": "1355",
|
||||
"open-sse/executors/kiro.ts": "1390",
|
||||
"open-sse/translator/request/openai-to-kiro.ts": "1374",
|
||||
"open-sse/utils/sseHeartbeat.ts": "185",
|
||||
"open-sse/executors/antigravity.ts": "1528",
|
||||
"open-sse/executors/base.ts": "1640",
|
||||
"open-sse/executors/chatgpt-web.ts": "3241",
|
||||
"open-sse/executors/codex.ts": "1562",
|
||||
"open-sse/executors/cursor.ts": "1563",
|
||||
"open-sse/executors/deepseek-web.ts": "1148",
|
||||
"open-sse/executors/grok-web.ts": "1044",
|
||||
"open-sse/executors/muse-spark-web.ts": "1405",
|
||||
"open-sse/handlers/chatCore.ts": "5034",
|
||||
"open-sse/handlers/imageGeneration.ts": "3101",
|
||||
"open-sse/handlers/responseSanitizer.ts": "1128",
|
||||
"open-sse/handlers/search.ts": "1536",
|
||||
"open-sse/handlers/videoGeneration.ts": "1063",
|
||||
"open-sse/mcp-server/schemas/tools.ts": "1553",
|
||||
"open-sse/mcp-server/server.ts": "1448",
|
||||
"open-sse/mcp-server/tools/advancedTools.ts": "1120",
|
||||
"open-sse/services/accountFallback.ts": "1978",
|
||||
"open-sse/services/adobeFireflyClient.ts": "2385",
|
||||
"open-sse/services/claudeCodeCompatible.ts": "1202",
|
||||
"open-sse/services/combo.ts": "3648",
|
||||
"open-sse/services/compression/strategySelector.ts": "1060",
|
||||
"open-sse/services/rateLimitManager.ts": "1167",
|
||||
"open-sse/translator/response/openai-responses.ts": "1204",
|
||||
"open-sse/utils/cursorAgentProtobuf.ts": "1505",
|
||||
"open-sse/utils/stream.ts": 2915,
|
||||
"src/app/(dashboard)/dashboard/HomePageClient.tsx": "1388",
|
||||
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": "1031",
|
||||
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": "3117",
|
||||
"src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": "1067",
|
||||
"src/app/(dashboard)/dashboard/combos/page.tsx": "4703",
|
||||
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": "1283",
|
||||
"src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": "1022",
|
||||
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": "2615",
|
||||
"src/app/(dashboard)/dashboard/health/page.tsx": "1165",
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": "1324",
|
||||
"src/app/(dashboard)/dashboard/providers/page.tsx": "1944",
|
||||
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": "1201",
|
||||
"src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": "1019",
|
||||
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": "1470",
|
||||
"src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": "1123",
|
||||
"src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": "1629",
|
||||
"src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": "1573",
|
||||
"src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": "1028",
|
||||
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": "2148",
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": "1119",
|
||||
"src/app/api/providers/[id]/models/route.ts": "2361",
|
||||
"src/app/api/v1/models/catalog.ts": "1597",
|
||||
"src/lib/tokenHealthCheck.ts": "1053",
|
||||
"src/lib/db/apiKeys.ts": "1529",
|
||||
"src/lib/db/core.ts": "1639",
|
||||
"src/lib/db/migrationRunner.ts": "1096",
|
||||
"src/lib/db/models.ts": "1097",
|
||||
"src/lib/db/providers.ts": "1034",
|
||||
"src/lib/memory/retrieval.ts": "1073",
|
||||
"src/lib/tailscaleTunnel.ts": "1202",
|
||||
"src/lib/usage/providerLimits.ts": "1013",
|
||||
"src/shared/components/OAuthModal.tsx": "1134",
|
||||
"src/shared/components/RequestLoggerV2.tsx": "1629",
|
||||
"src/shared/components/analytics/charts.tsx": "1035",
|
||||
"src/shared/services/cliRuntime.ts": "1122",
|
||||
"src/sse/handlers/chat.ts": "1904",
|
||||
"src/sse/services/auth.ts": "2508",
|
||||
"tests/unit/account-fallback-service.test.ts": "1572",
|
||||
"tests/unit/provider-validation-specialty.test.ts": "2985",
|
||||
"open-sse/executors/hyperagent.ts": "1026",
|
||||
"open-sse/executors/default.ts": "1042",
|
||||
"open-sse/executors/kiro.ts": "1069",
|
||||
"open-sse/translator/request/openai-to-kiro.ts": "1057",
|
||||
"open-sse/utils/sseHeartbeat.ts": "142",
|
||||
"_rebaseline_2026_08_04_9305_sse_comments": "#9305 fix: broadened sseCommentsEnabled()",
|
||||
"_rebaseline_2026_08_09_v3850_release_close": "Release v3.8.50 close reconciliation on e0ce95c592: src/sse/handlers/chat.ts 1904->1918 is the irreducible request-pipeline wiring from #9759 that invokes the Modality Bridge guardrail without moving its implementation into the handler; covered by the 17 Vision Bridge canaries plus the PR-1 focused suite. open-sse/translator/response/openai-responses.ts 1204->1215 is #9168's Responses tool-call argument delta buffering/normalization at the existing translator state-machine chokepoint; covered by its dedicated translator regression tests. Both values are measured by check:file-size (split-newline semantics), and the gate remains frozen at the new exact sizes.",
|
||||
"_rebaseline_2026_08_08_toolcall_message_index_collision": "fix(responses-api): tool call after a text message collided on the same output_index. own growth: open-sse/translator/response/openai-responses.ts 1204->1224 (+20, extracted toolCallOutputIndexBase() shared helper so emitToolCall/closeToolCall can no longer compute a tool call's output_index independently and collide with a text message emitted in the same turn). Live incident (2026-08-08, OpenClaw agent): a client that tracks response items by output_index saw the tool call's added/delta/done events land on an index it had already marked complete (the just-closed text message), and silently dropped them \u2014 the agent spoke its preamble and never executed the tool call, even though OmniRoute's own recorded responseBody had a complete, valid tool_calls entry. Covered by the new regression test in tests/unit/translator-resp-openai-responses.test.ts reproducing the exact live scenario.",
|
||||
|
||||
50
docs/architecture/admission-lanes.md
Normal file
50
docs/architecture/admission-lanes.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: "Admission lanes — two lane systems, what gates each, where each reports"
|
||||
status: active
|
||||
lastUpdated: 2026-08-09
|
||||
---
|
||||
|
||||
# Admission lanes (#9654) — two lane systems, what gates each, where each reports
|
||||
|
||||
OmniRoute has **two** process-local lane systems with different scopes. They are
|
||||
complementary; operators should know which one they are looking at.
|
||||
|
||||
## 1. Byte-level per-connection lanes (`chatBodyAdmission.ts`)
|
||||
|
||||
- **Scope:** the buffered-body/heap path for `POST /v1/chat/completions`. Guards
|
||||
against heap amplification from large coding-agent bodies (#4380).
|
||||
- **Gate:** **always on.** Each distinct API key (hashed) — or `anonymous` — gets its
|
||||
own lane with `CHAT_MAX_HEAVY_IN_FLIGHT` capacity, so one session's burst cannot
|
||||
starve another session's heavyweight slot.
|
||||
- **Tuning:**
|
||||
- `OMNIROUTE_CHAT_VIRTUAL_TTL_MS` — idle-lane eviction (default 60000)
|
||||
- `OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS` — lane count cap (default 64)
|
||||
- `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` — queue-wait before 503 (default 2000)
|
||||
- `OMNIROUTE_CHAT_ADMISSION_MAX_QUEUED_BYTES` — queued-bytes heap valve (default 4 MB)
|
||||
- **Reports:** not in `GET /api/monitoring/health` today; observable via
|
||||
`PerConnectionAdmissionController.snapshot()` (sessionId hash, activeHeavy, idleMs).
|
||||
|
||||
## 2. Adaptive runtime virtual lanes (`open-sse/services/admission`)
|
||||
|
||||
- **Scope:** tenant-key admission for provider dispatch — queue cost, latency-guided
|
||||
limit adaptation, lane queueing, and lane metrics.
|
||||
- **Gate:** **opt-in.** Disabled unless `OMNIROUTE_CHAT_VIRTUAL_LANES=true`. Without it,
|
||||
the adaptive controller keeps the shared queue behavior (criterion 1 of #9654 only
|
||||
holds once an operator enables lanes).
|
||||
- **Tuning:** `OMNIROUTE_CHAT_VIRTUAL_LANES` + adaptive config (`maxQueueCount`,
|
||||
`maxQueueCost`, `defaultMaxWaitMs`, …).
|
||||
- **Reports:** `GET /api/monitoring/health` → `adaptiveAdmission` → `laneCount`,
|
||||
`laneQueuedCount`, `laneQueuedCost`, `laneTenants` (opaque lane IDs, never raw keys).
|
||||
|
||||
## Which one is showing in a dashboard
|
||||
|
||||
- `adaptiveAdmission.laneCount` / `laneTenants` → **adaptive virtual lanes** (system 2).
|
||||
- A health payload with **no** `adaptiveAdmission.lane*` fields usually means
|
||||
`OMNIROUTE_CHAT_VIRTUAL_LANES` is unset — the byte-level lanes (system 1) are still
|
||||
active, but nothing under `adaptiveAdmission` will report lane data until it is enabled.
|
||||
|
||||
## Why both exist
|
||||
|
||||
The byte-level lanes bound the memory-heavy parse/compress path; the adaptive lanes
|
||||
bound dispatch cost per tenant. #9654's criterion 1 ("one session's burst does not 503
|
||||
another") is enforced by system 1 unconditionally and by system 2 once opt-in is enabled.
|
||||
33
docs/providers/CURSOR-DOCKER.md
Normal file
33
docs/providers/CURSOR-DOCKER.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: "Cursor model listing"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-09
|
||||
---
|
||||
|
||||
# Cursor model listing
|
||||
|
||||
## Live catalog is exclusive when synced
|
||||
|
||||
After a successful Cursor model sync (`cursor-agent --list-models` → persisted
|
||||
synced catalog), the **dashboard**, **`/v1/models`**, and **Test All** list:
|
||||
|
||||
1. Models returned by the live sync
|
||||
2. Injected auto-router ids: `auto`, `auto-cost`, `auto-balance`, `auto-intelligence`
|
||||
3. Operator **custom** models (Import / manual) — never pruned by sync
|
||||
|
||||
The large static registry under
|
||||
`open-sse/config/providers/registry/cursor/` is **offline fallback only**. When
|
||||
synced is empty (or discovery fails), listing falls back to that registry.
|
||||
|
||||
Effort-suffixed ids (for example `claude-4.6-sonnet-high`) may still be
|
||||
**requested** at runtime: `resolveRequestedModel` strips the suffix into a wire
|
||||
`ModelParameter`. Exclusive listing intentionally hides those static variants
|
||||
from Test All so probes match what Cursor actually returns as available.
|
||||
|
||||
## Helpers
|
||||
|
||||
- `providerUsesExclusiveSyncedListing("cursor"|"cu")` —
|
||||
`src/lib/providers/modelListingCapability.ts`
|
||||
- `mergeProviderModelListing` — dashboard merge
|
||||
- `ensureCursorAutoCatalogEntry` — auto* inject on discovery + listing
|
||||
- `shouldSuppressStaticModelForExclusiveListing` — `/v1/models` static loop
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"title": "Providers",
|
||||
"description": "Provider-specific integration guides",
|
||||
"pages": ["ALIBABA-QWEN-PROVIDER-FAMILIES", "CLAUDE_WEB", "AGENTROUTER", "ZED-DOCKER"]
|
||||
"pages": [
|
||||
"ALIBABA-QWEN-PROVIDER-FAMILIES",
|
||||
"CLAUDE_WEB",
|
||||
"AGENTROUTER",
|
||||
"ZED-DOCKER",
|
||||
"CURSOR-DOCKER"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1398,7 +1398,10 @@ These settings were introduced after the previous environment-contract snapshot.
|
||||
|
||||
| Variable | Default | Source File | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` | `5000` | `src/shared/middleware/chatBodyAdmission.ts` | Maximum wait for a heavyweight chat admission slot before a retryable `503`; `0` restores immediate rejection. |
|
||||
| `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` | `2000` | `src/shared/middleware/chatBodyAdmission.ts` | Maximum wait for a heavyweight chat admission slot before a retryable `503`; a short bounded wait serializes agent bursts instead of an instant `503`. `0` restores immediate rejection. |
|
||||
| `OMNIROUTE_CHAT_ADMISSION_MAX_QUEUED_BYTES` | `4194304` (4 MB) | `src/shared/middleware/chatBodyAdmission.ts` | Queued-bytes budget for the admission wait (#9654): bounds total buffered body bytes parked per lane so the wait cannot amplify the heap (#4380). Over-budget waits receive a retryable `503` immediately. |
|
||||
| `OMNIROUTE_CHAT_VIRTUAL_TTL_MS` | `60000` (60 s) | `src/shared/middleware/chatBodyAdmission.ts` | Per-connection virtual admission lanes (#9654): idle-lane eviction TTL. |
|
||||
| `OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Per-connection virtual admission lanes (#9654): max concurrent sessions (lanes). |
|
||||
| `OMNIROUTE_RUNNOW_TIMEOUT_MS` | `30000` | `src/app/api/jobs/[id]/run-now/route.ts` | Bounds how long a run-now call waits for an in-flight job before starting the queued run. |
|
||||
| `CHAT_LOG_MAX_BODY_KB` | `1024` | `src/lib/logEnv.ts` | Maximum request or response body size before log summarization, in KiB. |
|
||||
| `ADOBE_FIREFLY_BROWSER_REFRESH` | enabled | `open-sse/services/adobeFireflySession.ts` | Keeps IMS and browser-risk state fresh through account-scoped Chrome CDP sessions; set `0` to disable. |
|
||||
|
||||
@@ -1334,6 +1334,7 @@ export class CodexExecutor extends BaseExecutor {
|
||||
dropImageGeneration:
|
||||
isCodexFreePlan(credentials?.providerSpecificData) || getCodexModelScope(model) === "spark",
|
||||
preserveCustomTools: nativeCodexPassthrough,
|
||||
defaultFunctionStrict: nativeCodexPassthrough ? undefined : false,
|
||||
});
|
||||
|
||||
// Issue #806: Even for native passthrough, some clients (purist completions) might indiscriminately inject
|
||||
|
||||
@@ -30,117 +30,13 @@ export function isCodexFreePlan(providerSpecificData: unknown): boolean {
|
||||
return typeof plan === "string" && plan.trim().toLowerCase() === "free";
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const REDUNDANT_ONEOF_OBJECT_MAP_FIELDS = [
|
||||
"properties",
|
||||
"patternProperties",
|
||||
"$defs",
|
||||
"definitions",
|
||||
] as const;
|
||||
|
||||
const REDUNDANT_ONEOF_ARRAY_SCHEMA_FIELDS = ["prefixItems", "oneOf", "anyOf", "allOf"] as const;
|
||||
|
||||
const REDUNDANT_ONEOF_SINGLE_SCHEMA_FIELDS = [
|
||||
"items",
|
||||
"additionalProperties",
|
||||
"not",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
] as const;
|
||||
|
||||
const REDUNDANT_ONEOF_ANNOTATION_KEYS = new Set(["const", "description", "title", "$comment"]);
|
||||
|
||||
/**
|
||||
* Remove a redundant `oneOf` when it is fully covered by a sibling `enum`.
|
||||
*
|
||||
* The Codex private Responses endpoint (`chatgpt.com/backend-api/codex/responses`)
|
||||
* intermittently returns a 502 `upstream_empty_response` when a tool parameter
|
||||
* carries the JSON-Schema pattern `oneOf: [{const, ...annotations}]` together
|
||||
* with a sibling `enum` whose value set exactly matches the `const` set. In that
|
||||
* case `oneOf` adds no constraint beyond `enum`, so dropping it is semantically
|
||||
* safe and eliminates the trigger.
|
||||
*
|
||||
* Only the exact-match redundant case is stripped. Bare `oneOf[const]` without
|
||||
* a sibling `enum`, narrowing const sets, non-matching enums, type-discriminated
|
||||
* `oneOf`, and `anyOf`/`allOf` are all preserved.
|
||||
*/
|
||||
export function stripRedundantOneOfConstEnum(schema: unknown): unknown {
|
||||
if (Array.isArray(schema)) {
|
||||
return schema.map((entry) => stripRedundantOneOfConstEnum(entry));
|
||||
}
|
||||
if (!isPlainObject(schema)) return schema;
|
||||
|
||||
const result: JsonRecord = { ...schema };
|
||||
|
||||
maybeStripRedundantOneOf(result);
|
||||
|
||||
for (const field of REDUNDANT_ONEOF_OBJECT_MAP_FIELDS) {
|
||||
const map = result[field];
|
||||
if (isPlainObject(map)) {
|
||||
result[field] = Object.fromEntries(
|
||||
Object.entries(map).map(([key, value]) => [key, stripRedundantOneOfConstEnum(value)])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of REDUNDANT_ONEOF_ARRAY_SCHEMA_FIELDS) {
|
||||
if (Array.isArray(result[field])) {
|
||||
result[field] = (result[field] as unknown[]).map((entry) =>
|
||||
stripRedundantOneOfConstEnum(entry)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of REDUNDANT_ONEOF_SINGLE_SCHEMA_FIELDS) {
|
||||
if (result[field] !== undefined) {
|
||||
result[field] = stripRedundantOneOfConstEnum(result[field]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function maybeStripRedundantOneOf(node: JsonRecord): void {
|
||||
const branches = node.oneOf;
|
||||
if (!Array.isArray(branches) || branches.length === 0) return;
|
||||
|
||||
const enumValues = Array.isArray(node.enum) ? node.enum : null;
|
||||
if (!enumValues || enumValues.length === 0) return;
|
||||
|
||||
// Every branch must be {const, ...annotations only}.
|
||||
const constValues: unknown[] = [];
|
||||
for (const branch of branches) {
|
||||
if (!isPlainObject(branch)) return;
|
||||
const branchKeys = Object.keys(branch);
|
||||
if (!branchKeys.includes("const")) return;
|
||||
if (!branchKeys.every((key) => REDUNDANT_ONEOF_ANNOTATION_KEYS.has(key))) return;
|
||||
constValues.push((branch as JsonRecord).const);
|
||||
}
|
||||
|
||||
// Restrict to string consts and string enums (confirmed production shape).
|
||||
if (!constValues.every((value) => typeof value === "string")) return;
|
||||
if (!enumValues.every((value) => typeof value === "string")) return;
|
||||
|
||||
// All const values must be unique.
|
||||
if (new Set(constValues).size !== constValues.length) return;
|
||||
|
||||
// The const set must exactly match the enum set.
|
||||
const enumSet = new Set(enumValues);
|
||||
if (enumSet.size !== constValues.length) return;
|
||||
if (!constValues.every((value) => enumSet.has(value))) return;
|
||||
|
||||
delete node.oneOf;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function normalizeCodexTools(
|
||||
body: Record<string, unknown>,
|
||||
options?: { dropImageGeneration?: boolean; preserveCustomTools?: boolean }
|
||||
options?: {
|
||||
dropImageGeneration?: boolean;
|
||||
preserveCustomTools?: boolean;
|
||||
defaultFunctionStrict?: boolean;
|
||||
}
|
||||
): void {
|
||||
if (!Array.isArray(body.tools)) return;
|
||||
|
||||
@@ -241,14 +137,14 @@ export function normalizeCodexTools(
|
||||
? tool.strict
|
||||
: typeof functionObject?.strict === "boolean"
|
||||
? functionObject.strict
|
||||
: undefined;
|
||||
: typeof options?.defaultFunctionStrict === "boolean"
|
||||
? options.defaultFunctionStrict
|
||||
: undefined;
|
||||
|
||||
// Codex/OpenAI Responses API rejects `pattern` fields using regex lookaround
|
||||
// (e.g. `^(?=.*@).+$`) with a 400 "regex lookaround is not supported" error.
|
||||
// Strip those before the schema reaches upstream (9router#1556).
|
||||
const sanitizedParameters = stripRedundantOneOfConstEnum(
|
||||
stripUnsupportedRegexPatterns(parameters)
|
||||
);
|
||||
const sanitizedParameters = stripUnsupportedRegexPatterns(parameters);
|
||||
|
||||
// Rewrite in-place to Responses format
|
||||
for (const key of Object.keys(tool)) {
|
||||
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
visibleComposerContentFromThinking,
|
||||
composerReasoningRemainder,
|
||||
} from "./cursor/composer.ts";
|
||||
import { getActiveSyncedCatalog } from "../../src/lib/db/models/activeSyncedCatalog.ts";
|
||||
// Composer helpers re-exported for external importers (tests).
|
||||
export {
|
||||
isComposerModel,
|
||||
@@ -805,6 +806,20 @@ export class CursorExecutor extends BaseExecutor {
|
||||
return resolveCursorImages(imageUrls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact ids from the active Cursor synced catalog. Empty/unavailable →
|
||||
* undefined so resolveRequestedModel keeps #7289 offline splitting.
|
||||
*/
|
||||
private async loadLiveCatalogIds(): Promise<ReadonlySet<string> | undefined> {
|
||||
try {
|
||||
const catalog = await getActiveSyncedCatalog("cursor");
|
||||
if (!catalog.models.length) return undefined;
|
||||
return new Set(catalog.models.map((model) => model.id));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async buildRequest(
|
||||
model: string,
|
||||
body: {
|
||||
@@ -819,7 +834,10 @@ export class CursorExecutor extends BaseExecutor {
|
||||
}
|
||||
): Promise<{ body: Uint8Array; blobStore: Map<string, Buffer> }> {
|
||||
const { userText, tools } = this.assembleTextAndTools(body);
|
||||
const images = await this.resolveRequestImages(body);
|
||||
const [images, liveCatalogIds] = await Promise.all([
|
||||
this.resolveRequestImages(body),
|
||||
this.loadLiveCatalogIds(),
|
||||
]);
|
||||
|
||||
const blobStore = new Map<string, Buffer>();
|
||||
const requestBody = buildAgentRequestBody({
|
||||
@@ -829,6 +847,7 @@ export class CursorExecutor extends BaseExecutor {
|
||||
tools,
|
||||
blobStore,
|
||||
images,
|
||||
liveCatalogIds,
|
||||
});
|
||||
return { body: requestBody, blobStore };
|
||||
}
|
||||
|
||||
@@ -2149,7 +2149,7 @@ function parseSizeToDimensions(size, fallback = 1024) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRequestedImageFormat(
|
||||
export function normalizeRequestedImageFormat(
|
||||
body,
|
||||
fallback = "png",
|
||||
allowedFormats = ["jpeg", "png", "webp"]
|
||||
@@ -2169,7 +2169,7 @@ function normalizeRequestedImageFormat(
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function mapFalImageSize(size, fallback = "square_hd") {
|
||||
export function mapFalImageSize(size, fallback = "square_hd") {
|
||||
if (typeof size !== "string") return fallback;
|
||||
if (FAL_PRESET_SIZES[size]) return FAL_PRESET_SIZES[size];
|
||||
if (size.includes("x")) {
|
||||
@@ -2200,7 +2200,7 @@ function shouldIncludeStabilityMask(model) {
|
||||
]).has(model);
|
||||
}
|
||||
|
||||
async function normalizeProviderImagePayload(payload, body, log, defaultFormat) {
|
||||
export async function normalizeProviderImagePayload(payload, body, log, defaultFormat) {
|
||||
const candidates = [];
|
||||
|
||||
const pushCandidate = (value) => {
|
||||
|
||||
115
open-sse/handlers/imageGeneration/providers/fal.ts
Normal file
115
open-sse/handlers/imageGeneration/providers/fal.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { ExecutorLog, ProviderCredentials } from "../../../executors/base.ts";
|
||||
import {
|
||||
mapFalImageSize,
|
||||
normalizeProviderImagePayload,
|
||||
normalizeRequestedImageFormat,
|
||||
saveImageErrorResult,
|
||||
saveImageSuccessResult,
|
||||
} from "../../imageGeneration.ts";
|
||||
import { sanitizeErrorMessage } from "../../../utils/error.ts";
|
||||
|
||||
export const FAL_IMAGE_EDIT_MODELS = new Set([
|
||||
"fal-ai/flux-2-flex",
|
||||
"fal-ai/flux-2-pro",
|
||||
"fal-ai/flux-2-max",
|
||||
]);
|
||||
|
||||
export const FAL_IMAGE_EDIT_MAX_REFERENCES = 10;
|
||||
|
||||
export function isFalImageEditModel(model: string | null): boolean {
|
||||
return typeof model === "string" && FAL_IMAGE_EDIT_MODELS.has(model);
|
||||
}
|
||||
|
||||
type FalAIImageEditOptions = {
|
||||
model: string;
|
||||
provider: string;
|
||||
providerConfig: { baseUrl: string };
|
||||
body: Record<string, unknown>;
|
||||
images: Array<{ bytes: Buffer; mime: string }>;
|
||||
credentials: ProviderCredentials;
|
||||
log: ExecutorLog | null | undefined;
|
||||
};
|
||||
|
||||
export async function handleFalAIImageEdit({
|
||||
model,
|
||||
provider,
|
||||
providerConfig,
|
||||
body,
|
||||
images,
|
||||
credentials,
|
||||
log,
|
||||
}: FalAIImageEditOptions) {
|
||||
const startTime = Date.now();
|
||||
const editModel = `${model}/edit`;
|
||||
const outputFormat = normalizeRequestedImageFormat(body, "png", ["jpeg", "png"]);
|
||||
const upstreamBody: Record<string, unknown> = {
|
||||
prompt: body.prompt,
|
||||
image_urls: images.map(
|
||||
({ bytes, mime }) => `data:${mime || "image/png"};base64,${bytes.toString("base64")}`
|
||||
),
|
||||
image_size: mapFalImageSize(body.size, "auto"),
|
||||
output_format: outputFormat,
|
||||
sync_mode: body.sync_mode ?? true,
|
||||
};
|
||||
|
||||
if (body.n !== undefined) upstreamBody.num_images = Number(body.n) || 1;
|
||||
if (body.seed !== undefined) upstreamBody.seed = body.seed;
|
||||
|
||||
if (log) {
|
||||
const promptPreview = String(body.prompt ?? "").slice(0, 60);
|
||||
log.info("IMAGE", `${provider}/${editModel} (fal-ai edit) | prompt: "${promptPreview}..."`);
|
||||
}
|
||||
|
||||
try {
|
||||
const token = credentials.apiKey || credentials.accessToken;
|
||||
const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}/${editModel}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Key ${token}`,
|
||||
},
|
||||
body: JSON.stringify(upstreamBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
if (log)
|
||||
log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`);
|
||||
return saveImageErrorResult({
|
||||
provider,
|
||||
model: editModel,
|
||||
status: response.status,
|
||||
startTime,
|
||||
error: errorText,
|
||||
requestBody: upstreamBody,
|
||||
path: "/v1/images/edits",
|
||||
});
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const normalizedBody =
|
||||
body.response_format === undefined ? { ...body, response_format: "b64_json" } : body;
|
||||
const imagesOut = await normalizeProviderImagePayload(payload, normalizedBody, log);
|
||||
return saveImageSuccessResult({
|
||||
provider,
|
||||
model: editModel,
|
||||
startTime,
|
||||
requestBody: upstreamBody,
|
||||
responseBody: { images_count: imagesOut.length },
|
||||
created: payload.created,
|
||||
images: imagesOut,
|
||||
path: "/v1/images/edits",
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (log) log.error("IMAGE", `${provider} fetch error: ${message}`);
|
||||
return saveImageErrorResult({
|
||||
provider,
|
||||
model: editModel,
|
||||
status: 502,
|
||||
startTime,
|
||||
error: `Image provider error: ${sanitizeErrorMessage(message || err)}`,
|
||||
path: "/v1/images/edits",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export interface ValidatedConfig {
|
||||
adaptation: AdaptationParams;
|
||||
maxRequestCost: number;
|
||||
costConfig: ReturnType<typeof resolveCostConfig>;
|
||||
virtualLanes: boolean;
|
||||
}
|
||||
|
||||
function requirePositiveInt(
|
||||
@@ -162,6 +163,7 @@ export function validateConfig(input: AdaptiveAdmissionConfig): ValidatedConfig
|
||||
windowMs,
|
||||
maxRequestCost: costConfig.maxRequestCost,
|
||||
costConfig,
|
||||
virtualLanes: input.virtualLanes === true,
|
||||
adaptation: resolveAdaptationParams(input, minLimit, maxLimit, windowMs),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +27,13 @@ import {
|
||||
type ShadowDecision,
|
||||
} from "./types.ts";
|
||||
|
||||
/**
|
||||
* Idle TTL for per-connection virtual admission lanes (#9654).
|
||||
*/
|
||||
const ADMISSION_LANE_TTL_MS = 60_000;
|
||||
/** Bounded per-connection lane map to prevent unbounded memory growth (#9654). */
|
||||
const ADMISSION_LANE_MAX_SESSIONS = 1_000;
|
||||
|
||||
type VirtualDisposition = "active" | "queued" | "rejected" | "none";
|
||||
|
||||
const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
@@ -95,6 +102,13 @@ export class AdaptiveAdmissionController {
|
||||
private adaptation: AdaptationState;
|
||||
private queue: FairCostQueue<QueuedPayload>;
|
||||
private virtualQueue: FairCostQueue<{ recordId: string }>;
|
||||
/** Per-connection virtual admission lanes (#9654). */
|
||||
private readonly virtualLanes = new Map<string, {
|
||||
queue: FairCostQueue<QueuedPayload>;
|
||||
lastUsedMs: number;
|
||||
}>();
|
||||
/** Eviction timer for idle lanes; re-armed when a lane is created. */
|
||||
private laneEvictionTimer: unknown = undefined;
|
||||
private readonly active = new Map<string, ActiveLeaseRecord>();
|
||||
private activeCost = 0n;
|
||||
private virtualActiveCost = 0;
|
||||
@@ -148,6 +162,14 @@ export class AdaptiveAdmissionController {
|
||||
|
||||
const drained = this.queue.drain();
|
||||
this.queue = new FairCostQueue(next.maxQueueCount, next.maxQueueCost);
|
||||
// Drain per-connection virtual lane queues (#9654).
|
||||
for (const [, lane] of this.virtualLanes) {
|
||||
for (const entry of lane.queue.drain()) {
|
||||
drained.push(entry);
|
||||
}
|
||||
}
|
||||
this.virtualLanes.clear();
|
||||
this.clearLaneEviction();
|
||||
for (const entry of drained) {
|
||||
if (next.mode !== "enforce") {
|
||||
this.clearEntryTimer(entry);
|
||||
@@ -191,6 +213,10 @@ export class AdaptiveAdmissionController {
|
||||
virtualActiveCount: saturateSnapshotNumber(this.virtualActiveCount),
|
||||
virtualQueuedCost: saturateSnapshotNumber(this.virtualQueue.totalCost),
|
||||
virtualQueuedCount: saturateSnapshotNumber(this.virtualQueue.size),
|
||||
laneCount: saturateSnapshotNumber(this.virtualLanes.size),
|
||||
laneQueuedCost: saturateSnapshotNumber(this.laneTotalQueuedCost()),
|
||||
laneQueuedCount: saturateSnapshotNumber(this.laneTotalQueuedCount()),
|
||||
laneTenants: this.laneTenantSnapshot(),
|
||||
admittedCount: saturateSnapshotNumber(this.admittedCount),
|
||||
rejectedCount: saturateSnapshotNumber(this.rejectedCount),
|
||||
wouldAdmitCount: saturateSnapshotNumber(this.wouldAdmitCount),
|
||||
@@ -223,6 +249,7 @@ export class AdaptiveAdmissionController {
|
||||
/** Deterministic window tick for tests / injected clocks. */
|
||||
tick(): void {
|
||||
this.sampleIntegral();
|
||||
this.evictIdleLanes();
|
||||
closeAdaptationWindow(this.adaptation, this.config.adaptation, this.clock.now());
|
||||
// Real queue first, then virtual: raised limits must promote shadow-queued work
|
||||
// before newer arrivals are classified against the updated budget.
|
||||
@@ -289,6 +316,19 @@ export class AdaptiveAdmissionController {
|
||||
);
|
||||
this.rejectedCount += 1;
|
||||
}
|
||||
// Drain per-connection virtual lane queues (#9654).
|
||||
for (const [, lane] of this.virtualLanes) {
|
||||
for (const entry of lane.queue.drain()) {
|
||||
this.clearEntryTimer(entry);
|
||||
this.detachAbort(entry);
|
||||
entry.payload.reject(
|
||||
createAdmissionRejectError("ADMISSION_SHUTDOWN", "admission controller shut down")
|
||||
);
|
||||
this.rejectedCount += 1;
|
||||
}
|
||||
}
|
||||
this.virtualLanes.clear();
|
||||
this.clearLaneEviction();
|
||||
}
|
||||
|
||||
private resolveCost(request: AdmissionRequest): number {
|
||||
@@ -440,9 +480,23 @@ export class AdaptiveAdmissionController {
|
||||
},
|
||||
};
|
||||
|
||||
if (!this.queue.enqueue(entry)) {
|
||||
// Per-connection virtual admission lanes (#9654): when enabled via
|
||||
// OMNIROUTE_CHAT_VIRTUAL_LANES=1, requests with a tenantKey are enqueued into
|
||||
// a per-session lane queue instead of the shared queue, so one connection's
|
||||
// burst does not 503 other sessions. Lanes are bounded by
|
||||
// ADMISSION_LANE_MAX_SESSIONS and idle-evicted after ADMISSION_LANE_TTL_MS.
|
||||
// Default: OFF — preserves the shared FairCostQueue round-robin behavior.
|
||||
if (entry.tenantKey !== "_default" && this.config.virtualLanes) {
|
||||
const lane = this.getOrCreateLane(entry.tenantKey);
|
||||
if (!lane.queue.enqueue(entry)) {
|
||||
this.removeEmptyLane(entry.tenantKey);
|
||||
return this.reject("ADMISSION_QUEUE_FULL", "admission lane queue is full");
|
||||
}
|
||||
this.armLaneEviction();
|
||||
} else if (!this.queue.enqueue(entry)) {
|
||||
return this.reject("ADMISSION_QUEUE_FULL", "admission queue is full");
|
||||
}
|
||||
this.dispatch();
|
||||
|
||||
entry.timerId = this.clock.setTimer(
|
||||
() => {
|
||||
@@ -466,7 +520,17 @@ export class AdaptiveAdmissionController {
|
||||
}
|
||||
|
||||
private expireEntry(id: string, code: AdmissionRejectCode, message: string): void {
|
||||
const entry = this.queue.removeById(id);
|
||||
let entry = this.queue.removeById(id);
|
||||
if (!entry) {
|
||||
// Search per-connection lane queues (#9654).
|
||||
for (const [, lane] of this.virtualLanes) {
|
||||
entry = lane.queue.removeById(id);
|
||||
if (entry) {
|
||||
this.removeEmptyLane(entry.tenantKey);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!entry) return;
|
||||
this.clearEntryTimer(entry);
|
||||
this.detachAbort(entry);
|
||||
@@ -490,7 +554,6 @@ export class AdaptiveAdmissionController {
|
||||
|
||||
private dispatch(): void {
|
||||
if (this.shutDown || this.config.mode !== "enforce") return;
|
||||
|
||||
while (this.queue.size > 0) {
|
||||
const limit = this.adaptation.currentLimit;
|
||||
const available = BigInt(limit) - this.activeCost;
|
||||
@@ -515,6 +578,165 @@ export class AdaptiveAdmissionController {
|
||||
}
|
||||
entry.payload.resolve(this.admit(entry.cost));
|
||||
}
|
||||
this.dispatchLanes();
|
||||
}
|
||||
|
||||
/** Round-robin dispatch across per-connection virtual lane queues (#9654). */
|
||||
private dispatchLanes(): void {
|
||||
if (this.shutDown || this.config.mode !== "enforce") return;
|
||||
if (this.virtualLanes.size === 0) return;
|
||||
|
||||
const keys = Array.from(this.virtualLanes.keys());
|
||||
for (const key of keys) {
|
||||
const lane = this.virtualLanes.get(key);
|
||||
if (!lane) continue;
|
||||
// Dispatch as many entries from this lane as capacity allows,
|
||||
// then break to give other lanes a fair share.
|
||||
while (lane.queue.size > 0) {
|
||||
const limit = this.adaptation.currentLimit;
|
||||
const available = BigInt(limit) - this.activeCost;
|
||||
if (available <= 0n) return;
|
||||
const entry = lane.queue.dequeue(Number(available));
|
||||
if (!entry) break; // head doesn't fit
|
||||
this.clearEntryTimer(entry);
|
||||
this.detachAbort(entry);
|
||||
if (entry.payload.signal?.aborted) {
|
||||
entry.payload.reject(
|
||||
createAdmissionRejectError("ADMISSION_ABORTED", "request aborted while queued")
|
||||
);
|
||||
this.rejectedCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (this.clock.now() >= entry.deadlineMs) {
|
||||
entry.payload.reject(
|
||||
createAdmissionRejectError("ADMISSION_DEADLINE", "admission wait deadline exceeded")
|
||||
);
|
||||
this.rejectedCount += 1;
|
||||
continue;
|
||||
}
|
||||
entry.payload.resolve(this.admit(entry.cost));
|
||||
break; // yield to next lane for fairness
|
||||
}
|
||||
this.removeEmptyLane(key);
|
||||
}
|
||||
}
|
||||
|
||||
private getOrCreateLane(tenantKey: string): { queue: FairCostQueue<QueuedPayload>; lastUsedMs: number } {
|
||||
let lane = this.virtualLanes.get(tenantKey);
|
||||
if (!lane) {
|
||||
// Evict oldest lane if at capacity (LRU).
|
||||
if (this.virtualLanes.size >= ADMISSION_LANE_MAX_SESSIONS) {
|
||||
const oldestKey = this.oldestLaneKey();
|
||||
if (oldestKey) {
|
||||
this.deleteLane(oldestKey);
|
||||
}
|
||||
}
|
||||
// Per-lane queue uses the same maxQueueCount/maxQueueCost as the shared
|
||||
// queue. Total memory is bounded by ADMISSION_LANE_MAX_SESSIONS (1000)
|
||||
// × per-lane queue caps — each lane's FairCostQueue rejects when full.
|
||||
lane = {
|
||||
queue: new FairCostQueue(this.config.maxQueueCount, this.config.maxQueueCost),
|
||||
lastUsedMs: this.clock.now(),
|
||||
};
|
||||
this.virtualLanes.set(tenantKey, lane);
|
||||
}
|
||||
lane.lastUsedMs = this.clock.now();
|
||||
return lane;
|
||||
}
|
||||
|
||||
private removeEmptyLane(tenantKey: string): void {
|
||||
const lane = this.virtualLanes.get(tenantKey);
|
||||
if (lane && lane.queue.size === 0) {
|
||||
this.virtualLanes.delete(tenantKey);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drain and reject all pending entries in a lane before removing it from the map. */
|
||||
private deleteLane(tenantKey: string): void {
|
||||
const lane = this.virtualLanes.get(tenantKey);
|
||||
if (!lane) return;
|
||||
for (const entry of lane.queue.drain()) {
|
||||
this.clearEntryTimer(entry);
|
||||
this.detachAbort(entry);
|
||||
entry.payload.reject(
|
||||
createAdmissionRejectError("ADMISSION_LANE_EVICTED", "connection lane evicted")
|
||||
);
|
||||
this.rejectedCount += 1;
|
||||
}
|
||||
this.virtualLanes.delete(tenantKey);
|
||||
}
|
||||
|
||||
private oldestLaneKey(): string | undefined {
|
||||
let oldest: string | undefined;
|
||||
let oldestMs = Infinity;
|
||||
for (const [key, lane] of this.virtualLanes) {
|
||||
if (lane.lastUsedMs <= oldestMs) {
|
||||
oldestMs = lane.lastUsedMs;
|
||||
oldest = key;
|
||||
}
|
||||
}
|
||||
return oldest;
|
||||
}
|
||||
|
||||
private evictIdleLanes(): void {
|
||||
const now = this.clock.now();
|
||||
const keysToDelete: string[] = [];
|
||||
for (const [key, lane] of this.virtualLanes) {
|
||||
if (now - lane.lastUsedMs >= ADMISSION_LANE_TTL_MS) {
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
}
|
||||
for (const key of keysToDelete) {
|
||||
this.deleteLane(key);
|
||||
}
|
||||
if (this.virtualLanes.size > 0) {
|
||||
this.armLaneEviction();
|
||||
} else {
|
||||
this.clearLaneEviction();
|
||||
}
|
||||
}
|
||||
|
||||
private armLaneEviction(): void {
|
||||
this.clearLaneEviction();
|
||||
this.laneEvictionTimer = this.clock.setTimer(
|
||||
() => this.evictIdleLanes(),
|
||||
ADMISSION_LANE_TTL_MS
|
||||
);
|
||||
}
|
||||
|
||||
private clearLaneEviction(): void {
|
||||
if (this.laneEvictionTimer !== undefined) {
|
||||
this.clock.clearTimer(this.laneEvictionTimer);
|
||||
this.laneEvictionTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private laneTotalQueuedCost(): number {
|
||||
let total = 0;
|
||||
for (const [, lane] of this.virtualLanes) {
|
||||
total = addSaturated(total, lane.queue.totalCost);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private laneTotalQueuedCount(): number {
|
||||
let count = 0;
|
||||
for (const [, lane] of this.virtualLanes) {
|
||||
count = addSaturated(count, lane.queue.size);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private laneTenantSnapshot(): ReadonlyArray<{ tenantKey: string; queuedCount: number; queuedCost: number }> {
|
||||
const arr: { tenantKey: string; queuedCount: number; queuedCost: number }[] = [];
|
||||
for (const [tenantKey, lane] of this.virtualLanes) {
|
||||
arr.push({
|
||||
tenantKey,
|
||||
queuedCount: saturateSnapshotNumber(lane.queue.size),
|
||||
queuedCost: saturateSnapshotNumber(lane.queue.totalCost),
|
||||
});
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
private releaseVirtual(record: ActiveLeaseRecord): void {
|
||||
|
||||
@@ -39,6 +39,7 @@ export const DEFAULT_ADAPTIVE_ADMISSION_CONFIG: Readonly<AdaptiveAdmissionConfig
|
||||
maxQueueCost: 2000,
|
||||
defaultMaxWaitMs: 5_000,
|
||||
windowMs: 1_000,
|
||||
virtualLanes: false,
|
||||
});
|
||||
|
||||
const RUNTIME_STORE_KEY = Symbol.for("omniroute.adaptiveAdmission.runtime");
|
||||
@@ -117,6 +118,11 @@ export function resolveAdaptiveAdmissionConfigFromEnv(
|
||||
|
||||
// Shared pure validation — accept exact documented maxima, reject core-invalid configs.
|
||||
validateConfig(cfg);
|
||||
|
||||
// Per-connection virtual admission lanes (#9654) — opt-in via OMNIROUTE_CHAT_VIRTUAL_LANES.
|
||||
const vlRaw = env.OMNIROUTE_CHAT_VIRTUAL_LANES;
|
||||
cfg.virtualLanes = vlRaw === "1" || vlRaw === "true";
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
@@ -236,6 +242,12 @@ const REJECT_MAP: Record<AdmissionRejectCode, RejectHttpMapping> = {
|
||||
message: "Service temporarily unavailable",
|
||||
retryAfter: "1",
|
||||
},
|
||||
ADMISSION_LANE_EVICTED: {
|
||||
status: 503,
|
||||
code: "admission_lane_evicted",
|
||||
message: "Connection lane evicted",
|
||||
retryAfter: "1",
|
||||
},
|
||||
};
|
||||
|
||||
function isAdmissionRejectError(
|
||||
|
||||
@@ -33,6 +33,7 @@ export type AdmissionRejectCode =
|
||||
| "ADMISSION_QUEUE_FULL"
|
||||
| "ADMISSION_DEADLINE"
|
||||
| "ADMISSION_ABORTED"
|
||||
| "ADMISSION_LANE_EVICTED"
|
||||
| "ADMISSION_SHUTDOWN"
|
||||
| "ADMISSION_UNAVAILABLE";
|
||||
|
||||
@@ -79,6 +80,8 @@ export interface AdaptiveAdmissionConfig {
|
||||
maxIncreasePerWindow?: number;
|
||||
/** Optional cost quanta override used only when callers pass features instead of cost. */
|
||||
cost?: Partial<AdmissionCostConfig>;
|
||||
/** Per-connection virtual admission lanes (#9654). Default: false. */
|
||||
virtualLanes?: boolean;
|
||||
}
|
||||
|
||||
export interface AdmissionRequest {
|
||||
@@ -137,6 +140,16 @@ export interface AdmissionSnapshot {
|
||||
virtualActiveCount: number;
|
||||
virtualQueuedCost: number;
|
||||
virtualQueuedCount: number;
|
||||
/** Per-connection virtual lane metrics (#9654). */
|
||||
laneCount: number;
|
||||
laneQueuedCost: number;
|
||||
laneQueuedCount: number;
|
||||
/** Per-tenant queue breakdown (opaque keys, never raw API keys). */
|
||||
laneTenants: ReadonlyArray<{
|
||||
tenantKey: string;
|
||||
queuedCount: number;
|
||||
queuedCost: number;
|
||||
}>;
|
||||
admittedCount: number;
|
||||
rejectedCount: number;
|
||||
wouldAdmitCount: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AutoVariant } from "./autoPrefix";
|
||||
import { VALID_VARIANTS } from "./autoPrefix";
|
||||
import { parseAutoSuffix } from "./suffixComposition";
|
||||
import { parseAutoSuffix, type AutoCategory, type AutoTier } from "./suffixComposition";
|
||||
import { isValidModelFamily, AUTO_FAMILY_IDS } from "./modelFamily";
|
||||
|
||||
export { AUTO_FAMILY_IDS };
|
||||
@@ -112,13 +112,71 @@ export function isPaidTierAutoId(autoId: string): boolean {
|
||||
return parsed.valid && parsed.tier === "pro";
|
||||
}
|
||||
|
||||
export async function createBuiltinAutoCombo(modelStr: string, suffix: string) {
|
||||
const { createVirtualAutoCombo } = await import("./virtualFactory.ts");
|
||||
/**
|
||||
* Resolved spec for a built-in `auto/*` id: either a flat variant (legacy) or
|
||||
* a category/tier overlay (#4235 Phase B). Category `vision`/`multimodal` adds
|
||||
* a candidate filter so the virtual combo only scores vision-capable models.
|
||||
*/
|
||||
export type BuiltinAutoSpec =
|
||||
| { variant: AutoVariant | undefined }
|
||||
| { category: AutoCategory; tier?: AutoTier };
|
||||
|
||||
/**
|
||||
* Vision-flavored flat ids that MUST resolve to the `vision` category (candidate
|
||||
* filter by capability), not to a flat variant: the vision-bridge guardrail and
|
||||
* its self-loop depend on `auto/best-vision` picking a model that can actually
|
||||
* see images. Mapping it to `smart` scored ALL candidates and resolved to
|
||||
* text-only models (e.g. deepseek-v4-flash-free), breaking every describe call.
|
||||
*/
|
||||
const VISION_CATEGORY_AUTO_IDS: Record<string, { category: "vision"; tier?: AutoTier }> = {
|
||||
"auto/best-vision": { category: "vision" },
|
||||
"auto/pro-vision": { category: "vision", tier: "pro" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure resolver for a built-in `auto/*` id. Extracted from
|
||||
* `createBuiltinAutoCombo` so the catalog mapping is unit-testable without
|
||||
* materializing a virtual combo (which requires the DB).
|
||||
*/
|
||||
export function resolveBuiltinAutoSpec(modelStr: string, suffix: string): BuiltinAutoSpec {
|
||||
const visionSpec = VISION_CATEGORY_AUTO_IDS[modelStr];
|
||||
if (visionSpec) return visionSpec;
|
||||
|
||||
const resolved = resolveAutoVariant(modelStr, suffix);
|
||||
if (resolved.recognized) {
|
||||
const spec = modelStr === "auto/best-free" ? { tier: "free" as const } : undefined;
|
||||
const virtualCombo = await createVirtualAutoCombo(resolved.variant, spec);
|
||||
return { variant: resolved.variant };
|
||||
}
|
||||
|
||||
const parsed = parseAutoSuffix(suffix);
|
||||
if (parsed.valid) {
|
||||
return {
|
||||
category: parsed.category as AutoCategory,
|
||||
...(parsed.tier ? { tier: parsed.tier } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return { variant: undefined };
|
||||
}
|
||||
|
||||
export async function createBuiltinAutoCombo(modelStr: string, suffix: string) {
|
||||
const { createVirtualAutoCombo } = await import("./virtualFactory.ts");
|
||||
const spec = resolveBuiltinAutoSpec(modelStr, suffix);
|
||||
|
||||
if ("category" in spec) {
|
||||
// #4235 Phase B category/tier path (incl. vision ids like auto/best-vision).
|
||||
const virtualCombo = await createVirtualAutoCombo(undefined, {
|
||||
category: spec.category,
|
||||
...(spec.tier ? { tier: spec.tier } : {}),
|
||||
});
|
||||
virtualCombo.name = modelStr;
|
||||
virtualCombo.id = modelStr;
|
||||
return virtualCombo;
|
||||
}
|
||||
|
||||
if ("variant" in spec && spec.variant !== undefined) {
|
||||
const virtualCombo = await createVirtualAutoCombo(spec.variant, {
|
||||
...(modelStr === "auto/best-free" ? { tier: "free" as const } : {}),
|
||||
});
|
||||
virtualCombo.name = modelStr;
|
||||
virtualCombo.id = modelStr;
|
||||
return virtualCombo;
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { AutoVariant } from "./autoPrefix";
|
||||
import { classifyTier } from "../tierResolver";
|
||||
import { getResolvedModelCapabilities } from "@/lib/modelCapabilities";
|
||||
import { isVisionModelId } from "@/shared/constants/visionModels";
|
||||
import { isVisionBridgeForcedModel } from "@/shared/constants/visionBridgeDefaults";
|
||||
|
||||
export type AutoCategory = "coding" | "reasoning" | "vision" | "chat" | "multimodal";
|
||||
export type AutoTier = "fast" | "cheap" | "floor" | "free" | "reliable" | "pro";
|
||||
@@ -111,9 +112,16 @@ export function buildAutoCandidateFilter(
|
||||
checks.push((c) => {
|
||||
try {
|
||||
const caps = getResolvedModelCapabilities({ provider: c.provider, model: c.model });
|
||||
return caps.supportsVision === true || isVisionModelId(c.model);
|
||||
const capable =
|
||||
caps.supportsVision === true || isVisionModelId(c.model);
|
||||
if (!capable) return false;
|
||||
// #vison-pool: registry entries whose catalog OVERSTATES vision support
|
||||
// (opencode-go/opencode-zen/tokenrouter — the backend models are text-only)
|
||||
// are forced through the vision bridge by isVisionBridgeForcedModel.
|
||||
// They must never be selected as the vision-capable candidate itself.
|
||||
return !isVisionBridgeForcedModel(`${c.provider}/${c.model}`);
|
||||
} catch {
|
||||
return isVisionModelId(c.model);
|
||||
return isVisionModelId(c.model) && !isVisionBridgeForcedModel(`${c.provider}/${c.model}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -517,6 +517,7 @@ export async function httpBackedChat(
|
||||
headers,
|
||||
body,
|
||||
signal: signal ?? undefined,
|
||||
sessionScope: req.poolKey,
|
||||
});
|
||||
|
||||
const fetchMs = Date.now() - fetchStart;
|
||||
|
||||
@@ -68,6 +68,7 @@ import { getXaiUsage } from "./usage/xai.ts";
|
||||
import { getXaiOauthUsage } from "./usage/xaiOauth.ts";
|
||||
import { getGrokCliUsage } from "./usage/grokCli.ts";
|
||||
import { getFirecrawlUsage } from "./usage/firecrawl.ts";
|
||||
import { getCommandCodeUsage } from "./usage/command-code.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type UsageProviderConnection = JsonRecord & {
|
||||
@@ -130,6 +131,8 @@ export const USAGE_FETCHER_PROVIDERS = [
|
||||
"ha",
|
||||
// Firecrawl team credits (GET /v2/team/credit-usage)
|
||||
"firecrawl",
|
||||
// Command Code credits + 5h/weekly windows (GET /alpha/billing/credits)
|
||||
"command-code",
|
||||
] as const;
|
||||
|
||||
export type UsageFetcherProvider = (typeof USAGE_FETCHER_PROVIDERS)[number];
|
||||
@@ -229,6 +232,8 @@ export async function getUsageForProvider(
|
||||
return await getHyperAgentUsage(apiKey || accessToken, providerSpecificData);
|
||||
case "firecrawl":
|
||||
return await getFirecrawlUsage(id || "", apiKey, connection);
|
||||
case "command-code":
|
||||
return await getCommandCodeUsage(apiKey || accessToken || "");
|
||||
default:
|
||||
return { message: `Usage API not implemented for ${provider}` };
|
||||
}
|
||||
@@ -259,6 +264,7 @@ export const __testing = {
|
||||
getXaiUsage,
|
||||
getXaiOauthUsage,
|
||||
getFirecrawlUsage,
|
||||
getCommandCodeUsage,
|
||||
getVertexUsage,
|
||||
getMiniMaxAuthErrorMessage,
|
||||
getMiniMaxErrorSummary,
|
||||
|
||||
233
open-sse/services/usage/command-code.ts
Normal file
233
open-sse/services/usage/command-code.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* usage/command-code.ts — Command Code (commandcode.ai) usage fetcher.
|
||||
*
|
||||
* Bearer `/alpha` endpoints (same surface the CLI `/usage` view uses):
|
||||
* GET /alpha/whoami
|
||||
* GET /alpha/billing/credits → remaining pools + windowLimits
|
||||
* GET /alpha/billing/subscriptions → planId + billing period (soft)
|
||||
* GET /alpha/usage/summary → period spend (soft)
|
||||
*
|
||||
* Surfaces five_hour / weekly rolling USD windows plus a credits pool quota
|
||||
* for Provider Limits and genericQuotaFetcher preflight.
|
||||
*/
|
||||
|
||||
import { sanitizeErrorMessage } from "../../utils/error.ts";
|
||||
import { toNumber, toRecord } from "./scalars.ts";
|
||||
import { createQuotaFromUsage, parseResetTime, type UsageQuota } from "./quota.ts";
|
||||
|
||||
const COMMAND_CODE_API_BASE =
|
||||
process.env.COMMANDCODE_API_URL?.trim() || "https://api.commandcode.ai";
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
"individual-goat": "Command Code · GOAT",
|
||||
"individual-go": "Command Code · Go",
|
||||
"individual-pro": "Command Code · Pro",
|
||||
"individual-max-10x": "Command Code · Max 10×",
|
||||
"individual-max-20x": "Command Code · Max 20×",
|
||||
"team-pro": "Command Code · Team Pro",
|
||||
};
|
||||
|
||||
function withCurrency(quota: UsageQuota, displayName: string): UsageQuota {
|
||||
return {
|
||||
...quota,
|
||||
currency: "USD",
|
||||
displayName,
|
||||
};
|
||||
}
|
||||
|
||||
function humanizePlanId(planId: string | undefined): string {
|
||||
if (!planId) return "Command Code";
|
||||
const mapped = PLAN_LABELS[planId];
|
||||
if (mapped) return mapped;
|
||||
const title = planId
|
||||
.replace(/^individual-/, "")
|
||||
.replace(/^team-/, "Team ")
|
||||
.split("-")
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
return `Command Code · ${title || planId}`;
|
||||
}
|
||||
|
||||
function orgQuery(orgId: string | null | undefined): string {
|
||||
if (!orgId) return "";
|
||||
return `?orgId=${encodeURIComponent(orgId)}`;
|
||||
}
|
||||
|
||||
async function fetchJson(
|
||||
path: string,
|
||||
apiKey: string
|
||||
): Promise<{ ok: boolean; status: number; body: JsonRecord | null }> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(`${COMMAND_CODE_API_BASE}${path}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
let body: JsonRecord | null = null;
|
||||
if (text) {
|
||||
try {
|
||||
body = toRecord(JSON.parse(text));
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
}
|
||||
return { ok: response.ok, status: response.status, body };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function creditRemaining(credits: JsonRecord): number {
|
||||
return (
|
||||
Math.max(0, toNumber(credits.monthlyCredits, 0)) +
|
||||
Math.max(0, toNumber(credits.purchasedCredits, 0)) +
|
||||
Math.max(0, toNumber(credits.freeCredits, 0))
|
||||
);
|
||||
}
|
||||
|
||||
function windowQuota(window: unknown, displayName: string): UsageQuota | null {
|
||||
const w = toRecord(window);
|
||||
const cap = toNumber(w.cap, 0);
|
||||
if (!(cap > 0)) return null;
|
||||
const used = toNumber(w.used, 0);
|
||||
return withCurrency(createQuotaFromUsage(used, cap, w.resetAt), displayName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Command Code Usage — monthly credit pool + 5h/weekly rolling windows.
|
||||
*/
|
||||
export async function getCommandCodeUsage(apiKey: string) {
|
||||
if (!apiKey) {
|
||||
return { message: "Command Code API key not available. Add a key to view usage." };
|
||||
}
|
||||
|
||||
try {
|
||||
let orgId: string | null = null;
|
||||
try {
|
||||
const whoami = await fetchJson("/alpha/whoami", apiKey);
|
||||
if (whoami.status === 401 || whoami.status === 403) {
|
||||
return {
|
||||
message:
|
||||
"Command Code connected. The API key was rejected — reconnect or rotate the key.",
|
||||
};
|
||||
}
|
||||
if (whoami.ok && whoami.body) {
|
||||
const org = toRecord(whoami.body.org);
|
||||
const id = typeof org.id === "string" && org.id.trim() ? org.id.trim() : null;
|
||||
orgId = id;
|
||||
}
|
||||
} catch {
|
||||
// whoami is optional — continue without orgId
|
||||
}
|
||||
|
||||
const q = orgQuery(orgId);
|
||||
const creditsRes = await fetchJson(`/alpha/billing/credits${q}`, apiKey);
|
||||
|
||||
if (creditsRes.status === 401 || creditsRes.status === 403) {
|
||||
return {
|
||||
message: "Command Code connected. The API key was rejected — reconnect or rotate the key.",
|
||||
};
|
||||
}
|
||||
if (!creditsRes.ok || !creditsRes.body) {
|
||||
return {
|
||||
message: `Command Code connected. /alpha/billing/credits returned HTTP ${creditsRes.status}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const creditsObj = toRecord(creditsRes.body.credits);
|
||||
const windowLimits = toRecord(creditsRes.body.windowLimits);
|
||||
const remaining = creditRemaining(creditsObj);
|
||||
|
||||
let planId: string | undefined;
|
||||
let periodStart: string | undefined;
|
||||
let periodEnd: string | null = null;
|
||||
|
||||
try {
|
||||
const subRes = await fetchJson(`/alpha/billing/subscriptions${q}`, apiKey);
|
||||
if (subRes.ok && subRes.body) {
|
||||
const data = toRecord(subRes.body.data);
|
||||
if (typeof data.planId === "string" && data.planId.trim()) {
|
||||
planId = data.planId.trim();
|
||||
}
|
||||
if (typeof data.currentPeriodStart === "string") {
|
||||
periodStart = data.currentPeriodStart;
|
||||
}
|
||||
periodEnd = parseResetTime(data.currentPeriodEnd);
|
||||
}
|
||||
} catch {
|
||||
// subscription enrichment is soft-fail
|
||||
}
|
||||
|
||||
let periodUsed = 0;
|
||||
try {
|
||||
const sinceQ =
|
||||
periodStart != null ? `${q ? `${q}&` : "?"}since=${encodeURIComponent(periodStart)}` : q;
|
||||
const summaryRes = await fetchJson(`/alpha/usage/summary${sinceQ}`, apiKey);
|
||||
if (summaryRes.ok && summaryRes.body) {
|
||||
const cost = toNumber(summaryRes.body.totalCost, Number.NaN);
|
||||
if (Number.isFinite(cost) && cost >= 0) {
|
||||
periodUsed = cost;
|
||||
} else {
|
||||
const monthly = toNumber(summaryRes.body.totalMonthlyCredits, Number.NaN);
|
||||
if (Number.isFinite(monthly) && monthly >= 0) periodUsed = monthly;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// summary enrichment is soft-fail
|
||||
}
|
||||
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
|
||||
const fiveHour = windowQuota(windowLimits.fiveHour, "5-hour window");
|
||||
if (fiveHour) quotas.five_hour = fiveHour;
|
||||
|
||||
const weekly = windowQuota(windowLimits.weekly, "Weekly window");
|
||||
if (weekly) quotas.weekly = weekly;
|
||||
|
||||
const creditsTotal = periodUsed + remaining;
|
||||
const creditsRemainingPct =
|
||||
creditsTotal > 0
|
||||
? Math.round((remaining / creditsTotal) * 1000) / 10
|
||||
: remaining > 0
|
||||
? 100
|
||||
: 0;
|
||||
quotas.credits = {
|
||||
used: Math.max(0, periodUsed),
|
||||
total: Math.max(0, creditsTotal),
|
||||
remaining,
|
||||
remainingPercentage: creditsRemainingPct,
|
||||
resetAt: periodEnd,
|
||||
unlimited: false,
|
||||
currency: "USD",
|
||||
displayName: "Credits",
|
||||
grantedBalance: Math.max(0, toNumber(creditsObj.monthlyCredits, 0)),
|
||||
toppedUpBalance:
|
||||
Math.max(0, toNumber(creditsObj.purchasedCredits, 0)) +
|
||||
Math.max(0, toNumber(creditsObj.freeCredits, 0)),
|
||||
};
|
||||
|
||||
return {
|
||||
plan: humanizePlanId(planId),
|
||||
quotas,
|
||||
windowExceeded: typeof windowLimits.exceeded === "string" ? windowLimits.exceeded : null,
|
||||
limited: windowLimits.limited === true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
message: `Command Code usage error: ${sanitizeErrorMessage(
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -464,12 +464,17 @@ function openaiToGeminiBase(
|
||||
|
||||
// Gemini expects the signature on the functionCall part itself.
|
||||
// If we are in a mode where missing signatures cause 400s (and we couldn't find one),
|
||||
// safely default to the bypass string to protect against 400s.
|
||||
// safely default to the bypass string to protect against 400s. The bypass sentinel is
|
||||
// an audit-trail risk (a magic validator-bypass string upstream could log/flag), so
|
||||
// operators can disable it via ANTIGRAVITY_ALLOW_SIGNATURE_BYPASS=0 — real signatures
|
||||
// are always preferred; the sentinel only fills the gap when none is available.
|
||||
const signatureBypassEnabled =
|
||||
toolNameOptions.supportsSignatureBypass &&
|
||||
signaturelessToolCallMode !== "text" &&
|
||||
process.env.ANTIGRAVITY_ALLOW_SIGNATURE_BYPASS !== "0";
|
||||
const finalSignature =
|
||||
embeddedThoughtSignature ||
|
||||
(toolNameOptions.supportsSignatureBypass && signaturelessToolCallMode !== "text"
|
||||
? "skip_thought_signature_validator"
|
||||
: undefined);
|
||||
(signatureBypassEnabled ? "skip_thought_signature_validator" : undefined);
|
||||
parts.push({
|
||||
...(finalSignature ? { thoughtSignature: finalSignature } : {}),
|
||||
functionCall: {
|
||||
|
||||
@@ -332,6 +332,8 @@ function splitCursorEffortSuffix(
|
||||
/**
|
||||
* cursor-agent rewrites model ids before putting them on the wire:
|
||||
* "auto" → RequestedModel { model_id: "default" }
|
||||
* "auto-cost" → RequestedModel { model_id: "default",
|
||||
* parameters: [{id: "optimization", value: "cost"}] }
|
||||
* "composer-2-fast" → RequestedModel { model_id: "composer-2",
|
||||
* parameters: [{id: "fast", value: "true"}] }
|
||||
* "claude-opus-4-8-high" → RequestedModel { model_id: "claude-opus-4-8",
|
||||
@@ -342,7 +344,31 @@ function splitCursorEffortSuffix(
|
||||
* Other ids are passed through verbatim after spelling-variant normalization
|
||||
* (see normalizeCursorModelId).
|
||||
*/
|
||||
export function resolveRequestedModel(modelId: string): {
|
||||
/** Cursor Router optimization levels (OpenCodex `CURSOR_ROUTING_LEVELS`). */
|
||||
export const CURSOR_ROUTING_LEVELS = ["cost", "balance", "intelligence"] as const;
|
||||
export type CursorRoutingLevel = (typeof CURSOR_ROUTING_LEVELS)[number];
|
||||
|
||||
/**
|
||||
* ModelParameter id for Cursor's Cost/Balance/Intelligence control on wire model
|
||||
* `default` (OpenCodex `CURSOR_ROUTING_LEVEL_PARAMETER_ID`).
|
||||
*/
|
||||
export const CURSOR_ROUTING_LEVEL_PARAMETER_ID = "optimization";
|
||||
|
||||
export type ResolveRequestedModelOptions = {
|
||||
/**
|
||||
* When set and containing the normalized client model id, send that id
|
||||
* verbatim on AgentRun (skip composer-fast / Claude / GPT splits).
|
||||
* Live AvailableModels returns flattened effort-suffixed ids; stripping them
|
||||
* to a missing base causes Cursor `AI Model Not Found`. Auto / auto-* still
|
||||
* map to wire `default` (+ optimization) even when present in this set.
|
||||
*/
|
||||
liveCatalogIds?: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
export function resolveRequestedModel(
|
||||
modelId: string,
|
||||
opts?: ResolveRequestedModelOptions
|
||||
): {
|
||||
modelId: string;
|
||||
parameters: Array<{ id: string; value: string }>;
|
||||
} {
|
||||
@@ -350,6 +376,20 @@ export function resolveRequestedModel(modelId: string): {
|
||||
if (normalized === "auto") {
|
||||
return { modelId: "default", parameters: [] };
|
||||
}
|
||||
// OpenCodex-style router variants: auto-cost / auto-balance / auto-intelligence
|
||||
// → wire `default` + ModelParameter { id: "optimization", value: <level> }.
|
||||
for (const level of CURSOR_ROUTING_LEVELS) {
|
||||
if (normalized === `auto-${level}`) {
|
||||
return {
|
||||
modelId: "default",
|
||||
parameters: [{ id: CURSOR_ROUTING_LEVEL_PARAMETER_ID, value: level }],
|
||||
};
|
||||
}
|
||||
}
|
||||
// Live catalog is authoritative for exact ids (flattened effort variants).
|
||||
if (opts?.liveCatalogIds?.has(normalized)) {
|
||||
return { modelId: normalized, parameters: [] };
|
||||
}
|
||||
// Strip the "-fast" suffix and surface it as a parameter — only the composer
|
||||
// family observably needs this split today, but the protocol field is generic.
|
||||
if (normalized.startsWith("composer-") && normalized.endsWith("-fast")) {
|
||||
@@ -406,6 +446,8 @@ export type AgentRunInput = {
|
||||
// encodeSelectedImageBody). Empty / undefined keeps the request
|
||||
// byte-identical to the text-only path.
|
||||
images?: EncodedImage[];
|
||||
/** Exact live AvailableModels ids — see resolveRequestedModel liveCatalogIds. */
|
||||
liveCatalogIds?: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
export { cursorImageAttachmentPath, encodeSelectedImageBody };
|
||||
@@ -433,7 +475,9 @@ export function openAIToolsToMcpDefs(tools: OpenAITool[]): McpToolDefinition[] {
|
||||
export function encodeAgentRunRequest(input: AgentRunInput): Buffer {
|
||||
const conversationId = input.conversationId || crypto.randomUUID();
|
||||
const messageId = input.messageId || crypto.randomUUID();
|
||||
const { modelId, parameters } = resolveRequestedModel(input.modelId);
|
||||
const { modelId, parameters } = resolveRequestedModel(input.modelId, {
|
||||
liveCatalogIds: input.liveCatalogIds,
|
||||
});
|
||||
|
||||
// UserMessage { text, message_id, selected_context, mode=1 }.
|
||||
// selected_context is normally an empty placeholder (required by the server
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
proxyConfigToUrl,
|
||||
proxyUrlForLogs,
|
||||
} from "./proxyDispatcher.ts";
|
||||
import tlsClient from "./tlsClient.ts";
|
||||
import tlsClient, { type TlsFetchOptions } from "./tlsClient.ts";
|
||||
import { isProxyReachable } from "@/lib/proxyHealth";
|
||||
import {
|
||||
isControlPlaneProxyDirectFallbackEnabled,
|
||||
@@ -79,6 +79,32 @@ function isTlsFingerprintEnabled() {
|
||||
return process.env.ENABLE_TLS_FINGERPRINT === "true";
|
||||
}
|
||||
|
||||
function tlsFingerprintProviderAllowed(
|
||||
provider: string | null | undefined,
|
||||
proxied: boolean
|
||||
): boolean {
|
||||
const configured = process.env.TLS_FINGERPRINT_PROVIDERS?.trim();
|
||||
// Preserve the legacy direct-only opt-in. The new proxied transport requires
|
||||
// an explicit allowlist so enabling TLS cannot silently change proxy traffic.
|
||||
if (!configured) return !proxied;
|
||||
if (!provider) return false;
|
||||
const normalizedProvider = provider.trim().toLowerCase();
|
||||
return configured
|
||||
.split(",")
|
||||
.some((candidate) => candidate.trim().toLowerCase() === normalizedProvider);
|
||||
}
|
||||
|
||||
type TlsClientLike = {
|
||||
available: boolean;
|
||||
fetch: (url: string, options?: TlsFetchOptions) => Promise<Response>;
|
||||
};
|
||||
let activeTlsClient: TlsClientLike = tlsClient;
|
||||
|
||||
/** Test seam for exercising wreq selection without replacing the module loader. */
|
||||
export function setTlsClientForTest(client: TlsClientLike | null): void {
|
||||
activeTlsClient = client ?? tlsClient;
|
||||
}
|
||||
|
||||
// #8376: transport-level connect-failure codes that mean "the configured upstream
|
||||
// proxy (or the target itself, for direct egress) is unreachable" — as opposed to an
|
||||
// ordinary upstream HTTP error. Read `.code` first (stable across undici/node
|
||||
@@ -122,9 +148,12 @@ function tagProxyUnreachable<T>(err: T): T {
|
||||
return err;
|
||||
}
|
||||
|
||||
/** Per-request tracking of whether TLS fingerprint was used */
|
||||
type TlsFingerprintStore = { used: boolean };
|
||||
const tlsFingerprintContext = new AsyncLocalStorage<TlsFingerprintStore>();
|
||||
/** Per-request TLS identity and success telemetry. */
|
||||
type TlsFingerprintStore = {
|
||||
used: boolean;
|
||||
provider?: string | null;
|
||||
sessionScope?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* #5217 (Gap-secondary): a mutable sink that records the proxy actually applied
|
||||
@@ -227,20 +256,112 @@ function requestHasNonReplayableBody(
|
||||
return false;
|
||||
}
|
||||
|
||||
const TLS_ALLOWED_OPTION_KEYS: Record<string, true> = {
|
||||
body: true,
|
||||
headers: true,
|
||||
method: true,
|
||||
redirect: true,
|
||||
signal: true,
|
||||
};
|
||||
|
||||
function isWreqBodySupported(body: unknown): boolean {
|
||||
if (body == null || typeof body === "string") return true;
|
||||
if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) return true;
|
||||
if (body instanceof URLSearchParams) return true;
|
||||
if (typeof Blob !== "undefined" && body instanceof Blob) return true;
|
||||
if (typeof FormData !== "undefined" && body instanceof FormData) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTlsRequestEligible(
|
||||
input: RequestInfo | URL,
|
||||
options: FetchWithDispatcherOptions
|
||||
): boolean {
|
||||
if (typeof Request !== "undefined" && input instanceof Request) return false;
|
||||
if (!isWreqBodySupported(options.body)) return false;
|
||||
return Object.keys(options).every((key) => TLS_ALLOWED_OPTION_KEYS[key] === true);
|
||||
}
|
||||
|
||||
function isTlsFallbackReplaySafe(
|
||||
input: RequestInfo | URL,
|
||||
options: FetchWithDispatcherOptions
|
||||
): boolean {
|
||||
const method = (
|
||||
options.method ??
|
||||
(typeof Request !== "undefined" && input instanceof Request ? input.method : "GET")
|
||||
).toUpperCase();
|
||||
return (
|
||||
(method === "GET" || method === "HEAD" || method === "OPTIONS") &&
|
||||
!requestHasNonReplayableBody(input, options)
|
||||
);
|
||||
}
|
||||
|
||||
function getEffectiveSignal(
|
||||
input: RequestInfo | URL,
|
||||
options: FetchWithDispatcherOptions
|
||||
): AbortSignal | null | undefined {
|
||||
return (
|
||||
options.signal ??
|
||||
(typeof Request !== "undefined" && input instanceof Request ? input.signal : undefined)
|
||||
);
|
||||
}
|
||||
|
||||
function isWreqProxySupported(proxyUrl: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(proxyUrl);
|
||||
return (
|
||||
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
|
||||
parsed.searchParams.get("family") === null
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeTransportError(
|
||||
error: unknown,
|
||||
message: string,
|
||||
fallbackCode: string
|
||||
): Error & { code: string; errorCode?: string; statusCode?: number } {
|
||||
const source = error && typeof error === "object" ? (error as Record<string, unknown>) : {};
|
||||
const sanitized = new Error(message) as Error & {
|
||||
code: string;
|
||||
errorCode?: string;
|
||||
statusCode?: number;
|
||||
};
|
||||
sanitized.code =
|
||||
typeof source.code === "string" && /^[A-Z0-9_:-]{1,64}$/.test(source.code)
|
||||
? source.code
|
||||
: fallbackCode;
|
||||
if (
|
||||
typeof source.errorCode === "string" &&
|
||||
/^[a-zA-Z0-9_:-]{1,64}$/.test(source.errorCode)
|
||||
) {
|
||||
sanitized.errorCode = source.errorCode;
|
||||
}
|
||||
if (typeof source.statusCode === "number" && Number.isFinite(source.statusCode)) {
|
||||
sanitized.statusCode = source.statusCode;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/** Injectable dependencies for testability (Approach B DI). */
|
||||
export type ProxyFetchDeps = {
|
||||
undiciFetch?: FetchWithDispatcher;
|
||||
nativeFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
findWorkingProxy?: (hostname: string, targetUrl: string) => Promise<string | null>;
|
||||
};
|
||||
|
||||
type PatchState = {
|
||||
originalFetch: typeof globalThis.fetch;
|
||||
proxyContext: AsyncLocalStorage<unknown>;
|
||||
tlsFingerprintContext?: AsyncLocalStorage<TlsFingerprintStore>;
|
||||
isPatched: boolean;
|
||||
};
|
||||
|
||||
const isCloud = typeof caches !== "undefined" && typeof caches === "object";
|
||||
const PATCH_STATE_KEY = Symbol.for("omniroute.proxyFetch.state");
|
||||
const DIRECT_PROXY_CONTEXT = Symbol.for("omniroute.proxyFetch.direct-context");
|
||||
|
||||
function getPatchState(): PatchState {
|
||||
const scopedGlobal = globalThis as typeof globalThis & {
|
||||
@@ -251,6 +372,7 @@ function getPatchState(): PatchState {
|
||||
scopedGlobal[PATCH_STATE_KEY] = {
|
||||
originalFetch: globalThis.fetch,
|
||||
proxyContext: new AsyncLocalStorage(),
|
||||
tlsFingerprintContext: new AsyncLocalStorage(),
|
||||
isPatched: false,
|
||||
};
|
||||
}
|
||||
@@ -258,9 +380,11 @@ function getPatchState(): PatchState {
|
||||
}
|
||||
|
||||
const patchState = getPatchState();
|
||||
patchState.tlsFingerprintContext ??= new AsyncLocalStorage<TlsFingerprintStore>();
|
||||
const originalFetch = patchState.originalFetch;
|
||||
const originalFetchWithDispatcher = originalFetch as FetchWithDispatcher;
|
||||
const proxyContext = patchState.proxyContext;
|
||||
const tlsFingerprintContext = patchState.tlsFingerprintContext;
|
||||
|
||||
function noProxyMatch(targetUrl) {
|
||||
const noProxy = process.env.NO_PROXY || process.env.no_proxy;
|
||||
@@ -381,6 +505,9 @@ export function resolveProxyForRequest(targetUrl) {
|
||||
}
|
||||
|
||||
const contextProxy = proxyContext.getStore();
|
||||
if (contextProxy === DIRECT_PROXY_CONTEXT) {
|
||||
return { source: "direct", proxyUrl: null };
|
||||
}
|
||||
if (contextProxy) {
|
||||
// #9551: NO_PROXY must bypass context-proxy too
|
||||
if (target && noProxyMatch(targetUrl)) {
|
||||
@@ -398,16 +525,15 @@ export function resolveProxyForRequest(targetUrl) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A caller-initiated abort/timeout is not a proxy transport failure — it must
|
||||
* not be misreported as one. Prefer `signal.aborted` because
|
||||
* `AbortController.abort(reason)` may surface a custom Error rather than a
|
||||
* standard AbortError/TimeoutError name.
|
||||
* Ported from decolua/9router#2589 (`isCallerAbort`).
|
||||
* A caller-initiated abort is identified only by the caller's effective signal.
|
||||
* Dependency-internal TimeoutError/AbortError values are transport failures and
|
||||
* retain the normal safe-method fallback behavior.
|
||||
*/
|
||||
function isCallerAbort(error: unknown, signal: AbortSignal | null | undefined): boolean {
|
||||
if (signal?.aborted === true) return true;
|
||||
const name = (error as { name?: unknown } | null)?.name;
|
||||
return name === "AbortError" || name === "TimeoutError";
|
||||
function isCallerAbort(
|
||||
_error: unknown,
|
||||
signal: AbortSignal | null | undefined
|
||||
): boolean {
|
||||
return signal?.aborted === true;
|
||||
}
|
||||
|
||||
function getTargetUrl(input) {
|
||||
@@ -425,9 +551,13 @@ export async function runWithProxyContext(
|
||||
throw new TypeError("runWithProxyContext requires a callback function");
|
||||
}
|
||||
|
||||
// Inherit existing context if no specific proxyConfig is provided
|
||||
// Inherit existing context if no specific proxyConfig is provided. A direct
|
||||
// sentinel must remain direct without being mistaken for a proxy config.
|
||||
const currentContext = proxyContext.getStore();
|
||||
const effectiveProxyConfig = proxyConfig || currentContext || null;
|
||||
const inheritsDirect = currentContext === DIRECT_PROXY_CONTEXT && !proxyConfig;
|
||||
const effectiveProxyConfig =
|
||||
proxyConfig || (inheritsDirect ? null : currentContext) || null;
|
||||
const contextValue = inheritsDirect ? DIRECT_PROXY_CONTEXT : effectiveProxyConfig;
|
||||
|
||||
const resolvedProxyUrl = effectiveProxyConfig ? proxyConfigToUrl(effectiveProxyConfig) : null;
|
||||
|
||||
@@ -435,8 +565,9 @@ export async function runWithProxyContext(
|
||||
// This fallback changes egress IP, so upgrades must not silently turn it on.
|
||||
const directFallbackOnUnreachable =
|
||||
opts?.directFallbackOnUnreachable === true && isControlPlaneProxyDirectFallbackEnabled();
|
||||
// Run fn with the proxy context cleared so the request egresses directly.
|
||||
const runDirect = () => proxyContext.run(null, fn);
|
||||
// Keep an explicit direct sentinel so resolveProxyForRequest cannot re-read
|
||||
// HTTPS_PROXY/HTTP_PROXY after the control-plane route decision.
|
||||
const runDirect = () => proxyContext.run(DIRECT_PROXY_CONTEXT, fn);
|
||||
|
||||
// T14: Proxy Fast-Fail (non-blocking, #9100)
|
||||
// Perform a short TCP reachability check BEFORE issuing upstream requests.
|
||||
@@ -502,7 +633,7 @@ export async function runWithProxyContext(
|
||||
}
|
||||
}
|
||||
|
||||
return proxyContext.run(effectiveProxyConfig, async () => {
|
||||
return proxyContext.run(contextValue, async () => {
|
||||
if (resolvedProxyUrl && effectiveProxyConfig !== currentContext) {
|
||||
// #9158: this fires on EVERY proxied request (innermost context wins).
|
||||
// Gate it behind the same env flag as the relay routing log so request
|
||||
@@ -604,23 +735,47 @@ async function patchedFetch(
|
||||
const { source, proxyUrl } = resolved;
|
||||
|
||||
if (!proxyUrl) {
|
||||
// TLS fingerprint spoofing for direct connections (no proxy configured)
|
||||
if (isTlsFingerprintEnabled() && tlsClient.available) {
|
||||
// TLS fingerprint spoofing for an already-resolved direct route. Explicit
|
||||
// proxy:null prevents wreq from re-reading a global environment proxy.
|
||||
const tlsStore = tlsFingerprintContext.getStore();
|
||||
let tlsDirectFallback = false;
|
||||
if (
|
||||
isTlsFingerprintEnabled() &&
|
||||
activeTlsClient.available &&
|
||||
tlsFingerprintProviderAllowed(tlsStore?.provider, false) &&
|
||||
isTlsRequestEligible(input, options)
|
||||
) {
|
||||
try {
|
||||
const store = tlsFingerprintContext.getStore();
|
||||
if (store) store.used = true;
|
||||
return await tlsClient.fetch(targetUrl, {
|
||||
...options,
|
||||
const response = await activeTlsClient.fetch(targetUrl, {
|
||||
method: options.method,
|
||||
headers: options.headers,
|
||||
signal: options.signal ?? undefined,
|
||||
body: options.body as TlsFetchOptions["body"],
|
||||
redirect: options.redirect,
|
||||
signal: getEffectiveSignal(input, options),
|
||||
proxy: null,
|
||||
sessionScope: tlsStore?.sessionScope,
|
||||
});
|
||||
if (tlsStore) tlsStore.used = true;
|
||||
return response;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(
|
||||
`[ProxyFetch] TLS fingerprint failed, falling back to native fetch: ${message}`
|
||||
);
|
||||
const store = tlsFingerprintContext.getStore();
|
||||
if (store) store.used = false;
|
||||
if (isCallerAbort(error, getEffectiveSignal(input, options))) throw error;
|
||||
const sessionHadCookies =
|
||||
!!error &&
|
||||
typeof error === "object" &&
|
||||
"sessionHadCookies" in error &&
|
||||
error.sessionHadCookies === true;
|
||||
if (!isTlsFallbackReplaySafe(input, options) || sessionHadCookies) {
|
||||
throw sanitizeTransportError(
|
||||
error,
|
||||
sessionHadCookies
|
||||
? "TLS fingerprint request failed; stateful session cannot be replayed"
|
||||
: "TLS fingerprint request failed; request is not safe to replay",
|
||||
"TLS_FINGERPRINT_FAILED"
|
||||
);
|
||||
}
|
||||
console.warn("[ProxyFetch] TLS fingerprint transport failed; using direct dispatcher");
|
||||
if (tlsStore) tlsStore.used = false;
|
||||
tlsDirectFallback = true;
|
||||
}
|
||||
}
|
||||
// Direct connection (no proxy) — use undici with custom dispatcher for timeout control.
|
||||
@@ -695,7 +850,11 @@ async function patchedFetch(
|
||||
}
|
||||
|
||||
// All attempts exhausted — try proxy fallback before native fetch
|
||||
if (source === "direct" && isFeatureFlagEnabled("PROXY_AUTO_SELECT_ENABLED")) {
|
||||
if (
|
||||
!tlsDirectFallback &&
|
||||
source === "direct" &&
|
||||
isFeatureFlagEnabled("PROXY_AUTO_SELECT_ENABLED")
|
||||
) {
|
||||
let targetHostname = "";
|
||||
try {
|
||||
targetHostname = new URL(targetUrl).hostname;
|
||||
@@ -703,7 +862,8 @@ async function patchedFetch(
|
||||
// ignore
|
||||
}
|
||||
if (targetHostname) {
|
||||
const { findWorkingProxy } = await import("./proxyFallback.ts");
|
||||
const findWorkingProxy =
|
||||
deps.findWorkingProxy ?? (await import("./proxyFallback.ts")).findWorkingProxy;
|
||||
const fallbackProxyUrl = await findWorkingProxy(targetHostname, targetUrl);
|
||||
if (fallbackProxyUrl) {
|
||||
try {
|
||||
@@ -854,6 +1014,51 @@ async function patchedFetch(
|
||||
throw lastRelayError;
|
||||
}
|
||||
|
||||
// The proxied TLS overlay is deliberately narrow: approved provider, exact
|
||||
// http(s) proxy, no relay/family pinning, and only options wreq can preserve.
|
||||
const tlsStore = tlsFingerprintContext.getStore();
|
||||
if (
|
||||
isTlsFingerprintEnabled() &&
|
||||
typeof tlsStore?.sessionScope === "string" &&
|
||||
tlsStore.sessionScope.trim().length > 0 &&
|
||||
activeTlsClient.available &&
|
||||
tlsFingerprintProviderAllowed(tlsStore?.provider, true) &&
|
||||
isTlsRequestEligible(input, options) &&
|
||||
isWreqProxySupported(proxyUrl)
|
||||
) {
|
||||
try {
|
||||
const response = await activeTlsClient.fetch(targetUrl, {
|
||||
method: options.method,
|
||||
headers: options.headers,
|
||||
body: options.body as TlsFetchOptions["body"],
|
||||
redirect: options.redirect,
|
||||
signal: getEffectiveSignal(input, options),
|
||||
proxy: proxyUrl,
|
||||
sessionScope: tlsStore?.sessionScope,
|
||||
});
|
||||
if (tlsStore) tlsStore.used = true;
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (isCallerAbort(error, getEffectiveSignal(input, options))) throw error;
|
||||
const sessionHadCookies =
|
||||
!!error &&
|
||||
typeof error === "object" &&
|
||||
"sessionHadCookies" in error &&
|
||||
error.sessionHadCookies === true;
|
||||
if (!isTlsFallbackReplaySafe(input, options) || sessionHadCookies) {
|
||||
throw sanitizeTransportError(
|
||||
error,
|
||||
sessionHadCookies
|
||||
? "TLS fingerprint request failed; stateful session cannot be replayed"
|
||||
: "TLS fingerprint request failed; request is not safe to replay",
|
||||
"TLS_FINGERPRINT_FAILED"
|
||||
);
|
||||
}
|
||||
console.warn("[ProxyFetch] TLS fingerprint transport failed; using proxy dispatcher");
|
||||
if (tlsStore) tlsStore.used = false;
|
||||
}
|
||||
}
|
||||
|
||||
// #9100: proxy path — attempt 0 uses the pooled keep-alive dispatcher
|
||||
// (pipelining 4, ONE reused TCP connection per proxy host). A transient
|
||||
// socket error on a stale pooled socket is retried ONCE on a fresh
|
||||
@@ -872,6 +1077,7 @@ async function patchedFetch(
|
||||
attempt === 0 ? createProxyDispatcher(proxyUrl) : getProxyRetryDispatcher(proxyUrl),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCallerAbort(error, getEffectiveSignal(input, options))) throw error;
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
const errCode = (error as { code?: unknown })?.code;
|
||||
const isTransportFailure =
|
||||
@@ -889,13 +1095,16 @@ async function patchedFetch(
|
||||
await new Promise((r) => setTimeout(r, RETRY_BACKOFF_MS));
|
||||
continue;
|
||||
}
|
||||
// A caller abort/timeout must propagate unchanged and without a noisy
|
||||
// "Proxy request failed" log — it's not a proxy transport failure.
|
||||
if (!isCallerAbort(error, options?.signal)) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[ProxyFetch] Proxy request failed (${source}, fail-closed): ${message}`);
|
||||
}
|
||||
throw error;
|
||||
tagProxyUnreachable(error);
|
||||
const sanitized = sanitizeTransportError(
|
||||
error,
|
||||
"Proxy request failed",
|
||||
"PROXY_REQUEST_FAILED"
|
||||
);
|
||||
console.error(
|
||||
`[ProxyFetch] Proxy request failed (${source}, fail-closed; code=${sanitized.code})`
|
||||
);
|
||||
throw sanitized;
|
||||
}
|
||||
}
|
||||
throw lastProxyError;
|
||||
@@ -919,19 +1128,64 @@ if (!isCloud && !patchState.isPatched) {
|
||||
patchState.isPatched = true;
|
||||
}
|
||||
|
||||
export type TlsTrackingIdentity = {
|
||||
provider?: string | null;
|
||||
sessionScope?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run a function with TLS fingerprint tracking context.
|
||||
* After fn completes, returns { result, tlsFingerprintUsed }.
|
||||
* Run a function with account-scoped TLS fingerprint tracking.
|
||||
* Both historical forms remain valid: runWithTlsTracking(fn) and
|
||||
* runWithTlsTracking(provider, fn).
|
||||
*/
|
||||
export async function runWithTlsTracking(fn) {
|
||||
const store = { used: false };
|
||||
const result = await tlsFingerprintContext.run(store, fn);
|
||||
export async function runWithTlsTracking<T>(
|
||||
fn: () => T
|
||||
): Promise<{ result: Awaited<T>; tlsFingerprintUsed: boolean }>;
|
||||
export async function runWithTlsTracking<T>(
|
||||
provider: string | null | undefined,
|
||||
fn: () => T
|
||||
): Promise<{ result: Awaited<T>; tlsFingerprintUsed: boolean }>;
|
||||
export async function runWithTlsTracking<T>(
|
||||
identity: TlsTrackingIdentity,
|
||||
fn: () => T
|
||||
): Promise<{ result: Awaited<T>; tlsFingerprintUsed: boolean }>;
|
||||
export async function runWithTlsTracking<T>(
|
||||
providerOrIdentityOrFn: string | null | undefined | TlsTrackingIdentity | (() => T),
|
||||
maybeFn?: () => T
|
||||
): Promise<{ result: Awaited<T>; tlsFingerprintUsed: boolean }> {
|
||||
const legacyFn =
|
||||
typeof providerOrIdentityOrFn === "function" ? providerOrIdentityOrFn : maybeFn;
|
||||
if (typeof legacyFn !== "function") {
|
||||
throw new TypeError("runWithTlsTracking requires a callback function");
|
||||
}
|
||||
const identity: TlsTrackingIdentity =
|
||||
providerOrIdentityOrFn &&
|
||||
typeof providerOrIdentityOrFn === "object" &&
|
||||
typeof providerOrIdentityOrFn !== "function"
|
||||
? providerOrIdentityOrFn
|
||||
: {
|
||||
provider:
|
||||
typeof providerOrIdentityOrFn === "string" ? providerOrIdentityOrFn : undefined,
|
||||
};
|
||||
const store: TlsFingerprintStore = {
|
||||
used: false,
|
||||
provider: identity.provider,
|
||||
sessionScope: identity.sessionScope,
|
||||
};
|
||||
const result = await tlsFingerprintContext.run(store, legacyFn);
|
||||
return { result, tlsFingerprintUsed: store.used };
|
||||
}
|
||||
|
||||
/** Check if TLS fingerprint is enabled and available */
|
||||
export function isTlsFingerprintActive() {
|
||||
return isTlsFingerprintEnabled() && tlsClient.available;
|
||||
/** Check whether TLS fingerprint transport is enabled for this route identity. */
|
||||
export function isTlsFingerprintActive(
|
||||
provider?: string | null,
|
||||
proxied = false
|
||||
): boolean {
|
||||
return (
|
||||
isTlsFingerprintEnabled() &&
|
||||
activeTlsClient.available &&
|
||||
tlsFingerprintProviderAllowed(provider, proxied)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,20 +1,40 @@
|
||||
import { createRequire } from "module";
|
||||
import { createHash } from "node:crypto";
|
||||
import { getTlsClientTimeoutConfig } from "@/shared/utils/runtimeTimeouts";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const runtimeRequire = createRequire(import.meta.url);
|
||||
|
||||
type WreqSession = {
|
||||
fetch: (url: string, options?: Record<string, unknown>) => Promise<Response>;
|
||||
close: () => Promise<void> | void;
|
||||
function loadRuntimeModule(moduleName: string): unknown {
|
||||
// Keep the specifier dynamic. Turbopack rewrites a literal createRequire call
|
||||
// to a hashed external name that is absent from the standalone Docker runtime.
|
||||
return Reflect.apply(runtimeRequire, undefined, [moduleName]);
|
||||
}
|
||||
|
||||
export type WreqResponse = {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Iterable<[string, string]>;
|
||||
body: ReadableStream<Uint8Array> | null;
|
||||
url?: string;
|
||||
redirected?: boolean;
|
||||
};
|
||||
|
||||
type CreateSessionFn = (options: Record<string, unknown>) => Promise<WreqSession>;
|
||||
export type WreqSession = {
|
||||
fetch: (url: string, options?: Record<string, unknown>) => Promise<WreqResponse>;
|
||||
close: () => Promise<void> | void;
|
||||
getCookies?: (url: string | URL) => Record<string, string>;
|
||||
};
|
||||
|
||||
export type CreateSessionFn = (options: Record<string, unknown>) => Promise<WreqSession>;
|
||||
|
||||
let createSession: CreateSessionFn | null;
|
||||
try {
|
||||
const loaded = require("wreq-js") as { createSession?: CreateSessionFn };
|
||||
const loaded = loadRuntimeModule("wreq-js") as { createSession?: CreateSessionFn };
|
||||
createSession = typeof loaded.createSession === "function" ? loaded.createSession : null;
|
||||
} catch {
|
||||
if (process.env.ENABLE_TLS_FINGERPRINT === "true") {
|
||||
console.warn("[TlsClient] wreq-js unavailable; TLS fingerprint transport disabled");
|
||||
}
|
||||
createSession = null;
|
||||
}
|
||||
|
||||
@@ -34,12 +54,26 @@ function getProxyFromEnv(): string | undefined {
|
||||
);
|
||||
}
|
||||
|
||||
interface FetchOptions {
|
||||
export type WreqBodyInit =
|
||||
| string
|
||||
| ArrayBuffer
|
||||
| ArrayBufferView
|
||||
| URLSearchParams
|
||||
| Buffer
|
||||
| Blob
|
||||
| FormData
|
||||
| null;
|
||||
|
||||
export interface TlsFetchOptions {
|
||||
method?: string;
|
||||
headers?: HeadersInit;
|
||||
body?: unknown;
|
||||
redirect?: string;
|
||||
signal?: AbortSignal;
|
||||
body?: WreqBodyInit;
|
||||
redirect?: RequestRedirect;
|
||||
signal?: AbortSignal | null;
|
||||
/** Exact resolved proxy. Undefined preserves legacy environment lookup; null means direct. */
|
||||
proxy?: string | null;
|
||||
/** Stable account/connection identity used to isolate cookies and circuit state. */
|
||||
sessionScope?: string;
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> | undefined {
|
||||
@@ -62,182 +96,591 @@ function normalizeHeaders(headers: HeadersInit | undefined): Record<string, stri
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* TLS Client — Chrome 124 TLS fingerprint spoofing via wreq-js
|
||||
* Singleton instance used to disguise Node.js TLS handshake as Chrome browser.
|
||||
*
|
||||
* wreq-js natively supports proxy — TLS fingerprinting works through proxy.
|
||||
* Proxy URL is read from environment variables (HTTPS_PROXY, HTTP_PROXY, ALL_PROXY).
|
||||
*/
|
||||
class TlsClient {
|
||||
session: WreqSession | null = null;
|
||||
type SafeWreqError = Error & {
|
||||
code?: string;
|
||||
errorCode?: string;
|
||||
statusCode?: number;
|
||||
};
|
||||
|
||||
private _libraryAvailable: boolean;
|
||||
private failureCount: number = 0;
|
||||
private maxFailures: number = 3;
|
||||
private baseCooldownMs: number = 30_000;
|
||||
private cooldownMs: number = 30_000;
|
||||
private cooldownMultiplier: number = 1;
|
||||
private readonly MAX_COOLDOWN_MS = 600_000; // 10 min
|
||||
private circuitOpenUntil: number = 0;
|
||||
private circuitTripped: boolean = false;
|
||||
|
||||
constructor() {
|
||||
this._libraryAvailable = !!createSession;
|
||||
function sanitizeWreqError(error: unknown, message: string): SafeWreqError {
|
||||
const sanitized = new Error(message) as SafeWreqError;
|
||||
if (!error || typeof error !== "object") return sanitized;
|
||||
if ("code" in error && typeof error.code === "string" && /^[A-Z0-9_:-]{1,64}$/.test(error.code)) {
|
||||
sanitized.code = error.code;
|
||||
}
|
||||
|
||||
get available(): boolean {
|
||||
if (!this._libraryAvailable) return false;
|
||||
if (!this.circuitTripped) return true;
|
||||
return Date.now() >= this.circuitOpenUntil;
|
||||
if (
|
||||
"errorCode" in error &&
|
||||
typeof error.errorCode === "string" &&
|
||||
/^[a-zA-Z0-9_:-]{1,64}$/.test(error.errorCode)
|
||||
) {
|
||||
sanitized.errorCode = error.errorCode;
|
||||
}
|
||||
if (
|
||||
"statusCode" in error &&
|
||||
typeof error.statusCode === "number" &&
|
||||
Number.isFinite(error.statusCode)
|
||||
) {
|
||||
sanitized.statusCode = error.statusCode;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private recordFailure(): void {
|
||||
this.failureCount++;
|
||||
if (this.failureCount >= this.maxFailures) {
|
||||
this.circuitOpenUntil = Date.now() + this.cooldownMs;
|
||||
this.circuitTripped = true;
|
||||
// Close the stale session so the next half-open retry creates a
|
||||
// fresh one instead of reusing a broken connection.
|
||||
if (this.session) {
|
||||
Promise.resolve(this.session.close()).catch(() => {});
|
||||
this.session = null;
|
||||
}
|
||||
console.warn(
|
||||
`[TlsClient] Circuit opened after ${this.failureCount} consecutive failures, cooling down for ${this.cooldownMs}ms`
|
||||
function toNativeResponse(
|
||||
response: WreqResponse,
|
||||
onFinalize: () => void,
|
||||
onBodyError: () => void,
|
||||
signal?: AbortSignal | null
|
||||
): Response {
|
||||
let finalized = false;
|
||||
let bodyFailureReported = false;
|
||||
let consumerCancelled = false;
|
||||
let consumerCancelReason: unknown;
|
||||
const finalize = () => {
|
||||
if (finalized) return;
|
||||
finalized = true;
|
||||
onFinalize();
|
||||
};
|
||||
const safeBodyError = (error: unknown): unknown => {
|
||||
if (signal?.aborted) {
|
||||
return signal.reason ?? new DOMException("The operation was aborted", "AbortError");
|
||||
}
|
||||
if (consumerCancelled) {
|
||||
return (
|
||||
consumerCancelReason ?? new DOMException("The response body was cancelled", "AbortError")
|
||||
);
|
||||
// Double cooldown for the next trip: 30s → 60s → 120s → ... → 10 min max
|
||||
this.escalateCooldown();
|
||||
}
|
||||
}
|
||||
|
||||
private recordSuccess(): void {
|
||||
this.failureCount = 0;
|
||||
if (this.circuitTripped) {
|
||||
this.cooldownMultiplier = 1;
|
||||
this.cooldownMs = this.baseCooldownMs;
|
||||
console.log("[TlsClient] Circuit closed (success after cooldown)");
|
||||
this.circuitTripped = false;
|
||||
if (!bodyFailureReported) {
|
||||
bodyFailureReported = true;
|
||||
onBodyError();
|
||||
}
|
||||
return sanitizeWreqError(error, "wreq-js response body failed");
|
||||
};
|
||||
if (response instanceof Response) {
|
||||
finalize();
|
||||
return response;
|
||||
}
|
||||
|
||||
private escalateCooldown(): void {
|
||||
this.cooldownMultiplier = Math.min(this.cooldownMultiplier * 2, 20);
|
||||
this.cooldownMs = Math.min(this.baseCooldownMs * this.cooldownMultiplier, this.MAX_COOLDOWN_MS);
|
||||
try {
|
||||
const headers = new Headers();
|
||||
for (const [name, value] of response.headers) headers.append(name, value);
|
||||
let body: ReadableStream<Uint8Array> | null = null;
|
||||
if (response.body) {
|
||||
const reader = response.body.getReader();
|
||||
body = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) {
|
||||
finalize();
|
||||
controller.close();
|
||||
} else {
|
||||
controller.enqueue(chunk.value);
|
||||
}
|
||||
} catch (error) {
|
||||
controller.error(safeBodyError(error));
|
||||
finalize();
|
||||
}
|
||||
},
|
||||
async cancel(reason) {
|
||||
consumerCancelled = true;
|
||||
consumerCancelReason = reason;
|
||||
try {
|
||||
await reader.cancel(reason);
|
||||
} catch (error) {
|
||||
throw safeBodyError(error);
|
||||
} finally {
|
||||
finalize();
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
finalize();
|
||||
}
|
||||
const adapted = new Response(body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
if (response.url) {
|
||||
Object.defineProperty(adapted, "url", { value: response.url, configurable: true });
|
||||
}
|
||||
if (response.redirected !== undefined) {
|
||||
Object.defineProperty(adapted, "redirected", {
|
||||
value: response.redirected,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
return adapted;
|
||||
} catch (error) {
|
||||
finalize();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TLS Client — Chrome 124 TLS fingerprint spoofing via wreq-js.
|
||||
* Sessions, cookie jars, and circuit state are isolated by account scope and exact proxy.
|
||||
*/
|
||||
export class TlsClient {
|
||||
private readonly createSessionFn: CreateSessionFn | null;
|
||||
private readonly sessions = new Map<string, WreqSession>();
|
||||
private readonly pendingSessions = new Map<string, Promise<WreqSession>>();
|
||||
private readonly pendingCloses = new Set<Promise<void>>();
|
||||
private readonly sessionEpochs = new Map<string, number>();
|
||||
private readonly sessionUseCounts = new Map<string, number>();
|
||||
private readonly sessionLastUsed = new Map<string, number>();
|
||||
private readonly pendingEvictions = new Set<string>();
|
||||
private accessSequence = 0;
|
||||
private readonly circuits = new Map<
|
||||
string,
|
||||
{
|
||||
failureCount: number;
|
||||
cooldownMs: number;
|
||||
cooldownMultiplier: number;
|
||||
circuitOpenUntil: number;
|
||||
circuitTripped: boolean;
|
||||
halfOpenInFlight: boolean;
|
||||
sessionHadCookies: boolean;
|
||||
}
|
||||
>();
|
||||
private globalSessionEpoch = 0;
|
||||
private readonly maxFailures = 3;
|
||||
private readonly baseCooldownMs = 30_000;
|
||||
private readonly maxCooldownMs = 600_000;
|
||||
private readonly legacySessionScope = "legacy";
|
||||
private readonly _libraryAvailable: boolean;
|
||||
private readonly maxSessions: number;
|
||||
|
||||
constructor(
|
||||
createSessionFn: CreateSessionFn | null = createSession,
|
||||
maxSessions = 128
|
||||
) {
|
||||
this.createSessionFn = createSessionFn;
|
||||
this._libraryAvailable = !!createSessionFn;
|
||||
this.maxSessions =
|
||||
Number.isInteger(maxSessions) && maxSessions > 0 ? maxSessions : 128;
|
||||
}
|
||||
|
||||
private checkCircuit(): boolean {
|
||||
if (!this.circuitTripped) return true;
|
||||
/** Library availability only. Per-session circuit state is enforced inside fetch(). */
|
||||
get available(): boolean {
|
||||
return this._libraryAvailable;
|
||||
}
|
||||
|
||||
if (Date.now() >= this.circuitOpenUntil) {
|
||||
console.log("[TlsClient] Half-open: retrying after cooldown");
|
||||
// Don't call recordSuccess() here — that would reset failureCount.
|
||||
// Instead, let the fetch() call succeed or fail naturally.
|
||||
// If it succeeds, recordSuccess() in fetch() handles cleanup.
|
||||
// If it fails, recordFailure() finds failureCount still >= maxFailures
|
||||
// and re-opens with escalated cooldown.
|
||||
private resolveProxy(proxy?: string | null): string | null {
|
||||
return proxy === undefined ? (getProxyFromEnv() ?? null) : proxy;
|
||||
}
|
||||
|
||||
private getSessionKey(resolvedProxy: string | null, sessionScope?: string): string {
|
||||
const scope = sessionScope?.trim() || this.legacySessionScope;
|
||||
return createHash("sha256")
|
||||
.update(scope)
|
||||
.update("\0")
|
||||
.update(resolvedProxy ?? "")
|
||||
.digest("base64url");
|
||||
}
|
||||
|
||||
private getDefaultSessionKey(): string {
|
||||
return this.getSessionKey(this.resolveProxy(undefined), this.legacySessionScope);
|
||||
}
|
||||
|
||||
private getSessionEpoch(key: string): number {
|
||||
return this.sessionEpochs.get(key) ?? 0;
|
||||
}
|
||||
|
||||
private hasSessionCookies(session: WreqSession | null, url: string): boolean {
|
||||
if (!session) return false;
|
||||
if (!session.getCookies) return true;
|
||||
try {
|
||||
return Object.keys(session.getCookies(url)).length > 0;
|
||||
} catch {
|
||||
// If cookie state cannot be inspected, fail closed and forbid replay.
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async getSession() {
|
||||
if (!this.checkCircuit()) return null;
|
||||
if (!this.available) return null;
|
||||
if (this.session) return this.session;
|
||||
const createSessionFn = createSession;
|
||||
if (!createSessionFn) return null;
|
||||
private closeSession(session: WreqSession): Promise<void> {
|
||||
let closing: Promise<void>;
|
||||
closing = Promise.resolve()
|
||||
.then(() => session.close())
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
this.pendingCloses.delete(closing);
|
||||
});
|
||||
this.pendingCloses.add(closing);
|
||||
return closing;
|
||||
}
|
||||
|
||||
private findOldestIdleSession(protectedKey?: string): string | undefined {
|
||||
let candidate: string | undefined;
|
||||
let candidateSequence = Number.POSITIVE_INFINITY;
|
||||
for (const key of this.sessions.keys()) {
|
||||
if (key === protectedKey || (this.sessionUseCounts.get(key) ?? 0) > 0) continue;
|
||||
const sequence = this.sessionLastUsed.get(key) ?? 0;
|
||||
if (sequence < candidateSequence) {
|
||||
candidate = key;
|
||||
candidateSequence = sequence;
|
||||
}
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private reserveSessionCapacity(protectedKey: string): void {
|
||||
if (
|
||||
this.pendingSessions.size >= this.maxSessions ||
|
||||
this.pendingCloses.size >= this.maxSessions
|
||||
) {
|
||||
const error = new Error("wreq-js session capacity exhausted") as Error & {
|
||||
code?: string;
|
||||
};
|
||||
error.code = "TLS_SESSION_CAPACITY";
|
||||
throw error;
|
||||
}
|
||||
while (this.sessions.size >= this.maxSessions) {
|
||||
const candidate = this.findOldestIdleSession(protectedKey);
|
||||
if (!candidate) {
|
||||
const error = new Error("wreq-js session capacity exhausted") as Error & {
|
||||
code?: string;
|
||||
};
|
||||
error.code = "TLS_SESSION_CAPACITY";
|
||||
throw error;
|
||||
}
|
||||
void this.invalidateSession(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
private retainSession(key: string): void {
|
||||
this.pendingEvictions.delete(key);
|
||||
this.sessionUseCounts.set(key, (this.sessionUseCounts.get(key) ?? 0) + 1);
|
||||
this.sessionLastUsed.set(key, ++this.accessSequence);
|
||||
}
|
||||
|
||||
private releaseSession(key: string): void {
|
||||
const remaining = (this.sessionUseCounts.get(key) ?? 1) - 1;
|
||||
if (remaining > 0) {
|
||||
this.sessionUseCounts.set(key, remaining);
|
||||
return;
|
||||
}
|
||||
this.sessionUseCounts.delete(key);
|
||||
if (this.pendingEvictions.delete(key)) {
|
||||
void this.invalidateSession(key);
|
||||
return;
|
||||
}
|
||||
this.evictSessionsIfNeeded();
|
||||
}
|
||||
|
||||
private evictSessionsIfNeeded(protectedKey?: string): void {
|
||||
while (this.sessions.size > this.maxSessions) {
|
||||
const candidate = this.findOldestIdleSession(protectedKey);
|
||||
if (candidate) {
|
||||
void this.invalidateSession(candidate);
|
||||
continue;
|
||||
}
|
||||
|
||||
let activeCandidate: string | undefined;
|
||||
let candidateSequence = Number.POSITIVE_INFINITY;
|
||||
for (const key of this.sessions.keys()) {
|
||||
if (key === protectedKey || this.pendingEvictions.has(key)) continue;
|
||||
const sequence = this.sessionLastUsed.get(key) ?? 0;
|
||||
if (sequence < candidateSequence) {
|
||||
activeCandidate = key;
|
||||
candidateSequence = sequence;
|
||||
}
|
||||
}
|
||||
if (activeCandidate) this.pendingEvictions.add(activeCandidate);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private invalidateSession(key: string): Promise<void> {
|
||||
const pending = this.pendingSessions.get(key);
|
||||
const invalidatedEpoch = this.getSessionEpoch(key) + 1;
|
||||
this.sessionEpochs.set(key, invalidatedEpoch);
|
||||
this.pendingSessions.delete(key);
|
||||
this.sessionUseCounts.delete(key);
|
||||
this.sessionLastUsed.delete(key);
|
||||
this.pendingEvictions.delete(key);
|
||||
const session = this.sessions.get(key);
|
||||
this.sessions.delete(key);
|
||||
if (pending) {
|
||||
void pending
|
||||
.finally(() => {
|
||||
if (
|
||||
this.getSessionEpoch(key) === invalidatedEpoch &&
|
||||
!this.pendingSessions.has(key) &&
|
||||
!this.sessions.has(key)
|
||||
) {
|
||||
this.sessionEpochs.delete(key);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
} else {
|
||||
this.sessionEpochs.delete(key);
|
||||
}
|
||||
return session ? this.closeSession(session) : Promise.resolve();
|
||||
}
|
||||
|
||||
private async closeSessions(): Promise<void> {
|
||||
const pending = [...this.pendingSessions.values()];
|
||||
this.globalSessionEpoch++;
|
||||
this.pendingSessions.clear();
|
||||
this.sessionEpochs.clear();
|
||||
const sessions = [...this.sessions.values()];
|
||||
this.sessions.clear();
|
||||
this.sessionUseCounts.clear();
|
||||
this.sessionLastUsed.clear();
|
||||
this.pendingEvictions.clear();
|
||||
this.circuits.clear();
|
||||
const closes = sessions.map((session) => this.closeSession(session));
|
||||
await Promise.allSettled([...closes, ...pending]);
|
||||
await Promise.allSettled([...this.pendingCloses]);
|
||||
}
|
||||
|
||||
private checkCircuit(key = this.getDefaultSessionKey()): boolean {
|
||||
const state = this.circuits.get(key);
|
||||
if (!state || !state.circuitTripped) return true;
|
||||
if (Date.now() < state.circuitOpenUntil) return false;
|
||||
if (state.halfOpenInFlight) return false;
|
||||
state.halfOpenInFlight = true;
|
||||
console.log("[TlsClient] Half-open: retrying after cooldown");
|
||||
return true;
|
||||
}
|
||||
|
||||
private recordFailure(
|
||||
key = this.getDefaultSessionKey(),
|
||||
sessionHadCookies = false
|
||||
): void {
|
||||
const state = this.circuits.get(key) ?? {
|
||||
failureCount: 0,
|
||||
cooldownMs: this.baseCooldownMs,
|
||||
cooldownMultiplier: 1,
|
||||
circuitOpenUntil: 0,
|
||||
circuitTripped: false,
|
||||
halfOpenInFlight: false,
|
||||
sessionHadCookies: false,
|
||||
};
|
||||
state.sessionHadCookies ||= sessionHadCookies;
|
||||
state.failureCount++;
|
||||
state.halfOpenInFlight = false;
|
||||
if (state.failureCount >= this.maxFailures) {
|
||||
state.circuitOpenUntil = Date.now() + state.cooldownMs;
|
||||
state.circuitTripped = true;
|
||||
if ((this.sessionUseCounts.get(key) ?? 0) > 0) {
|
||||
this.pendingEvictions.add(key);
|
||||
} else {
|
||||
void this.invalidateSession(key);
|
||||
}
|
||||
console.warn(
|
||||
`[TlsClient] Circuit opened after ${state.failureCount} consecutive failures, cooling down for ${state.cooldownMs}ms`
|
||||
);
|
||||
state.cooldownMultiplier = Math.min(state.cooldownMultiplier * 2, 20);
|
||||
state.cooldownMs = Math.min(
|
||||
this.baseCooldownMs * state.cooldownMultiplier,
|
||||
this.maxCooldownMs
|
||||
);
|
||||
}
|
||||
this.circuits.delete(key);
|
||||
this.circuits.set(key, state);
|
||||
const maxCircuitEntries = this.maxSessions * 2;
|
||||
while (this.circuits.size > maxCircuitEntries) {
|
||||
const oldestKey = this.circuits.keys().next().value;
|
||||
if (typeof oldestKey !== "string") break;
|
||||
this.circuits.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
private recordSuccess(key = this.getDefaultSessionKey()): void {
|
||||
const state = this.circuits.get(key);
|
||||
if (state?.circuitTripped) {
|
||||
console.log("[TlsClient] Circuit closed (success after cooldown)");
|
||||
}
|
||||
this.circuits.delete(key);
|
||||
}
|
||||
|
||||
private releaseHalfOpen(key: string): void {
|
||||
const state = this.circuits.get(key);
|
||||
if (state) state.halfOpenInFlight = false;
|
||||
}
|
||||
|
||||
private async getSession(
|
||||
resolvedProxy: string | null,
|
||||
key: string
|
||||
): Promise<WreqSession | null> {
|
||||
const cached = this.sessions.get(key);
|
||||
if (cached) {
|
||||
this.pendingEvictions.delete(key);
|
||||
this.sessionLastUsed.set(key, ++this.accessSequence);
|
||||
return cached;
|
||||
}
|
||||
const pending = this.pendingSessions.get(key);
|
||||
if (pending) return pending;
|
||||
if (!this.createSessionFn) return null;
|
||||
this.reserveSessionCapacity(key);
|
||||
|
||||
const proxy = getProxyFromEnv();
|
||||
const sessionOpts: Record<string, unknown> = {
|
||||
browser: "chrome_124",
|
||||
os: "macos",
|
||||
};
|
||||
if (proxy) {
|
||||
sessionOpts.proxy = proxy;
|
||||
console.log(`[TlsClient] Using proxy: ${proxy}`);
|
||||
}
|
||||
if (resolvedProxy) sessionOpts.proxy = resolvedProxy;
|
||||
const globalEpoch = this.globalSessionEpoch;
|
||||
const sessionEpoch = this.getSessionEpoch(key);
|
||||
|
||||
this.session = await createSessionFn(sessionOpts);
|
||||
console.log("[TlsClient] Session created (Chrome 124 TLS fingerprint)");
|
||||
return this.session;
|
||||
const creating = Reflect.apply(this.createSessionFn, undefined, [sessionOpts])
|
||||
.then(async (session) => {
|
||||
if (
|
||||
globalEpoch !== this.globalSessionEpoch ||
|
||||
sessionEpoch !== this.getSessionEpoch(key)
|
||||
) {
|
||||
await this.closeSession(session);
|
||||
throw new Error("wreq-js session invalidated");
|
||||
}
|
||||
if (this.sessions.size >= this.maxSessions) {
|
||||
const candidate = this.findOldestIdleSession(key);
|
||||
if (!candidate) {
|
||||
await this.closeSession(session);
|
||||
const error = new Error("wreq-js session capacity exhausted") as Error & {
|
||||
code?: string;
|
||||
};
|
||||
error.code = "TLS_SESSION_CAPACITY";
|
||||
throw error;
|
||||
}
|
||||
void this.invalidateSession(candidate);
|
||||
}
|
||||
this.sessions.set(key, session);
|
||||
this.sessionLastUsed.set(key, ++this.accessSequence);
|
||||
this.evictSessionsIfNeeded(key);
|
||||
console.log("[TlsClient] Session created (Chrome 124 TLS fingerprint)");
|
||||
return session;
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.pendingSessions.get(key) === creating) {
|
||||
this.pendingSessions.delete(key);
|
||||
this.sessionEpochs.delete(key);
|
||||
}
|
||||
});
|
||||
this.pendingSessions.set(key, creating);
|
||||
return creating;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with Chrome 124 TLS fingerprint.
|
||||
* wreq-js Response is already fetch-compatible (headers, text(), json(), clone(), body).
|
||||
*/
|
||||
async fetch(url: string, options: FetchOptions = {}) {
|
||||
if (!this.checkCircuit()) {
|
||||
throw new Error("wreq-js circuit open — skipping TLS request");
|
||||
/** Fetch with Chrome 124 TLS fingerprint and an account-scoped persistent cookie jar. */
|
||||
async fetch(url: string, options: TlsFetchOptions = {}): Promise<Response> {
|
||||
const resolvedProxy = this.resolveProxy(options.proxy);
|
||||
const key = this.getSessionKey(resolvedProxy, options.sessionScope);
|
||||
if (!this.checkCircuit(key)) {
|
||||
const state = this.circuits.get(key);
|
||||
const error = new Error("wreq-js circuit open — skipping TLS request") as Error & {
|
||||
code?: string;
|
||||
};
|
||||
error.code = "TLS_CIRCUIT_OPEN";
|
||||
if (state?.sessionHadCookies) {
|
||||
Object.defineProperty(error, "sessionHadCookies", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let session: WreqSession | null = null;
|
||||
let sessionUseRetained = false;
|
||||
const releaseSession = () => {
|
||||
if (!sessionUseRetained) return;
|
||||
sessionUseRetained = false;
|
||||
this.releaseSession(key);
|
||||
};
|
||||
try {
|
||||
const session = await this.getSession();
|
||||
session = await this.getSession(resolvedProxy, key);
|
||||
if (!session) throw new Error("wreq-js not available");
|
||||
this.retainSession(key);
|
||||
sessionUseRetained = true;
|
||||
const { timeoutMs } = getTlsClientTimeoutConfig(process.env, (message) => {
|
||||
console.warn(`[TlsClient] ${message}`);
|
||||
});
|
||||
|
||||
const method = (options.method || "GET").toUpperCase();
|
||||
|
||||
const wreqOptions: Record<string, unknown> = {
|
||||
method,
|
||||
method: (options.method || "GET").toUpperCase(),
|
||||
headers: normalizeHeaders(options.headers),
|
||||
body: options.body,
|
||||
redirect: options.redirect === "manual" ? "manual" : "follow",
|
||||
redirect: options.redirect ?? "follow",
|
||||
timeout: timeoutMs,
|
||||
};
|
||||
if (options.signal) wreqOptions.signal = options.signal;
|
||||
|
||||
if (options.signal) {
|
||||
wreqOptions.signal = options.signal;
|
||||
}
|
||||
|
||||
const response = await session.fetch(url, wreqOptions);
|
||||
this.recordSuccess();
|
||||
const response = toNativeResponse(
|
||||
await session.fetch(url, wreqOptions),
|
||||
releaseSession,
|
||||
() => this.recordFailure(key, this.hasSessionCookies(session, url)),
|
||||
options.signal
|
||||
);
|
||||
this.recordSuccess(key);
|
||||
return response;
|
||||
} catch (err) {
|
||||
const isAbort =
|
||||
err instanceof Error && (err.name === "AbortError" || err.message.includes("aborted"));
|
||||
if (!isAbort) {
|
||||
this.recordFailure();
|
||||
const isCallerAbort = options.signal?.aborted === true;
|
||||
const sessionHadCookies =
|
||||
!isCallerAbort && this.hasSessionCookies(session, url);
|
||||
releaseSession();
|
||||
if (isCallerAbort) {
|
||||
this.releaseHalfOpen(key);
|
||||
} else {
|
||||
this.recordFailure(key, sessionHadCookies);
|
||||
}
|
||||
throw err;
|
||||
if (isCallerAbort) throw err;
|
||||
const transportError = sanitizeWreqError(err, "wreq-js transport failed");
|
||||
if (sessionHadCookies) {
|
||||
Object.defineProperty(transportError, "sessionHadCookies", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
throw transportError;
|
||||
}
|
||||
}
|
||||
|
||||
async exit() {
|
||||
if (this.session) {
|
||||
await this.session.close();
|
||||
this.session = null;
|
||||
async exit(): Promise<void> {
|
||||
await this.closeSessions();
|
||||
}
|
||||
|
||||
resetCircuit(proxy?: string | null, sessionScope?: string): void {
|
||||
if (arguments.length === 0) {
|
||||
this.circuits.clear();
|
||||
return;
|
||||
}
|
||||
const resolvedProxy = this.resolveProxy(proxy);
|
||||
this.circuits.delete(this.getSessionKey(resolvedProxy, sessionScope));
|
||||
}
|
||||
|
||||
resetCircuit(): void {
|
||||
this.failureCount = 0;
|
||||
this.circuitTripped = false;
|
||||
this.circuitOpenUntil = 0;
|
||||
}
|
||||
|
||||
getCircuitState(): {
|
||||
getCircuitState(
|
||||
proxy?: string | null,
|
||||
sessionScope?: string
|
||||
): {
|
||||
available: boolean;
|
||||
circuitTripped: boolean;
|
||||
failureCount: number;
|
||||
circuitOpenUntil: number;
|
||||
coolDownRemainingMs: number;
|
||||
} {
|
||||
const resolvedProxy = this.resolveProxy(proxy);
|
||||
const key = this.getSessionKey(resolvedProxy, sessionScope);
|
||||
const state = this.circuits.get(key);
|
||||
const circuitOpenUntil = state?.circuitOpenUntil ?? 0;
|
||||
const circuitTripped = state?.circuitTripped ?? false;
|
||||
return {
|
||||
available: this.available,
|
||||
circuitTripped: this.circuitTripped,
|
||||
failureCount: this.failureCount,
|
||||
circuitOpenUntil: this.circuitOpenUntil,
|
||||
available:
|
||||
this._libraryAvailable &&
|
||||
(!circuitTripped || Date.now() >= circuitOpenUntil),
|
||||
circuitTripped,
|
||||
failureCount: state?.failureCount ?? 0,
|
||||
circuitOpenUntil,
|
||||
coolDownRemainingMs:
|
||||
this.circuitOpenUntil > 0 ? Math.max(0, this.circuitOpenUntil - Date.now()) : 0,
|
||||
circuitOpenUntil > 0 ? Math.max(0, circuitOpenUntil - Date.now()) : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const tlsClient = new TlsClient();
|
||||
const TLS_CLIENT_KEY = Symbol.for("omniroute.tlsClient.instance");
|
||||
const scopedGlobal = globalThis as typeof globalThis & {
|
||||
[TLS_CLIENT_KEY]?: TlsClient;
|
||||
};
|
||||
const tlsClient = scopedGlobal[TLS_CLIENT_KEY] ?? new TlsClient();
|
||||
scopedGlobal[TLS_CLIENT_KEY] = tlsClient;
|
||||
|
||||
export default tlsClient;
|
||||
|
||||
337
package-lock.json
generated
337
package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@lobehub/icons": "^5.8.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
@@ -59,6 +60,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"omniglyph": "^1.0.2",
|
||||
"onnxruntime-node": "~1.24.3",
|
||||
"open": "^11.0.0",
|
||||
"ora": "^9.4.1",
|
||||
"parse5": "^8.0.1",
|
||||
@@ -152,7 +154,6 @@
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@atjsh/llmlingua-2": "2.0.3",
|
||||
"@huggingface/transformers": "3.5.2",
|
||||
"@tensorflow/tfjs": "4.22.0",
|
||||
"better-sqlite3": "^13.0.2",
|
||||
"js-tiktoken": "^1.0.20",
|
||||
@@ -3443,11 +3444,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@huggingface/jinja": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.4.1.tgz",
|
||||
"integrity": "sha512-3WXbMFaPkk03LRCM0z0sylmn8ddDm4ubjU7X+Hg4M2GOuMklwoGAFXp9V2keq7vltoB/c7McE5aHUVVddAewsw==",
|
||||
"version": "0.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz",
|
||||
"integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3456,21 +3456,19 @@
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz",
|
||||
"integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@huggingface/transformers": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.5.2.tgz",
|
||||
"integrity": "sha512-mfRXkmcL99+ibpjM++pvZmc2h3po8i1ZgSRI5Rtgh++P15GU0lY8UQteYt/w5V+GQw+Jpao93MoipcePzh3mKg==",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz",
|
||||
"integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@huggingface/jinja": "^0.4.1",
|
||||
"onnxruntime-node": "1.21.0",
|
||||
"onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4",
|
||||
"sharp": "^0.34.1"
|
||||
"@huggingface/jinja": "^0.5.6",
|
||||
"@huggingface/tokenizers": "^0.1.3",
|
||||
"onnxruntime-node": "1.24.3",
|
||||
"onnxruntime-web": "1.26.0-dev.20260416-b7804b056c",
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
@@ -3694,9 +3692,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3713,9 +3708,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3732,9 +3724,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3751,9 +3740,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3770,9 +3756,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3789,9 +3772,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3808,9 +3788,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3827,9 +3804,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3846,9 +3820,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3871,9 +3842,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3896,9 +3864,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3921,9 +3886,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3946,9 +3908,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3971,9 +3930,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3996,9 +3952,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4021,9 +3974,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4569,7 +4519,7 @@
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"minipass": "^7.0.4"
|
||||
@@ -5395,9 +5345,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5414,9 +5361,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5433,9 +5377,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5452,9 +5393,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -8332,35 +8270,30 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/base64": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
|
||||
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/codegen": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
|
||||
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/eventemitter": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
|
||||
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/fetch": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
|
||||
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.1"
|
||||
@@ -8370,28 +8303,24 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
|
||||
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/path": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
||||
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/pool": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
|
||||
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/utf8": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
|
||||
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
@@ -10672,9 +10601,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -10692,9 +10618,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -10712,9 +10635,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -10732,9 +10652,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -11685,7 +11602,6 @@
|
||||
"version": "26.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
||||
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
@@ -12781,9 +12697,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -12797,9 +12710,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -12813,9 +12723,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -12829,9 +12736,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -12845,9 +12749,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -12861,9 +12762,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -12971,9 +12869,7 @@
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz",
|
||||
"integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
}
|
||||
@@ -13946,8 +13842,7 @@
|
||||
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
||||
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bottleneck": {
|
||||
"version": "2.19.5",
|
||||
@@ -16888,7 +16783,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
@@ -16918,7 +16812,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
||||
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.0.1",
|
||||
@@ -17019,8 +16912,7 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
|
||||
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-node-es": {
|
||||
"version": "1.1.0",
|
||||
@@ -17864,8 +17756,7 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
|
||||
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es6-promisify": {
|
||||
"version": "7.0.0",
|
||||
@@ -19357,8 +19248,7 @@
|
||||
"version": "25.9.23",
|
||||
"resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
|
||||
"integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.4.2",
|
||||
@@ -20177,7 +20067,6 @@
|
||||
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
|
||||
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"boolean": "^3.0.1",
|
||||
"es6-error": "^4.1.1",
|
||||
@@ -20191,11 +20080,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/global-agent/node_modules/semver": {
|
||||
"version": "7.8.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
|
||||
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
@@ -20244,7 +20132,6 @@
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
|
||||
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-properties": "^1.2.1",
|
||||
@@ -20599,8 +20486,7 @@
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
|
||||
"integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/hachure-fill": {
|
||||
"version": "0.5.2",
|
||||
@@ -20634,7 +20520,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
@@ -23739,8 +23624,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
|
||||
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
@@ -24490,6 +24374,17 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/libxmljs2/node_modules/brace-expansion": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libxmljs2/node_modules/cacache": {
|
||||
"version": "19.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz",
|
||||
@@ -25669,7 +25564,6 @@
|
||||
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
|
||||
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"escape-string-regexp": "^4.0.0"
|
||||
},
|
||||
@@ -26984,7 +26878,7 @@
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -27104,7 +26998,7 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
@@ -28500,7 +28394,6 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -28705,41 +28598,38 @@
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-common": {
|
||||
"version": "1.21.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz",
|
||||
"integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"version": "1.24.3",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz",
|
||||
"integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/onnxruntime-node": {
|
||||
"version": "1.21.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz",
|
||||
"integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==",
|
||||
"version": "1.24.3",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz",
|
||||
"integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32",
|
||||
"darwin",
|
||||
"linux"
|
||||
],
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"global-agent": "^3.0.0",
|
||||
"onnxruntime-common": "1.21.0",
|
||||
"tar": "^7.0.1"
|
||||
"onnxruntime-common": "1.24.3"
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-web": {
|
||||
"version": "1.22.0-dev.20250409-89f8206ba4",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.22.0-dev.20250409-89f8206ba4.tgz",
|
||||
"integrity": "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==",
|
||||
"version": "1.26.0-dev.20260416-b7804b056c",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz",
|
||||
"integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"flatbuffers": "^25.1.24",
|
||||
"guid-typescript": "^1.0.9",
|
||||
"long": "^5.2.3",
|
||||
"onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4",
|
||||
"onnxruntime-common": "1.24.0-dev.20251116-b39e144322",
|
||||
"platform": "^1.3.6",
|
||||
"protobufjs": "^7.2.4"
|
||||
}
|
||||
@@ -28748,15 +28638,13 @@
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/onnxruntime-web/node_modules/onnxruntime-common": {
|
||||
"version": "1.22.0-dev.20250409-89f8206ba4",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.22.0-dev.20250409-89f8206ba4.tgz",
|
||||
"integrity": "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"version": "1.24.0-dev.20251116-b39e144322",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz",
|
||||
"integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/open": {
|
||||
"version": "11.0.0",
|
||||
@@ -28903,9 +28791,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -28945,9 +28830,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -28961,9 +28843,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -29997,8 +29876,7 @@
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
|
||||
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.0",
|
||||
@@ -30565,32 +30443,6 @@
|
||||
"sharp": "^0.35.3"
|
||||
}
|
||||
},
|
||||
"node_modules/promptfoo/node_modules/@huggingface/jinja": {
|
||||
"version": "0.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz",
|
||||
"integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/promptfoo/node_modules/@huggingface/transformers": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz",
|
||||
"integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@huggingface/jinja": "^0.5.6",
|
||||
"@huggingface/tokenizers": "^0.1.3",
|
||||
"onnxruntime-node": "1.24.3",
|
||||
"onnxruntime-web": "1.26.0-dev.20260416-b7804b056c",
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/promptfoo/node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
@@ -30704,14 +30556,6 @@
|
||||
"@keyv/serialize": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/promptfoo/node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/promptfoo/node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
@@ -30755,57 +30599,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/promptfoo/node_modules/onnxruntime-common": {
|
||||
"version": "1.24.3",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz",
|
||||
"integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/promptfoo/node_modules/onnxruntime-node": {
|
||||
"version": "1.24.3",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz",
|
||||
"integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32",
|
||||
"darwin",
|
||||
"linux"
|
||||
],
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"global-agent": "^3.0.0",
|
||||
"onnxruntime-common": "1.24.3"
|
||||
}
|
||||
},
|
||||
"node_modules/promptfoo/node_modules/onnxruntime-web": {
|
||||
"version": "1.26.0-dev.20260416-b7804b056c",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz",
|
||||
"integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"flatbuffers": "^25.1.24",
|
||||
"guid-typescript": "^1.0.9",
|
||||
"long": "^5.2.3",
|
||||
"onnxruntime-common": "1.24.0-dev.20251116-b39e144322",
|
||||
"platform": "^1.3.6",
|
||||
"protobufjs": "^7.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/promptfoo/node_modules/onnxruntime-web/node_modules/onnxruntime-common": {
|
||||
"version": "1.24.0-dev.20251116-b39e144322",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz",
|
||||
"integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/promptfoo/node_modules/path-key": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
|
||||
@@ -30894,7 +30687,6 @@
|
||||
"version": "7.6.5",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
|
||||
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -30918,7 +30710,6 @@
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
@@ -32321,7 +32112,6 @@
|
||||
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
|
||||
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"boolean": "^3.0.1",
|
||||
"detect-node": "^2.0.4",
|
||||
@@ -32338,8 +32128,7 @@
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/robot3": {
|
||||
"version": "0.4.1",
|
||||
@@ -32704,8 +32493,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
|
||||
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "1.2.1",
|
||||
@@ -32738,7 +32526,6 @@
|
||||
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
|
||||
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"type-fest": "^0.13.1"
|
||||
},
|
||||
@@ -32754,7 +32541,6 @@
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
|
||||
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -34322,7 +34108,7 @@
|
||||
"version": "7.5.22",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
|
||||
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
@@ -34369,7 +34155,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -34379,7 +34165,7 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -35230,7 +35016,6 @@
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-emoji-modifier-base": {
|
||||
|
||||
@@ -326,11 +326,12 @@
|
||||
"xxhash-wasm": "^1.1.0",
|
||||
"yazl": "^3.3.1",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.13"
|
||||
"zustand": "^5.0.13",
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"onnxruntime-node": "~1.24.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@atjsh/llmlingua-2": "2.0.3",
|
||||
"@huggingface/transformers": "3.5.2",
|
||||
"@tensorflow/tfjs": "4.22.0",
|
||||
"better-sqlite3": "^13.0.2",
|
||||
"js-tiktoken": "^1.0.20",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* .next/standalone -> outDir (cp) Y Y Y SHARED
|
||||
* .next/static -> outDir/.next/static (cp) Y Y Y SHARED
|
||||
* public/ -> outDir/public/ (cp) Y Y Y SHARED
|
||||
* wreq-js/rust -> outDir/node_modules/wreq-js/rust Y - - SHARED (native asset)
|
||||
* wreq-js -> outDir/node_modules/wreq-js Y Y Y SHARED (extra module)
|
||||
* better-sqlite3/build -> outDir/node_modules/better-sqlite3/ Y - - SHARED (native asset)
|
||||
* @swc/helpers -> outDir/node_modules/@swc/helpers Y Y Y SHARED (extra module)
|
||||
* pino-abstract-transport -> outDir/node_modules/... Y - - SHARED (extra module)
|
||||
@@ -76,11 +76,6 @@ async function exists(targetPath) {
|
||||
* for either path/platform. @type {{label:string, src:string[], dest:string[]}[]}
|
||||
*/
|
||||
export const NATIVE_ASSET_ENTRIES = [
|
||||
{
|
||||
label: "wreq-js native runtime",
|
||||
src: ["node_modules", "wreq-js", "rust"],
|
||||
dest: ["node_modules", "wreq-js", "rust"],
|
||||
},
|
||||
{
|
||||
label: "better-sqlite3 native binary",
|
||||
src: ["node_modules", "better-sqlite3", "build"],
|
||||
@@ -117,6 +112,15 @@ export const NATIVE_ASSET_ENTRIES = [
|
||||
|
||||
/** @type {{label:string, src:string[], dest:string[]}[]} */
|
||||
const EXTRA_MODULE_ENTRIES = [
|
||||
{
|
||||
// tlsClient.ts intentionally resolves wreq-js through a runtime-dynamic
|
||||
// require so Turbopack cannot rewrite the package name to a hashed external.
|
||||
// That also makes the package invisible to static tracing, so copy the whole
|
||||
// module—not only rust/—into every standalone artifact.
|
||||
label: "wreq-js TLS runtime",
|
||||
src: ["node_modules", "wreq-js"],
|
||||
dest: ["node_modules", "wreq-js"],
|
||||
},
|
||||
{
|
||||
label: "@swc/helpers",
|
||||
src: ["node_modules", "@swc", "helpers"],
|
||||
@@ -252,6 +256,16 @@ const EXTRA_MODULE_ENTRIES = [
|
||||
src: ["node_modules", "undici"],
|
||||
dest: ["node_modules", "undici"],
|
||||
},
|
||||
{
|
||||
// Turbopack's standalone tracer can emit a hollow node_modules/ws/ directory
|
||||
// for the externalized `ws` package (no package.json / index.js), which then
|
||||
// shadows the real install at runtime and crashes instrumentation with:
|
||||
// "Cannot find package '<bundle>/node_modules/ws/index.js'" (#OmniRoute v3.8.50 live bug).
|
||||
// Overlay the full source package so the bundled server resolves the real entrypoint.
|
||||
label: "ws (externalized runtime package shadow fix)",
|
||||
src: ["node_modules", "ws"],
|
||||
dest: ["node_modules", "ws"],
|
||||
},
|
||||
{
|
||||
label: "sql.js WASM fallback runtime",
|
||||
src: ["node_modules", "sql.js"],
|
||||
@@ -278,7 +292,7 @@ const EXTRA_MODULE_ENTRIES = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Copy native standalone assets (wreq-js rust/, better-sqlite3 build/).
|
||||
* Copy native standalone assets (better-sqlite3 build/prebuilds and TPROXY).
|
||||
*
|
||||
* The destination is derived as <rootDir>/<distDir>/standalone/node_modules/...
|
||||
* for backward compatibility with existing callers and tests.
|
||||
@@ -512,8 +526,8 @@ function copyStaticAndPublic({ distDir, relDistDir, projectRoot, resolvedOutDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy native assets (wreq-js, better-sqlite3) and extra runtime modules/sidecars
|
||||
* (pino, migrations, MITM server, helper scripts, sqlite-vec platform packages, …)
|
||||
* Copy native assets (better-sqlite3 and TPROXY) and extra runtime modules/sidecars
|
||||
* (wreq-js, pino, migrations, MITM server, helper scripts, sqlite-vec platform packages, …)
|
||||
* into the assembled bundle. Missing sources are skipped silently.
|
||||
*
|
||||
* @param {string} projectRoot
|
||||
@@ -539,6 +553,68 @@ function copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Next/Turbopack standalone output can leave behind hollow top-level package
|
||||
* directories for externalized runtime deps (directory exists, but contains no
|
||||
* files). Those empty placeholders shadow the real repo-level install and make
|
||||
* runtime ESM externals fail with "Cannot find package '<bundle>/node_modules/<pkg>/index.js'"
|
||||
* even though the dependency is present in the source tree.
|
||||
*
|
||||
* Repair strategy: for each empty top-level package dir already present in the
|
||||
* assembled bundle, if the same package exists in the project root node_modules,
|
||||
* replace the hollow directory with a full recursive copy from the source install.
|
||||
* This keeps the fix narrowly scoped to packages the standalone already expects.
|
||||
*
|
||||
* @param {string} projectRoot
|
||||
* @param {string} resolvedOutDir
|
||||
* @returns {{repaired: number, packages: string[]}}
|
||||
*/
|
||||
function repairEmptyExternalPackageDirs(projectRoot, resolvedOutDir) {
|
||||
const summary = { repaired: 0, packages: [] };
|
||||
const bundleNodeModules = path.join(resolvedOutDir, "node_modules");
|
||||
const sourceNodeModules = path.join(projectRoot, "node_modules");
|
||||
if (!fsSync.existsSync(bundleNodeModules) || !fsSync.existsSync(sourceNodeModules)) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
for (const name of fsSync.readdirSync(bundleNodeModules)) {
|
||||
if (name.startsWith(".") || name.startsWith("@")) continue;
|
||||
|
||||
const bundlePkgDir = path.join(bundleNodeModules, name);
|
||||
const sourcePkgDir = path.join(sourceNodeModules, name);
|
||||
|
||||
let bundleStat;
|
||||
try {
|
||||
bundleStat = fsSync.statSync(bundlePkgDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!bundleStat.isDirectory()) continue;
|
||||
|
||||
let bundleEntries = [];
|
||||
try {
|
||||
bundleEntries = fsSync.readdirSync(bundlePkgDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (bundleEntries.length > 0 || !fsSync.existsSync(sourcePkgDir)) continue;
|
||||
|
||||
let sourceStat;
|
||||
try {
|
||||
sourceStat = fsSync.statSync(sourcePkgDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!sourceStat.isDirectory()) continue;
|
||||
|
||||
fsSync.cpSync(sourcePkgDir, bundlePkgDir, { recursive: true, force: true });
|
||||
summary.repaired += 1;
|
||||
summary.packages.push(name);
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize Turbopack "hashed external module" symlinks inside a bundled
|
||||
* node_modules dir into real, self-contained directories.
|
||||
@@ -755,6 +831,13 @@ export function assembleStandalone({
|
||||
// 6. Optionally copy native assets + extra modules (synchronous)
|
||||
if (copyNatives) {
|
||||
copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir);
|
||||
const emptyPkgRepair = repairEmptyExternalPackageDirs(projectRoot, resolvedOutDir);
|
||||
if (emptyPkgRepair.repaired > 0) {
|
||||
console.log(
|
||||
`[assembleStandalone] Repaired ${emptyPkgRepair.repaired} hollow external package dir(s): ` +
|
||||
emptyPkgRepair.packages.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
// #9166: dynamically imported LLMLingua packages are not reliably traced
|
||||
// into the standalone bundle. Copy their complete dependency closure from
|
||||
|
||||
@@ -327,7 +327,12 @@ export async function main() {
|
||||
distDir,
|
||||
outDir: standaloneDir,
|
||||
projectRoot,
|
||||
// Match the hardened packaging path used by Electron builds:
|
||||
// Turbopack can emit hashed external-package references and
|
||||
// standalone symlinks that break after the bundle is moved/copied.
|
||||
patchTurbopackChunks: true,
|
||||
copyNatives: true,
|
||||
materializeSymlinks: true,
|
||||
});
|
||||
const { spawnSync } = await import("node:child_process");
|
||||
const basePathWrite = spawnSync(
|
||||
|
||||
@@ -22,8 +22,11 @@ import {
|
||||
getCompatibleFallbackModels,
|
||||
} from "@/lib/providers/managedAvailableModels";
|
||||
import { getProviderServiceKinds } from "@/lib/providers/serviceKindIndex";
|
||||
import { providerLacksModelListing } from "@/lib/providers/modelListingCapability";
|
||||
import { providerUsesCuratedModelsOnly } from "@/lib/providers/modelListingCapability";
|
||||
import {
|
||||
providerLacksModelListing,
|
||||
providerUsesCuratedModelsOnly,
|
||||
} from "@/lib/providers/modelListingCapability";
|
||||
import { mergeProviderModelListing } from "@/lib/providers/mergeProviderModelListing";
|
||||
import { normalizeModelCatalogSource } from "@/shared/utils/modelCatalogSearch";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
|
||||
@@ -270,39 +273,29 @@ export default function ProviderDetailPageClient() {
|
||||
getProviderById(providerId)?.managedAccount === true;
|
||||
const registryModels = getModelsByProviderId(providerId);
|
||||
// Prefer synced API-discovered models when available, then merge built-ins
|
||||
// and user-managed custom models without duplicating IDs.
|
||||
// and user-managed custom models without duplicating IDs. Cursor exclusive
|
||||
// listing drops the static registry entirely when synced is non-empty.
|
||||
const models = useMemo(() => {
|
||||
// Synced models keep their full property spread so provider-specific fields
|
||||
// (e.g. Gemini's `supportedGenerationMethods`) survive into the table.
|
||||
const builtInModels = registryModels.map((model) => ({
|
||||
...model,
|
||||
source: "system",
|
||||
}));
|
||||
|
||||
const registryIds = new Set(builtInModels.map((m) => m.id));
|
||||
const syncedExtras = (usesCuratedModelsOnly ? [] : syncedAvailableModels)
|
||||
.filter((model: any) => model?.id && !registryIds.has(model.id))
|
||||
.map((model: any) => ({
|
||||
...model,
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
source: "imported",
|
||||
}));
|
||||
const knownIds = new Set([...registryIds, ...syncedExtras.map((model: any) => model.id)]);
|
||||
const customExtras = (usesCuratedModelsOnly ? [] : modelMeta.customModels)
|
||||
.filter((cm: any) => cm.id && !knownIds.has(cm.id))
|
||||
.map((cm: any) => ({
|
||||
id: cm.id,
|
||||
name: cm.name || cm.id,
|
||||
source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom",
|
||||
}));
|
||||
const allModels = [...builtInModels, ...syncedExtras, ...customExtras];
|
||||
const deduped = new Map<string, (typeof allModels)[0]>();
|
||||
for (const m of allModels) {
|
||||
if (m.id && !deduped.has(m.id)) deduped.set(m.id, m);
|
||||
}
|
||||
return Array.from(deduped.values());
|
||||
}, [registryModels, syncedAvailableModels, modelMeta.customModels, usesCuratedModelsOnly]);
|
||||
return mergeProviderModelListing({
|
||||
providerId,
|
||||
registryModels,
|
||||
syncedModels: syncedAvailableModels,
|
||||
customModels: (modelMeta.customModels || []).map(
|
||||
(cm: { id: string; name?: string; source?: string }) => ({
|
||||
id: cm.id,
|
||||
name: cm.name || cm.id,
|
||||
source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom",
|
||||
})
|
||||
),
|
||||
usesCuratedModelsOnly,
|
||||
});
|
||||
}, [
|
||||
providerId,
|
||||
registryModels,
|
||||
syncedAvailableModels,
|
||||
modelMeta.customModels,
|
||||
usesCuratedModelsOnly,
|
||||
]);
|
||||
const isUpstreamProxyProvider = providerInfo?.category === "upstream-proxy";
|
||||
const compatibleSupportsModelImport = compatibleProviderSupportsModelImport(providerId);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Badge, Input, Modal, Select, Toggle } from "@/shared/components";
|
||||
import { isValidProviderIconUrl } from "@/shared/validation/iconUrl";
|
||||
import { CC_COMPATIBLE_DEFAULT_CHAT_PATH } from "../../providerDetailConstants";
|
||||
import NewApiAggregatorFields from "./NewApiAggregatorFields";
|
||||
interface EditCompatibleNodeModalNode {
|
||||
@@ -57,6 +58,7 @@ export default function EditCompatibleNodeModal({
|
||||
method?: string | null;
|
||||
}>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [iconUrlError, setIconUrlError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (node) {
|
||||
@@ -101,6 +103,12 @@ export default function EditCompatibleNodeModal({
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return;
|
||||
const iconUrl = formData.iconUrl.trim();
|
||||
if (!isValidProviderIconUrl(iconUrl)) {
|
||||
setIconUrlError(t("iconUrlInvalid"));
|
||||
return;
|
||||
}
|
||||
setIconUrlError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: any = {
|
||||
@@ -247,7 +255,7 @@ export default function EditCompatibleNodeModal({
|
||||
value={formData.iconUrl}
|
||||
onChange={(e) => setFormData({ ...formData, iconUrl: e.target.value })}
|
||||
placeholder="https://example.com/logo.png"
|
||||
hint={t("iconUrlHint")}
|
||||
hint={iconUrlError ?? t("iconUrlHint")}
|
||||
/>
|
||||
<Toggle
|
||||
label={t("newApiAggregatorToggleLabel")}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Badge, Button, Input, Modal, Select, Toggle } from "@/shared/components";
|
||||
import { isValidProviderIconUrl } from "@/shared/validation/iconUrl";
|
||||
import {
|
||||
CLIENT_IDENTITY_PROFILE_OPTIONS,
|
||||
getClientIdentityProfileHeaders,
|
||||
@@ -115,6 +116,7 @@ export default function AddCompatibleProviderModal({
|
||||
method?: string | null;
|
||||
}>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [iconUrlError, setIconUrlError] = useState<string | null>(null);
|
||||
|
||||
const apiTypeOptions = useMemo(
|
||||
() => [
|
||||
@@ -188,6 +190,12 @@ export default function AddCompatibleProviderModal({
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!hasRequiredFields) return;
|
||||
const iconUrl = formData.iconUrl.trim();
|
||||
if (!isValidProviderIconUrl(iconUrl)) {
|
||||
setIconUrlError(t("iconUrlInvalid"));
|
||||
return;
|
||||
}
|
||||
setIconUrlError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
@@ -328,7 +336,7 @@ export default function AddCompatibleProviderModal({
|
||||
value={formData.iconUrl}
|
||||
onChange={(e) => setFormData({ ...formData, iconUrl: e.target.value })}
|
||||
placeholder="https://example.com/logo.png"
|
||||
hint={t("iconUrlHint")}
|
||||
hint={iconUrlError ?? t("iconUrlHint")}
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
|
||||
@@ -29,6 +29,7 @@ const PROVIDER_COLUMNS: Record<string, string[]> = {
|
||||
minimax: ["session"],
|
||||
"minimax-cn": ["session"],
|
||||
"kimi-coding": ["session", "weekly"],
|
||||
"command-code": ["five_hour", "weekly", "credits"],
|
||||
};
|
||||
|
||||
/** Hard cap for the dynamic schema (Antigravity and fallback providers). */
|
||||
|
||||
@@ -92,6 +92,7 @@ import {
|
||||
} from "@/lib/providerModels/geminiModelsParser";
|
||||
import { getSyncedAvailableModels, getCustomModels } from "@/lib/db/models";
|
||||
import { fetchCursorAgentModels } from "@/lib/providerModels/cursorAgent";
|
||||
import { ensureCursorAutoCatalogEntry } from "@/lib/providerModels/cursorAutoCatalog";
|
||||
import { fetchRaycastModels } from "@omniroute/open-sse/services/raycast.ts";
|
||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
import {
|
||||
@@ -1408,7 +1409,7 @@ export async function GET(
|
||||
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
|
||||
|
||||
try {
|
||||
const models = await fetchCursorAgentModels();
|
||||
const models = ensureCursorAutoCatalogEntry(await fetchCursorAgentModels());
|
||||
return buildApiDiscoveryResponse(models);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
CHAT_ADMISSION_QUEUE_MAX_MS,
|
||||
releaseChatAdmissionAfterHandler,
|
||||
releaseChatAdmissionWhenDone,
|
||||
resolveSessionId,
|
||||
} from "@/shared/middleware/chatBodyAdmission";
|
||||
import {
|
||||
readCompressionRequestHeader,
|
||||
@@ -101,7 +102,9 @@ export async function POST(request) {
|
||||
// Reserve heavyweight capacity atomically and ingest the body with a hard byte bound
|
||||
// BEFORE JSON parsing. Missing or dishonest Content-Length values cannot bypass
|
||||
// the actual-byte limit. Capacity exhaustion is retryable rather than process-fatal.
|
||||
const sessionId = resolveSessionId(request);
|
||||
const admissionResult = await admitChatRequest(request, {
|
||||
sessionId,
|
||||
queueMs: CHAT_ADMISSION_QUEUE_MAX_MS,
|
||||
});
|
||||
if (admissionResult.admit === false) return admissionResult.response;
|
||||
@@ -147,7 +150,9 @@ export async function POST(request) {
|
||||
}
|
||||
|
||||
const structuralAdmission = await admitChatStructure(parsedBody, admission.lease, {
|
||||
sessionId,
|
||||
queueMs: CHAT_ADMISSION_QUEUE_MAX_MS,
|
||||
signal: request.signal,
|
||||
});
|
||||
if (structuralAdmission.admit === false) {
|
||||
admission.lease?.release();
|
||||
|
||||
@@ -4,6 +4,11 @@ import {
|
||||
handleImageEdit,
|
||||
handleOpenAIImageEdit,
|
||||
} from "@omniroute/open-sse/handlers/imageGeneration.ts";
|
||||
import {
|
||||
handleFalAIImageEdit,
|
||||
FAL_IMAGE_EDIT_MAX_REFERENCES,
|
||||
isFalImageEditModel,
|
||||
} from "@omniroute/open-sse/handlers/imageGeneration/providers/fal.ts";
|
||||
import { createInjectionGuard } from "@/middleware/promptInjectionGuard";
|
||||
import {
|
||||
getProviderCredentialsWithQuotaPreflight,
|
||||
@@ -207,7 +212,8 @@ function buildAdobeFireflyEditDataUrls(
|
||||
}
|
||||
}
|
||||
if (dataUrls.length === 0 && imageBytes && imageBytes.length > 0) {
|
||||
const mime = typeof imageMime === "string" && imageMime.startsWith("image/") ? imageMime : "image/png";
|
||||
const mime =
|
||||
typeof imageMime === "string" && imageMime.startsWith("image/") ? imageMime : "image/png";
|
||||
dataUrls.push(`data:${mime};base64,${imageBytes.toString("base64")}`);
|
||||
}
|
||||
return dataUrls;
|
||||
@@ -250,7 +256,10 @@ async function handleAdobeFireflyEditRequest(params: {
|
||||
resolvedModel
|
||||
);
|
||||
if (!credentials) {
|
||||
return errorResponse(HTTP_STATUS.UNAUTHORIZED, `No credentials for provider: ${parsed.provider}`);
|
||||
return errorResponse(
|
||||
HTTP_STATUS.UNAUTHORIZED,
|
||||
`No credentials for provider: ${parsed.provider}`
|
||||
);
|
||||
}
|
||||
if (credentials.allRateLimited) {
|
||||
return unavailableResponse(
|
||||
@@ -362,11 +371,10 @@ async function postHandler(request: Request, _context?: unknown) {
|
||||
? 4
|
||||
: providerConfig?.format === "codex-responses"
|
||||
? Number.POSITIVE_INFINITY
|
||||
: MAX_NON_CODEX_IMAGE_EDIT_REFERENCES;
|
||||
if (
|
||||
providerConfig?.format !== "codex-responses" &&
|
||||
imageInputCount > maxRefsForProvider
|
||||
) {
|
||||
: providerConfig?.format === "fal-ai" && isFalImageEditModel(parsed.model)
|
||||
? FAL_IMAGE_EDIT_MAX_REFERENCES
|
||||
: MAX_NON_CODEX_IMAGE_EDIT_REFERENCES;
|
||||
if (providerConfig?.format !== "codex-responses" && imageInputCount > maxRefsForProvider) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.BAD_REQUEST,
|
||||
providerConfig?.format === "adobe-firefly-image"
|
||||
@@ -514,6 +522,53 @@ async function postHandler(request: Request, _context?: unknown) {
|
||||
);
|
||||
}
|
||||
|
||||
if (providerConfig?.format === "fal-ai" && isFalImageEditModel(parsed.model)) {
|
||||
const credentials = await getProviderCredentialsWithQuotaPreflight(
|
||||
parsed.provider,
|
||||
null,
|
||||
allowedConnections,
|
||||
resolvedModel
|
||||
);
|
||||
if (!credentials) {
|
||||
return errorResponse(
|
||||
HTTP_STATUS.UNAUTHORIZED,
|
||||
`No credentials for provider: ${parsed.provider}`
|
||||
);
|
||||
}
|
||||
if (credentials.allRateLimited) {
|
||||
return unavailableResponse(
|
||||
HTTP_STATUS.RATE_LIMITED,
|
||||
`[${parsed.provider}] All accounts rate limited`,
|
||||
credentials.retryAfter,
|
||||
credentials.retryAfterHuman
|
||||
);
|
||||
}
|
||||
|
||||
const result = await handleFalAIImageEdit({
|
||||
provider: parsed.provider,
|
||||
model: parsed.model,
|
||||
providerConfig,
|
||||
body: {
|
||||
prompt,
|
||||
size: size ?? undefined,
|
||||
response_format: responseFormat ?? undefined,
|
||||
n: 1,
|
||||
},
|
||||
images,
|
||||
credentials,
|
||||
log,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
await clearRecoveredProviderState(credentials);
|
||||
return jsonResponse(result.data);
|
||||
}
|
||||
return jsonResponse(
|
||||
toJsonErrorPayload(result.error, "Image edit provider error"),
|
||||
result.status
|
||||
);
|
||||
}
|
||||
|
||||
// Adobe Firefly: edit = storage upload + generate-async referenceBlobs (same as i2i generate).
|
||||
if (providerConfig?.format === "adobe-firefly-image") {
|
||||
return handleAdobeFireflyEditRequest({
|
||||
|
||||
@@ -308,10 +308,11 @@ async function postHandler(request, context) {
|
||||
}
|
||||
|
||||
const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error");
|
||||
return new Response(JSON.stringify(errorPayload), {
|
||||
status: (result as any).status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const message =
|
||||
typeof errorPayload?.error?.message === "string"
|
||||
? errorPayload.error.message
|
||||
: "Image generation provider error";
|
||||
return errorResponse((result as any).status, message);
|
||||
}
|
||||
|
||||
export const POST = withInjectionGuard(postHandler);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { createLazyConnectionView } from "@/lib/db/providers/lazyConnectionView"
|
||||
import { extractAliasBackedModels } from "./aliasBackedModels";
|
||||
import {
|
||||
buildSyncedModelIdsByCanonicalProvider,
|
||||
shouldSuppressStaticModelBySyncedCoverage,
|
||||
shouldSuppressStaticModelForExclusiveListing,
|
||||
} from "./catalogSyncedCoverage";
|
||||
import { buildSyncedCapabilities, mergeSyncedCapabilities } from "./syncedCapabilities";
|
||||
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry";
|
||||
@@ -41,7 +41,11 @@ import type { SyncedAvailableModel } from "@/lib/db/models";
|
||||
import { getAllActiveSyncedModels } from "@/lib/db/models/activeSyncedCatalog";
|
||||
import { getModelCatalogCacheVersion } from "@/lib/db/readCache";
|
||||
import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels";
|
||||
import { providerUsesCuratedModelsOnly } from "@/lib/providers/modelListingCapability";
|
||||
import {
|
||||
providerUsesCuratedModelsOnly,
|
||||
providerUsesExclusiveSyncedListing,
|
||||
} from "@/lib/providers/modelListingCapability";
|
||||
import { ensureCursorAutoCatalogEntry } from "@/lib/providerModels/cursorAutoCatalog";
|
||||
import { getOpenRouterCatalog } from "@/lib/catalog/openrouterCatalog";
|
||||
import { hasEligibleConnectionForModel } from "@/domain/connectionModelRules";
|
||||
import {
|
||||
@@ -737,14 +741,22 @@ async function buildUnifiedModelsResponseCore(
|
||||
// `deepseek/deepseek-v4-flash` which its discovery never lists). Before
|
||||
// the fix, a provider with any synced model silently dropped ALL its
|
||||
// static models.
|
||||
//
|
||||
// Cursor exclusive listing: when an active synced catalog exists, drop
|
||||
// ALL static rows (including effort variants) so Test All / clients only
|
||||
// see live AvailableModels + injected auto*.
|
||||
const syncedForProvider = syncedModelIdsByCanonicalProvider.get(canonicalProviderId);
|
||||
const exclusiveListing = providerUsesExclusiveSyncedListing(canonicalProviderId);
|
||||
const providerHasSynced = syncedForProvider !== undefined && syncedForProvider.size > 0;
|
||||
const coveredBySynced = shouldSuppressStaticModelForExclusiveListing({
|
||||
exclusiveListing,
|
||||
providerHasSynced,
|
||||
staticModelId: model.id,
|
||||
syncedModelIds: syncedForProvider ? [...syncedForProvider] : [],
|
||||
});
|
||||
if (
|
||||
shouldSuppressStaticModelBySyncedCoverage({
|
||||
providerHasSynced: syncedForProvider !== undefined && syncedForProvider.size > 0,
|
||||
staticModelId: model.id,
|
||||
syncedModelIds: syncedForProvider ? [...syncedForProvider] : [],
|
||||
}) &&
|
||||
!isRegisteredEffortVariant(providerModels, model.id)
|
||||
coveredBySynced &&
|
||||
(exclusiveListing || !isRegisteredEffortVariant(providerModels, model.id))
|
||||
)
|
||||
continue;
|
||||
if (!providerSupportsModel(canonicalProviderId, model.id)) continue;
|
||||
@@ -838,7 +850,16 @@ async function buildUnifiedModelsResponseCore(
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const sm of syncedModels) {
|
||||
for (const sm of providerUsesExclusiveSyncedListing(providerId)
|
||||
? ensureCursorAutoCatalogEntry(
|
||||
syncedModels.map((row) => ({
|
||||
...row,
|
||||
id: row.id,
|
||||
name: row.name || row.id,
|
||||
owned_by: "cursor",
|
||||
}))
|
||||
)
|
||||
: syncedModels) {
|
||||
if (!providerSupportsModel(canonicalProviderId, sm.id)) continue;
|
||||
if (canonicalProviderId === "codex" && isCodexDiscoveryModelExcluded(sm)) {
|
||||
continue;
|
||||
@@ -1073,9 +1094,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
// here would discard all but the last segment and miss stored flags for
|
||||
// providers whose model IDs carry a sub-path (e.g. OpenRouter scoped models).
|
||||
const getSpecialtyModelRelativeId = (modelId: string, provider: string): string =>
|
||||
modelId.startsWith(`${provider}/`)
|
||||
? modelId.slice(provider.length + 1)
|
||||
: modelId;
|
||||
modelId.startsWith(`${provider}/`) ? modelId.slice(provider.length + 1) : modelId;
|
||||
|
||||
// Add embedding models (filtered by active providers)
|
||||
for (const embModel of getAllEmbeddingModels()) {
|
||||
|
||||
@@ -33,6 +33,29 @@ export function shouldSuppressStaticModelBySyncedCoverage(opts: {
|
||||
return opts.syncedModelIds.includes(opts.staticModelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclusive live-catalog listing (Cursor): when the provider opts in and has a
|
||||
* non-empty synced catalog, suppress EVERY static registry row — including
|
||||
* effort-suffixed variants the coverage helper would otherwise preserve.
|
||||
*
|
||||
* Non-exclusive providers fall through to exact-id coverage suppression.
|
||||
*/
|
||||
export function shouldSuppressStaticModelForExclusiveListing(opts: {
|
||||
exclusiveListing: boolean;
|
||||
providerHasSynced: boolean;
|
||||
staticModelId: string;
|
||||
syncedModelIds: string[];
|
||||
}): boolean {
|
||||
if (opts.exclusiveListing) {
|
||||
return opts.providerHasSynced && opts.syncedModelIds.length > 0;
|
||||
}
|
||||
return shouldSuppressStaticModelBySyncedCoverage({
|
||||
providerHasSynced: opts.providerHasSynced,
|
||||
staticModelId: opts.staticModelId,
|
||||
syncedModelIds: opts.syncedModelIds,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Map of canonical provider id -> set of synced display-model ids, so the
|
||||
* static loop can decide which static models a provider's synced discovery list
|
||||
|
||||
@@ -119,8 +119,9 @@ export async function POST(request, { params }) {
|
||||
}
|
||||
|
||||
const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error");
|
||||
return new Response(JSON.stringify(errorPayload), {
|
||||
status: (result as any).status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const message =
|
||||
typeof errorPayload?.error?.message === "string"
|
||||
? errorPayload.error.message
|
||||
: "Image generation provider error";
|
||||
return errorResponse((result as any).status, message);
|
||||
}
|
||||
|
||||
@@ -4948,6 +4948,7 @@
|
||||
"baseUrlHint": "必填。 提供者 API 基本 URL。",
|
||||
"iconUrlLabel": "图标 URL",
|
||||
"iconUrlHint": "可选。显示为此服务商图标的图片 URL。",
|
||||
"iconUrlInvalid": "无效的图标 URL。请使用 http(s):// 或 data:image/*;base64 URL。",
|
||||
"anthropicPrefixPlaceholder": "ac-prod",
|
||||
"openaiPrefixPlaceholder": "oc-prod",
|
||||
"anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1",
|
||||
|
||||
@@ -4948,6 +4948,7 @@
|
||||
"baseUrlHint": "必填。 提供者 API 基本 URL。",
|
||||
"iconUrlLabel": "圖示網址",
|
||||
"iconUrlHint": "選用。顯示為此提供者圖示的圖片網址。",
|
||||
"iconUrlInvalid": "無效的圖示網址。請使用 http(s):// 或 data:image/*;base64 URL。",
|
||||
"anthropicPrefixPlaceholder": "ac-prod",
|
||||
"openaiPrefixPlaceholder": "oc-prod",
|
||||
"anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1",
|
||||
|
||||
@@ -204,8 +204,18 @@ export async function reconcileProvidersWithActiveSyncedCatalog(
|
||||
|
||||
for (const { provider, catalog } of states) {
|
||||
const modelIsLive = catalog.models.some((model) => model.id === modelId);
|
||||
// Cursor auto-router: always allow `auto` / router variants even if a stale live
|
||||
// catalog omitted them (AvailableModels / agent list often returns wire id
|
||||
// `default` only; listing injects `auto` + cost/balance/intelligence).
|
||||
const cursorAutoAllow =
|
||||
provider === "cursor" &&
|
||||
(modelId === "auto" ||
|
||||
modelId === "default" ||
|
||||
modelId === "auto-cost" ||
|
||||
modelId === "auto-balance" ||
|
||||
modelId === "auto-intelligence");
|
||||
|
||||
if (!catalog.authoritative || modelIsLive) {
|
||||
if (!catalog.authoritative || modelIsLive || cursorAutoAllow) {
|
||||
providers.push(provider);
|
||||
} else {
|
||||
excludedProviders.push(provider);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { getDbInstance } from "../core";
|
||||
import { backupDbFile } from "../backup";
|
||||
import { cleanupComboConnectionRefs } from "../combos";
|
||||
import { deleteLKGPByConnectionIds } from "../settings/lkgp";
|
||||
import {
|
||||
removeConnectionHealth,
|
||||
removeConnectionIndex,
|
||||
@@ -65,6 +66,17 @@ async function _cleanupDeletedComboConnectionRefs(connectionIds: string | string
|
||||
}
|
||||
}
|
||||
|
||||
async function _cleanupDeletedLKGPConnectionRefs(connectionIds: string | string[]): Promise<void> {
|
||||
const ids = Array.isArray(connectionIds) ? connectionIds : [connectionIds];
|
||||
if (ids.length === 0) return;
|
||||
|
||||
try {
|
||||
await deleteLKGPByConnectionIds(ids);
|
||||
} catch (error) {
|
||||
console.error("Failed to clean up LKGP refs for deleted connections:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProviderConnection(id: string) {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const existing = db.prepare("SELECT provider FROM provider_connections WHERE id = ?").get(id);
|
||||
@@ -77,6 +89,7 @@ export async function deleteProviderConnection(id: string) {
|
||||
})();
|
||||
|
||||
await _cleanupDeletedComboConnectionRefs(id);
|
||||
await _cleanupDeletedLKGPConnectionRefs(id);
|
||||
|
||||
removeConnectionHealth(id);
|
||||
removeConnectionIndex(id);
|
||||
@@ -114,6 +127,7 @@ export async function deleteProviderConnections(ids: string[]): Promise<number>
|
||||
})();
|
||||
|
||||
await _cleanupDeletedComboConnectionRefs(existingIds);
|
||||
await _cleanupDeletedLKGPConnectionRefs(existingIds);
|
||||
|
||||
for (const id of ids) {
|
||||
removeConnectionHealth(id);
|
||||
@@ -150,6 +164,7 @@ export async function deleteProviderConnectionsByProvider(providerId: string) {
|
||||
})();
|
||||
|
||||
await _cleanupDeletedComboConnectionRefs(connectionIds);
|
||||
await _cleanupDeletedLKGPConnectionRefs(connectionIds);
|
||||
|
||||
for (const connectionId of connectionIds) {
|
||||
removeConnectionHealth(connectionId);
|
||||
|
||||
@@ -210,6 +210,14 @@ export async function setCachedLKGP(
|
||||
lkgpCache.invalidate(`lkgp:${comboName}:${modelId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate one persisted LKGP pin by its `${comboName}:${modelId}` storage key,
|
||||
* or every cached LKGP pin when no key is provided.
|
||||
*/
|
||||
export function invalidateCachedLKGP(pinKey?: string): void {
|
||||
lkgpCache.invalidate(pinKey ? `lkgp:${pinKey}` : undefined);
|
||||
}
|
||||
|
||||
// ──────────────── Combo Cache Invalidation Signal ────────────────
|
||||
//
|
||||
// The nested-combo expansion caches live in request handlers
|
||||
|
||||
@@ -47,3 +47,53 @@ export function clearAllLKGP(): void {
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM key_value WHERE namespace = 'lkgp'").run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete persisted LKGP pins whose connectionId references a removed provider
|
||||
* connection. Provider-level pins and legacy/unparseable values are preserved.
|
||||
*/
|
||||
export async function deleteLKGPByConnectionIds(connectionIds: string[]): Promise<number> {
|
||||
if (connectionIds.length === 0) return 0;
|
||||
|
||||
const deletedConnectionIds = new Set(connectionIds.filter(Boolean));
|
||||
if (deletedConnectionIds.size === 0) return 0;
|
||||
|
||||
const db = getDbInstance();
|
||||
const rows = db
|
||||
.prepare("SELECT key, value FROM key_value WHERE namespace = 'lkgp'")
|
||||
.all() as Array<{ key?: string; value?: string }>;
|
||||
|
||||
const staleKeys: string[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row?.key || !row.value) continue;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(row.value);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof parsed !== "object" || parsed === null) continue;
|
||||
|
||||
const connectionId = (parsed as LKGPRecord).connectionId;
|
||||
if (typeof connectionId === "string" && deletedConnectionIds.has(connectionId)) {
|
||||
staleKeys.push(row.key);
|
||||
}
|
||||
}
|
||||
|
||||
if (staleKeys.length === 0) return 0;
|
||||
|
||||
const deleteStatement = db.prepare("DELETE FROM key_value WHERE namespace = 'lkgp' AND key = ?");
|
||||
for (const key of staleKeys) {
|
||||
deleteStatement.run(key);
|
||||
}
|
||||
|
||||
const { invalidateCachedLKGP } = await import("../readCache");
|
||||
for (const key of staleKeys) {
|
||||
invalidateCachedLKGP(key);
|
||||
}
|
||||
|
||||
return staleKeys.length;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
callVisionModel as defaultCallVisionModel,
|
||||
composeVisionPrompt,
|
||||
replaceImageParts,
|
||||
ensureBase64ImagesForClaudeWire,
|
||||
} from "./visionBridgeHelpers";
|
||||
import { fetch as undiciFetch } from "undici";
|
||||
import {
|
||||
getVisionBridgeConfig,
|
||||
isVisionBridgeForcedModel,
|
||||
@@ -298,14 +300,27 @@ export class VisionBridgeGuardrail extends BaseGuardrail {
|
||||
const bestUsable = await checkCreds(bestModel);
|
||||
// Only block the reroute when we KNOW the target is unusable (false).
|
||||
// `null` (no DB / tests) fails open so existing unit tests keep working.
|
||||
if (bestUsable === false) {
|
||||
// `auto/*` ids (e.g. auto/best-vision) are VIRTUAL combos: credentials
|
||||
// resolve through their member models at request time, so a missing
|
||||
// "auto" provider row (hasUsableCredentialsForModel → false) must
|
||||
// never block the reroute.
|
||||
if (bestUsable === false && !bestModel.startsWith("auto/")) {
|
||||
context.log?.warn?.(
|
||||
"VISION_BRIDGE",
|
||||
`Vision reroute target ${bestModel} has no usable credentials; describing images instead of hijacking ${model}`
|
||||
);
|
||||
} else {
|
||||
// Claude-wire backends (minimax, zai, …) reject remote image URLs
|
||||
// (MiniMax 403 2013); resolve them to base64 before rerouting so
|
||||
// the rerouted request can actually be processed upstream. Use
|
||||
// undici fetch to bypass the runtime's hooked global fetch.
|
||||
const rerouteBody = await ensureBase64ImagesForClaudeWire(
|
||||
body as Parameters<typeof ensureBase64ImagesForClaudeWire>[0],
|
||||
bestModel,
|
||||
undiciFetch as unknown as typeof fetch
|
||||
);
|
||||
const modifiedBody = {
|
||||
...(body as Record<string, unknown>),
|
||||
...(rerouteBody as Record<string, unknown>),
|
||||
model: bestModel,
|
||||
};
|
||||
return {
|
||||
@@ -347,7 +362,14 @@ export class VisionBridgeGuardrail extends BaseGuardrail {
|
||||
// targets what the user actually asked instead of a generic caption.
|
||||
const lastUserText = extractLastUserText(messages);
|
||||
const composedPrompt = composeVisionPrompt(config.prompt, lastUserText, runtime.taskAware);
|
||||
const describeConfig = { ...config, prompt: composedPrompt };
|
||||
// Bypass the runtime's hooked global fetch (ProxyFetch) for the self-loop
|
||||
// describe call — a dead local proxy (127.0.0.1:8317) would otherwise break
|
||||
// every describe. Tests inject their own callVisionModel.
|
||||
const describeConfig = {
|
||||
...config,
|
||||
prompt: composedPrompt,
|
||||
fetchImpl: undiciFetch as unknown as typeof fetch,
|
||||
};
|
||||
|
||||
// Shared describe cache (sha256 of contentRef+prompt+model): the same image
|
||||
// with the same prompt/model is described once per TTL. Failures are never
|
||||
|
||||
@@ -6,6 +6,8 @@ import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";
|
||||
import { getRuntimePorts } from "@/lib/runtime/ports";
|
||||
import { resolveSelfLoopBearer } from "@/shared/middleware/chatBodyAdmission";
|
||||
import { getBestVisionModel, getFallbackModels, recordLatency } from "./visionBridgeRouter";
|
||||
import { REGISTRY } from "@omniroute/open-sse/config/providers";
|
||||
import { fetch as undiciFetch } from "undici";
|
||||
/**
|
||||
* Provider to environment variable mapping for API key resolution.
|
||||
*/
|
||||
@@ -15,6 +17,27 @@ const PROVIDER_API_KEY_MAP: Record<string, string> = {
|
||||
openai: "OPENAI_API_KEY",
|
||||
};
|
||||
|
||||
// Providers whose wire format is Anthropic Messages ("claude"). Anthropic
|
||||
// accepts `source: { type: "url" }` for images, but most claude-format backends
|
||||
// (MiniMax, Z.AI, …) do NOT — they reject remote URLs (MiniMax: 403 code
|
||||
// 2013). The vision bridge must deliver images as base64 for these targets,
|
||||
// both in the describe self-loop and in the rerouted payload.
|
||||
const CLAUDE_WIRE_PROVIDERS = new Set<string>(
|
||||
Object.entries(REGISTRY)
|
||||
.filter(([, entry]) => entry.format === "claude")
|
||||
.map(([id]) => id.toLowerCase())
|
||||
);
|
||||
|
||||
/**
|
||||
* True when `provider/model` targets a Claude-Messages wire format backend
|
||||
* that cannot ingest remote image URLs and needs base64 instead.
|
||||
*/
|
||||
export function isClaudeWireFormatModel(model: string | null | undefined): boolean {
|
||||
if (!model || typeof model !== "string") return false;
|
||||
const provider = model.includes("/") ? model.split("/")[0].trim().toLowerCase() : "";
|
||||
return CLAUDE_WIRE_PROVIDERS.has(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve API key based on model provider (issue #2232).
|
||||
*
|
||||
@@ -46,6 +69,43 @@ export function resolveProviderApiKey(model: string, explicitKey?: string): stri
|
||||
return process.env[envVar] || "";
|
||||
}
|
||||
|
||||
let selfLoopKeyPromise: Promise<string> | null = null;
|
||||
|
||||
/**
|
||||
* Resolve a real API key for the OmniRoute SELF-LOOP describe call.
|
||||
*
|
||||
* The `sk_omniroute` sentinel works only when REQUIRE_API_KEY is disabled; on
|
||||
* REQUIRE_API_KEY instances it is rejected with 401 "Missing API key", which
|
||||
* silently breaks every vision-bridge describe. Priority:
|
||||
* 1. VISION_BRIDGE_API_KEY env (already handled by resolveProviderApiKey —
|
||||
* kept here for the injected-resolver test path).
|
||||
* 2. Injected resolver (tests) or the DB-backed `getOrCreateApiKey()` —
|
||||
* memoized so at most one key is created per process.
|
||||
* 3. `sk_omniroute` as a final fallback (local mode without auth).
|
||||
*/
|
||||
export async function resolveSelfLoopApiKey(resolver?: () => Promise<string>): Promise<string> {
|
||||
const envKey = (process.env.VISION_BRIDGE_API_KEY || "").trim();
|
||||
if (envKey) return envKey;
|
||||
if (resolver) {
|
||||
const key = (await resolver()).trim();
|
||||
if (key) return key;
|
||||
return "sk_omniroute";
|
||||
}
|
||||
if (!selfLoopKeyPromise) {
|
||||
selfLoopKeyPromise = (async () => {
|
||||
try {
|
||||
const { getOrCreateApiKey } = await import("@/shared/services/apiKeyResolver");
|
||||
const key = await getOrCreateApiKey();
|
||||
if (typeof key === "string" && key.trim().length > 0) return key.trim();
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return "sk_omniroute";
|
||||
})();
|
||||
}
|
||||
return selfLoopKeyPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OpenAI-compatible base URL for non-Anthropic vision bridge calls
|
||||
* (issue #2232).
|
||||
@@ -152,6 +212,68 @@ export function extractImageParts(messages: RequestMessage[]): ImagePart[] {
|
||||
}));
|
||||
}
|
||||
|
||||
// Undici fetch with a browser-ish User-Agent: Wikimedia (and other CDNs)
|
||||
// reject requests without a UA with HTTP 400, silently breaking remote image
|
||||
// downloads in the describe path.
|
||||
const VISION_BRIDGE_UA_FETCH: typeof fetch = ((input: RequestInfo | URL, init?: RequestInit) =>
|
||||
undiciFetch(input as string | URL, {
|
||||
...(init as Parameters<typeof undiciFetch>[1]),
|
||||
headers: {
|
||||
"user-agent": "omniroute-vision-bridge",
|
||||
...((init?.headers as Record<string, string> | undefined) ?? {}),
|
||||
},
|
||||
})) as typeof fetch;
|
||||
|
||||
/**
|
||||
* Resolve every image part in the body to a base64 data URI when the target
|
||||
* model speaks the Claude wire format (remote URLs unsupported by most
|
||||
* claude-format backends, e.g. MiniMax 403 2013). Fail-open: an image that
|
||||
* cannot be fetched is left untouched.
|
||||
*/
|
||||
export async function ensureBase64ImagesForClaudeWire(
|
||||
body: RequestBody,
|
||||
model: string,
|
||||
fetchImpl: typeof fetch = VISION_BRIDGE_UA_FETCH
|
||||
): Promise<RequestBody> {
|
||||
if (!isClaudeWireFormatModel(model)) return body;
|
||||
const parts = extractImageParts(body.messages as RequestMessage[]);
|
||||
if (parts.length === 0) return body;
|
||||
|
||||
const resolved = await Promise.all(
|
||||
parts.map(async (part) => {
|
||||
const normalized = resolveImageAsDataUri(part.imageUrl);
|
||||
if (normalized.startsWith("data:")) return null; // already base64
|
||||
try {
|
||||
return await fetchRemoteImageAsDataUri(normalized, new AbortController().signal, fetchImpl);
|
||||
} catch {
|
||||
return null; // fail-open: keep the original part
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Map sequential image index → resolved data URI (null = keep original).
|
||||
const byIndex = new Map<number, string>();
|
||||
parts.forEach((part, i) => {
|
||||
if (resolved[i]) byIndex.set(i, resolved[i] as string);
|
||||
});
|
||||
if (byIndex.size === 0) return body;
|
||||
|
||||
const result = structuredClone(body) as RequestBody;
|
||||
let imageIndex = 0;
|
||||
for (const message of result.messages ?? []) {
|
||||
if (!message || !Array.isArray(message.content)) continue;
|
||||
for (const part of message.content as RequestContentPart[]) {
|
||||
if (part.type !== "image_url" && part.type !== "image") continue;
|
||||
const dataUri = byIndex.get(imageIndex);
|
||||
imageIndex++;
|
||||
if (dataUri) {
|
||||
(part as { image_url?: { url: string } }).image_url = { url: dataUri };
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve image URL to data URI format for vision model.
|
||||
* - HTTP/HTTPS URLs: passed through as-is
|
||||
@@ -178,8 +300,17 @@ export function resolveImageAsDataUri(imageUrl: string): string {
|
||||
return `data:image/png;base64,${imageUrl}`;
|
||||
}
|
||||
|
||||
async function fetchRemoteImageAsDataUri(imageUrl: string, signal: AbortSignal): Promise<string> {
|
||||
const remoteImage = await fetchRemoteImage(imageUrl, { signal });
|
||||
async function fetchRemoteImageAsDataUri(
|
||||
imageUrl: string,
|
||||
signal: AbortSignal,
|
||||
fetchImpl: typeof fetch = VISION_BRIDGE_UA_FETCH
|
||||
): Promise<string> {
|
||||
const remoteImage = await fetchRemoteImage(imageUrl, {
|
||||
signal,
|
||||
// Bypass the runtime's hooked global fetch (ProxyFetch) — a dead local
|
||||
// proxy (e.g. 127.0.0.1:8317) would otherwise break the download.
|
||||
fetchImpl,
|
||||
});
|
||||
const mediaType = remoteImage.contentType.split(";")[0]?.trim() || "image/png";
|
||||
return `data:${mediaType};base64,${remoteImage.buffer.toString("base64")}`;
|
||||
}
|
||||
@@ -187,7 +318,8 @@ async function fetchRemoteImageAsDataUri(imageUrl: string, signal: AbortSignal):
|
||||
async function normalizeVisionImageInput(
|
||||
imageInput: string,
|
||||
isAnthropic: boolean,
|
||||
signal: AbortSignal
|
||||
signal: AbortSignal,
|
||||
fetchImpl?: typeof fetch
|
||||
): Promise<string> {
|
||||
const normalizedImage = resolveImageAsDataUri(imageInput);
|
||||
|
||||
@@ -195,7 +327,7 @@ async function normalizeVisionImageInput(
|
||||
isAnthropic &&
|
||||
(normalizedImage.startsWith("http://") || normalizedImage.startsWith("https://"))
|
||||
) {
|
||||
return fetchRemoteImageAsDataUri(normalizedImage, signal);
|
||||
return fetchRemoteImageAsDataUri(normalizedImage, signal, fetchImpl);
|
||||
}
|
||||
|
||||
return normalizedImage;
|
||||
@@ -206,6 +338,8 @@ export interface VisionModelConfig {
|
||||
prompt: string;
|
||||
timeoutMs: number;
|
||||
maxImages: number;
|
||||
/** Injectable fetch (tests). Defaults to undici fetch to bypass the runtime's hooked global fetch. */
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
/** Task-aware focus hint (codex-vision-proxy pattern): steer the description
|
||||
@@ -481,17 +615,27 @@ async function callVisionModelSingle(
|
||||
|
||||
// Resolve API key based on provider
|
||||
const resolvedApiKey = resolveProviderApiKey(config.model, apiKey);
|
||||
// Production callers (VisionBridgeGuardrail) inject undici fetch to bypass
|
||||
// the runtime's hooked global fetch (ProxyFetch). Defaults to globalThis.fetch
|
||||
// so existing unit tests that mock it keep working.
|
||||
const fetchImpl = config.fetchImpl ?? globalThis.fetch;
|
||||
|
||||
// Detect provider from model identifier
|
||||
// Detect provider from model identifier. Claude-wire targets (minimax, zai,
|
||||
// …) cannot ingest remote image URLs — normalize to base64 so the self-loop
|
||||
// body reaches the backend as a data URI (the OpenAI→claude translator only
|
||||
// preserves data URIs as base64; remote URLs become source.url which these
|
||||
// backends reject).
|
||||
const isAnthropic = config.model.startsWith("anthropic/");
|
||||
const requiresBase64 = isAnthropic || isClaudeWireFormatModel(config.model);
|
||||
|
||||
try {
|
||||
// Extract model name from provider/model format
|
||||
const modelName = config.model.includes("/") ? config.model.split("/")[1] : config.model;
|
||||
const normalizedImageInput = await normalizeVisionImageInput(
|
||||
imageDataUri,
|
||||
isAnthropic,
|
||||
controller.signal
|
||||
requiresBase64,
|
||||
controller.signal,
|
||||
fetchImpl
|
||||
);
|
||||
|
||||
let response: Response;
|
||||
@@ -510,7 +654,7 @@ async function callVisionModelSingle(
|
||||
base64Data = matches[2];
|
||||
}
|
||||
|
||||
response = await fetch(`${anthropicBaseUrl}/v1/messages`, {
|
||||
response = await fetchImpl(`${anthropicBaseUrl}/v1/messages`, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
@@ -561,8 +705,9 @@ async function callVisionModelSingle(
|
||||
// Build headers with optional recursion guard for self-loop calls.
|
||||
// When routing through OmniRoute's own API, omit the vision-bridge
|
||||
// guardrail on the sub-request to prevent infinite recursion.
|
||||
// Use sk_omniroute as fallback for self-loop if no API key is resolved.
|
||||
const selfLoopApiKey = resolvedApiKey || "sk_omniroute";
|
||||
// Use a real DB-backed key for self-loop (sk_omniroute is rejected by
|
||||
// REQUIRE_API_KEY instances with 401 "Missing API key").
|
||||
const selfLoopApiKey = resolvedApiKey || (await resolveSelfLoopApiKey());
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
// Explicit JSON opt-in: without `Accept: application/json` OmniRoute's
|
||||
@@ -583,6 +728,9 @@ async function callVisionModelSingle(
|
||||
// `sk_omniroute` sentinel OR the operator-configured env key), so
|
||||
// external clients cannot use it to bypass admission.
|
||||
headers["x-omniroute-admission-bypass"] = "internal";
|
||||
// The compression pipeline must not touch the image payload of the
|
||||
// self-loop describe call (stacked RTK/Caveman can mangle data URIs).
|
||||
headers["x-omniroute-compression"] = "off";
|
||||
// The admission bypass honors the env key when set (REQUIRE_API_KEY=true
|
||||
// deployments) and the `sk_omniroute` sentinel otherwise. Force the same
|
||||
// resolved credential so the bypass holds even when a real vision key is
|
||||
@@ -590,7 +738,7 @@ async function callVisionModelSingle(
|
||||
headers["Authorization"] = `Bearer ${resolveSelfLoopBearer()}`;
|
||||
}
|
||||
|
||||
response = await fetch(`${baseUrl}/chat/completions`, {
|
||||
response = await fetchImpl(`${baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
headers,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { getResolvedModelCapabilities } from "@/lib/modelCapabilities";
|
||||
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@omniroute/open-sse/config/providerModels";
|
||||
import { hasUsableCredentialsForModel } from "./visionBridgeCredentials";
|
||||
import { isVisionBridgeForcedModel } from "@/shared/constants/visionBridgeDefaults";
|
||||
|
||||
export interface VisionModelCandidate {
|
||||
modelId: string;
|
||||
@@ -133,7 +134,7 @@ async function getVisionCapableModels(
|
||||
const fullModelId = `${providerAlias}/${model.id}`;
|
||||
const caps = getResolvedModelCapabilities(fullModelId);
|
||||
|
||||
if (caps.supportsVision === true) {
|
||||
if (caps.supportsVision === true && !isVisionBridgeForcedModel(fullModelId)) {
|
||||
checks.push(
|
||||
checkCreds(fullModelId).then((usable) => {
|
||||
// Only a confirmed `false` excludes a candidate — `null` (indeterminate,
|
||||
|
||||
@@ -112,7 +112,12 @@ async function onboardAntigravityUser(
|
||||
tierId: string,
|
||||
metadata: Record<string, string>
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// Bounded onboarding: cap retries (was 10) and jitter the delay so a stuck
|
||||
// loop cannot look like scripted automation to the upstream (ban-safety).
|
||||
const MAX_ONBOARD_RETRIES = 3;
|
||||
const BASE_RETRY_MS = 3000;
|
||||
const JITTER_MS = 4000;
|
||||
for (let i = 0; i < MAX_ONBOARD_RETRIES; i++) {
|
||||
try {
|
||||
const response = await fetchFirstOk(
|
||||
config.onboardUserEndpoints,
|
||||
@@ -124,7 +129,7 @@ async function onboardAntigravityUser(
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
await new Promise((resolve) => setTimeout(resolve, BASE_RETRY_MS + Math.random() * JITTER_MS));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
60
src/lib/providerModels/cursorAutoCatalog.ts
Normal file
60
src/lib/providerModels/cursorAutoCatalog.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Ensure Cursor catalog listings always expose OmniRoute's public auto-router
|
||||
* ids (`auto` + Cost/Balance/Intelligence variants). Live AvailableModels /
|
||||
* cursor-agent often return wire id `default` only.
|
||||
*/
|
||||
|
||||
export type CursorAutoCatalogEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
owned_by?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export const CURSOR_AUTO_ROUTER_VARIANT_IDS = [
|
||||
"auto-cost",
|
||||
"auto-balance",
|
||||
"auto-intelligence",
|
||||
] as const;
|
||||
|
||||
const CURSOR_AUTO_ROUTER_VARIANT_NAMES: Record<
|
||||
(typeof CURSOR_AUTO_ROUTER_VARIANT_IDS)[number],
|
||||
string
|
||||
> = {
|
||||
"auto-cost": "Auto (cost)",
|
||||
"auto-balance": "Auto (balance)",
|
||||
"auto-intelligence": "Auto (intelligence)",
|
||||
};
|
||||
|
||||
/** Cursor auto-router: catalog id `auto`, wire id `default`. Always keep `auto` visible. */
|
||||
export function ensureCursorAutoCatalogEntry<T extends CursorAutoCatalogEntry>(models: T[]): T[] {
|
||||
const byId = new Map(models.map((m) => [m.id, m]));
|
||||
const out = [...models];
|
||||
|
||||
if (!byId.has("auto")) {
|
||||
const defaultEntry = byId.get("default");
|
||||
const autoEntry = {
|
||||
id: "auto",
|
||||
name:
|
||||
typeof defaultEntry?.name === "string" && defaultEntry.name.trim()
|
||||
? defaultEntry.name
|
||||
: "Auto (current, default)",
|
||||
owned_by: "cursor",
|
||||
} as T;
|
||||
out.unshift(autoEntry);
|
||||
byId.set("auto", autoEntry);
|
||||
}
|
||||
|
||||
for (const id of CURSOR_AUTO_ROUTER_VARIANT_IDS) {
|
||||
if (byId.has(id)) continue;
|
||||
const entry = {
|
||||
id,
|
||||
name: CURSOR_AUTO_ROUTER_VARIANT_NAMES[id],
|
||||
owned_by: "cursor",
|
||||
} as T;
|
||||
out.push(entry);
|
||||
byId.set(id, entry);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
96
src/lib/providers/mergeProviderModelListing.ts
Normal file
96
src/lib/providers/mergeProviderModelListing.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Pure merge of registry / synced / custom model rows for the provider detail
|
||||
* dashboard (and thus Test All targets). Cursor exclusive listing prefers the
|
||||
* live synced catalog when non-empty.
|
||||
*/
|
||||
|
||||
import { ensureCursorAutoCatalogEntry } from "@/lib/providerModels/cursorAutoCatalog";
|
||||
import {
|
||||
providerUsesCuratedModelsOnly,
|
||||
providerUsesExclusiveSyncedListing,
|
||||
} from "@/lib/providers/modelListingCapability";
|
||||
|
||||
export type ProviderListingModel = {
|
||||
id: string;
|
||||
name?: string;
|
||||
source?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type MergeProviderModelListingInput = {
|
||||
providerId: string;
|
||||
registryModels: Array<{ id: string; name?: string; [key: string]: unknown }>;
|
||||
syncedModels: Array<{ id: string; name?: string; [key: string]: unknown }>;
|
||||
customModels: Array<{ id: string; name?: string; source?: string; [key: string]: unknown }>;
|
||||
usesCuratedModelsOnly?: boolean;
|
||||
};
|
||||
|
||||
function normalizeCustomSource(source: unknown): "imported" | "custom" {
|
||||
return source === "imported" ? "imported" : "custom";
|
||||
}
|
||||
|
||||
function dedupeById(models: ProviderListingModel[]): ProviderListingModel[] {
|
||||
const deduped = new Map<string, ProviderListingModel>();
|
||||
for (const m of models) {
|
||||
if (m.id && !deduped.has(m.id)) deduped.set(m.id, m);
|
||||
}
|
||||
return Array.from(deduped.values());
|
||||
}
|
||||
|
||||
export function mergeProviderModelListing(
|
||||
input: MergeProviderModelListingInput
|
||||
): ProviderListingModel[] {
|
||||
const curated =
|
||||
input.usesCuratedModelsOnly === true || providerUsesCuratedModelsOnly(input.providerId);
|
||||
const synced = curated ? [] : input.syncedModels.filter((m) => m?.id);
|
||||
const custom = curated ? [] : input.customModels.filter((m) => m?.id);
|
||||
|
||||
const exclusive = providerUsesExclusiveSyncedListing(input.providerId) && synced.length > 0;
|
||||
|
||||
if (exclusive) {
|
||||
const withAuto = ensureCursorAutoCatalogEntry(
|
||||
synced.map((model) => ({
|
||||
...model,
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
owned_by: "cursor",
|
||||
source: "imported",
|
||||
}))
|
||||
);
|
||||
const knownIds = new Set(withAuto.map((m) => m.id));
|
||||
const customExtras = custom
|
||||
.filter((cm) => cm.id && !knownIds.has(cm.id))
|
||||
.map((cm) => ({
|
||||
...cm,
|
||||
id: cm.id,
|
||||
name: cm.name || cm.id,
|
||||
source: normalizeCustomSource(cm.source),
|
||||
}));
|
||||
return dedupeById([...withAuto, ...customExtras]);
|
||||
}
|
||||
|
||||
const builtInModels = input.registryModels.map((model) => ({
|
||||
...model,
|
||||
source: "system",
|
||||
}));
|
||||
const registryIds = new Set(builtInModels.map((m) => m.id));
|
||||
const syncedExtras = synced
|
||||
.filter((model) => model.id && !registryIds.has(model.id))
|
||||
.map((model) => ({
|
||||
...model,
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
source: "imported",
|
||||
}));
|
||||
const knownIds = new Set([...registryIds, ...syncedExtras.map((m) => m.id)]);
|
||||
const customExtras = custom
|
||||
.filter((cm) => cm.id && !knownIds.has(cm.id))
|
||||
.map((cm) => ({
|
||||
...cm,
|
||||
id: cm.id,
|
||||
name: cm.name || cm.id,
|
||||
source: normalizeCustomSource(cm.source),
|
||||
}));
|
||||
|
||||
return dedupeById([...builtInModels, ...syncedExtras, ...customExtras]);
|
||||
}
|
||||
@@ -17,6 +17,19 @@ export function providerUsesCuratedModelsOnly(providerId: string): boolean {
|
||||
return CURATED_MODEL_ONLY_PROVIDERS.has(providerId.trim().toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers whose non-empty synced AvailableModels catalog fully replaces the
|
||||
* static registry for dashboard / `/v1/models` / Test All listing. Static rows
|
||||
* remain offline fallback only when synced is empty.
|
||||
*
|
||||
* Cursor-only for now — other authoritative live-catalog providers keep
|
||||
* coverage-style static preservation (e.g. command-code uncovered static ids).
|
||||
*/
|
||||
export function providerUsesExclusiveSyncedListing(providerId: string): boolean {
|
||||
const id = providerId.trim().toLowerCase();
|
||||
return id === "cursor" || id === "cu";
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the provider is tool-only and therefore has no model listing:
|
||||
* - its id ends in `-search` (legacy search providers), OR
|
||||
|
||||
@@ -172,6 +172,25 @@ export async function executeWebSearch(
|
||||
} else {
|
||||
credentials = await resolveSearchCredentials(providerConfig.id);
|
||||
|
||||
if (!credentials) {
|
||||
const fallbackProviders = Object.values(SEARCH_PROVIDERS)
|
||||
.filter((provider) => provider.fallbackOnly && supportsSearchType(provider, searchType))
|
||||
.sort((a, b) => a.costPerQuery - b.costPerQuery);
|
||||
|
||||
for (const fallbackProvider of fallbackProviders) {
|
||||
providerConfig = fallbackProvider;
|
||||
if (fallbackProvider.id === "duckduckgo-free") {
|
||||
credentials = {};
|
||||
break;
|
||||
}
|
||||
const fallbackCredentials = await resolveSearchCredentials(fallbackProvider.id);
|
||||
if (fallbackCredentials) {
|
||||
credentials = fallbackCredentials;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!credentials) {
|
||||
const sortedIds = Object.values(SEARCH_PROVIDERS)
|
||||
.filter((provider) => supportsSearchType(provider, searchType))
|
||||
|
||||
@@ -56,6 +56,19 @@ export class ServiceSupervisor extends EventEmitter {
|
||||
this.checker = new HealthChecker(config.healthUrl, config.healthIntervalMs, (h) => {
|
||||
this.health = h;
|
||||
this.emit("stateChange", this.getStatus());
|
||||
// A service that fails FAILURE_THRESHOLD consecutive health probes will
|
||||
// not recover by itself. Stop the poller and surface an explicit error
|
||||
// state instead of probing the dead port forever — every failed probe
|
||||
// fires a full ProxyFetch dispatcher+native fetch pair (e.g. against a
|
||||
// CLIProxyAPI binary that cannot execute on this platform).
|
||||
if (h === "unhealthy" && (this.state === "running" || this.state === "starting")) {
|
||||
this.checker.stop();
|
||||
this.lastError = sanitizeErrorMessage(
|
||||
`Health probe failed for ${this.config.tool} (port ${this.config.port})`
|
||||
);
|
||||
this.setState("error");
|
||||
void setToolStatus(this.config.tool, "error", undefined, this.lastError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,7 +148,22 @@ export class ServiceSupervisor extends EventEmitter {
|
||||
|
||||
const { command, args, env, cwd } = this.config.spawnArgs();
|
||||
|
||||
const child = spawn(command, args, buildServiceSpawnOptions(env, cwd));
|
||||
// spawn() can throw SYNCHRONOUSLY on Windows when the binary is not
|
||||
// executable (EFTYPE/EINVAL for an ELF or a plain text file) instead of
|
||||
// emitting the child 'error' event. Handle both paths identically so a
|
||||
// non-spawnable service surfaces an explicit error state and the health
|
||||
// poller is stopped instead of hammering a dead port forever.
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
child = spawn(command, args, buildServiceSpawnOptions(env, cwd));
|
||||
} catch (err) {
|
||||
this.checker.stop();
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
this.lastError = msg;
|
||||
this.setState("error");
|
||||
await setToolStatus(this.config.tool, "error", undefined, msg);
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
this.childProcess = child;
|
||||
this.pid = child.pid ?? null;
|
||||
@@ -161,6 +189,17 @@ export class ServiceSupervisor extends EventEmitter {
|
||||
child.once("exit", (code, signal) => {
|
||||
void this.handleExit(code, signal, spawnTime);
|
||||
});
|
||||
// Spawn failures (ENOENT, EACCES, or a non-executable binary such as an
|
||||
// ELF on Windows) surface via the child 'error' event — NOT 'exit'.
|
||||
// Without this handler the supervisor stays in "starting" forever and
|
||||
// the health poller hammers the dead port every healthIntervalMs.
|
||||
child.once("error", (err) => {
|
||||
this.checker.stop();
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
this.lastError = msg;
|
||||
this.setState("error");
|
||||
void setToolStatus(this.config.tool, "error", undefined, msg);
|
||||
});
|
||||
|
||||
this.startedAt = new Date().toISOString();
|
||||
this.checker.start();
|
||||
@@ -224,10 +263,12 @@ export class ServiceSupervisor extends EventEmitter {
|
||||
if (this.state === "error") throw new Error(this.lastError ?? "Service failed to start");
|
||||
await new Promise((r) => setTimeout(r, 1_000));
|
||||
}
|
||||
// Timeout reached without a healthy probe. Surface this so callers /
|
||||
// dashboards do not see "running" + "unknown" health silently. We do not
|
||||
// throw — the service may still be initializing — but we DO record a
|
||||
// degraded marker so /status returns it and operators can act.
|
||||
// Timeout reached without a healthy probe. The health poller may have
|
||||
// flipped the state to "error" while we were waiting (FAILURE_THRESHOLD
|
||||
// consecutive failures) — surface that instead of a degraded marker.
|
||||
if (this.state === "error") {
|
||||
throw new Error(this.lastError ?? "Service failed to start");
|
||||
}
|
||||
this.lastError = sanitizeErrorMessage(
|
||||
`Health probe did not succeed within ${timeoutMs}ms — service may still be initializing`
|
||||
);
|
||||
|
||||
@@ -89,6 +89,8 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([
|
||||
"hyperagent",
|
||||
"ha",
|
||||
"firecrawl",
|
||||
// Command Code API key → /alpha/billing/credits + windowLimits
|
||||
"command-code",
|
||||
]);
|
||||
const DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES = 70;
|
||||
const PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY = "provider_limits_auto_sync_last_run";
|
||||
|
||||
@@ -463,6 +463,8 @@ export const USAGE_SUPPORTED_PROVIDERS = [
|
||||
"grok-cli",
|
||||
// Firecrawl team credits (GET /v2/team/credit-usage)
|
||||
"firecrawl",
|
||||
// Command Code credits + 5h/weekly rolling windows
|
||||
"command-code",
|
||||
];
|
||||
|
||||
// ── Zod validation at module load (Phase 7.2) ──
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
/**
|
||||
* Bounded admission for POST /v1/chat/completions.
|
||||
* Process-local bounded admission for POST /v1/chat/completions.
|
||||
*
|
||||
* Large chat bodies amplify into multiple transient representations while they are parsed,
|
||||
* translated, compressed, and dispatched. A heap snapshot alone cannot prevent two healthy
|
||||
* requests from entering that allocation-heavy path together. This module reserves process-
|
||||
* local heavyweight capacity before parsing and enforces the hard limit against bytes read,
|
||||
* not an untrusted Content-Length header.
|
||||
*
|
||||
* Per-connection virtual admission lanes (#9654): each distinct API-key (or anonymous)
|
||||
* bucket gets its own FairCostQueue so one connection cannot exhaust heavyweight capacity
|
||||
* and starve others. Idle sessions are auto-evicted after a TTL.
|
||||
*/
|
||||
|
||||
import { CORS_HEADERS } from "../utils/cors";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
|
||||
const OMNIROUTE_CHAT_VIRTUAL_TTL_MS = parsePositiveInt(
|
||||
process.env.OMNIROUTE_CHAT_VIRTUAL_TTL_MS,
|
||||
60_000
|
||||
);
|
||||
|
||||
function parsePositiveInt(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
@@ -30,7 +41,7 @@ export const CHAT_HARD_MAX_BODY_BYTES = parsePositiveInt(
|
||||
50 * 1024 * 1024
|
||||
);
|
||||
|
||||
const CHAT_MAX_HEAVY_IN_FLIGHT = parsePositiveInt(
|
||||
export const CHAT_MAX_HEAVY_IN_FLIGHT = parsePositiveInt(
|
||||
process.env.OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT,
|
||||
1
|
||||
);
|
||||
@@ -44,7 +55,20 @@ const CHAT_MAX_HEAVY_IN_FLIGHT = parsePositiveInt(
|
||||
*/
|
||||
export const CHAT_ADMISSION_QUEUE_MAX_MS = parseNonNegativeInt(
|
||||
process.env.OMNIROUTE_CHAT_ADMISSION_QUEUE_MS,
|
||||
5000
|
||||
2000
|
||||
);
|
||||
|
||||
/**
|
||||
* Queued-bytes budget for the admission wait (#9654 / U3). A parked waiter holds a
|
||||
* fully-buffered request body; several large coding-agent bodies (~750 KB) waiting at
|
||||
* once is exactly the heap-amplification scenario chatBodyAdmission was built to stop
|
||||
* (#4380). Each lane's controller charges every parked waiter's buffered size against
|
||||
* this budget and rejects over-budget waits immediately (retryable 503) instead of
|
||||
* parking. Bytes are released when a waiter wakes, aborts, or times out.
|
||||
*/
|
||||
export const CHAT_ADMISSION_MAX_QUEUED_BYTES = parsePositiveInt(
|
||||
process.env.OMNIROUTE_CHAT_ADMISSION_MAX_QUEUED_BYTES,
|
||||
4 * 1024 * 1024
|
||||
);
|
||||
|
||||
export const CHAT_HEAVY_MESSAGE_COUNT = parsePositiveInt(
|
||||
@@ -94,18 +118,30 @@ export interface ChatAdmissionLease {
|
||||
*/
|
||||
export class ChatAdmissionController {
|
||||
#activeHeavy = 0;
|
||||
#queuedBytes = 0;
|
||||
#waiters: Array<() => void> = [];
|
||||
|
||||
constructor(readonly maxHeavyInFlight = 1) {
|
||||
constructor(
|
||||
readonly maxHeavyInFlight = 1,
|
||||
readonly maxQueuedBytes = CHAT_ADMISSION_MAX_QUEUED_BYTES
|
||||
) {
|
||||
if (!Number.isSafeInteger(maxHeavyInFlight) || maxHeavyInFlight < 1) {
|
||||
throw new RangeError("maxHeavyInFlight must be a positive integer");
|
||||
}
|
||||
if (!Number.isSafeInteger(maxQueuedBytes) || maxQueuedBytes < 0) {
|
||||
throw new RangeError("maxQueuedBytes must be a non-negative integer");
|
||||
}
|
||||
}
|
||||
|
||||
get activeHeavy(): number {
|
||||
return this.#activeHeavy;
|
||||
}
|
||||
|
||||
/** Total buffered bytes currently parked in the FIFO (heap valve accounting). */
|
||||
get queuedBytes(): number {
|
||||
return this.#queuedBytes;
|
||||
}
|
||||
|
||||
tryAcquireHeavy(): ChatAdmissionLease | null {
|
||||
if (this.#activeHeavy >= this.maxHeavyInFlight) return null;
|
||||
this.#activeHeavy += 1;
|
||||
@@ -128,27 +164,71 @@ export class ChatAdmissionController {
|
||||
* release. Resolves `null` when the deadline expires with no capacity freed, in
|
||||
* which case the caller answers the retryable 503. `timeoutMs <= 0` is the
|
||||
* legacy immediate-reject path. Waiters are served FIFO.
|
||||
*
|
||||
* When `signal` aborts while parked (client disconnect), the waiter is removed
|
||||
* from the FIFO immediately and the promise resolves `null` early instead of
|
||||
* parking for the full `timeoutMs` — the caller's 503 is dropped on the dead
|
||||
* connection, so no capacity is consumed and the freed slot never wakes a
|
||||
* waiter the client no longer needs. A signal that is already aborted never
|
||||
* parks at all.
|
||||
*
|
||||
* `queuedBytes` is the buffered body size this waiter will hold while parked;
|
||||
* it is charged against `maxQueuedBytes` so a burst of large bodies cannot
|
||||
* amplify the heap (#4380). An over-budget wait is rejected immediately with
|
||||
* `null` (retryable 503) and never parks; the charge is released on wake,
|
||||
* abort, or timeout.
|
||||
*/
|
||||
async acquireHeavyWithin(timeoutMs: number): Promise<ChatAdmissionLease | null> {
|
||||
async acquireHeavyWithin(
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
queuedBytes = 0
|
||||
): Promise<ChatAdmissionLease | null> {
|
||||
const deadline = Date.now() + Math.max(0, Math.floor(timeoutMs));
|
||||
for (;;) {
|
||||
if (signal?.aborted) return null;
|
||||
const lease = this.tryAcquireHeavy();
|
||||
if (lease) return lease;
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) return null;
|
||||
// Heap valve: refuse to park when the queued-bytes budget is exhausted.
|
||||
if (queuedBytes > 0 && this.#queuedBytes + queuedBytes > this.maxQueuedBytes) {
|
||||
return null;
|
||||
}
|
||||
this.#queuedBytes += queuedBytes;
|
||||
let resolver: (() => void) | null = null;
|
||||
const released = new Promise<void>((resolve) => {
|
||||
resolver = () => resolve();
|
||||
this.#waiters.push(resolver);
|
||||
});
|
||||
const timedOut = await Promise.race([
|
||||
let deadlineTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const races: Array<Promise<boolean>> = [
|
||||
released.then(() => false),
|
||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(true), remaining)),
|
||||
]);
|
||||
new Promise<boolean>((resolve) => {
|
||||
deadlineTimer = setTimeout(() => resolve(true), remaining);
|
||||
}),
|
||||
];
|
||||
let onAbort: (() => void) | null = null;
|
||||
if (signal) {
|
||||
races.push(
|
||||
new Promise<boolean>((resolve) => {
|
||||
const listener = () => resolve(true);
|
||||
onAbort = listener;
|
||||
signal.addEventListener("abort", listener, { once: true });
|
||||
// Already-aborted signals must settle without parking.
|
||||
if (signal.aborted) resolve(true);
|
||||
})
|
||||
);
|
||||
}
|
||||
const timedOut = await Promise.race(races);
|
||||
// The waiter has left the FIFO (wake, abort, or timeout) — release its charge.
|
||||
this.#queuedBytes = Math.max(0, this.#queuedBytes - queuedBytes);
|
||||
if (resolver) {
|
||||
const index = this.#waiters.indexOf(resolver);
|
||||
if (index >= 0) this.#waiters.splice(index, 1);
|
||||
}
|
||||
// Cancel the deadline timer when abort/release wins; a fired timer is a no-op.
|
||||
if (deadlineTimer) clearTimeout(deadlineTimer);
|
||||
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
||||
if (timedOut) return null;
|
||||
}
|
||||
}
|
||||
@@ -156,6 +236,142 @@ export class ChatAdmissionController {
|
||||
|
||||
const defaultAdmissionController = new ChatAdmissionController(CHAT_MAX_HEAVY_IN_FLIGHT);
|
||||
|
||||
/**
|
||||
* Per-connection virtual admission lanes (#9654).
|
||||
*
|
||||
* Maps a sessionId (API-key hash or "anonymous") → ChatAdmissionController.
|
||||
Each connection gets its own bounded heavyweight capacity so one connection
|
||||
* cannot exhaust `CHAT_MAX_HEAVY_IN_FLIGHT` and starve others at the byte-level
|
||||
* admission stage.
|
||||
*
|
||||
* Idle sessions are auto-evicted after OMNIROUTE_CHAT_VIRTUAL_TTL_MS
|
||||
* (default 60s) to prevent unbounded Map growth.
|
||||
*/
|
||||
const OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS = parsePositiveInt(
|
||||
process.env.OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS,
|
||||
64
|
||||
);
|
||||
|
||||
export function resolveSessionId(request: Request): string {
|
||||
// Reuse the existing internal-bypass auth extraction: bearer token from
|
||||
// Authorization, x-api-key (Anthropic-style), or Google API key header.
|
||||
const authHeader = request.headers.get("authorization") || "";
|
||||
const bearerMatch = /^bearer\s+(\S+)$/i.exec(authHeader.trim());
|
||||
if (bearerMatch) {
|
||||
return "key_" + createHash("sha256").update(bearerMatch[1]).digest("hex").slice(0, 16);
|
||||
}
|
||||
const xApiKey = request.headers.get("x-api-key") || "";
|
||||
if (xApiKey.trim().length > 0) {
|
||||
return "key_" + createHash("sha256").update(xApiKey.trim()).digest("hex").slice(0, 16);
|
||||
}
|
||||
const xGoogApiKey = request.headers.get("x-goog-api-key") || "";
|
||||
if (xGoogApiKey.trim().length > 0) {
|
||||
return "key_" + createHash("sha256").update(xGoogApiKey.trim()).digest("hex").slice(0, 16);
|
||||
}
|
||||
return "anonymous";
|
||||
}
|
||||
|
||||
interface SessionRecord {
|
||||
controller: ChatAdmissionController;
|
||||
lastUsedMs: number;
|
||||
}
|
||||
|
||||
export class PerConnectionAdmissionController {
|
||||
#sessions = new Map<string, SessionRecord>();
|
||||
#evictionTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
readonly maxSessions: number;
|
||||
readonly sessionTtlMs: number;
|
||||
|
||||
constructor(
|
||||
readonly maxHeavyPerSession: number,
|
||||
opts?: { maxSessions?: number; sessionTtlMs?: number }
|
||||
) {
|
||||
this.maxSessions = opts?.maxSessions ?? OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS;
|
||||
this.sessionTtlMs = opts?.sessionTtlMs ?? OMNIROUTE_CHAT_VIRTUAL_TTL_MS;
|
||||
}
|
||||
|
||||
getController(sessionId: string): ChatAdmissionController {
|
||||
this.evictIfDue();
|
||||
const existing = this.#sessions.get(sessionId);
|
||||
if (existing) {
|
||||
existing.lastUsedMs = Date.now();
|
||||
return existing.controller;
|
||||
}
|
||||
// Evict oldest if at capacity (LRU fallback when TTL hasn't fired).
|
||||
if (this.#sessions.size >= this.maxSessions) {
|
||||
const oldestKey = this.oldestKey();
|
||||
if (oldestKey) this.#sessions.delete(oldestKey);
|
||||
}
|
||||
const controller = new ChatAdmissionController(this.maxHeavyPerSession);
|
||||
this.#sessions.set(sessionId, { controller, lastUsedMs: Date.now() });
|
||||
this.armEviction();
|
||||
return controller;
|
||||
}
|
||||
|
||||
/** Snapshot for observability — never exposes raw API keys. */
|
||||
snapshot(): ReadonlyArray<{ sessionId: string; activeHeavy: number; idleMs: number }> {
|
||||
const now = Date.now();
|
||||
const arr: Array<{ sessionId: string; activeHeavy: number; idleMs: number }> = [];
|
||||
for (const [sessionId, record] of this.#sessions) {
|
||||
arr.push({
|
||||
sessionId,
|
||||
activeHeavy: record.controller.activeHeavy,
|
||||
idleMs: now - record.lastUsedMs,
|
||||
});
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
get sessionCount(): number {
|
||||
return this.#sessions.size;
|
||||
}
|
||||
|
||||
private oldestKey(): string | undefined {
|
||||
let oldest: string | undefined;
|
||||
let oldestMs = Infinity;
|
||||
for (const [key, record] of this.#sessions) {
|
||||
// Use <= so that for equal timestamps, later-inserted entries win,
|
||||
// preserving LRU semantics when Date.now() returns the same value.
|
||||
if (record.lastUsedMs <= oldestMs) {
|
||||
oldestMs = record.lastUsedMs;
|
||||
oldest = key;
|
||||
}
|
||||
}
|
||||
return oldest;
|
||||
}
|
||||
|
||||
private evictIfDue(): void {
|
||||
const now = Date.now();
|
||||
let evicted = false;
|
||||
for (const [sessionId, record] of this.#sessions) {
|
||||
if (now - record.lastUsedMs >= this.sessionTtlMs) {
|
||||
this.#sessions.delete(sessionId);
|
||||
evicted = true;
|
||||
}
|
||||
}
|
||||
if (evicted) this.armEviction();
|
||||
}
|
||||
|
||||
private armEviction(): void {
|
||||
if (this.#evictionTimer !== null) return;
|
||||
this.#evictionTimer = setTimeout(() => {
|
||||
this.#evictionTimer = null;
|
||||
this.evictIfDue();
|
||||
}, this.sessionTtlMs).unref();
|
||||
}
|
||||
|
||||
/** Force cleanup of all sessions (used by shutdown / tests). */
|
||||
dispose(): void {
|
||||
this.#sessions.clear();
|
||||
if (this.#evictionTimer !== null) {
|
||||
clearTimeout(this.#evictionTimer);
|
||||
this.#evictionTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const perConnectionAdmissionController = new PerConnectionAdmissionController(CHAT_MAX_HEAVY_IN_FLIGHT);
|
||||
|
||||
export type ChatRequestAdmission =
|
||||
| { admit: true; request: Request; lease: ChatAdmissionLease | null }
|
||||
| { admit: false; response: Response };
|
||||
@@ -264,11 +480,13 @@ export async function admitChatStructure(
|
||||
lease: ChatAdmissionLease | null,
|
||||
options: {
|
||||
controller?: ChatAdmissionController;
|
||||
sessionId?: string;
|
||||
maxMessages?: number;
|
||||
heavyMessages?: number;
|
||||
heavyTools?: number;
|
||||
heavyTokens?: number;
|
||||
queueMs?: number;
|
||||
signal?: AbortSignal;
|
||||
} = {}
|
||||
): Promise<ChatStructureAdmission> {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return { admit: true, lease };
|
||||
@@ -301,8 +519,18 @@ export async function admitChatStructure(
|
||||
estimatedTokens >= heavyTokens;
|
||||
if (!heavy || lease) return { admit: true, lease };
|
||||
|
||||
const acquired = await (options.controller ?? defaultAdmissionController).acquireHeavyWithin(
|
||||
options.queueMs ?? 0
|
||||
const controller =
|
||||
options.controller ??
|
||||
(options.sessionId
|
||||
? perConnectionAdmissionController.getController(options.sessionId)
|
||||
: defaultAdmissionController);
|
||||
// Structural-only waits happen on byte-light bodies (a byte-heavy body already
|
||||
// holds the byte-stage lease), so the conservative 256KB weight bounds the
|
||||
// parsed JSON the waiter keeps resident while parked.
|
||||
const acquired = await controller.acquireHeavyWithin(
|
||||
options.queueMs ?? 0,
|
||||
options.signal,
|
||||
CHAT_LARGE_BODY_BYTES
|
||||
);
|
||||
return acquired
|
||||
? { admit: true, lease: acquired }
|
||||
@@ -413,12 +641,15 @@ export async function admitChatRequest(
|
||||
request: Request,
|
||||
options: {
|
||||
controller?: ChatAdmissionController;
|
||||
sessionId?: string;
|
||||
largeBodyBytes?: number;
|
||||
hardMaxBytes?: number;
|
||||
queueMs?: number;
|
||||
} = {}
|
||||
): Promise<ChatRequestAdmission> {
|
||||
const controller = options.controller ?? defaultAdmissionController;
|
||||
const sessionId = options.sessionId ?? resolveSessionId(request);
|
||||
const controller =
|
||||
options.controller ?? perConnectionAdmissionController.getController(sessionId);
|
||||
const largeBodyBytes = options.largeBodyBytes ?? CHAT_LARGE_BODY_BYTES;
|
||||
const hardMaxBytes = options.hardMaxBytes ?? CHAT_HARD_MAX_BODY_BYTES;
|
||||
const queueMs = options.queueMs ?? 0;
|
||||
@@ -467,15 +698,19 @@ export async function admitChatRequest(
|
||||
}
|
||||
|
||||
let lease: ChatAdmissionLease | null = null;
|
||||
const reserve = async (): Promise<boolean> => {
|
||||
const reserve = async (bytes = 0): Promise<boolean> => {
|
||||
if (lease) return true;
|
||||
lease = await controller.acquireHeavyWithin(queueMs);
|
||||
lease = await controller.acquireHeavyWithin(queueMs, request.signal, bytes);
|
||||
return lease !== null;
|
||||
};
|
||||
|
||||
// A known-large declaration can reserve before ingestion. Unknown lengths are boundedly
|
||||
// sniffed below; this avoids consuming scarce heavyweight capacity for small chunked bodies.
|
||||
if (contentLength !== null && contentLength >= largeBodyBytes && !(await reserve())) {
|
||||
if (
|
||||
contentLength !== null &&
|
||||
contentLength >= largeBodyBytes &&
|
||||
!(await reserve(Math.min(contentLength, hardMaxBytes)))
|
||||
) {
|
||||
return { admit: false, response: rejectionResponse(503, hardMaxBytes) };
|
||||
}
|
||||
|
||||
@@ -494,7 +729,7 @@ export async function admitChatRequest(
|
||||
lease?.release();
|
||||
return { admit: false, response: rejectionResponse(413, hardMaxBytes) };
|
||||
}
|
||||
if (totalBytes >= largeBodyBytes && !(await reserve())) {
|
||||
if (totalBytes >= largeBodyBytes && !(await reserve(totalBytes))) {
|
||||
await reader.cancel("chat admission capacity unavailable").catch(() => undefined);
|
||||
return { admit: false, response: rejectionResponse(503, hardMaxBytes) };
|
||||
}
|
||||
|
||||
221
src/shared/validation/iconUrl.ts
Normal file
221
src/shared/validation/iconUrl.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Shared source of truth for compatible-provider node icon URL validation
|
||||
* (#2166 + data-URL support). Used by both the server-side Zod schema
|
||||
* (`providerNodeIconUrlSchema` in `schemas/provider.ts`) and the Add/Edit
|
||||
* compatible-provider UI modals for field-level errors — a single validator,
|
||||
* no divergent regexes.
|
||||
*
|
||||
* Accepted shapes:
|
||||
* - "" → no custom icon (clears a previously stored one)
|
||||
* - http(s) URL → existing #2166 semantics, 2000-char cap
|
||||
* - `data:image/*;base64,...` → valid image MIME + non-empty, valid base64 payload
|
||||
*
|
||||
* The data-URL header (scheme, media type, base64 marker) is matched
|
||||
* case-insensitively per RFC 2397, and optional media-type parameters (e.g.
|
||||
* `;charset=utf-8`) are accepted before the terminal `;base64` marker.
|
||||
* `image/svg+xml` is an ordinary member of `image/*` and is accepted — it is
|
||||
* rendered as an operator-supplied <img> `src` (see ProviderIcon.tsx) exactly
|
||||
* like any other image data URL, with the same onError fallback.
|
||||
*
|
||||
* Rejected:
|
||||
* - malformed values, unsafe schemes (javascript:, ftp:, …)
|
||||
* - non-image data URLs (`data:text/html;base64,…`, `data:application/…`)
|
||||
* - data URLs without `;base64` (`data:image/png,…`)
|
||||
* - data URLs with empty or invalid base64 payloads
|
||||
* - payloads containing whitespace or other non-base64 characters
|
||||
* (strict stored-payload validation — no whitespace stripping)
|
||||
*/
|
||||
export const MAX_ICON_URL_LENGTH = 2000;
|
||||
// A real base64 icon legitimately exceeds the http(s) 2000-char cap — a small
|
||||
// PNG/WebP badge is typically tens of KB of base64 text. Bound the data URL to
|
||||
// a generous but strictly-bounded ceiling (256 KB base64 text ≈ a sizeable
|
||||
// icon) so garbage input is still rejected while realistic icons are accepted.
|
||||
// The DB column is plain TEXT and the request-body limit is 10 MB, so this cap
|
||||
// is the governing constraint for data URLs.
|
||||
export const MAX_ICON_DATA_URL_LENGTH = 256 * 1024;
|
||||
|
||||
// HTTP token code points (RFC 7230 `tchar` / WHATWG "HTTP token code points") —
|
||||
// the complete set `!#$%&'*+-.^_`|~` plus alphanumerics. Subtypes and parameter
|
||||
// attributes are validated against this full alphabet, not a partial subset.
|
||||
const HTTP_TOKEN_RE = /^[-!#$%&'*+.^_`|~A-Za-z0-9]+$/;
|
||||
|
||||
const HTTP_SCHEME_RE = /^https?:\/\//i;
|
||||
const DATA_SCHEME_RE = /^data:/i;
|
||||
|
||||
// Terminal base64 marker (RFC 2397 — it comes AFTER all media-type parameters).
|
||||
const BASE64_MARKER = ";base64";
|
||||
const DATA_SCHEME_LENGTH = "data:".length;
|
||||
|
||||
export function isValidProviderIconUrl(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") return true;
|
||||
|
||||
if (DATA_SCHEME_RE.test(trimmed)) {
|
||||
if (trimmed.length > MAX_ICON_DATA_URL_LENGTH) return false;
|
||||
return isValidDataIconUrl(trimmed);
|
||||
}
|
||||
|
||||
// http(s) branch — preserves the pre-existing semantics and 2000-char cap.
|
||||
if (trimmed.length > MAX_ICON_URL_LENGTH) return false;
|
||||
if (!HTTP_SCHEME_RE.test(trimmed)) return false;
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates `data:image/<subtype>[;attr=value]*;base64,<payload>` (RFC 2397).
|
||||
*
|
||||
* Steps:
|
||||
* 1. Overall data: scheme is checked with the native URL parser.
|
||||
* 2. The first comma splits the metadata header from the base64 payload.
|
||||
* 3. The metadata segment must end (case-insensitively) in the terminal
|
||||
* `;base64` marker.
|
||||
* 4. The media type + parameters before the marker are parsed with a complete
|
||||
* standard MIME grammar (RFC 2045/6838 + RFC 7230 tokens): type must be
|
||||
* `image` (case-insensitive), subtype must be a non-empty HTTP token, and
|
||||
* every parameter must be `attr=value` with a token attribute and a value
|
||||
* that is either a token or a quoted-string. Valueless parameters
|
||||
* (`;foo`) are rejected — RFC 2397 requires `parameter := attribute "="
|
||||
* value`.
|
||||
* 5. The payload is validated strictly as RFC 4648 base64 (correct alphabet
|
||||
* and padding, no whitespace).
|
||||
*
|
||||
* Quoted-string parameter values are accepted per RFC 2045; because the header
|
||||
* is split at the FIRST comma, a quoted-string value containing a literal
|
||||
* comma is conservatively rejected.
|
||||
*/
|
||||
function isValidDataIconUrl(value: string): boolean {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (parsed.protocol !== "data:") return false;
|
||||
|
||||
const commaIndex = value.indexOf(",");
|
||||
if (commaIndex === -1) return false;
|
||||
const metadata = value.slice(0, commaIndex);
|
||||
const payload = value.slice(commaIndex + 1);
|
||||
if (payload.length === 0) return false;
|
||||
|
||||
if (!metadata.toLowerCase().endsWith(BASE64_MARKER)) return false;
|
||||
const mediaTypeWithParams = metadata.slice(
|
||||
DATA_SCHEME_LENGTH,
|
||||
metadata.length - BASE64_MARKER.length
|
||||
);
|
||||
|
||||
if (!isValidImageMediaType(mediaTypeWithParams)) return false;
|
||||
|
||||
// Strict stored-payload validation: no whitespace stripping. The payload
|
||||
// must be exactly valid base64 (RFC 4648 alphabet, correct padding).
|
||||
return z.base64().safeParse(payload).success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `image/<subtype>[;attr=value]*` with the complete MIME grammar:
|
||||
* type must be exactly `image` (case-insensitive), subtype a non-empty HTTP
|
||||
* token, followed by zero or more `;attr=value` parameters whose attribute is
|
||||
* an HTTP token and whose value is either an HTTP token or a quoted-string
|
||||
* (RFC 2045). Whitespace, valueless parameters, empty attributes/values, and
|
||||
* trailing garbage are rejected.
|
||||
*/
|
||||
function isValidImageMediaType(input: string): boolean {
|
||||
if (input.length === 0) return false;
|
||||
|
||||
const slashIndex = input.indexOf("/");
|
||||
if (slashIndex <= 0 || slashIndex === input.length - 1) return false;
|
||||
if (input.slice(0, slashIndex).toLowerCase() !== "image") return false;
|
||||
|
||||
let position = slashIndex + 1;
|
||||
let subtype = "";
|
||||
while (position < input.length && input[position] !== ";") {
|
||||
subtype += input[position];
|
||||
++position;
|
||||
}
|
||||
if (subtype.length === 0 || !HTTP_TOKEN_RE.test(subtype)) return false;
|
||||
|
||||
while (position < input.length) {
|
||||
if (input[position] !== ";") return false;
|
||||
++position;
|
||||
|
||||
let attribute = "";
|
||||
while (position < input.length && input[position] !== "=" && input[position] !== ";") {
|
||||
attribute += input[position];
|
||||
++position;
|
||||
}
|
||||
if (attribute.length === 0 || !HTTP_TOKEN_RE.test(attribute)) return false;
|
||||
// Valueless parameter — rejected per RFC 2397 (`attribute "=" value`).
|
||||
if (position >= input.length || input[position] !== "=") return false;
|
||||
++position;
|
||||
|
||||
const valueEnd = parseParameterValue(input, position);
|
||||
if (valueEnd === null) return false;
|
||||
position = valueEnd;
|
||||
if (position < input.length && input[position] !== ";") return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a parameter value starting at `start` and returns the position just
|
||||
* after it, or null on failure. A value is either an HTTP token or a
|
||||
* quoted-string (RFC 2045 `value := token / quoted-string`).
|
||||
*/
|
||||
function parseParameterValue(input: string, start: number): number | null {
|
||||
if (start >= input.length) return null;
|
||||
let position = start;
|
||||
|
||||
if (input[start] === '"') {
|
||||
// quoted-string: DQUOTE *( qdtext / quoted-pair ) DQUOTE
|
||||
let position = start + 1;
|
||||
while (position < input.length) {
|
||||
const char = input[position];
|
||||
if (char === '"') return position + 1;
|
||||
if (char === "\\") {
|
||||
// quoted-pair: "\" HTAB / SP / VCHAR / obs-text
|
||||
if (position + 1 >= input.length) return null;
|
||||
position += 2;
|
||||
continue;
|
||||
}
|
||||
// qdtext: HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text
|
||||
const code = char.charCodeAt(0);
|
||||
if (
|
||||
char === "\t" ||
|
||||
code === 0x20 ||
|
||||
code === 0x21 ||
|
||||
(code >= 0x23 && code <= 0x5b) ||
|
||||
(code >= 0x5d && code <= 0x7e) ||
|
||||
code >= 0x80
|
||||
) {
|
||||
++position;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null; // unterminated quote
|
||||
}
|
||||
|
||||
let value = "";
|
||||
while (position < input.length && input[position] !== ";") {
|
||||
const char = input[position];
|
||||
if (char === "%") {
|
||||
const escape = input.slice(position + 1, position + 3);
|
||||
if (!/^[0-9A-Fa-f]{2}$/.test(escape)) return null;
|
||||
value += `%${escape}`;
|
||||
position += 3;
|
||||
continue;
|
||||
}
|
||||
value += char;
|
||||
++position;
|
||||
}
|
||||
if (value.length === 0 || !HTTP_TOKEN_RE.test(value)) return null;
|
||||
return position;
|
||||
}
|
||||
@@ -23,21 +23,20 @@ import {
|
||||
|
||||
export { validateProviderSpecificData };
|
||||
|
||||
import { isValidProviderIconUrl } from "@/shared/validation/iconUrl";
|
||||
|
||||
// ──── Provider Schemas ────
|
||||
|
||||
// #2166: shared optional remote icon URL for compatible provider nodes. Empty string
|
||||
// is accepted as "no custom icon" (clears any previously stored value). Restricted to
|
||||
// http(s) — `.url()` alone also accepts syntactically-valid-but-unsafe schemes like
|
||||
// `javascript:`/`data:`, which we never want persisted as an <img src>.
|
||||
// #2166 + data-URL support: shared optional remote icon URL for compatible provider
|
||||
// nodes. Empty string is accepted as "no custom icon". Accepts http(s) URLs AND
|
||||
// valid `data:image/*;base64,...` data URLs; rejects malformed/unsafe schemes. The
|
||||
// validator lives in src/shared/validation/iconUrl.ts so UI and API never diverge.
|
||||
const providerNodeIconUrlSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(2000)
|
||||
.refine((value) => value === "" || z.string().url().safeParse(value).success, {
|
||||
message: "Icon URL must be a valid URL",
|
||||
})
|
||||
.refine((value) => value === "" || /^https?:\/\//i.test(value), {
|
||||
message: "Icon URL must be a valid http:// or https:// URL",
|
||||
.refine((value) => isValidProviderIconUrl(value), {
|
||||
message: "Icon URL must be a valid http(s) or data:image/*;base64 URL",
|
||||
})
|
||||
.optional();
|
||||
|
||||
|
||||
@@ -529,6 +529,15 @@ export async function executeChatWithBreaker({
|
||||
)
|
||||
);
|
||||
|
||||
const tlsTrackingIdentity = {
|
||||
provider,
|
||||
sessionScope: credentials.connectionId,
|
||||
};
|
||||
// Track whenever direct TLS is possible. proxyFetch decides against wreq only
|
||||
// after resolving NO_PROXY/local bypasses, so predicting from proxyInfo here
|
||||
// would drop the account scope when a configured proxy resolves to direct.
|
||||
const tlsFingerprintActive = isTlsFingerprintActive(provider);
|
||||
|
||||
if (isShadowTraffic) {
|
||||
if (!bypassCircuitBreaker && breaker && !breaker.canExecute()) {
|
||||
const retryAfterMs = breaker.getRetryAfterMs();
|
||||
@@ -542,8 +551,8 @@ export async function executeChatWithBreaker({
|
||||
};
|
||||
}
|
||||
|
||||
if (!proxyInfo?.proxy && isTlsFingerprintActive()) {
|
||||
const tracked = await runWithTlsTracking(chatFn);
|
||||
if (tlsFingerprintActive) {
|
||||
const tracked = await runWithTlsTracking(tlsTrackingIdentity, chatFn);
|
||||
return { result: tracked.result, tlsFingerprintUsed: tracked.tlsFingerprintUsed };
|
||||
}
|
||||
|
||||
@@ -552,8 +561,8 @@ export async function executeChatWithBreaker({
|
||||
}
|
||||
|
||||
if (bypassCircuitBreaker) {
|
||||
if (!proxyInfo?.proxy && isTlsFingerprintActive()) {
|
||||
const tracked = await runWithTlsTracking(chatFn);
|
||||
if (tlsFingerprintActive) {
|
||||
const tracked = await runWithTlsTracking(tlsTrackingIdentity, chatFn);
|
||||
return { result: tracked.result, tlsFingerprintUsed: tracked.tlsFingerprintUsed };
|
||||
}
|
||||
|
||||
@@ -561,8 +570,10 @@ export async function executeChatWithBreaker({
|
||||
return { result, tlsFingerprintUsed: false };
|
||||
}
|
||||
|
||||
if (!proxyInfo?.proxy && isTlsFingerprintActive()) {
|
||||
const tracked = await breaker.execute(async () => runWithTlsTracking(chatFn));
|
||||
if (tlsFingerprintActive) {
|
||||
const tracked = await breaker.execute(async () =>
|
||||
runWithTlsTracking(tlsTrackingIdentity, chatFn)
|
||||
);
|
||||
return { result: tracked.result, tlsFingerprintUsed: tracked.tlsFingerprintUsed };
|
||||
}
|
||||
|
||||
|
||||
@@ -600,6 +600,51 @@ describe("rejection mapping", () => {
|
||||
runtime.dispose();
|
||||
oversizedRuntime.dispose();
|
||||
});
|
||||
|
||||
it("maps ADMISSION_LANE_EVICTED to a sanitized 503 with Retry-After", async () => {
|
||||
const runtime = makeRuntime(clock, {
|
||||
config: enforceConfig({
|
||||
initialLimit: 1,
|
||||
minLimit: 1,
|
||||
maxLimit: 1,
|
||||
maxQueueCount: 4,
|
||||
maxQueueCost: 40,
|
||||
defaultMaxWaitMs: 120_000, // must outlive the 60s lane TTL so the lane eviction wins
|
||||
windowMs: 1_000,
|
||||
virtualLanes: true,
|
||||
cost: { maxRequestCost: 1, baseCost: 1 },
|
||||
}),
|
||||
});
|
||||
const hold = await runtime.acquire({
|
||||
tenantKey: "hold",
|
||||
body: { stream: true },
|
||||
});
|
||||
assert.equal(hold.status, "admitted");
|
||||
|
||||
// Park a waiter in a virtual lane; its own deadline is far beyond the TTL.
|
||||
const pending = runtime.acquire({
|
||||
tenantKey: "lane-waiter",
|
||||
body: { stream: true },
|
||||
maxWaitMs: 120_000,
|
||||
});
|
||||
|
||||
// Advance past the 60s lane TTL: the window tick evicts idle lanes, which
|
||||
// drains and rejects the queued waiter with ADMISSION_LANE_EVICTED.
|
||||
clock.advance(60_001);
|
||||
|
||||
const rejected = await pending;
|
||||
assert.equal(rejected.status, "rejected");
|
||||
if (rejected.status === "rejected") {
|
||||
assert.equal(rejected.code, "admission_lane_evicted");
|
||||
assert.equal(rejected.response.status, 503);
|
||||
assert.equal(rejected.response.headers.get("Retry-After"), "1");
|
||||
const body = await parseJson(rejected.response);
|
||||
assert.equal(body.error.code, "admission_lane_evicted");
|
||||
assert.ok(!JSON.stringify(body).includes("lane-waiter"));
|
||||
}
|
||||
if (hold.status === "admitted") hold.lease.release();
|
||||
runtime.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resource pressure integration", () => {
|
||||
|
||||
240
tests/unit/admission-virtual-lanes-9654.test.ts
Normal file
240
tests/unit/admission-virtual-lanes-9654.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
// #9654: Per-connection virtual admission lanes on AdaptiveAdmissionController
|
||||
// Tests with virtualLanes config option enabled.
|
||||
import { describe, it, beforeEach, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
AdaptiveAdmissionController,
|
||||
type AdaptiveAdmissionConfig,
|
||||
type AdmissionRequest,
|
||||
} from "../../open-sse/services/admission/index.ts";
|
||||
|
||||
const LANE_CONFIG = { virtualLanes: true } as const;
|
||||
|
||||
class FakeClock {
|
||||
nowMs = 0;
|
||||
private nextId = 1;
|
||||
private timers = new Map<number, { due: number; fn: () => void }>();
|
||||
now = () => this.nowMs;
|
||||
setTimer = (fn: () => void, delayMs: number): number => {
|
||||
const id = this.nextId++;
|
||||
this.timers.set(id, { due: this.nowMs + Math.max(0, delayMs), fn });
|
||||
return id;
|
||||
};
|
||||
clearTimer = (id: number): void => {
|
||||
this.timers.delete(id);
|
||||
};
|
||||
get pendingTimerCount(): number {
|
||||
return this.timers.size;
|
||||
}
|
||||
advance(ms: number): void {
|
||||
const target = this.nowMs + ms;
|
||||
while (true) {
|
||||
let nextId: number | undefined;
|
||||
let nextDue = Number.POSITIVE_INFINITY;
|
||||
for (const [id, t] of this.timers) {
|
||||
if (t.due <= target && t.due < nextDue) {
|
||||
nextDue = t.due;
|
||||
nextId = id;
|
||||
}
|
||||
}
|
||||
if (nextId === undefined) {
|
||||
this.nowMs = target;
|
||||
return;
|
||||
}
|
||||
const timer = this.timers.get(nextId)!;
|
||||
this.timers.delete(nextId);
|
||||
this.nowMs = timer.due;
|
||||
timer.fn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function baseConfig(overrides: Partial<AdaptiveAdmissionConfig> = {}): AdaptiveAdmissionConfig {
|
||||
return {
|
||||
mode: "enforce",
|
||||
minLimit: 10,
|
||||
maxLimit: 100,
|
||||
initialLimit: 20,
|
||||
maxQueueCount: 4,
|
||||
maxQueueCost: 40,
|
||||
defaultMaxWaitMs: 1000,
|
||||
windowMs: 100,
|
||||
shortLatencyAlpha: 0.5,
|
||||
longLatencyAlpha: 0.1,
|
||||
increaseStep: 2,
|
||||
decreaseFactor: 0.8,
|
||||
criticalDecreaseFactor: 0.5,
|
||||
highUtilizationThreshold: 0.7,
|
||||
lowUtilizationThreshold: 0.3,
|
||||
latencyGradientThreshold: 0.25,
|
||||
maxIncreasePerWindow: 4,
|
||||
virtualLanes: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function req(partial: Partial<AdmissionRequest> & { cost: number }): AdmissionRequest {
|
||||
return {
|
||||
tenantKey: "t-default",
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
async function mustAdmit(
|
||||
controller: AdaptiveAdmissionController,
|
||||
request: AdmissionRequest
|
||||
): Promise<import("../../open-sse/services/admission/types.ts").AdmissionLease> {
|
||||
const result = await controller.acquire(request);
|
||||
assert.equal(result.status, "admitted");
|
||||
if (result.status !== "admitted") throw new Error("expected admitted");
|
||||
return result.lease;
|
||||
}
|
||||
|
||||
describe("Per-connection virtual lanes #9654", () => {
|
||||
let clock: FakeClock;
|
||||
const live: AdaptiveAdmissionController[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
clock = new FakeClock();
|
||||
live.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const c of live) c.shutdown();
|
||||
live.length = 0;
|
||||
});
|
||||
|
||||
function controller(overrides: Partial<AdaptiveAdmissionConfig> = {}) {
|
||||
const c = new AdaptiveAdmissionController(baseConfig(overrides), {
|
||||
now: clock.now,
|
||||
setTimer: clock.setTimer,
|
||||
clearTimer: clock.clearTimer,
|
||||
});
|
||||
live.push(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
it("isolates queue capacity across sessions (one burst does not 503 others)", async () => {
|
||||
const c = controller({ initialLimit: 10, maxQueueCount: 4, maxQueueCost: 40 });
|
||||
const held = await mustAdmit(c, { cost: 10, tenantKey: "_default" });
|
||||
|
||||
// Session A queues entries in its own lane.
|
||||
const a1 = await c.acquire(req({ cost: 5, tenantKey: "a" }));
|
||||
assert.equal(a1.status, "queued");
|
||||
|
||||
const a2 = await c.acquire(req({ cost: 5, tenantKey: "a" }));
|
||||
assert.equal(a2.status, "queued");
|
||||
|
||||
// Session B has its own lane — should still be queued in its own lane,
|
||||
// NOT rejected because session A filled up.
|
||||
const b1 = await c.acquire(req({ cost: 5, tenantKey: "b" }));
|
||||
assert.equal(b1.status, "queued");
|
||||
|
||||
// Session B is NOT rejected despite session A's burst.
|
||||
assert.notEqual(b1.status, "rejected");
|
||||
|
||||
const snap = c.snapshot();
|
||||
assert.equal(snap.laneCount, 2, `expected exactly 2 lanes, got ${snap.laneCount}`);
|
||||
assert.equal(snap.laneQueuedCount, 3, `expected exactly 3 lane-queued, got ${snap.laneQueuedCount}`);
|
||||
|
||||
held.release("success");
|
||||
if (a1.status === "queued") (await a1.promise).lease.release("success");
|
||||
if (a2.status === "queued") (await a2.promise).lease.release("success");
|
||||
if (b1.status === "queued") (await b1.promise).lease.release("success");
|
||||
});
|
||||
|
||||
it("routes entries to per-session lane queues, not the shared queue", async () => {
|
||||
const c = controller({ initialLimit: 10 });
|
||||
const held = await mustAdmit(c, req({ cost: 10 }));
|
||||
|
||||
const a1 = await c.acquire(req({ cost: 5, tenantKey: "a" }));
|
||||
assert.equal(a1.status, "queued");
|
||||
|
||||
const snap = c.snapshot();
|
||||
// With lanes enabled, tenant entries go to lane queues, not shared queue.
|
||||
assert.equal(snap.queuedCount, 0, "shared queue should be empty");
|
||||
assert.ok(snap.laneQueuedCount >= 1, "lane queues should have entries");
|
||||
|
||||
held.release("success");
|
||||
if (a1.status === "queued") (await a1.promise).lease.release("success");
|
||||
});
|
||||
|
||||
it("dispatches from lane queues in round-robin across tenants", async () => {
|
||||
const c = controller({ initialLimit: 10, maxQueueCount: 10, maxQueueCost: 100 });
|
||||
const held = await mustAdmit(c, req({ cost: 10 }));
|
||||
|
||||
const a = await c.acquire(req({ cost: 5, tenantKey: "tenant-a" }));
|
||||
const b = await c.acquire(req({ cost: 5, tenantKey: "tenant-b" }));
|
||||
const d = await c.acquire(req({ cost: 5, tenantKey: "tenant-c" }));
|
||||
|
||||
assert.equal(a.status, "queued");
|
||||
assert.equal(b.status, "queued");
|
||||
assert.equal(d.status, "queued");
|
||||
|
||||
held.release("success");
|
||||
|
||||
// All three should be admitted via round-robin dispatch.
|
||||
const aAdmitted = await a.promise;
|
||||
assert.equal(aAdmitted.status, "admitted");
|
||||
aAdmitted.lease.release("success");
|
||||
|
||||
const bAdmitted = await b.promise;
|
||||
assert.equal(bAdmitted.status, "admitted");
|
||||
bAdmitted.lease.release("success");
|
||||
|
||||
const dAdmitted = await d.promise;
|
||||
assert.equal(dAdmitted.status, "admitted");
|
||||
dAdmitted.lease.release("success");
|
||||
});
|
||||
|
||||
it("evicts idle lanes after TTL", async () => {
|
||||
const c = controller({ initialLimit: 10, maxQueueCount: 2, maxQueueCost: 20 });
|
||||
const held = await mustAdmit(c, req({ cost: 10 }));
|
||||
|
||||
const a = await c.acquire(req({ cost: 5, tenantKey: "a" }));
|
||||
const b = await c.acquire(req({ cost: 5, tenantKey: "b" }));
|
||||
|
||||
let snap = c.snapshot();
|
||||
assert.ok(snap.laneCount >= 2, "lanes should exist after enqueue");
|
||||
|
||||
// Advance clock past TTL (60s). The lane eviction timer fires during advance.
|
||||
// Lanes with queued entries are NOT empty, so they survive until evicted by TTL.
|
||||
// Attach catch handlers to avoid unhandled rejection noise from deadline timers.
|
||||
if (a.status === "queued") a.promise.catch(() => {});
|
||||
if (b.status === "queued") b.promise.catch(() => {});
|
||||
clock.advance(60_001);
|
||||
|
||||
snap = c.snapshot();
|
||||
assert.equal(snap.laneCount, 0, "idle lanes should be evicted after TTL");
|
||||
|
||||
held.release("success");
|
||||
});
|
||||
|
||||
it("does not leak raw tenant keys in laneTenants snapshot", async () => {
|
||||
const c = controller({ initialLimit: 10, maxQueueCount: 2, maxQueueCost: 20 });
|
||||
const held = await mustAdmit(c, req({ cost: 10 }));
|
||||
|
||||
await c.acquire(req({ cost: 5, tenantKey: "secret-key-12345" }));
|
||||
|
||||
const snap = c.snapshot();
|
||||
const tenants = snap.laneTenants ?? [];
|
||||
for (const t of tenants) {
|
||||
// The snapshot stores opaque lane IDs, not the raw API key.
|
||||
// (The lane key is an internal hash, never the raw key.)
|
||||
assert.ok(t.tenantKey.length > 0);
|
||||
}
|
||||
|
||||
held.release("success");
|
||||
});
|
||||
|
||||
it("default config (virtualLanes unset) preserves shared queue behavior", async () => {
|
||||
const c = controller({ virtualLanes: false });
|
||||
const snap = c.snapshot();
|
||||
// laneCount should be 0 (no lanes created yet)
|
||||
assert.equal(snap.laneCount, 0);
|
||||
// Snapshot should include lane fields
|
||||
assert.ok("laneQueuedCount" in snap);
|
||||
assert.ok("laneTenants" in snap);
|
||||
c.shutdown();
|
||||
});
|
||||
});
|
||||
44
tests/unit/autoCombo/builtin-vision-spec.test.ts
Normal file
44
tests/unit/autoCombo/builtin-vision-spec.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Regression: `auto/best-vision` must resolve to the `vision` CATEGORY (candidate
|
||||
* filter by vision capability), not to the flat `smart` variant.
|
||||
*
|
||||
* Root cause on runtime v3.8.49: AUTO_TEMPLATE_VARIANTS mapped
|
||||
* `"auto/best-vision": "smart"`, so the virtual combo scored ALL candidates
|
||||
* (verified: it resolved to text-only `deepseek-v4-flash-free`), making the
|
||||
* vision-bridge describe/reroute target useless.
|
||||
*
|
||||
* Runs under Vitest (the autoCombo suite is Vitest-only in this repo).
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveBuiltinAutoSpec } from "../../../open-sse/services/autoCombo/builtinCatalog";
|
||||
|
||||
describe("resolveBuiltinAutoSpec — vision category ids", () => {
|
||||
it("auto/best-vision resolves to category vision (not smart variant)", () => {
|
||||
expect(resolveBuiltinAutoSpec("auto/best-vision", "best-vision")).toEqual({
|
||||
category: "vision",
|
||||
});
|
||||
});
|
||||
|
||||
it("auto/pro-vision resolves to category vision + tier pro", () => {
|
||||
expect(resolveBuiltinAutoSpec("auto/pro-vision", "pro-vision")).toEqual({
|
||||
category: "vision",
|
||||
tier: "pro",
|
||||
});
|
||||
});
|
||||
|
||||
it("legacy flat variants keep their variant mapping", () => {
|
||||
expect(resolveBuiltinAutoSpec("auto/best-coding", "best-coding")).toEqual({
|
||||
variant: "coding",
|
||||
});
|
||||
expect(resolveBuiltinAutoSpec("auto/fast", "fast")).toEqual({ variant: "fast" });
|
||||
expect(resolveBuiltinAutoSpec("auto/chat", "chat")).toEqual({ variant: undefined });
|
||||
});
|
||||
|
||||
it("category:tier suffix still resolves via parseAutoSuffix", () => {
|
||||
expect(resolveBuiltinAutoSpec("auto/coding:fast", "coding:fast")).toEqual({
|
||||
category: "coding",
|
||||
tier: "fast",
|
||||
});
|
||||
expect(resolveBuiltinAutoSpec("auto/vision", "vision")).toEqual({ category: "vision" });
|
||||
});
|
||||
});
|
||||
39
tests/unit/autoCombo/vision-filter-excludes-forced.test.ts
Normal file
39
tests/unit/autoCombo/vision-filter-excludes-forced.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Regression: the vision-category candidate filter must exclude registry
|
||||
* entries whose catalog OVERSTATES vision support (opencode-go/opencode-zen/
|
||||
* tokenrouter backends are text-only and are forced through the vision bridge
|
||||
* by isVisionBridgeForcedModel). Otherwise `auto/best-vision` pools include
|
||||
* models that can never process images (e.g. deepseek-v4-flash-max), breaking
|
||||
* the vision bridge describe/reroute.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildAutoCandidateFilter } from "../../../open-sse/services/autoCombo/suffixComposition";
|
||||
|
||||
describe("buildAutoCandidateFilter — vision category", () => {
|
||||
it("keeps genuinely vision-capable models", () => {
|
||||
const filter = buildAutoCandidateFilter("vision");
|
||||
expect(filter).not.toBeNull();
|
||||
// MiniMax M3 is a real multimodal model (format claude, supportsVision: true).
|
||||
expect(filter?.({ provider: "minimax", model: "MiniMax-M3" })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects models whose catalog entry overstates vision (forced through the bridge)", () => {
|
||||
const filter = buildAutoCandidateFilter("vision");
|
||||
// opencode-go/deepseek-v4-flash-max is in FORCED_VISION_BRIDGE_MODELS —
|
||||
// the catalog claims vision but the backend is text-only.
|
||||
expect(filter?.({ provider: "opencode-go", model: "deepseek-v4-flash-max" })).toBe(false);
|
||||
expect(filter?.({ provider: "opencode-go", model: "deepseek-v4-flash" })).toBe(false);
|
||||
expect(filter?.({ provider: "opencode-zen", model: "deepseek-v4-flash" })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects models with no confirmed vision support", () => {
|
||||
const filter = buildAutoCandidateFilter("vision");
|
||||
// Unknown catalog entry → no confirmed vision → must be rejected.
|
||||
expect(filter?.({ provider: "acme", model: "acme-text" })).toBe(false);
|
||||
});
|
||||
|
||||
it("non-vision categories are unaffected", () => {
|
||||
const filter = buildAutoCandidateFilter("coding");
|
||||
expect(filter).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,14 +4,15 @@ import fs from "node:fs/promises";
|
||||
import fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const {
|
||||
import {
|
||||
getTransientBuildPaths,
|
||||
movePath,
|
||||
pruneStandaloneArtifacts,
|
||||
resolveNextBuildEnv,
|
||||
syncStandaloneExtraModules,
|
||||
syncStandaloneNativeAssets,
|
||||
} = await import("../../scripts/build/build-next-isolated.mjs");
|
||||
} from "../../scripts/build/build-next-isolated.mjs";
|
||||
|
||||
|
||||
async function withTempDir(fn) {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omniroute-build-next-isolated-"));
|
||||
@@ -177,36 +178,49 @@ test("pruneStandaloneArtifacts removes traced _tasks from standalone output", as
|
||||
});
|
||||
});
|
||||
|
||||
test("syncStandaloneNativeAssets copies wreq-js native runtime into standalone output", async () => {
|
||||
test("syncStandaloneExtraModules copies the complete wreq-js runtime", async () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
const sourceNativeFile = path.join(
|
||||
tempDir,
|
||||
"node_modules",
|
||||
"wreq-js",
|
||||
"rust",
|
||||
"wreq-js.linux-x64-gnu.node"
|
||||
);
|
||||
const destinationNativeFile = path.join(
|
||||
const sourcePackage = path.join(tempDir, "node_modules", "wreq-js");
|
||||
const destinationPackage = path.join(
|
||||
tempDir,
|
||||
".build",
|
||||
"next",
|
||||
"standalone",
|
||||
"node_modules",
|
||||
"wreq-js",
|
||||
"rust",
|
||||
"wreq-js.linux-x64-gnu.node"
|
||||
"wreq-js"
|
||||
);
|
||||
const logs: string[] = [];
|
||||
|
||||
await fs.mkdir(path.dirname(sourceNativeFile), { recursive: true });
|
||||
await fs.writeFile(sourceNativeFile, "native module bytes");
|
||||
|
||||
const changed = await syncStandaloneNativeAssets(tempDir, fs, {
|
||||
const logger: Console = Object.assign(Object.create(console), {
|
||||
log: (message: unknown) => logs.push(String(message)),
|
||||
});
|
||||
|
||||
await fs.mkdir(path.join(sourcePackage, "dist"), { recursive: true });
|
||||
await fs.mkdir(path.join(sourcePackage, "rust"), { recursive: true });
|
||||
await fs.writeFile(path.join(sourcePackage, "package.json"), '{"name":"wreq-js"}');
|
||||
await fs.writeFile(path.join(sourcePackage, "dist", "wreq-js.cjs"), "exports.fetch = () => {}");
|
||||
await fs.writeFile(
|
||||
path.join(sourcePackage, "rust", "wreq-js.linux-x64-gnu.node"),
|
||||
"native module bytes"
|
||||
);
|
||||
|
||||
const changed = await syncStandaloneExtraModules(tempDir, fs, logger);
|
||||
|
||||
assert.equal(changed, true);
|
||||
assert.equal(await fs.readFile(destinationNativeFile, "utf8"), "native module bytes");
|
||||
assert.match((logs[0] ?? "").replaceAll("\\", "/"), /wreq-js\/rust/);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(destinationPackage, "package.json"), "utf8"),
|
||||
'{"name":"wreq-js"}'
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(destinationPackage, "dist", "wreq-js.cjs"), "utf8"),
|
||||
"exports.fetch = () => {}"
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(
|
||||
path.join(destinationPackage, "rust", "wreq-js.linux-x64-gnu.node"),
|
||||
"utf8"
|
||||
),
|
||||
"native module bytes"
|
||||
);
|
||||
assert.match(logs[0] ?? "", /wreq-js TLS runtime/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import assert from "node:assert/strict";
|
||||
import {
|
||||
buildSyncedModelIdsByCanonicalProvider,
|
||||
shouldSuppressStaticModelBySyncedCoverage,
|
||||
shouldSuppressStaticModelForExclusiveListing,
|
||||
} from "../../src/app/api/v1/models/catalogSyncedCoverage.ts";
|
||||
import type { SyncedAvailableModel } from "../../src/lib/db/models/synced.ts";
|
||||
|
||||
@@ -75,3 +76,57 @@ test("buildSyncedModelIdsByCanonicalProvider groups synced ids by canonical prov
|
||||
assert.ok(ds);
|
||||
assert.ok(ds.has("deepseek-v4-flash"));
|
||||
});
|
||||
|
||||
test("exclusive listing: any static row suppressed when provider has synced catalog", () => {
|
||||
assert.equal(
|
||||
shouldSuppressStaticModelForExclusiveListing({
|
||||
exclusiveListing: true,
|
||||
providerHasSynced: true,
|
||||
staticModelId: "claude-4.6-sonnet-high",
|
||||
syncedModelIds: ["claude-4.6-sonnet", "composer-2.5"],
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
shouldSuppressStaticModelForExclusiveListing({
|
||||
exclusiveListing: true,
|
||||
providerHasSynced: true,
|
||||
staticModelId: "composer-2.5",
|
||||
syncedModelIds: ["claude-4.6-sonnet", "composer-2.5"],
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("exclusive listing: does not suppress when synced is empty", () => {
|
||||
assert.equal(
|
||||
shouldSuppressStaticModelForExclusiveListing({
|
||||
exclusiveListing: true,
|
||||
providerHasSynced: false,
|
||||
staticModelId: "claude-4.6-sonnet-high",
|
||||
syncedModelIds: [],
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("exclusive listing: non-exclusive providers keep coverage behavior", () => {
|
||||
assert.equal(
|
||||
shouldSuppressStaticModelForExclusiveListing({
|
||||
exclusiveListing: false,
|
||||
providerHasSynced: true,
|
||||
staticModelId: "deepseek/deepseek-v4-flash",
|
||||
syncedModelIds: ["gpt-5.6-luna"],
|
||||
}),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
shouldSuppressStaticModelForExclusiveListing({
|
||||
exclusiveListing: false,
|
||||
providerHasSynced: true,
|
||||
staticModelId: "gpt-5.6-luna",
|
||||
syncedModelIds: ["gpt-5.6-luna"],
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
513
tests/unit/chat-body-admission-queue.test.ts
Normal file
513
tests/unit/chat-body-admission-queue.test.ts
Normal file
@@ -0,0 +1,513 @@
|
||||
// #9654: queue-wait, AbortSignal cancellation, and the queued-bytes heap valve.
|
||||
// Split from chat-body-admission.test.ts to stay under the 1000-line new-file cap.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const admissionModule = await import("../../src/shared/middleware/chatBodyAdmission.ts");
|
||||
const {
|
||||
admitChatRequest,
|
||||
admitChatStructure,
|
||||
ChatAdmissionController,
|
||||
CHAT_ADMISSION_QUEUE_MAX_MS,
|
||||
CHAT_ADMISSION_MAX_QUEUED_BYTES,
|
||||
CHAT_LARGE_BODY_BYTES,
|
||||
} = admissionModule;
|
||||
|
||||
function chatRequest(body: string, contentLength: string | null = String(body.length)): Request {
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
if (contentLength !== null) headers["content-length"] = contentLength;
|
||||
return new Request("http://x/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
test("a heavy structural request waits for capacity instead of failing immediately", async () => {
|
||||
const controller = new ChatAdmissionController(1);
|
||||
const held = controller.tryAcquireHeavy();
|
||||
assert.ok(held);
|
||||
|
||||
const pending = admitChatStructure(
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "one" },
|
||||
{ role: "user", content: "two" },
|
||||
],
|
||||
},
|
||||
null,
|
||||
{
|
||||
controller,
|
||||
maxMessages: 10,
|
||||
heavyMessages: 2,
|
||||
heavyTools: 10,
|
||||
heavyTokens: 10_000,
|
||||
queueMs: 500,
|
||||
}
|
||||
);
|
||||
|
||||
// Capacity is still busy: the request must not have resolved (admit/reject) yet.
|
||||
let settled = false;
|
||||
void pending.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(settled, false, "must wait while capacity is busy");
|
||||
|
||||
held.release();
|
||||
const result = await pending;
|
||||
assert.equal(result.admit, true);
|
||||
if (result.admit) {
|
||||
assert.equal(controller.activeHeavy, 1, "waiting request acquires the freed lease");
|
||||
result.lease?.release();
|
||||
}
|
||||
assert.equal(controller.activeHeavy, 0);
|
||||
});
|
||||
|
||||
test("waiting for admission times out into a retryable 503", async () => {
|
||||
const controller = new ChatAdmissionController(1);
|
||||
const held = controller.tryAcquireHeavy();
|
||||
assert.ok(held);
|
||||
|
||||
const started = Date.now();
|
||||
const result = await admitChatStructure(
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "one" },
|
||||
{ role: "user", content: "two" },
|
||||
],
|
||||
},
|
||||
null,
|
||||
{
|
||||
controller,
|
||||
maxMessages: 10,
|
||||
heavyMessages: 2,
|
||||
heavyTools: 10,
|
||||
heavyTokens: 10_000,
|
||||
queueMs: 50,
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.admit, false);
|
||||
if (!result.admit) {
|
||||
assert.equal(result.response.status, 503);
|
||||
assert.equal(result.response.headers.get("retry-after"), "1");
|
||||
assert.equal((await result.response.json()).error.code, "chat_admission_busy");
|
||||
}
|
||||
assert.ok(Date.now() - started >= 40, "must wait for the queue deadline before rejecting");
|
||||
assert.equal(controller.activeHeavy, 1, "the holder keeps its lease");
|
||||
held.release();
|
||||
assert.equal(controller.activeHeavy, 0);
|
||||
});
|
||||
|
||||
test("byte-heavy admission waits for capacity when queueMs is set", async () => {
|
||||
const controller = new ChatAdmissionController(1);
|
||||
const body = JSON.stringify({ messages: [{ role: "user", content: "x".repeat(40) }] });
|
||||
const options = { controller, largeBodyBytes: 32, hardMaxBytes: 1024, queueMs: 500 };
|
||||
|
||||
const first = await admitChatRequest(chatRequest(body), options);
|
||||
assert.equal(first.admit, true);
|
||||
if (!first.admit) return;
|
||||
|
||||
const second = admitChatRequest(chatRequest(body), options);
|
||||
let secondSettled = false;
|
||||
void second.then(() => {
|
||||
secondSettled = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(
|
||||
secondSettled,
|
||||
false,
|
||||
"second heavy request must queue while the first holds capacity"
|
||||
);
|
||||
|
||||
first.lease?.release();
|
||||
const secondResult = await second;
|
||||
assert.equal(secondResult.admit, true, "second request acquires capacity after release");
|
||||
if (secondResult.admit) secondResult.lease?.release();
|
||||
assert.equal(controller.activeHeavy, 0);
|
||||
});
|
||||
|
||||
test("expired admission queue keeps the legacy immediate 503 behaviour", async () => {
|
||||
const controller = new ChatAdmissionController(1);
|
||||
const held = controller.tryAcquireHeavy();
|
||||
assert.ok(held);
|
||||
|
||||
const result = await admitChatStructure(
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "one" },
|
||||
{ role: "user", content: "two" },
|
||||
],
|
||||
},
|
||||
null,
|
||||
{
|
||||
controller,
|
||||
maxMessages: 10,
|
||||
heavyMessages: 2,
|
||||
heavyTools: 10,
|
||||
heavyTokens: 10_000,
|
||||
queueMs: 0,
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.admit, false);
|
||||
if (!result.admit) assert.equal(result.response.status, 503);
|
||||
held.release();
|
||||
});
|
||||
|
||||
test("admission waiters are served FIFO as capacity frees", async () => {
|
||||
const controller = new ChatAdmissionController(1);
|
||||
const held = controller.tryAcquireHeavy();
|
||||
assert.ok(held);
|
||||
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "user", content: "one" },
|
||||
{ role: "user", content: "two" },
|
||||
],
|
||||
};
|
||||
const options = {
|
||||
controller,
|
||||
maxMessages: 10,
|
||||
heavyMessages: 2,
|
||||
heavyTools: 10,
|
||||
heavyTokens: 10_000,
|
||||
queueMs: 500,
|
||||
};
|
||||
const first = admitChatStructure(body, null, options);
|
||||
const second = admitChatStructure(body, null, options);
|
||||
|
||||
held.release();
|
||||
const firstResult = await first;
|
||||
assert.equal(firstResult.admit, true);
|
||||
if (firstResult.admit) firstResult.lease?.release();
|
||||
const secondResult = await second;
|
||||
assert.equal(secondResult.admit, true);
|
||||
if (secondResult.admit) secondResult.lease?.release();
|
||||
assert.equal(controller.activeHeavy, 0);
|
||||
});
|
||||
|
||||
// ── AbortSignal support in acquireHeavyWithin (#9654 / U2) ────────────────
|
||||
// A disconnected client must not keep parking in the admission queue for the
|
||||
// full queueMs. On abort the waiter is removed from the FIFO immediately and
|
||||
// the acquire resolves `null` early (the caller's 503 is dropped on the dead
|
||||
// connection); no capacity is consumed and the freed slot does not wake it.
|
||||
|
||||
test("aborting the admission wait settles early, grants no lease, and removes the waiter", async () => {
|
||||
const controller = new ChatAdmissionController(1);
|
||||
const held = controller.tryAcquireHeavy();
|
||||
assert.ok(held);
|
||||
|
||||
const abortController = new AbortController();
|
||||
const pending = controller.acquireHeavyWithin(2_000, abortController.signal);
|
||||
|
||||
// Parked while capacity is busy.
|
||||
let settled = false;
|
||||
void pending.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(settled, false, "must be parked while capacity is busy");
|
||||
|
||||
abortController.abort();
|
||||
|
||||
// Must settle well before the 2s deadline.
|
||||
let settledAfterAbort = false;
|
||||
void pending.then(() => {
|
||||
settledAfterAbort = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
assert.equal(settledAfterAbort, true, "abort must settle the wait promptly, not park for queueMs");
|
||||
|
||||
const lease = await pending;
|
||||
assert.equal(lease, null, "abort must not grant a lease");
|
||||
assert.equal(controller.activeHeavy, 1, "the holder keeps its lease; the aborted wait consumed nothing");
|
||||
|
||||
// Releasing must NOT wake the removed waiter: capacity stays free.
|
||||
held.release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(
|
||||
controller.activeHeavy,
|
||||
0,
|
||||
"releasing after abort must not wake the removed waiter"
|
||||
);
|
||||
});
|
||||
|
||||
test("aborting the head waiter preserves FIFO order for remaining waiters", async () => {
|
||||
const controller = new ChatAdmissionController(1);
|
||||
const held = controller.tryAcquireHeavy();
|
||||
assert.ok(held);
|
||||
|
||||
const firstAbort = new AbortController();
|
||||
const first = controller.acquireHeavyWithin(2_000, firstAbort.signal);
|
||||
const second = controller.acquireHeavyWithin(2_000);
|
||||
|
||||
// Both are parked, head-first.
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
|
||||
// Abort the HEAD waiter: it must leave the queue without disturbing the rest.
|
||||
firstAbort.abort();
|
||||
assert.equal(await first, null, "head waiter returns null on abort");
|
||||
|
||||
// The remaining waiter is now first in line and must get the freed capacity.
|
||||
held.release();
|
||||
const secondLease = await second;
|
||||
assert.ok(secondLease, "remaining waiter must acquire the freed capacity");
|
||||
secondLease?.release();
|
||||
assert.equal(controller.activeHeavy, 0);
|
||||
});
|
||||
|
||||
// ── Heap-pressure safety valve (#9654 / U3) ───────────────────────────────
|
||||
// The queue-wait parks fully-buffered bodies; the queued-bytes cap bounds the
|
||||
// total buffered memory parked per lane so the wait cannot recreate the #4380
|
||||
// heap amplification. Over-budget waits are rejected immediately (503).
|
||||
|
||||
test("queued-bytes cap rejects an over-budget wait without parking", async () => {
|
||||
const controller = new ChatAdmissionController(1, 200);
|
||||
const held = controller.tryAcquireHeavy();
|
||||
assert.ok(held);
|
||||
|
||||
// First waiter parks within budget.
|
||||
const first = controller.acquireHeavyWithin(2_000, undefined, 150);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(controller.queuedBytes, 150);
|
||||
|
||||
// Second waiter would push the total over the 200-byte budget → must NOT park.
|
||||
const started = Date.now();
|
||||
const second = await controller.acquireHeavyWithin(2_000, undefined, 100);
|
||||
assert.equal(second, null, "over-budget wait must be rejected");
|
||||
assert.ok(Date.now() - started < 500, "rejection must be immediate, not park for queueMs");
|
||||
assert.equal(controller.queuedBytes, 150, "rejected waiter must not be charged");
|
||||
assert.equal(controller.activeHeavy, 1, "holder keeps its lease");
|
||||
|
||||
// Free the slot: the parked waiter acquires and its bytes leave the queue.
|
||||
held.release();
|
||||
const firstLease = await first;
|
||||
assert.ok(firstLease, "in-budget waiter acquires the freed slot");
|
||||
assert.equal(controller.queuedBytes, 0, "acquired waiter's bytes must leave the queue");
|
||||
firstLease?.release();
|
||||
assert.equal(controller.activeHeavy, 0);
|
||||
});
|
||||
|
||||
test("aborting a parked wait releases its queued bytes", async () => {
|
||||
const controller = new ChatAdmissionController(1, 1_000);
|
||||
const held = controller.tryAcquireHeavy();
|
||||
assert.ok(held);
|
||||
|
||||
const abortController = new AbortController();
|
||||
const pending = controller.acquireHeavyWithin(2_000, abortController.signal, 400);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(controller.queuedBytes, 400);
|
||||
|
||||
abortController.abort();
|
||||
assert.equal(await pending, null);
|
||||
assert.equal(controller.queuedBytes, 0, "abort must release the charged bytes");
|
||||
|
||||
held.release();
|
||||
assert.equal(controller.activeHeavy, 0);
|
||||
});
|
||||
|
||||
test("a timed-out wait releases its queued bytes", async () => {
|
||||
const controller = new ChatAdmissionController(1, 1_000);
|
||||
const held = controller.tryAcquireHeavy();
|
||||
assert.ok(held);
|
||||
|
||||
const pending = controller.acquireHeavyWithin(50, undefined, 400);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(controller.queuedBytes, 400);
|
||||
|
||||
assert.equal(await pending, null);
|
||||
assert.equal(controller.queuedBytes, 0, "timeout must release the charged bytes");
|
||||
held.release();
|
||||
assert.equal(controller.activeHeavy, 0);
|
||||
});
|
||||
|
||||
test("byte-heavy admission enforces the queued-bytes cap end-to-end", async () => {
|
||||
const controller = new ChatAdmissionController(1, 100);
|
||||
const held = controller.tryAcquireHeavy();
|
||||
assert.ok(held);
|
||||
|
||||
const body = JSON.stringify({ messages: [{ role: "user", content: "x".repeat(40) }] });
|
||||
const options = { controller, largeBodyBytes: 32, hardMaxBytes: 1024, queueMs: 2_000 };
|
||||
|
||||
// First request parks: declared length (~70B) fits the budget.
|
||||
const first = admitChatRequest(chatRequest(body), options);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
|
||||
// Second request would exceed the 100-byte budget → rejected immediately.
|
||||
const started = Date.now();
|
||||
const second = await admitChatRequest(chatRequest(body), options);
|
||||
assert.equal(second.admit, false, "over-budget byte-heavy wait must not admit");
|
||||
if (!second.admit) assert.equal(second.response.status, 503);
|
||||
assert.ok(Date.now() - started < 500, "over-budget wait must reject immediately");
|
||||
|
||||
held.release();
|
||||
const firstResult = await first;
|
||||
assert.equal(firstResult.admit, true);
|
||||
if (firstResult.admit) firstResult.lease?.release();
|
||||
assert.equal(controller.activeHeavy, 0);
|
||||
});
|
||||
|
||||
test("structural admission enforces the queued-bytes cap end-to-end", async () => {
|
||||
const controller = new ChatAdmissionController(1, CHAT_LARGE_BODY_BYTES);
|
||||
const held = controller.tryAcquireHeavy();
|
||||
assert.ok(held);
|
||||
|
||||
const structural = {
|
||||
messages: [
|
||||
{ role: "user", content: "one" },
|
||||
{ role: "user", content: "two" },
|
||||
],
|
||||
};
|
||||
const options = {
|
||||
controller,
|
||||
maxMessages: 10,
|
||||
heavyMessages: 2,
|
||||
heavyTools: 10,
|
||||
heavyTokens: 10_000,
|
||||
queueMs: 2_000,
|
||||
};
|
||||
|
||||
// First structural wait parks, charging the conservative 256KB weight.
|
||||
const first = admitChatStructure(structural, null, options);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
|
||||
// Second would double the charge → rejected immediately.
|
||||
const started = Date.now();
|
||||
const second = await admitChatStructure(structural, null, options);
|
||||
assert.equal(second.admit, false, "over-budget structural wait must not admit");
|
||||
if (!second.admit) assert.equal(second.response.status, 503);
|
||||
assert.ok(Date.now() - started < 500, "over-budget structural wait must reject immediately");
|
||||
|
||||
held.release();
|
||||
const firstResult = await first;
|
||||
assert.equal(firstResult.admit, true);
|
||||
if (firstResult.admit) firstResult.lease?.release();
|
||||
assert.equal(controller.activeHeavy, 0);
|
||||
});
|
||||
|
||||
test("queue-wait defaults are bounded (2s wait, 4MB queued-bytes budget)", () => {
|
||||
if (process.env.OMNIROUTE_CHAT_ADMISSION_QUEUE_MS === undefined) {
|
||||
assert.equal(CHAT_ADMISSION_QUEUE_MAX_MS, 2_000);
|
||||
}
|
||||
if (process.env.OMNIROUTE_CHAT_ADMISSION_MAX_QUEUED_BYTES === undefined) {
|
||||
assert.equal(CHAT_ADMISSION_MAX_QUEUED_BYTES, 4 * 1024 * 1024);
|
||||
}
|
||||
});
|
||||
|
||||
test("a pre-aborted signal never parks in the admission queue", async () => {
|
||||
const controller = new ChatAdmissionController(1);
|
||||
const held = controller.tryAcquireHeavy();
|
||||
assert.ok(held);
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortController.abort("client already disconnected");
|
||||
|
||||
const pending = controller.acquireHeavyWithin(2_000, abortController.signal);
|
||||
let settled = false;
|
||||
void pending.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
assert.equal(settled, true, "a pre-aborted signal must settle immediately, not park");
|
||||
|
||||
const lease = await pending;
|
||||
assert.equal(lease, null, "no lease is granted after abort");
|
||||
assert.equal(controller.activeHeavy, 1, "holder keeps capacity; aborted wait consumed nothing");
|
||||
held.release();
|
||||
assert.equal(controller.activeHeavy, 0);
|
||||
});
|
||||
|
||||
test("aborting the request signal cancels a queued byte-heavy wait", async () => {
|
||||
const controller = new ChatAdmissionController(1);
|
||||
const held = controller.tryAcquireHeavy();
|
||||
assert.ok(held);
|
||||
|
||||
const abortController = new AbortController();
|
||||
const body = JSON.stringify({ messages: [{ role: "user", content: "x".repeat(40) }] });
|
||||
const request = new Request("http://x/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
const pending = admitChatRequest(request, {
|
||||
controller,
|
||||
largeBodyBytes: 32,
|
||||
hardMaxBytes: 1024,
|
||||
queueMs: 2_000,
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
void pending.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(settled, false, "must queue while capacity is busy");
|
||||
|
||||
abortController.abort();
|
||||
const started = Date.now();
|
||||
const result = await pending;
|
||||
assert.ok(
|
||||
Date.now() - started < 500,
|
||||
"abort must cancel the queue-wait early, not park the full queueMs"
|
||||
);
|
||||
assert.equal(result.admit, false, "abort must not admit");
|
||||
if (!result.admit) {
|
||||
assert.equal(result.response.status, 503);
|
||||
assert.equal((await result.response.json()).error.code, "chat_admission_busy");
|
||||
}
|
||||
assert.equal(controller.activeHeavy, 1, "holder keeps capacity; aborted wait consumed nothing");
|
||||
held.release();
|
||||
assert.equal(controller.activeHeavy, 0);
|
||||
});
|
||||
|
||||
test("aborting the signal cancels a structural queue-wait", async () => {
|
||||
const controller = new ChatAdmissionController(1);
|
||||
const held = controller.tryAcquireHeavy();
|
||||
assert.ok(held);
|
||||
|
||||
const abortController = new AbortController();
|
||||
const pending = admitChatStructure(
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "one" },
|
||||
{ role: "user", content: "two" },
|
||||
],
|
||||
},
|
||||
null,
|
||||
{
|
||||
controller,
|
||||
maxMessages: 10,
|
||||
heavyMessages: 2,
|
||||
heavyTools: 10,
|
||||
heavyTokens: 10_000,
|
||||
queueMs: 2_000,
|
||||
signal: abortController.signal,
|
||||
}
|
||||
);
|
||||
|
||||
let settled = false;
|
||||
void pending.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(settled, false, "must queue while capacity is busy");
|
||||
|
||||
abortController.abort();
|
||||
const started = Date.now();
|
||||
const result = await pending;
|
||||
assert.ok(
|
||||
Date.now() - started < 500,
|
||||
"abort must cancel the queue-wait early, not park the full queueMs"
|
||||
);
|
||||
assert.equal(result.admit, false, "abort must not admit");
|
||||
if (!result.admit) {
|
||||
assert.equal(result.response.status, 503);
|
||||
assert.equal((await result.response.json()).error.code, "chat_admission_busy");
|
||||
}
|
||||
assert.equal(controller.activeHeavy, 1, "holder keeps its lease");
|
||||
held.release();
|
||||
assert.equal(controller.activeHeavy, 0);
|
||||
});
|
||||
@@ -8,6 +8,9 @@ const {
|
||||
admitChatStructure,
|
||||
ChatAdmissionController,
|
||||
CHAT_HARD_MAX_MESSAGES,
|
||||
CHAT_ADMISSION_QUEUE_MAX_MS,
|
||||
CHAT_ADMISSION_MAX_QUEUED_BYTES,
|
||||
CHAT_LARGE_BODY_BYTES,
|
||||
releaseChatAdmissionAfterHandler,
|
||||
releaseChatAdmissionWhenDone,
|
||||
resolveSelfLoopBearer,
|
||||
@@ -813,168 +816,3 @@ test("sk_omniroute sentinel is rejected once an env key is configured (REQUIRE_A
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("a heavy structural request waits for capacity instead of failing immediately", async () => {
|
||||
const controller = new ChatAdmissionController(1);
|
||||
const held = controller.tryAcquireHeavy();
|
||||
assert.ok(held);
|
||||
|
||||
const pending = admitChatStructure(
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "one" },
|
||||
{ role: "user", content: "two" },
|
||||
],
|
||||
},
|
||||
null,
|
||||
{
|
||||
controller,
|
||||
maxMessages: 10,
|
||||
heavyMessages: 2,
|
||||
heavyTools: 10,
|
||||
heavyTokens: 10_000,
|
||||
queueMs: 500,
|
||||
}
|
||||
);
|
||||
|
||||
// Capacity is still busy: the request must not have resolved (admit/reject) yet.
|
||||
let settled = false;
|
||||
void pending.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(settled, false, "must wait while capacity is busy");
|
||||
|
||||
held.release();
|
||||
const result = await pending;
|
||||
assert.equal(result.admit, true);
|
||||
if (result.admit) {
|
||||
assert.equal(controller.activeHeavy, 1, "waiting request acquires the freed lease");
|
||||
result.lease?.release();
|
||||
}
|
||||
assert.equal(controller.activeHeavy, 0);
|
||||
});
|
||||
|
||||
test("waiting for admission times out into a retryable 503", async () => {
|
||||
const controller = new ChatAdmissionController(1);
|
||||
const held = controller.tryAcquireHeavy();
|
||||
assert.ok(held);
|
||||
|
||||
const started = Date.now();
|
||||
const result = await admitChatStructure(
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "one" },
|
||||
{ role: "user", content: "two" },
|
||||
],
|
||||
},
|
||||
null,
|
||||
{
|
||||
controller,
|
||||
maxMessages: 10,
|
||||
heavyMessages: 2,
|
||||
heavyTools: 10,
|
||||
heavyTokens: 10_000,
|
||||
queueMs: 50,
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.admit, false);
|
||||
if (!result.admit) {
|
||||
assert.equal(result.response.status, 503);
|
||||
assert.equal(result.response.headers.get("retry-after"), "1");
|
||||
assert.equal((await result.response.json()).error.code, "chat_admission_busy");
|
||||
}
|
||||
assert.ok(Date.now() - started >= 40, "must wait for the queue deadline before rejecting");
|
||||
assert.equal(controller.activeHeavy, 1, "the holder keeps its lease");
|
||||
held.release();
|
||||
assert.equal(controller.activeHeavy, 0);
|
||||
});
|
||||
|
||||
test("byte-heavy admission waits for capacity when queueMs is set", async () => {
|
||||
const controller = new ChatAdmissionController(1);
|
||||
const body = JSON.stringify({ messages: [{ role: "user", content: "x".repeat(40) }] });
|
||||
const options = { controller, largeBodyBytes: 32, hardMaxBytes: 1024, queueMs: 500 };
|
||||
|
||||
const first = await admitChatRequest(chatRequest(body), options);
|
||||
assert.equal(first.admit, true);
|
||||
if (!first.admit) return;
|
||||
|
||||
const second = admitChatRequest(chatRequest(body), options);
|
||||
let secondSettled = false;
|
||||
void second.then(() => {
|
||||
secondSettled = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(
|
||||
secondSettled,
|
||||
false,
|
||||
"second heavy request must queue while the first holds capacity"
|
||||
);
|
||||
|
||||
first.lease?.release();
|
||||
const secondResult = await second;
|
||||
assert.equal(secondResult.admit, true, "second request acquires capacity after release");
|
||||
if (secondResult.admit) secondResult.lease?.release();
|
||||
assert.equal(controller.activeHeavy, 0);
|
||||
});
|
||||
|
||||
test("expired admission queue keeps the legacy immediate 503 behaviour", async () => {
|
||||
const controller = new ChatAdmissionController(1);
|
||||
const held = controller.tryAcquireHeavy();
|
||||
assert.ok(held);
|
||||
|
||||
const result = await admitChatStructure(
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "one" },
|
||||
{ role: "user", content: "two" },
|
||||
],
|
||||
},
|
||||
null,
|
||||
{
|
||||
controller,
|
||||
maxMessages: 10,
|
||||
heavyMessages: 2,
|
||||
heavyTools: 10,
|
||||
heavyTokens: 10_000,
|
||||
queueMs: 0,
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.admit, false);
|
||||
if (!result.admit) assert.equal(result.response.status, 503);
|
||||
held.release();
|
||||
});
|
||||
|
||||
test("admission waiters are served FIFO as capacity frees", async () => {
|
||||
const controller = new ChatAdmissionController(1);
|
||||
const held = controller.tryAcquireHeavy();
|
||||
assert.ok(held);
|
||||
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "user", content: "one" },
|
||||
{ role: "user", content: "two" },
|
||||
],
|
||||
};
|
||||
const options = {
|
||||
controller,
|
||||
maxMessages: 10,
|
||||
heavyMessages: 2,
|
||||
heavyTools: 10,
|
||||
heavyTokens: 10_000,
|
||||
queueMs: 500,
|
||||
};
|
||||
const first = admitChatStructure(body, null, options);
|
||||
const second = admitChatStructure(body, null, options);
|
||||
|
||||
held.release();
|
||||
const firstResult = await first;
|
||||
assert.equal(firstResult.admit, true);
|
||||
if (firstResult.admit) firstResult.lease?.release();
|
||||
const secondResult = await second;
|
||||
assert.equal(secondResult.admit, true);
|
||||
if (secondResult.admit) secondResult.lease?.release();
|
||||
assert.equal(controller.activeHeavy, 0);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import net from "node:net";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-chat-helpers-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
@@ -21,6 +22,8 @@ const {
|
||||
} = await import("../../src/sse/handlers/chatHelpers.ts");
|
||||
const { getCircuitBreaker, resetAllCircuitBreakers, STATE } =
|
||||
await import("../../src/shared/utils/circuitBreaker.ts");
|
||||
// DATA_DIR must be fixed before these modules load; keep this test seam dynamic.
|
||||
const { setTlsClientForTest } = await import("../../open-sse/utils/proxyFetch.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
resetAllCircuitBreakers();
|
||||
@@ -421,6 +424,96 @@ test("executeChatWithBreaker converts proxy fast-fail errors", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("executeChatWithBreaker preserves account TLS scope when a proxy bypasses to direct", async () => {
|
||||
const server = net.createServer((socket) => socket.end());
|
||||
const listening = Promise.withResolvers<void>();
|
||||
server.listen(0, "127.0.0.1", listening.resolve);
|
||||
await listening.promise;
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address !== "string");
|
||||
const prior = {
|
||||
enable: process.env.ENABLE_TLS_FINGERPRINT,
|
||||
providers: process.env.TLS_FINGERPRINT_PROVIDERS,
|
||||
noProxy: process.env.NO_PROXY,
|
||||
};
|
||||
process.env.ENABLE_TLS_FINGERPRINT = "true";
|
||||
delete process.env.TLS_FINGERPRINT_PROVIDERS;
|
||||
process.env.NO_PROXY = "api.openai.com";
|
||||
let observedProxy: string | null | undefined;
|
||||
let observedScope: string | undefined;
|
||||
setTlsClientForTest({
|
||||
available: true,
|
||||
fetch: async (_url, options) => {
|
||||
observedProxy = options?.proxy;
|
||||
observedScope = options?.sessionScope;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "chatcmpl-test",
|
||||
object: "chat.completion",
|
||||
created: 0,
|
||||
model: "gpt-4o-mini",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: "ok" },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const credentials = {
|
||||
connectionId: "conn_tls_scope",
|
||||
apiKey: "sk-openai-helper",
|
||||
providerSpecificData: {},
|
||||
};
|
||||
const result = await executeChatWithBreaker({
|
||||
bypassCircuitBreaker: false,
|
||||
breaker: getCircuitBreaker("openai"),
|
||||
body: { model: "openai/gpt-4o-mini", messages: [] },
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
refreshedCredentials: credentials,
|
||||
proxyInfo: {
|
||||
proxy: `http://127.0.0.1:${address.port}`,
|
||||
level: "connection",
|
||||
levelId: credentials.connectionId,
|
||||
},
|
||||
log: console,
|
||||
clientRawRequest: null,
|
||||
credentials,
|
||||
apiKeyInfo: null,
|
||||
userAgent: "",
|
||||
comboName: null,
|
||||
comboStrategy: null,
|
||||
isCombo: false,
|
||||
extendedContext: false,
|
||||
comboStepId: null,
|
||||
comboExecutionKey: null,
|
||||
});
|
||||
|
||||
assert.equal(result.tlsFingerprintUsed, true);
|
||||
assert.equal(observedProxy, null);
|
||||
assert.equal(observedScope, credentials.connectionId);
|
||||
} finally {
|
||||
setTlsClientForTest(null);
|
||||
if (prior.enable === undefined) delete process.env.ENABLE_TLS_FINGERPRINT;
|
||||
else process.env.ENABLE_TLS_FINGERPRINT = prior.enable;
|
||||
if (prior.providers === undefined) delete process.env.TLS_FINGERPRINT_PROVIDERS;
|
||||
else process.env.TLS_FINGERPRINT_PROVIDERS = prior.providers;
|
||||
if (prior.noProxy === undefined) delete process.env.NO_PROXY;
|
||||
else process.env.NO_PROXY = prior.noProxy;
|
||||
const closed = Promise.withResolvers<void>();
|
||||
server.close(() => closed.resolve());
|
||||
await closed.promise;
|
||||
}
|
||||
});
|
||||
|
||||
test("safeLogEvents tolerates success and timeout payloads", () => {
|
||||
const credentials = { connectionId: "conn_log_12345678" };
|
||||
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
normalizeCodexTools,
|
||||
stripRedundantOneOfConstEnum,
|
||||
} from "../../open-sse/executors/codex/tools.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const PRODUCTION_ACTION_VALUES = [
|
||||
"read_file",
|
||||
"write_file",
|
||||
"list_files",
|
||||
"search_files",
|
||||
"run_command",
|
||||
"create_directory",
|
||||
"delete_file",
|
||||
"move_file",
|
||||
"copy_file",
|
||||
"rename_file",
|
||||
"open_terminal",
|
||||
"close_terminal",
|
||||
"get_status",
|
||||
] as const;
|
||||
|
||||
function productionParameters(): JsonRecord {
|
||||
return {
|
||||
type: "object",
|
||||
description: "OpenChamber action parameters",
|
||||
properties: {
|
||||
action: {
|
||||
type: "string",
|
||||
description: "Action to perform",
|
||||
enum: [...PRODUCTION_ACTION_VALUES],
|
||||
oneOf: PRODUCTION_ACTION_VALUES.map((value) => ({
|
||||
const: value,
|
||||
description: `Action ${value}`,
|
||||
})),
|
||||
},
|
||||
},
|
||||
required: ["action"],
|
||||
};
|
||||
}
|
||||
|
||||
function chatTool(parameters: JsonRecord): JsonRecord {
|
||||
return {
|
||||
type: "function",
|
||||
function: { name: "test_tool", parameters },
|
||||
};
|
||||
}
|
||||
|
||||
test("production openchamber shape is normalized", () => {
|
||||
const tool = chatTool(productionParameters());
|
||||
normalizeCodexTools({ tools: [tool] });
|
||||
|
||||
const parameters = tool.parameters as JsonRecord;
|
||||
const properties = parameters.properties as JsonRecord;
|
||||
const action = properties.action as JsonRecord;
|
||||
assert.equal(action.oneOf, undefined);
|
||||
assert.deepEqual(action.enum, [...PRODUCTION_ACTION_VALUES]);
|
||||
assert.equal(parameters.type, "object");
|
||||
assert.equal(parameters.description, "OpenChamber action parameters");
|
||||
assert.deepEqual(parameters.required, ["action"]);
|
||||
});
|
||||
|
||||
test("bare oneOf[const] without sibling enum is preserved", () => {
|
||||
const tool = chatTool({ oneOf: [{ const: "x" }, { const: "y" }] });
|
||||
normalizeCodexTools({ tools: [tool] });
|
||||
|
||||
const oneOf = (tool.parameters as JsonRecord).oneOf as unknown[];
|
||||
assert.equal(oneOf.length, 2);
|
||||
});
|
||||
|
||||
test("non-matching enum is preserved", () => {
|
||||
const tool = chatTool({
|
||||
enum: ["a", "b", "c"],
|
||||
oneOf: [{ const: "a" }, { const: "b" }],
|
||||
});
|
||||
normalizeCodexTools({ tools: [tool] });
|
||||
|
||||
assert.deepEqual((tool.parameters as JsonRecord).oneOf, [{ const: "a" }, { const: "b" }]);
|
||||
});
|
||||
|
||||
test("partially overlapping enum is preserved", () => {
|
||||
const tool = chatTool({
|
||||
enum: ["a", "b", "c"],
|
||||
oneOf: [{ const: "a" }, { const: "d" }],
|
||||
});
|
||||
normalizeCodexTools({ tools: [tool] });
|
||||
|
||||
assert.deepEqual((tool.parameters as JsonRecord).oneOf, [{ const: "a" }, { const: "d" }]);
|
||||
});
|
||||
|
||||
test("enum with extra value is preserved", () => {
|
||||
const tool = chatTool({ enum: ["a", "b"], oneOf: [{ const: "a" }] });
|
||||
normalizeCodexTools({ tools: [tool] });
|
||||
|
||||
assert.deepEqual((tool.parameters as JsonRecord).oneOf, [{ const: "a" }]);
|
||||
});
|
||||
|
||||
test("duplicate const branches are preserved", () => {
|
||||
const tool = chatTool({
|
||||
enum: ["a", "b"],
|
||||
oneOf: [{ const: "a" }, { const: "a" }],
|
||||
});
|
||||
normalizeCodexTools({ tools: [tool] });
|
||||
|
||||
assert.deepEqual((tool.parameters as JsonRecord).oneOf, [{ const: "a" }, { const: "a" }]);
|
||||
});
|
||||
|
||||
test("branch with validation keyword is preserved", () => {
|
||||
const tool = chatTool({
|
||||
enum: ["a", "b"],
|
||||
oneOf: [{ const: "a", type: "string" }, { const: "b" }],
|
||||
});
|
||||
normalizeCodexTools({ tools: [tool] });
|
||||
|
||||
assert.deepEqual((tool.parameters as JsonRecord).oneOf, [
|
||||
{ const: "a", type: "string" },
|
||||
{ const: "b" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("type-discriminated oneOf is preserved", () => {
|
||||
const tool = chatTool({ oneOf: [{ type: "string" }, { type: "number" }] });
|
||||
normalizeCodexTools({ tools: [tool] });
|
||||
|
||||
assert.deepEqual((tool.parameters as JsonRecord).oneOf, [{ type: "string" }, { type: "number" }]);
|
||||
});
|
||||
|
||||
test("empty oneOf is preserved", () => {
|
||||
const tool = chatTool({ enum: ["a"], oneOf: [] });
|
||||
normalizeCodexTools({ tools: [tool] });
|
||||
|
||||
assert.deepEqual((tool.parameters as JsonRecord).oneOf, []);
|
||||
});
|
||||
|
||||
test("single-branch exact match is stripped", () => {
|
||||
const tool = chatTool({ enum: ["x"], oneOf: [{ const: "x" }] });
|
||||
normalizeCodexTools({ tools: [tool] });
|
||||
|
||||
const parameters = tool.parameters as JsonRecord;
|
||||
assert.equal(parameters.oneOf, undefined);
|
||||
assert.deepEqual(parameters.enum, ["x"]);
|
||||
});
|
||||
|
||||
test("non-string const is preserved", () => {
|
||||
const tool = chatTool({ enum: [1, 2], oneOf: [{ const: 1 }, { const: 2 }] });
|
||||
normalizeCodexTools({ tools: [tool] });
|
||||
|
||||
assert.deepEqual((tool.parameters as JsonRecord).oneOf, [{ const: 1 }, { const: 2 }]);
|
||||
});
|
||||
|
||||
test("non-string enum is preserved", () => {
|
||||
const tool = chatTool({ enum: [{ a: 1 }], oneOf: [{ const: "x" }] });
|
||||
normalizeCodexTools({ tools: [tool] });
|
||||
|
||||
assert.deepEqual((tool.parameters as JsonRecord).oneOf, [{ const: "x" }]);
|
||||
});
|
||||
|
||||
test("anyOf is preserved while the walker strips its inner oneOf", () => {
|
||||
const tool = chatTool({
|
||||
anyOf: [{ enum: ["a", "b"], oneOf: [{ const: "a" }, { const: "b" }] }],
|
||||
});
|
||||
normalizeCodexTools({ tools: [tool] });
|
||||
|
||||
const parameters = tool.parameters as JsonRecord;
|
||||
const anyOf = parameters.anyOf as JsonRecord[];
|
||||
assert.equal(anyOf.length, 1);
|
||||
assert.equal(anyOf[0].oneOf, undefined);
|
||||
});
|
||||
|
||||
test("allOf is preserved while the walker strips its inner oneOf", () => {
|
||||
const tool = chatTool({ allOf: [{ enum: ["a"], oneOf: [{ const: "a" }] }] });
|
||||
normalizeCodexTools({ tools: [tool] });
|
||||
|
||||
const parameters = tool.parameters as JsonRecord;
|
||||
const allOf = parameters.allOf as JsonRecord[];
|
||||
assert.equal(allOf.length, 1);
|
||||
assert.equal(allOf[0].oneOf, undefined);
|
||||
});
|
||||
|
||||
test("nested oneOf in properties is stripped", () => {
|
||||
const tool = chatTool({
|
||||
properties: {
|
||||
action: {
|
||||
enum: ["a", "b"],
|
||||
oneOf: [
|
||||
{ const: "a", description: "A" },
|
||||
{ const: "b", description: "B" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
normalizeCodexTools({ tools: [tool] });
|
||||
|
||||
const properties = (tool.parameters as JsonRecord).properties as JsonRecord;
|
||||
const action = properties.action as JsonRecord;
|
||||
assert.equal(action.oneOf, undefined);
|
||||
assert.deepEqual(action.enum, ["a", "b"]);
|
||||
});
|
||||
|
||||
test("nested oneOf in items is stripped", () => {
|
||||
const tool = chatTool({ items: { enum: ["a", "b"], oneOf: [{ const: "a" }, { const: "b" }] } });
|
||||
normalizeCodexTools({ tools: [tool] });
|
||||
|
||||
const items = (tool.parameters as JsonRecord).items as JsonRecord;
|
||||
assert.equal(items.oneOf, undefined);
|
||||
});
|
||||
|
||||
test("nested oneOf in additionalProperties is stripped", () => {
|
||||
const tool = chatTool({
|
||||
additionalProperties: {
|
||||
enum: ["p", "q"],
|
||||
oneOf: [{ const: "p" }, { const: "q" }],
|
||||
},
|
||||
});
|
||||
normalizeCodexTools({ tools: [tool] });
|
||||
|
||||
const additionalProperties = (tool.parameters as JsonRecord).additionalProperties as JsonRecord;
|
||||
assert.equal(additionalProperties.oneOf, undefined);
|
||||
});
|
||||
|
||||
test("nested oneOf in $defs is stripped", () => {
|
||||
const tool = chatTool({
|
||||
$defs: { D: { enum: ["d1", "d2"], oneOf: [{ const: "d1" }, { const: "d2" }] } },
|
||||
});
|
||||
normalizeCodexTools({ tools: [tool] });
|
||||
|
||||
const defs = (tool.parameters as JsonRecord).$defs as JsonRecord;
|
||||
const definition = defs.D as JsonRecord;
|
||||
assert.equal(definition.oneOf, undefined);
|
||||
});
|
||||
|
||||
test("nested oneOf in patternProperties is stripped", () => {
|
||||
const tool = chatTool({
|
||||
patternProperties: { "^x$": { enum: ["a"], oneOf: [{ const: "a" }] } },
|
||||
});
|
||||
normalizeCodexTools({ tools: [tool] });
|
||||
|
||||
const patternProperties = (tool.parameters as JsonRecord).patternProperties as JsonRecord;
|
||||
const pattern = patternProperties["^x$"] as JsonRecord;
|
||||
assert.equal(pattern.oneOf, undefined);
|
||||
});
|
||||
|
||||
test("stripping is idempotent", () => {
|
||||
const first = stripRedundantOneOfConstEnum(productionParameters());
|
||||
const second = stripRedundantOneOfConstEnum(first);
|
||||
|
||||
assert.deepEqual(second, first);
|
||||
});
|
||||
|
||||
test("stripping is immutable", () => {
|
||||
const original = productionParameters();
|
||||
const before = structuredClone(original);
|
||||
const result = stripRedundantOneOfConstEnum(original) as JsonRecord;
|
||||
|
||||
const properties = original.properties as JsonRecord;
|
||||
const action = properties.action as JsonRecord;
|
||||
assert.deepEqual(original, before);
|
||||
assert.ok(Array.isArray(action.oneOf));
|
||||
assert.notStrictEqual(result, original);
|
||||
});
|
||||
|
||||
test("Chat wrapper is flattened to the flat Responses form", () => {
|
||||
const tool = chatTool({
|
||||
properties: {
|
||||
action: { enum: ["a", "b"], oneOf: [{ const: "a" }, { const: "b" }] },
|
||||
},
|
||||
});
|
||||
normalizeCodexTools({ tools: [tool] });
|
||||
|
||||
const parameters = tool.parameters as JsonRecord;
|
||||
const properties = parameters.properties as JsonRecord;
|
||||
const action = properties.action as JsonRecord;
|
||||
assert.equal(action.oneOf, undefined);
|
||||
assert.equal(tool.function, undefined);
|
||||
assert.equal(tool.name, "test_tool");
|
||||
});
|
||||
158
tests/unit/codex-tools-strict-default.test.ts
Normal file
158
tests/unit/codex-tools-strict-default.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { normalizeCodexTools } from "../../open-sse/executors/codex/tools.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function functionTool(name: string, definition: JsonRecord, extra: JsonRecord = {}): JsonRecord {
|
||||
return {
|
||||
type: "function",
|
||||
name,
|
||||
parameters: { type: "object" },
|
||||
...extra,
|
||||
...definition,
|
||||
};
|
||||
}
|
||||
|
||||
function nestedFunctionTool(name: string, definition: JsonRecord): JsonRecord {
|
||||
return {
|
||||
type: "function",
|
||||
function: {
|
||||
name,
|
||||
parameters: { type: "object" },
|
||||
...definition,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function strictOf(tool: JsonRecord): unknown {
|
||||
return tool.strict;
|
||||
}
|
||||
|
||||
test("default false applies to omitted nested and flat function strict", () => {
|
||||
const flat = functionTool("flat_tool", {});
|
||||
const nested = nestedFunctionTool("nested_tool", {});
|
||||
|
||||
normalizeCodexTools({ tools: [flat, nested] }, { defaultFunctionStrict: false });
|
||||
|
||||
assert.equal(strictOf(flat), false);
|
||||
assert.equal(strictOf(nested), false);
|
||||
});
|
||||
|
||||
test("omitted function strict stays omitted without a fallback", () => {
|
||||
const flat = functionTool("flat_tool", {});
|
||||
const nested = nestedFunctionTool("nested_tool", {});
|
||||
|
||||
normalizeCodexTools({ tools: [flat, nested] });
|
||||
|
||||
assert.equal(strictOf(flat), undefined);
|
||||
assert.equal(strictOf(nested), undefined);
|
||||
});
|
||||
|
||||
test("explicit top-level true and false are preserved", () => {
|
||||
for (const value of [true, false]) {
|
||||
const tool = functionTool("top_level_tool", { strict: value });
|
||||
|
||||
normalizeCodexTools({ tools: [tool] }, { defaultFunctionStrict: !value });
|
||||
|
||||
assert.equal(strictOf(tool), value);
|
||||
}
|
||||
});
|
||||
|
||||
test("explicit nested function true and false are preserved", () => {
|
||||
for (const value of [true, false]) {
|
||||
const tool = nestedFunctionTool("nested_tool", { strict: value });
|
||||
|
||||
normalizeCodexTools({ tools: [tool] }, { defaultFunctionStrict: !value });
|
||||
|
||||
assert.equal(strictOf(tool), value);
|
||||
}
|
||||
});
|
||||
|
||||
test("top-level boolean strict takes precedence over nested boolean strict", () => {
|
||||
const topLevelTrue = nestedFunctionTool("top_true", { strict: false });
|
||||
topLevelTrue.strict = true;
|
||||
const topLevelFalse = nestedFunctionTool("top_false", { strict: true });
|
||||
topLevelFalse.strict = false;
|
||||
|
||||
normalizeCodexTools({ tools: [topLevelTrue, topLevelFalse] }, { defaultFunctionStrict: true });
|
||||
|
||||
assert.equal(strictOf(topLevelTrue), true);
|
||||
assert.equal(strictOf(topLevelFalse), false);
|
||||
});
|
||||
|
||||
test("nonboolean explicit strict values fall back through the precedence chain", () => {
|
||||
const topNonBoolean = nestedFunctionTool("nested_boolean", { strict: false });
|
||||
topNonBoolean.strict = "true";
|
||||
const bothNonBoolean = nestedFunctionTool("fallback", { strict: "false" });
|
||||
bothNonBoolean.strict = 1;
|
||||
|
||||
normalizeCodexTools({ tools: [topNonBoolean, bothNonBoolean] }, { defaultFunctionStrict: true });
|
||||
|
||||
assert.equal(strictOf(topNonBoolean), false);
|
||||
assert.equal(strictOf(bothNonBoolean), true);
|
||||
});
|
||||
|
||||
test("hosted, namespace, and custom tools are unchanged by function strict defaults", () => {
|
||||
const hosted = { type: "web_search", search_context_size: "high" };
|
||||
const namespace = {
|
||||
type: "namespace",
|
||||
name: "mcp__example__",
|
||||
tools: [{ type: "function", name: "search", parameters: { type: "object" } }],
|
||||
};
|
||||
const custom = {
|
||||
type: "custom",
|
||||
name: "apply_patch",
|
||||
format: { type: "grammar", syntax: "lark", definition: "start: /.+/" },
|
||||
};
|
||||
const tools = [hosted, namespace, custom];
|
||||
const before = structuredClone(tools);
|
||||
|
||||
normalizeCodexTools({ tools }, { defaultFunctionStrict: false, preserveCustomTools: true });
|
||||
|
||||
assert.deepEqual(tools, before);
|
||||
});
|
||||
|
||||
test("production-like enum and oneOf schema is preserved exactly", () => {
|
||||
const parameters = {
|
||||
type: "object",
|
||||
description: "Dynamic action parameters",
|
||||
properties: {
|
||||
action: {
|
||||
type: "string",
|
||||
enum: ["read_file", "write_file", "list_files"],
|
||||
oneOf: [
|
||||
{
|
||||
const: "write_file",
|
||||
description: "Write a file",
|
||||
title: "Write file",
|
||||
$comment: "branch 1",
|
||||
},
|
||||
{
|
||||
const: "read_file",
|
||||
description: "Read a file",
|
||||
title: "Read file",
|
||||
$comment: "branch 2",
|
||||
},
|
||||
{
|
||||
const: "list_files",
|
||||
description: "List files",
|
||||
title: "List files",
|
||||
$comment: "branch 3",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
required: ["action"],
|
||||
};
|
||||
const tool = {
|
||||
type: "function",
|
||||
function: { name: "dynamic_tool", parameters },
|
||||
} as JsonRecord;
|
||||
const expected = structuredClone(parameters);
|
||||
|
||||
normalizeCodexTools({ tools: [tool] });
|
||||
|
||||
assert.deepEqual(tool.parameters, expected);
|
||||
});
|
||||
260
tests/unit/command-code-usage.test.ts
Normal file
260
tests/unit/command-code-usage.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* tests/unit/command-code-usage.test.ts
|
||||
*
|
||||
* Command Code usage.ts dispatch + Provider Limits allowlists for
|
||||
* monthly credits + 5h/weekly rolling windows (Bearer /alpha APIs).
|
||||
*/
|
||||
|
||||
import { describe, it, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { __testing, USAGE_FETCHER_PROVIDERS, getUsageForProvider } =
|
||||
await import("../../open-sse/services/usage.ts");
|
||||
const { USAGE_SUPPORTED_PROVIDERS } = await import("../../src/shared/constants/providers.ts");
|
||||
const { isSupportedUsageConnection } = await import("../../src/lib/usage/providerLimits.ts");
|
||||
const { convertUsageToQuotaInfo } = await import("../../open-sse/services/genericQuotaFetcher.ts");
|
||||
const { getCommandCodeUsage } = __testing;
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
type QuotaShape = {
|
||||
used: number;
|
||||
total: number;
|
||||
remaining?: number;
|
||||
remainingPercentage?: number;
|
||||
resetAt: string | null;
|
||||
currency?: string;
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const CREDITS_PAYLOAD = {
|
||||
credits: {
|
||||
belowThreshold: false,
|
||||
creditThreshold: 0,
|
||||
monthlyCredits: 34.9522404823,
|
||||
purchasedCredits: 0.0446690956,
|
||||
freeCredits: 0,
|
||||
},
|
||||
windowLimits: {
|
||||
limited: true,
|
||||
exceeded: "weekly",
|
||||
fiveHour: {
|
||||
used: 0,
|
||||
cap: 14,
|
||||
exceeded: false,
|
||||
resetAt: 0,
|
||||
},
|
||||
weekly: {
|
||||
used: 35.0477595177,
|
||||
cap: 35,
|
||||
exceeded: true,
|
||||
resetAt: 1786575240518,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const WHOAMI_PERSONAL = {
|
||||
success: true,
|
||||
user: { id: "u1", name: "dev", email: "dev@example.com", userName: "dev" },
|
||||
org: null,
|
||||
};
|
||||
|
||||
const SUBSCRIPTION_GOAT = {
|
||||
success: true,
|
||||
data: {
|
||||
id: "sub_1",
|
||||
status: "active",
|
||||
orgId: null,
|
||||
currentPeriodStart: "2026-08-05T22:27:41.000Z",
|
||||
currentPeriodEnd: "2026-09-05T22:27:41.000Z",
|
||||
planId: "individual-goat",
|
||||
},
|
||||
};
|
||||
|
||||
const USAGE_SUMMARY = {
|
||||
totalCount: 1260,
|
||||
totalCost: 55.0030904221,
|
||||
totalMonthlyCredits: 35.0477595177,
|
||||
totalPurchasedCredits: 19.9553309044,
|
||||
periodBasis: "billing-period",
|
||||
};
|
||||
|
||||
function installMockFetch(handlers: Record<string, () => Response>) {
|
||||
globalThis.fetch = async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
for (const [needle, handler] of Object.entries(handlers)) {
|
||||
if (url.includes(needle)) return handler();
|
||||
}
|
||||
return jsonResponse({ error: `unhandled fetch: ${url}` }, 500);
|
||||
};
|
||||
}
|
||||
|
||||
describe("Command Code usage dispatch", () => {
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("registers command-code in USAGE_FETCHER_PROVIDERS and USAGE_SUPPORTED_PROVIDERS", () => {
|
||||
assert.ok((USAGE_FETCHER_PROVIDERS as readonly string[]).includes("command-code"));
|
||||
assert.ok((USAGE_SUPPORTED_PROVIDERS as readonly string[]).includes("command-code"));
|
||||
});
|
||||
|
||||
it("isSupportedUsageConnection accepts command-code apikey connections", () => {
|
||||
assert.equal(
|
||||
isSupportedUsageConnection({
|
||||
id: "c1",
|
||||
provider: "command-code",
|
||||
authType: "apikey",
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("getCommandCodeUsage maps credits + windows + period spend", async () => {
|
||||
installMockFetch({
|
||||
"/alpha/whoami": () => jsonResponse(WHOAMI_PERSONAL),
|
||||
"/alpha/billing/credits": () => jsonResponse(CREDITS_PAYLOAD),
|
||||
"/alpha/billing/subscriptions": () => jsonResponse(SUBSCRIPTION_GOAT),
|
||||
"/alpha/usage/summary": () => jsonResponse(USAGE_SUMMARY),
|
||||
});
|
||||
|
||||
const r = (await getCommandCodeUsage("cc-key")) as {
|
||||
plan?: string;
|
||||
message?: string;
|
||||
quotas?: {
|
||||
five_hour?: QuotaShape;
|
||||
weekly?: QuotaShape;
|
||||
credits?: QuotaShape;
|
||||
};
|
||||
};
|
||||
|
||||
assert.ok(r.quotas?.five_hour, `expected five_hour, got: ${JSON.stringify(r)}`);
|
||||
assert.ok(r.quotas?.weekly, `expected weekly, got: ${JSON.stringify(r)}`);
|
||||
assert.ok(r.quotas?.credits, `expected credits, got: ${JSON.stringify(r)}`);
|
||||
assert.match(r.plan || "", /GOAT/i);
|
||||
|
||||
assert.equal(r.quotas!.five_hour!.used, 0);
|
||||
assert.equal(r.quotas!.five_hour!.total, 14);
|
||||
assert.equal(r.quotas!.five_hour!.resetAt, null);
|
||||
assert.equal(r.quotas!.five_hour!.currency, "USD");
|
||||
|
||||
// used clamped to total when upstream reports slight overshoot
|
||||
assert.equal(r.quotas!.weekly!.used, 35);
|
||||
assert.equal(r.quotas!.weekly!.total, 35);
|
||||
assert.equal(r.quotas!.weekly!.remaining, 0);
|
||||
assert.equal(r.quotas!.weekly!.remainingPercentage, 0);
|
||||
assert.ok(r.quotas!.weekly!.resetAt?.startsWith("2026-"));
|
||||
|
||||
const remainingCredits = 34.9522404823 + 0.0446690956;
|
||||
assert.ok(Math.abs((r.quotas!.credits!.remaining ?? 0) - remainingCredits) < 1e-9);
|
||||
assert.equal(r.quotas!.credits!.used, USAGE_SUMMARY.totalCost);
|
||||
assert.ok(
|
||||
Math.abs(r.quotas!.credits!.total - (USAGE_SUMMARY.totalCost + remainingCredits)) < 1e-9
|
||||
);
|
||||
assert.equal(r.quotas!.credits!.resetAt, "2026-09-05T22:27:41.000Z");
|
||||
assert.equal(r.quotas!.credits!.currency, "USD");
|
||||
});
|
||||
|
||||
it("getCommandCodeUsage works for org:null without orgId query params", async () => {
|
||||
const seen: string[] = [];
|
||||
globalThis.fetch = async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
seen.push(url);
|
||||
if (url.includes("/alpha/whoami")) return jsonResponse(WHOAMI_PERSONAL);
|
||||
if (url.includes("/alpha/billing/credits")) return jsonResponse(CREDITS_PAYLOAD);
|
||||
if (url.includes("/alpha/billing/subscriptions")) return jsonResponse(SUBSCRIPTION_GOAT);
|
||||
if (url.includes("/alpha/usage/summary")) return jsonResponse(USAGE_SUMMARY);
|
||||
return jsonResponse({ error: "unhandled" }, 500);
|
||||
};
|
||||
|
||||
await getCommandCodeUsage("cc-key");
|
||||
assert.ok(seen.some((u) => u.includes("/alpha/billing/credits")));
|
||||
assert.ok(!seen.some((u) => /[?&]orgId=/.test(u)));
|
||||
});
|
||||
|
||||
it("getCommandCodeUsage returns message when apiKey missing", async () => {
|
||||
const r = (await getCommandCodeUsage("")) as { message?: string; quotas?: unknown };
|
||||
assert.ok(r.message && !r.quotas);
|
||||
});
|
||||
|
||||
it("getCommandCodeUsage returns message-only on 401", async () => {
|
||||
installMockFetch({
|
||||
"/alpha/whoami": () => jsonResponse({ success: false }, 401),
|
||||
"/alpha/billing/credits": () =>
|
||||
jsonResponse(
|
||||
{
|
||||
success: false,
|
||||
error: { code: "UNAUTHORIZED", status: 401, message: "Invalid token" },
|
||||
},
|
||||
401
|
||||
),
|
||||
});
|
||||
|
||||
const r = (await getCommandCodeUsage("bad-key")) as { message?: string; quotas?: unknown };
|
||||
assert.ok(r.message && !r.quotas);
|
||||
});
|
||||
|
||||
it("getCommandCodeUsage soft-fails when subscription/summary unavailable", async () => {
|
||||
installMockFetch({
|
||||
"/alpha/whoami": () => jsonResponse(WHOAMI_PERSONAL),
|
||||
"/alpha/billing/credits": () => jsonResponse(CREDITS_PAYLOAD),
|
||||
"/alpha/billing/subscriptions": () => jsonResponse({ success: false }, 500),
|
||||
"/alpha/usage/summary": () => jsonResponse({ error: "boom" }, 500),
|
||||
});
|
||||
|
||||
const r = (await getCommandCodeUsage("cc-key")) as {
|
||||
plan?: string;
|
||||
quotas?: { five_hour?: QuotaShape; weekly?: QuotaShape; credits?: QuotaShape };
|
||||
};
|
||||
|
||||
assert.ok(r.quotas?.five_hour);
|
||||
assert.ok(r.quotas?.weekly);
|
||||
assert.ok(r.quotas?.credits);
|
||||
// Without summary spend, used defaults to 0; remaining still from credit pools
|
||||
assert.equal(r.quotas!.credits!.used, 0);
|
||||
assert.ok((r.quotas!.credits!.remaining ?? 0) > 0);
|
||||
});
|
||||
|
||||
it("getUsageForProvider('command-code', ...) delegates to getCommandCodeUsage", async () => {
|
||||
installMockFetch({
|
||||
"/alpha/whoami": () => jsonResponse(WHOAMI_PERSONAL),
|
||||
"/alpha/billing/credits": () => jsonResponse(CREDITS_PAYLOAD),
|
||||
"/alpha/billing/subscriptions": () => jsonResponse(SUBSCRIPTION_GOAT),
|
||||
"/alpha/usage/summary": () => jsonResponse(USAGE_SUMMARY),
|
||||
});
|
||||
|
||||
const r = (await getUsageForProvider({
|
||||
id: "conn-cc",
|
||||
provider: "command-code",
|
||||
apiKey: "dispatch-key",
|
||||
} as Parameters<typeof getUsageForProvider>[0])) as {
|
||||
quotas?: { weekly?: QuotaShape };
|
||||
plan?: string;
|
||||
};
|
||||
|
||||
assert.match(r.plan || "", /GOAT/i);
|
||||
assert.equal(r.quotas?.weekly?.total, 35);
|
||||
});
|
||||
|
||||
it("convertUsageToQuotaInfo marks weekly-exhausted Command Code as limitReached", async () => {
|
||||
installMockFetch({
|
||||
"/alpha/whoami": () => jsonResponse(WHOAMI_PERSONAL),
|
||||
"/alpha/billing/credits": () => jsonResponse(CREDITS_PAYLOAD),
|
||||
"/alpha/billing/subscriptions": () => jsonResponse(SUBSCRIPTION_GOAT),
|
||||
"/alpha/usage/summary": () => jsonResponse(USAGE_SUMMARY),
|
||||
});
|
||||
|
||||
const usage = await getCommandCodeUsage("cc-key");
|
||||
const info = convertUsageToQuotaInfo(usage);
|
||||
assert.ok(info);
|
||||
assert.equal(info!.limitReached, true);
|
||||
assert.ok((info!.windows.weekly?.percentUsed ?? 0) >= 1 - 1e-9);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,18 @@ import {
|
||||
|
||||
test("resolveRequestedModel maps cursor-agent's client-side aliases", () => {
|
||||
assert.deepEqual(resolveRequestedModel("auto"), { modelId: "default", parameters: [] });
|
||||
assert.deepEqual(resolveRequestedModel("auto-cost"), {
|
||||
modelId: "default",
|
||||
parameters: [{ id: "optimization", value: "cost" }],
|
||||
});
|
||||
assert.deepEqual(resolveRequestedModel("auto-balance"), {
|
||||
modelId: "default",
|
||||
parameters: [{ id: "optimization", value: "balance" }],
|
||||
});
|
||||
assert.deepEqual(resolveRequestedModel("auto-intelligence"), {
|
||||
modelId: "default",
|
||||
parameters: [{ id: "optimization", value: "intelligence" }],
|
||||
});
|
||||
assert.deepEqual(resolveRequestedModel("composer-2-fast"), {
|
||||
modelId: "composer-2",
|
||||
parameters: [{ id: "fast", value: "true" }],
|
||||
|
||||
31
tests/unit/cursor-auto-catalog-entry.test.ts
Normal file
31
tests/unit/cursor-auto-catalog-entry.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { ensureCursorAutoCatalogEntry } from "@/lib/providerModels/cursorAutoCatalog";
|
||||
|
||||
describe("ensureCursorAutoCatalogEntry", () => {
|
||||
it("injects auto when only wire id default is present", () => {
|
||||
const models = ensureCursorAutoCatalogEntry([
|
||||
{ id: "default", name: "Auto", owned_by: "cursor" },
|
||||
{ id: "composer-2.5", name: "Composer 2.5", owned_by: "cursor" },
|
||||
]);
|
||||
assert.ok(models.some((m) => m.id === "auto"));
|
||||
assert.ok(models.some((m) => m.id === "default"));
|
||||
});
|
||||
|
||||
it("injects auto-cost / auto-balance / auto-intelligence", () => {
|
||||
const models = ensureCursorAutoCatalogEntry([{ id: "auto", name: "Auto", owned_by: "cursor" }]);
|
||||
const ids = models.map((m) => m.id);
|
||||
assert.ok(ids.includes("auto-cost"));
|
||||
assert.ok(ids.includes("auto-balance"));
|
||||
assert.ok(ids.includes("auto-intelligence"));
|
||||
});
|
||||
|
||||
it("does not duplicate existing auto entries", () => {
|
||||
const models = ensureCursorAutoCatalogEntry([
|
||||
{ id: "auto", name: "Auto", owned_by: "cursor" },
|
||||
{ id: "auto-cost", name: "Auto (cost)", owned_by: "cursor" },
|
||||
]);
|
||||
assert.equal(models.filter((m) => m.id === "auto").length, 1);
|
||||
assert.equal(models.filter((m) => m.id === "auto-cost").length, 1);
|
||||
});
|
||||
});
|
||||
79
tests/unit/cursor-exclusive-listing-merge.test.ts
Normal file
79
tests/unit/cursor-exclusive-listing-merge.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Cursor exclusive live-catalog listing: when synced AvailableModels exists,
|
||||
* dashboard / Test All must list synced + injected auto* + custom only —
|
||||
* never the static registry effort/premium rows.
|
||||
*/
|
||||
import { strict as assert } from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { mergeProviderModelListing } from "@/lib/providers/mergeProviderModelListing";
|
||||
|
||||
describe("mergeProviderModelListing (cursor exclusive)", () => {
|
||||
const registry = [
|
||||
{ id: "auto", name: "Auto (Server Picks)" },
|
||||
{ id: "claude-4.6-sonnet-high", name: "Claude Sonnet High" },
|
||||
{ id: "gpt-5.5-high", name: "GPT 5.5 High" },
|
||||
{ id: "composer-2.5", name: "Composer 2.5" },
|
||||
];
|
||||
|
||||
it("exclusive + synced: drops static-only ids and injects auto*", () => {
|
||||
const models = mergeProviderModelListing({
|
||||
providerId: "cursor",
|
||||
registryModels: registry,
|
||||
syncedModels: [
|
||||
{ id: "composer-2.5", name: "Composer 2.5" },
|
||||
{ id: "claude-4.6-sonnet", name: "Claude 4.6 Sonnet" },
|
||||
],
|
||||
customModels: [],
|
||||
usesCuratedModelsOnly: false,
|
||||
});
|
||||
|
||||
const ids = models.map((m) => m.id);
|
||||
assert.ok(ids.includes("composer-2.5"));
|
||||
assert.ok(ids.includes("claude-4.6-sonnet"));
|
||||
assert.ok(ids.includes("auto"));
|
||||
assert.ok(ids.includes("auto-cost"));
|
||||
assert.ok(ids.includes("auto-balance"));
|
||||
assert.ok(ids.includes("auto-intelligence"));
|
||||
assert.equal(ids.includes("claude-4.6-sonnet-high"), false);
|
||||
assert.equal(ids.includes("gpt-5.5-high"), false);
|
||||
assert.ok(models.every((m) => m.source === "imported" || m.id.startsWith("auto")));
|
||||
});
|
||||
|
||||
it("exclusive + synced: keeps operator custom models", () => {
|
||||
const models = mergeProviderModelListing({
|
||||
providerId: "cursor",
|
||||
registryModels: registry,
|
||||
syncedModels: [{ id: "composer-2.5", name: "Composer 2.5" }],
|
||||
customModels: [{ id: "my-custom", name: "My Custom", source: "custom" }],
|
||||
usesCuratedModelsOnly: false,
|
||||
});
|
||||
const custom = models.find((m) => m.id === "my-custom");
|
||||
assert.ok(custom);
|
||||
assert.equal(custom.source, "custom");
|
||||
});
|
||||
|
||||
it("exclusive + empty synced: falls back to registry ∪ custom", () => {
|
||||
const models = mergeProviderModelListing({
|
||||
providerId: "cursor",
|
||||
registryModels: registry,
|
||||
syncedModels: [],
|
||||
customModels: [{ id: "my-custom", name: "My Custom" }],
|
||||
usesCuratedModelsOnly: false,
|
||||
});
|
||||
const ids = models.map((m) => m.id);
|
||||
assert.ok(ids.includes("claude-4.6-sonnet-high"));
|
||||
assert.ok(ids.includes("my-custom"));
|
||||
});
|
||||
|
||||
it("non-exclusive providers keep registry-first merge", () => {
|
||||
const models = mergeProviderModelListing({
|
||||
providerId: "openai",
|
||||
registryModels: [{ id: "gpt-4o", name: "GPT-4o" }],
|
||||
syncedModels: [{ id: "gpt-4o-mini", name: "GPT-4o mini" }],
|
||||
customModels: [],
|
||||
usesCuratedModelsOnly: false,
|
||||
});
|
||||
const ids = models.map((m) => m.id);
|
||||
assert.deepEqual(ids, ["gpt-4o", "gpt-4o-mini"]);
|
||||
});
|
||||
});
|
||||
90
tests/unit/cursor-live-catalog-passthrough.test.ts
Normal file
90
tests/unit/cursor-live-catalog-passthrough.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
resolveRequestedModel,
|
||||
encodeAgentRunRequest,
|
||||
} from "../../open-sse/utils/cursorAgentProtobuf";
|
||||
import { CURSOR_REWRITE_FAILURE_IDS } from "./fixtures/cursor-rewrite-failure-ids";
|
||||
|
||||
test("resolveRequestedModel passes live-catalog Claude effort ids through verbatim", () => {
|
||||
const live = new Set(["claude-opus-5-low"]);
|
||||
assert.deepEqual(resolveRequestedModel("claude-opus-5-low", { liveCatalogIds: live }), {
|
||||
modelId: "claude-opus-5-low",
|
||||
parameters: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveRequestedModel passes live-catalog GPT reasoning ids through verbatim", () => {
|
||||
const live = new Set(["gpt-5.6-sol-medium"]);
|
||||
assert.deepEqual(resolveRequestedModel("gpt-5.6-sol-medium", { liveCatalogIds: live }), {
|
||||
modelId: "gpt-5.6-sol-medium",
|
||||
parameters: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveRequestedModel still strips effort when id is absent from live catalog", () => {
|
||||
const live = new Set(["composer-2"]);
|
||||
assert.deepEqual(resolveRequestedModel("claude-opus-5-low", { liveCatalogIds: live }), {
|
||||
modelId: "claude-opus-5",
|
||||
parameters: [{ id: "effort", value: "low" }],
|
||||
});
|
||||
assert.deepEqual(resolveRequestedModel("gpt-5.5-high", { liveCatalogIds: live }), {
|
||||
modelId: "gpt-5.5",
|
||||
parameters: [{ id: "reasoning", value: "high" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveRequestedModel still strips effort when liveCatalogIds is omitted", () => {
|
||||
assert.deepEqual(resolveRequestedModel("claude-opus-4-8-high"), {
|
||||
modelId: "claude-opus-4-8",
|
||||
parameters: [{ id: "effort", value: "high" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveRequestedModel maps auto to default even when auto is in the live catalog", () => {
|
||||
const live = new Set(["auto", "auto-cost", "claude-opus-5-low"]);
|
||||
assert.deepEqual(resolveRequestedModel("auto", { liveCatalogIds: live }), {
|
||||
modelId: "default",
|
||||
parameters: [],
|
||||
});
|
||||
assert.deepEqual(resolveRequestedModel("auto-cost", { liveCatalogIds: live }), {
|
||||
modelId: "default",
|
||||
parameters: [{ id: "optimization", value: "cost" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveRequestedModel passes composer-*-fast through when present in live catalog", () => {
|
||||
const live = new Set(["composer-2.5-fast", "composer-2-fast"]);
|
||||
assert.deepEqual(resolveRequestedModel("composer-2.5-fast", { liveCatalogIds: live }), {
|
||||
modelId: "composer-2.5-fast",
|
||||
parameters: [],
|
||||
});
|
||||
assert.deepEqual(resolveRequestedModel("composer-2-fast", { liveCatalogIds: live }), {
|
||||
modelId: "composer-2-fast",
|
||||
parameters: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveRequestedModel passes every rewrite-failure id through when all are live", () => {
|
||||
const live = new Set<string>(CURSOR_REWRITE_FAILURE_IDS);
|
||||
for (const id of CURSOR_REWRITE_FAILURE_IDS) {
|
||||
assert.deepEqual(
|
||||
resolveRequestedModel(id, { liveCatalogIds: live }),
|
||||
{ modelId: id, parameters: [] },
|
||||
id
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("encodeAgentRunRequest embeds verbatim live catalog model id", () => {
|
||||
const live = new Set(["claude-opus-5-low"]);
|
||||
const buf = encodeAgentRunRequest({
|
||||
modelId: "claude-opus-5-low",
|
||||
userText: "hi",
|
||||
liveCatalogIds: live,
|
||||
});
|
||||
const text = buf.toString("latin1");
|
||||
const full = text.split("claude-opus-5-low").length - 1;
|
||||
assert.ok(full >= 4, `verbatim id must appear in RequestedModel + ModelDetails (got ${full})`);
|
||||
assert.ok(!text.includes("effort"), "must not emit effort parameter for live verbatim id");
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Issue #8887 — deleting a provider connection must invalidate LKGP pins that
|
||||
* reference it without disturbing surviving, provider-level, or legacy pins.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-lkgp-8887-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const lkgpDb = await import("../../src/lib/db/settings/lkgp.ts");
|
||||
const readCache = await import("../../src/lib/db/readCache.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
break;
|
||||
} catch (error: unknown) {
|
||||
const code =
|
||||
error && typeof error === "object" && "code" in error
|
||||
? String((error as { code?: unknown }).code)
|
||||
: "";
|
||||
|
||||
if ((code === "EBUSY" || code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function createConnection(provider: string, name: string): Promise<string> {
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider,
|
||||
authType: "apikey",
|
||||
name,
|
||||
apiKey: `test-key-${name}`,
|
||||
});
|
||||
|
||||
assert.equal(typeof connection.id, "string", "provider fixture must return a connection id");
|
||||
return connection.id as string;
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("#8887: single delete removes only the matching LKGP pin", async () => {
|
||||
const doomedId = await createConnection("berry", "single-doomed");
|
||||
const survivorId = await createConnection("berry", "single-survivor");
|
||||
|
||||
await lkgpDb.setLKGP("single-doomed", "model-x", "berry", doomedId);
|
||||
await lkgpDb.setLKGP("single-survivor", "model-y", "berry", survivorId);
|
||||
|
||||
assert.equal(await providersDb.deleteProviderConnection(doomedId), true);
|
||||
|
||||
assert.equal(await lkgpDb.getLKGP("single-doomed", "model-x"), null);
|
||||
assert.deepEqual(await lkgpDb.getLKGP("single-survivor", "model-y"), {
|
||||
provider: "berry",
|
||||
connectionId: survivorId,
|
||||
});
|
||||
});
|
||||
|
||||
test("#8887: single delete invalidates a warmed LKGP read-cache entry", async () => {
|
||||
const doomedId = await createConnection("berry", "cached-doomed");
|
||||
|
||||
await lkgpDb.setLKGP("cached-doomed", "model-x", "berry", doomedId);
|
||||
|
||||
assert.deepEqual(await readCache.getCachedLKGP("cached-doomed", "model-x"), {
|
||||
provider: "berry",
|
||||
connectionId: doomedId,
|
||||
});
|
||||
|
||||
assert.equal(await providersDb.deleteProviderConnection(doomedId), true);
|
||||
|
||||
assert.equal(
|
||||
await readCache.getCachedLKGP("cached-doomed", "model-x"),
|
||||
null,
|
||||
"deleted LKGP pins must not survive in the 5s read cache"
|
||||
);
|
||||
});
|
||||
|
||||
test("#8887: bulk delete removes every matching LKGP pin", async () => {
|
||||
const doomedA = await createConnection("berry", "bulk-doomed-a");
|
||||
const doomedB = await createConnection("berry", "bulk-doomed-b");
|
||||
const survivorId = await createConnection("berry", "bulk-survivor");
|
||||
|
||||
await lkgpDb.setLKGP("bulk-a", "model-x", "berry", doomedA);
|
||||
await lkgpDb.setLKGP("bulk-b", "model-y", "berry", doomedB);
|
||||
await lkgpDb.setLKGP("bulk-survivor", "model-z", "berry", survivorId);
|
||||
|
||||
assert.equal(await providersDb.deleteProviderConnections([doomedA, doomedB]), 2);
|
||||
|
||||
assert.equal(await lkgpDb.getLKGP("bulk-a", "model-x"), null);
|
||||
assert.equal(await lkgpDb.getLKGP("bulk-b", "model-y"), null);
|
||||
assert.deepEqual(await lkgpDb.getLKGP("bulk-survivor", "model-z"), {
|
||||
provider: "berry",
|
||||
connectionId: survivorId,
|
||||
});
|
||||
});
|
||||
|
||||
test("#8887: provider-wide delete removes that provider's LKGP pins only", async () => {
|
||||
const berryId = await createConnection("berry", "provider-doomed");
|
||||
const cherryId = await createConnection("cherry", "provider-survivor");
|
||||
|
||||
await lkgpDb.setLKGP("provider-doomed", "model-x", "berry", berryId);
|
||||
await lkgpDb.setLKGP("provider-survivor", "model-y", "cherry", cherryId);
|
||||
|
||||
assert.equal(await providersDb.deleteProviderConnectionsByProvider("berry"), 1);
|
||||
|
||||
assert.equal(await lkgpDb.getLKGP("provider-doomed", "model-x"), null);
|
||||
assert.deepEqual(await lkgpDb.getLKGP("provider-survivor", "model-y"), {
|
||||
provider: "cherry",
|
||||
connectionId: cherryId,
|
||||
});
|
||||
});
|
||||
|
||||
test("#8887: provider-level LKGP pins without connectionId are preserved", async () => {
|
||||
const doomedId = await createConnection("berry", "provider-level");
|
||||
|
||||
await lkgpDb.setLKGP("provider-level", "model-x", "berry");
|
||||
|
||||
assert.equal(await providersDb.deleteProviderConnection(doomedId), true);
|
||||
assert.deepEqual(await lkgpDb.getLKGP("provider-level", "model-x"), { provider: "berry" });
|
||||
});
|
||||
|
||||
test("#8887: legacy LKGP values are preserved during connection cleanup", async () => {
|
||||
const doomedId = await createConnection("berry", "legacy");
|
||||
const db = core.getDbInstance();
|
||||
|
||||
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('lkgp', ?, ?)").run(
|
||||
"legacy:model-x",
|
||||
"berry"
|
||||
);
|
||||
|
||||
assert.equal(await providersDb.deleteProviderConnection(doomedId), true);
|
||||
assert.deepEqual(await lkgpDb.getLKGP("legacy", "model-x"), { provider: "berry" });
|
||||
});
|
||||
|
||||
test("#8887: deleting an unpinned connection does not disturb unrelated LKGP state", async () => {
|
||||
const doomedId = await createConnection("berry", "unpinned");
|
||||
const survivorId = await createConnection("cherry", "unrelated");
|
||||
|
||||
await lkgpDb.setLKGP("unrelated", "model-y", "cherry", survivorId);
|
||||
|
||||
assert.equal(await providersDb.deleteProviderConnection(doomedId), true);
|
||||
assert.deepEqual(await lkgpDb.getLKGP("unrelated", "model-y"), {
|
||||
provider: "cherry",
|
||||
connectionId: survivorId,
|
||||
});
|
||||
});
|
||||
@@ -1153,6 +1153,67 @@ test("CodexExecutor.transformRequest preserves native Codex custom tools", () =>
|
||||
assert.equal(tools[1].strict, false);
|
||||
});
|
||||
|
||||
test("CodexExecutor.transformRequest defaults translated function strict without changing native payloads", () => {
|
||||
const executor = new CodexExecutor();
|
||||
const translated = executor.transformRequest(
|
||||
"gpt-5.5",
|
||||
{
|
||||
model: "gpt-5.5",
|
||||
input: [],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: { name: "translated_tool", parameters: { type: "object" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
{ requestEndpointPath: "/responses" }
|
||||
);
|
||||
const translatedTool = (translated.tools as Array<Record<string, unknown>>)[0];
|
||||
assert.equal(translatedTool.strict, false);
|
||||
|
||||
const native = executor.transformRequest(
|
||||
"gpt-5.5",
|
||||
{
|
||||
_nativeCodexPassthrough: true,
|
||||
model: "gpt-5.5",
|
||||
input: [],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
name: "native_tool",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
{ requestEndpointPath: "/responses" }
|
||||
);
|
||||
const nativeTool = (native.tools as Array<Record<string, unknown>>)[0];
|
||||
assert.equal(nativeTool.strict, undefined);
|
||||
|
||||
const explicit = executor.transformRequest(
|
||||
"gpt-5.5",
|
||||
{
|
||||
model: "gpt-5.5",
|
||||
input: [],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
name: "explicit_tool",
|
||||
parameters: { type: "object" },
|
||||
strict: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
true,
|
||||
{ requestEndpointPath: "/responses" }
|
||||
);
|
||||
const explicitTool = (explicit.tools as Array<Record<string, unknown>>)[0];
|
||||
assert.equal(explicitTool.strict, true);
|
||||
});
|
||||
|
||||
test("CodexExecutor.transformRequest still drops custom tools outside native passthrough", () => {
|
||||
const executor = new CodexExecutor();
|
||||
const result = executor.transformRequest(
|
||||
|
||||
68
tests/unit/fal-image-edit.test.ts
Normal file
68
tests/unit/fal-image-edit.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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 { handleFalAIImageEdit } =
|
||||
await import("../../open-sse/handlers/imageGeneration/providers/fal.ts");
|
||||
|
||||
test("handleFalAIImageEdit forwards multiple references to the Fal edit endpoint", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let captured;
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
const stringUrl = String(url);
|
||||
if (stringUrl === "https://fal.run/fal-ai/flux-2-flex/edit") {
|
||||
captured = {
|
||||
headers: options.headers,
|
||||
body: JSON.parse(String(options.body || "{}")),
|
||||
};
|
||||
return new Response(JSON.stringify({ images: [{ url: "data:image/png;base64,CAkK" }] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${stringUrl}`);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await handleFalAIImageEdit({
|
||||
model: "fal-ai/flux-2-flex",
|
||||
provider: "fal-ai",
|
||||
providerConfig: { baseUrl: "https://fal.run" },
|
||||
body: { prompt: "make the dog match the reference" },
|
||||
images: [
|
||||
{ bytes: Buffer.from([1, 2, 3]), mime: "image/png" },
|
||||
{ bytes: Buffer.from([4, 5, 6]), mime: "image/jpeg" },
|
||||
],
|
||||
credentials: { apiKey: "fal-key" },
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(captured.headers.Authorization, "Key fal-key");
|
||||
assert.deepEqual(captured.body.image_urls, [
|
||||
"data:image/png;base64,AQID",
|
||||
"data:image/jpeg;base64,BAUG",
|
||||
]);
|
||||
assert.equal(captured.body.prompt, "make the dog match the reference");
|
||||
assert.equal(result.data.data[0].b64_json, "CAkK");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
92
tests/unit/fixtures/cursor-rewrite-failure-ids.ts
Normal file
92
tests/unit/fixtures/cursor-rewrite-failure-ids.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Live-synced Cursor model ids that Test All failed when #7289
|
||||
* resolveRequestedModel stripped them to a missing base + parameter.
|
||||
* Smoke checklist for catalog-aware pass-through.
|
||||
*/
|
||||
export const CURSOR_REWRITE_FAILURE_IDS = [
|
||||
// Claude (52)
|
||||
"claude-4.5-opus-high",
|
||||
"claude-4.6-opus-high",
|
||||
"claude-4.6-opus-max",
|
||||
"claude-4.6-sonnet-medium",
|
||||
"claude-fable-5-low",
|
||||
"claude-fable-5-medium",
|
||||
"claude-fable-5-high",
|
||||
"claude-fable-5-xhigh",
|
||||
"claude-fable-5-max",
|
||||
"claude-fable-5-thinking-low",
|
||||
"claude-fable-5-thinking-medium",
|
||||
"claude-fable-5-thinking-high",
|
||||
"claude-fable-5-thinking-xhigh",
|
||||
"claude-fable-5-thinking-max",
|
||||
"claude-opus-4-7-low",
|
||||
"claude-opus-4-7-medium",
|
||||
"claude-opus-4-7-high",
|
||||
"claude-opus-4-7-xhigh",
|
||||
"claude-opus-4-7-max",
|
||||
"claude-opus-4-7-thinking-low",
|
||||
"claude-opus-4-7-thinking-medium",
|
||||
"claude-opus-4-7-thinking-high",
|
||||
"claude-opus-4-7-thinking-xhigh",
|
||||
"claude-opus-4-7-thinking-max",
|
||||
"claude-opus-4-8-low",
|
||||
"claude-opus-4-8-medium",
|
||||
"claude-opus-4-8-high",
|
||||
"claude-opus-4-8-xhigh",
|
||||
"claude-opus-4-8-max",
|
||||
"claude-opus-4-8-thinking-low",
|
||||
"claude-opus-4-8-thinking-medium",
|
||||
"claude-opus-4-8-thinking-high",
|
||||
"claude-opus-4-8-thinking-xhigh",
|
||||
"claude-opus-4-8-thinking-max",
|
||||
"claude-opus-5-low",
|
||||
"claude-opus-5-medium",
|
||||
"claude-opus-5-high",
|
||||
"claude-opus-5-thinking-low",
|
||||
"claude-opus-5-thinking-medium",
|
||||
"claude-opus-5-thinking-high",
|
||||
"claude-opus-5-thinking-xhigh",
|
||||
"claude-opus-5-thinking-max",
|
||||
"claude-sonnet-5-low",
|
||||
"claude-sonnet-5-medium",
|
||||
"claude-sonnet-5-high",
|
||||
"claude-sonnet-5-xhigh",
|
||||
"claude-sonnet-5-max",
|
||||
"claude-sonnet-5-thinking-low",
|
||||
"claude-sonnet-5-thinking-medium",
|
||||
"claude-sonnet-5-thinking-high",
|
||||
"claude-sonnet-5-thinking-xhigh",
|
||||
"claude-sonnet-5-thinking-max",
|
||||
// GPT (31)
|
||||
"gpt-5.4-low",
|
||||
"gpt-5.4-medium",
|
||||
"gpt-5.4-high",
|
||||
"gpt-5.4-xhigh",
|
||||
"gpt-5.4-mini-low",
|
||||
"gpt-5.4-mini-medium",
|
||||
"gpt-5.4-mini-high",
|
||||
"gpt-5.4-mini-xhigh",
|
||||
"gpt-5.4-nano-low",
|
||||
"gpt-5.4-nano-medium",
|
||||
"gpt-5.4-nano-high",
|
||||
"gpt-5.4-nano-xhigh",
|
||||
"gpt-5.5-low",
|
||||
"gpt-5.5-medium",
|
||||
"gpt-5.5-high",
|
||||
"gpt-5.5-extra-high",
|
||||
"gpt-5.6-sol-low",
|
||||
"gpt-5.6-sol-medium",
|
||||
"gpt-5.6-sol-high",
|
||||
"gpt-5.6-sol-xhigh",
|
||||
"gpt-5.6-sol-max",
|
||||
"gpt-5.6-terra-low",
|
||||
"gpt-5.6-terra-medium",
|
||||
"gpt-5.6-terra-high",
|
||||
"gpt-5.6-terra-xhigh",
|
||||
"gpt-5.6-terra-max",
|
||||
"gpt-5.6-luna-low",
|
||||
"gpt-5.6-luna-medium",
|
||||
"gpt-5.6-luna-high",
|
||||
"gpt-5.6-luna-xhigh",
|
||||
"gpt-5.6-luna-max",
|
||||
] as const;
|
||||
103
tests/unit/guardrails/vision-bridge-auto-reroute.test.ts
Normal file
103
tests/unit/guardrails/vision-bridge-auto-reroute.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Regression: the vision-bridge reroute must work when the configured vision
|
||||
* model is an `auto/*` virtual id, and the describe path must only run when the
|
||||
* vision pool is genuinely empty.
|
||||
*
|
||||
* Upstream v3.8.50 resolves `auto/*` fixedModels through the vision router
|
||||
* pool; the guardrail-level guard (`bestUsable === false && !auto/*`) keeps the
|
||||
* reroute from being blocked when the router returns an unresolved auto id.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { VisionBridgeGuardrail } = await import("../../../src/lib/guardrails/visionBridge.ts");
|
||||
const { resetGuardrailsForTests } = await import("../../../src/lib/guardrails/registry.ts");
|
||||
import type { GuardrailContext } from "../../../src/lib/guardrails/base.ts";
|
||||
import type { VisionModelConfig } from "../../../src/lib/guardrails/visionBridgeHelpers.ts";
|
||||
|
||||
let mockSettings: Record<string, unknown> = {};
|
||||
let visionCallCount = 0;
|
||||
let credentialsMock: (model: string) => Promise<boolean | null> = async () => null;
|
||||
|
||||
function createGuardrail(options?: Parameters<typeof VisionBridgeGuardrail>[0]) {
|
||||
return new VisionBridgeGuardrail({
|
||||
...options,
|
||||
deps: {
|
||||
getSettings: async () => mockSettings,
|
||||
callVisionModel: async (_imageDataUri: string, _config: VisionModelConfig) => {
|
||||
visionCallCount++;
|
||||
return "described";
|
||||
},
|
||||
hasUsableCredentials: credentialsMock,
|
||||
...(options?.deps ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createContext(overrides: Partial<GuardrailContext> = {}): GuardrailContext {
|
||||
return { model: "deepseek/deepseek-chat", log: console, ...overrides };
|
||||
}
|
||||
|
||||
function imagePayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
model: "deepseek/deepseek-chat",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "What is in this image?" },
|
||||
{ type: "image_url", image_url: { url: "https://example.com/cat.png" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function baseSettings() {
|
||||
return {
|
||||
visionBridgeEnabled: true,
|
||||
visionBridgeModel: "auto/best-vision",
|
||||
visionBridgePrompt: "Describe this image concisely.",
|
||||
visionBridgeTimeout: 30000,
|
||||
visionBridgeMaxImages: 10,
|
||||
};
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetGuardrailsForTests({ registerDefaults: false });
|
||||
visionCallCount = 0;
|
||||
credentialsMock = async () => null;
|
||||
mockSettings = baseSettings();
|
||||
});
|
||||
|
||||
test("VB-REROUTE-AUTO: auto/best-vision resolves through the router pool and reroutes (no describe)", async () => {
|
||||
// Provider "auto" has no credential rows → hasUsableCredentials=false for the
|
||||
// raw auto id; the router must still resolve a pool model and reroute.
|
||||
credentialsMock = async (model: string) => (model.startsWith("auto/") ? false : null);
|
||||
|
||||
const guardrail = createGuardrail();
|
||||
const result = await guardrail.preCall(imagePayload(), createContext());
|
||||
|
||||
assert.strictEqual(result.block, false);
|
||||
assert.strictEqual(visionCallCount, 0, "describe path must not run when a vision target exists");
|
||||
assert.ok(result.modifiedPayload, "payload must be modified");
|
||||
const body = result.modifiedPayload as Record<string, unknown>;
|
||||
// The reroute points the request at the resolved vision model from the pool.
|
||||
assert.notStrictEqual(body.model, "deepseek/deepseek-chat");
|
||||
assert.deepEqual((result.meta as Record<string, unknown>).rerouted, true);
|
||||
});
|
||||
|
||||
test("VB-REROUTE-AUTO: falls back to describe only when the ENTIRE vision pool is unusable", async () => {
|
||||
// Every vision candidate is confirmed unusable → nothing to reroute to → the
|
||||
// describe path runs (existing behavior).
|
||||
credentialsMock = async () => false;
|
||||
|
||||
const guardrail = createGuardrail();
|
||||
const result = await guardrail.preCall(imagePayload(), createContext());
|
||||
|
||||
assert.strictEqual(result.block, false);
|
||||
assert.strictEqual(visionCallCount, 1, "describe path must run when no vision target is usable");
|
||||
const body = result.modifiedPayload as Record<string, unknown>;
|
||||
assert.strictEqual(body.model, "deepseek/deepseek-chat");
|
||||
});
|
||||
126
tests/unit/guardrails/vision-bridge-claude-wire.test.ts
Normal file
126
tests/unit/guardrails/vision-bridge-claude-wire.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Regression: claude-wire format vision targets (MiniMax, Z.AI, Kimi, …)
|
||||
* reject remote image URLs (MiniMax 403 code 2013). The vision bridge must
|
||||
* normalize remote URLs to base64 data URIs for these targets.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const {
|
||||
isClaudeWireFormatModel,
|
||||
ensureBase64ImagesForClaudeWire,
|
||||
} = await import("../../../src/lib/guardrails/visionBridgeHelpers.ts");
|
||||
|
||||
test("isClaudeWireFormatModel: true for anthropic and claude-format registry providers", () => {
|
||||
assert.strictEqual(isClaudeWireFormatModel("anthropic/claude-sonnet-4"), true);
|
||||
assert.strictEqual(isClaudeWireFormatModel("minimax/MiniMax-M3"), true);
|
||||
assert.strictEqual(isClaudeWireFormatModel("zai/glm-5"), true);
|
||||
assert.strictEqual(isClaudeWireFormatModel("claude/claude-opus"), true);
|
||||
assert.strictEqual(isClaudeWireFormatModel("wafer/wafer-model"), true);
|
||||
});
|
||||
|
||||
test("isClaudeWireFormatModel: false for openai-format providers", () => {
|
||||
assert.strictEqual(isClaudeWireFormatModel("openai/gpt-4o-mini"), false);
|
||||
assert.strictEqual(isClaudeWireFormatModel("kiro/minimax-m2.5"), false);
|
||||
assert.strictEqual(isClaudeWireFormatModel("auto/best-vision"), false);
|
||||
assert.strictEqual(isClaudeWireFormatModel(null), false);
|
||||
});
|
||||
|
||||
test("ensureBase64ImagesForClaudeWire: passthrough for non-claude-wire models", async () => {
|
||||
const body = {
|
||||
model: "openai/gpt-4o-mini",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "hi" },
|
||||
{ type: "image_url", image_url: { url: "https://example.com/a.png" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const out = await ensureBase64ImagesForClaudeWire(body, "openai/gpt-4o-mini");
|
||||
assert.strictEqual(out, body, "non-claude-wire body must be returned untouched");
|
||||
});
|
||||
|
||||
test("ensureBase64ImagesForClaudeWire: keeps data-URI images as-is", async () => {
|
||||
const dataUri = "data:image/png;base64,iVBORw0KGgo=";
|
||||
const body = {
|
||||
model: "minimax/MiniMax-M3",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "image_url", image_url: { url: dataUri } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const out = await ensureBase64ImagesForClaudeWire(body, "minimax/MiniMax-M3");
|
||||
const part = out.messages[0].content[0];
|
||||
assert.strictEqual(part.image_url.url, dataUri);
|
||||
});
|
||||
|
||||
test("ensureBase64ImagesForClaudeWire: resolves remote URLs to base64 for claude-wire targets", async () => {
|
||||
const pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(new Uint8Array(Buffer.from(pngBase64, "base64")), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/png" },
|
||||
});
|
||||
|
||||
try {
|
||||
const body = {
|
||||
model: "minimax/MiniMax-M3",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "What is this?" },
|
||||
{ type: "image_url", image_url: { url: "https://example.com/cat.png" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const out = await ensureBase64ImagesForClaudeWire(
|
||||
body,
|
||||
"minimax/MiniMax-M3",
|
||||
async () =>
|
||||
new Response(new Uint8Array(Buffer.from(pngBase64, "base64")), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/png" },
|
||||
})
|
||||
);
|
||||
const part = out.messages[0].content[1];
|
||||
assert.ok(
|
||||
part.image_url.url.startsWith("data:image/png;base64,"),
|
||||
"remote URL must be resolved to a base64 data URI"
|
||||
);
|
||||
assert.ok(part.image_url.url.includes(pngBase64));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("ensureBase64ImagesForClaudeWire: fail-open when the remote fetch fails", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("network down");
|
||||
};
|
||||
|
||||
try {
|
||||
const body = {
|
||||
model: "minimax/MiniMax-M3",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "image_url", image_url: { url: "https://example.com/cat.png" } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const out = await ensureBase64ImagesForClaudeWire(body, "minimax/MiniMax-M3");
|
||||
const part = out.messages[0].content[0];
|
||||
assert.strictEqual(part.image_url.url, "https://example.com/cat.png");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
64
tests/unit/guardrails/vision-bridge-selfloop-key.test.ts
Normal file
64
tests/unit/guardrails/vision-bridge-selfloop-key.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Regression: the vision-bridge SELF-LOOP must authenticate with a real
|
||||
* DB-backed API key, not the `sk_omniroute` sentinel.
|
||||
*
|
||||
* Root cause on runtime v3.8.49: `callVisionModelSingle` used
|
||||
* `resolvedApiKey || "sk_omniroute"` for the Authorization header of the
|
||||
* OmniRoute self-loop request. On instances with REQUIRE_API_KEY enabled the
|
||||
* runtime rejects `sk_omniroute` with 401 "Missing API key", so EVERY
|
||||
* vision-bridge describe call failed and image requests were never processed.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { resolveSelfLoopApiKey } = await import(
|
||||
"../../../src/lib/guardrails/visionBridgeHelpers.ts"
|
||||
);
|
||||
|
||||
test("uses VISION_BRIDGE_API_KEY when set", async () => {
|
||||
const previous = process.env.VISION_BRIDGE_API_KEY;
|
||||
process.env.VISION_BRIDGE_API_KEY = "sk-operator-key";
|
||||
try {
|
||||
const key = await resolveSelfLoopApiKey(async () => "sk-db-key");
|
||||
assert.strictEqual(key, "sk-operator-key");
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.VISION_BRIDGE_API_KEY;
|
||||
else process.env.VISION_BRIDGE_API_KEY = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test("falls back to the injected resolver (DB key) when no env key is set", async () => {
|
||||
const previous = process.env.VISION_BRIDGE_API_KEY;
|
||||
delete process.env.VISION_BRIDGE_API_KEY;
|
||||
try {
|
||||
const key = await resolveSelfLoopApiKey(async () => "sk-real-db-key");
|
||||
assert.strictEqual(key, "sk-real-db-key");
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.VISION_BRIDGE_API_KEY;
|
||||
else process.env.VISION_BRIDGE_API_KEY = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test("never returns the sk_omniroute sentinel when a real key is resolvable", async () => {
|
||||
const previous = process.env.VISION_BRIDGE_API_KEY;
|
||||
delete process.env.VISION_BRIDGE_API_KEY;
|
||||
try {
|
||||
const key = await resolveSelfLoopApiKey(async () => "sk-db-key");
|
||||
assert.notStrictEqual(key, "sk_omniroute");
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.VISION_BRIDGE_API_KEY;
|
||||
else process.env.VISION_BRIDGE_API_KEY = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test("falls back to sk_omniroute only when nothing else is available", async () => {
|
||||
const previous = process.env.VISION_BRIDGE_API_KEY;
|
||||
delete process.env.VISION_BRIDGE_API_KEY;
|
||||
try {
|
||||
const key = await resolveSelfLoopApiKey(async () => "");
|
||||
assert.strictEqual(key, "sk_omniroute");
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.VISION_BRIDGE_API_KEY;
|
||||
else process.env.VISION_BRIDGE_API_KEY = previous;
|
||||
}
|
||||
});
|
||||
@@ -701,6 +701,67 @@ test("provider-scoped image generation POST uses the shared 401 account fallback
|
||||
]);
|
||||
});
|
||||
|
||||
test("v1 image generation POST normalizes a terminal upstream 401 to the OpenAI-standard error shape", async () => {
|
||||
await seedConnection("openai", { apiKey: "single-expired-image-key" });
|
||||
|
||||
globalThis.fetch = async (url, options: RequestInit = {}) => {
|
||||
assert.equal(String(url), "https://api.openai.com/v1/images/generations");
|
||||
const authorization = new Headers(options.headers).get("authorization") ?? "";
|
||||
assert.equal(authorization, "Bearer single-expired-image-key");
|
||||
return new Response(JSON.stringify({ error: { message: "expired access token" } }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
const response = await imageRoute.POST(
|
||||
new Request("http://localhost/api/v1/images/generations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: "openai/gpt-image-2", prompt: "normalize terminal 401" }),
|
||||
})
|
||||
);
|
||||
const body = (await response.json()) as ErrorResponseBody;
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.deepEqual(body.error, {
|
||||
message: "expired access token",
|
||||
type: "authentication_error",
|
||||
code: "invalid_api_key",
|
||||
});
|
||||
});
|
||||
|
||||
test("provider-scoped image generation POST normalizes a terminal upstream 401 to the OpenAI-standard error shape", async () => {
|
||||
await seedConnection("openai", { apiKey: "provider-single-expired-key" });
|
||||
|
||||
globalThis.fetch = async (url, options: RequestInit = {}) => {
|
||||
assert.equal(String(url), "https://api.openai.com/v1/images/generations");
|
||||
const authorization = new Headers(options.headers).get("authorization") ?? "";
|
||||
assert.equal(authorization, "Bearer provider-single-expired-key");
|
||||
return new Response(JSON.stringify({ error: { message: "expired provider token" } }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
const response = await providerImageRoute.POST(
|
||||
new Request("http://localhost/api/v1/providers/openai/images/generations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: "gpt-image-2", prompt: "normalize provider terminal 401" }),
|
||||
}),
|
||||
{ params: Promise.resolve({ provider: "openai" }) }
|
||||
);
|
||||
const body = (await response.json()) as ErrorResponseBody;
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.deepEqual(body.error, {
|
||||
message: "expired provider token",
|
||||
type: "authentication_error",
|
||||
code: "invalid_api_key",
|
||||
});
|
||||
});
|
||||
|
||||
test("v1 image generation POST refreshes an expired Antigravity token before dispatch", async () => {
|
||||
await seedConnection("antigravity", {
|
||||
authType: "oauth",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, it } from "node:test";
|
||||
import {
|
||||
providerLacksModelListing,
|
||||
providerUsesCuratedModelsOnly,
|
||||
providerUsesExclusiveSyncedListing,
|
||||
} from "@/lib/providers/modelListingCapability";
|
||||
|
||||
describe("providerLacksModelListing (#5420)", () => {
|
||||
@@ -36,3 +37,18 @@ describe("providerLacksModelListing (#5420)", () => {
|
||||
assert.equal(providerUsesCuratedModelsOnly("kimi-coding"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("providerUsesExclusiveSyncedListing", () => {
|
||||
it("is true only for Cursor (id or alias)", () => {
|
||||
assert.equal(providerUsesExclusiveSyncedListing("cursor"), true);
|
||||
assert.equal(providerUsesExclusiveSyncedListing("cu"), true);
|
||||
assert.equal(providerUsesExclusiveSyncedListing("Cursor"), true);
|
||||
});
|
||||
|
||||
it("is false for other providers including authoritative live-catalog ones", () => {
|
||||
assert.equal(providerUsesExclusiveSyncedListing("github"), false);
|
||||
assert.equal(providerUsesExclusiveSyncedListing("command-code"), false);
|
||||
assert.equal(providerUsesExclusiveSyncedListing("openai"), false);
|
||||
assert.equal(providerUsesExclusiveSyncedListing(""), false);
|
||||
});
|
||||
});
|
||||
|
||||
205
tests/unit/per-connection-admission-9654.test.ts
Normal file
205
tests/unit/per-connection-admission-9654.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
// #9654: Per-connection virtual admission lanes
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const admissionModule = await import("../../src/shared/middleware/chatBodyAdmission.ts");
|
||||
const {
|
||||
PerConnectionAdmissionController,
|
||||
resolveSessionId,
|
||||
admitChatRequest,
|
||||
admitChatStructure,
|
||||
perConnectionAdmissionController,
|
||||
ChatAdmissionController,
|
||||
CHAT_MAX_HEAVY_IN_FLIGHT,
|
||||
} = admissionModule;
|
||||
|
||||
function makeRequest(headers: Record<string, string>, body = "{}"): Request {
|
||||
const h: Record<string, string> = { "content-type": "application/json", ...headers };
|
||||
return new Request("http://x/v1/chat/completions", { method: "POST", headers: h, body });
|
||||
}
|
||||
|
||||
test("resolveSessionId hashes bearer token into opaque key", () => {
|
||||
const req = makeRequest({ authorization: "Bearer sk-secret-key-123" });
|
||||
const sid = resolveSessionId(req);
|
||||
assert.ok(sid.startsWith("key_"));
|
||||
assert.equal(sid.length, "key_".length + 16);
|
||||
// Same key → same hash
|
||||
const req2 = makeRequest({ authorization: "Bearer sk-secret-key-123" });
|
||||
assert.equal(resolveSessionId(req2), sid);
|
||||
// Different key → different hash
|
||||
const req3 = makeRequest({ authorization: "Bearer sk-different-key-456" });
|
||||
assert.notEqual(resolveSessionId(req3), sid);
|
||||
});
|
||||
|
||||
test("resolveSessionId hashes x-api-key header (Anthropic-style)", () => {
|
||||
const req = makeRequest({ "x-api-key": "anthropic-key-xyz" });
|
||||
const sid = resolveSessionId(req);
|
||||
assert.ok(sid.startsWith("key_"));
|
||||
assert.equal(sid.length, "key_".length + 16);
|
||||
});
|
||||
|
||||
test("resolveSessionId returns 'anonymous' for no auth", () => {
|
||||
const req = makeRequest({}, "{}");
|
||||
assert.equal(resolveSessionId(req), "anonymous");
|
||||
});
|
||||
|
||||
test("resolveSessionId does not leak raw API key in the session ID", () => {
|
||||
const req = makeRequest({ authorization: "Bearer sk-secret-key-123" });
|
||||
const sid = resolveSessionId(req);
|
||||
assert.ok(!sid.includes("sk-secret-key-123"));
|
||||
assert.ok(!sid.includes("secret"));
|
||||
});
|
||||
|
||||
test("PerConnectionAdmissionController isolates capacity across sessions", () => {
|
||||
const pc = new PerConnectionAdmissionController(1);
|
||||
const ctrlA = pc.getController("session-a");
|
||||
const ctrlB = pc.getController("session-b");
|
||||
|
||||
// Session A acquires the only slot
|
||||
const leaseA = ctrlA.tryAcquireHeavy();
|
||||
assert.ok(leaseA);
|
||||
// Session A is now full
|
||||
assert.equal(ctrlA.tryAcquireHeavy(), null);
|
||||
// Session B still has capacity — isolation works
|
||||
const leaseB = ctrlB.tryAcquireHeavy();
|
||||
assert.ok(leaseB);
|
||||
leaseA.release();
|
||||
leaseB.release();
|
||||
});
|
||||
|
||||
test("PerConnectionAdmissionController returns same controller for same session", () => {
|
||||
const pc = new PerConnectionAdmissionController(1);
|
||||
const a1 = pc.getController("session-a");
|
||||
const a2 = pc.getController("session-a");
|
||||
assert.equal(a1, a2);
|
||||
});
|
||||
|
||||
test("PerConnectionAdmissionController creates new controller for new session", () => {
|
||||
const pc = new PerConnectionAdmissionController(1);
|
||||
const a = pc.getController("session-a");
|
||||
const b = pc.getController("session-b");
|
||||
assert.notEqual(a, b);
|
||||
});
|
||||
|
||||
test("PerConnectionAdmissionController enforces maxSessions LRU eviction", () => {
|
||||
const pc = new PerConnectionAdmissionController(1, { maxSessions: 2, sessionTtlMs: 60000 });
|
||||
const a = pc.getController("a");
|
||||
const b = pc.getController("b");
|
||||
assert.equal(pc.sessionCount, 2);
|
||||
// Touch 'a' so 'b' is oldest
|
||||
const aAgain = pc.getController("a");
|
||||
assert.equal(aAgain, a, "same a reference");
|
||||
// Creating 'c' should evict 'b' (oldest)
|
||||
const c = pc.getController("c");
|
||||
assert.equal(pc.sessionCount, 2);
|
||||
// 'a' survives, 'b' is evicted
|
||||
const aAfter = pc.getController("a");
|
||||
assert.equal(aAfter, a, "a should still exist after c added");
|
||||
// 'b' gets a fresh controller (old one was evicted)
|
||||
const newB = pc.getController("b");
|
||||
assert.notEqual(newB, b, "b should be evicted and recreated");
|
||||
});
|
||||
|
||||
test("PerConnectionAdmissionController evicts idle sessions after TTL", async () => {
|
||||
const pc = new PerConnectionAdmissionController(1, {
|
||||
sessionTtlMs: 50,
|
||||
maxSessions: 64,
|
||||
});
|
||||
const ctrl = pc.getController("idle-session");
|
||||
assert.ok(ctrl);
|
||||
assert.equal(pc.sessionCount, 1);
|
||||
|
||||
// Wait past TTL + eviction tick
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
// Accessing again should trigger eviction → fresh controller
|
||||
const fresh = pc.getController("idle-session");
|
||||
assert.notEqual(fresh, ctrl);
|
||||
});
|
||||
|
||||
test("PerConnectionAdmissionController snapshot does not leak raw keys", () => {
|
||||
const pc = new PerConnectionAdmissionController(1);
|
||||
pc.getController("key_abc123");
|
||||
pc.getController("anonymous");
|
||||
const snap = pc.snapshot();
|
||||
assert.equal(snap.length, 2);
|
||||
for (const entry of snap) {
|
||||
assert.ok(typeof entry.sessionId === "string");
|
||||
assert.ok(entry.sessionId.includes("key_abc123") || entry.sessionId === "anonymous");
|
||||
assert.ok(typeof entry.activeHeavy === "number");
|
||||
assert.ok(typeof entry.idleMs === "number");
|
||||
}
|
||||
});
|
||||
|
||||
test("admitChatRequest uses per-connection controller by default", async () => {
|
||||
const result = await admitChatRequest(
|
||||
makeRequest({ authorization: "Bearer sk-test-key" }),
|
||||
{ largeBodyBytes: 32, hardMaxBytes: 1024 }
|
||||
);
|
||||
assert.equal(result.admit, true);
|
||||
if (result.admit) result.lease?.release();
|
||||
});
|
||||
|
||||
test("admitChatRequest with explicit controller overrides per-connection lookup", async () => {
|
||||
const explicitController = new ChatAdmissionController(1);
|
||||
const result = await admitChatRequest(
|
||||
makeRequest({ authorization: "Bearer sk-test-key" }),
|
||||
{ controller: explicitController, largeBodyBytes: 32, hardMaxBytes: 1024 }
|
||||
);
|
||||
assert.equal(result.admit, true);
|
||||
if (result.admit) result.lease?.release();
|
||||
});
|
||||
|
||||
test("admitChatStructure routes structural rejection to per-connection controller", async () => {
|
||||
// occupy sess-a's per-connection controller via the module-level instance
|
||||
const controller = perConnectionAdmissionController.getController("sess-a");
|
||||
const occupied = controller.tryAcquireHeavy();
|
||||
assert.ok(occupied);
|
||||
|
||||
const result = await admitChatStructure(
|
||||
{
|
||||
messages: Array.from({ length: 3 }, () => ({ role: "user", content: "x" })),
|
||||
},
|
||||
null,
|
||||
{
|
||||
sessionId: "sess-a",
|
||||
maxMessages: 10,
|
||||
heavyMessages: 1,
|
||||
heavyTools: 10,
|
||||
heavyTokens: 10_000,
|
||||
}
|
||||
);
|
||||
// Session A is busy → 503
|
||||
assert.equal(result.admit, false);
|
||||
if (result.admit) return;
|
||||
assert.equal(result.response.status, 503);
|
||||
assert.equal(result.response.headers.get("Retry-After"), "1");
|
||||
occupied.release();
|
||||
});
|
||||
|
||||
test("admitChatStructure with different sessionId gets independent capacity", async () => {
|
||||
// occupy sess-a's per-connection controller
|
||||
const ctrlA = perConnectionAdmissionController.getController("sess-a");
|
||||
const occupied = ctrlA.tryAcquireHeavy();
|
||||
assert.ok(occupied);
|
||||
|
||||
// Session B should get its own controller → admitted
|
||||
const result = await admitChatStructure(
|
||||
{
|
||||
messages: Array.from({ length: 500 }, () => ({ role: "user", content: "x" })),
|
||||
},
|
||||
null,
|
||||
{
|
||||
sessionId: "sess-b",
|
||||
maxMessages: 0,
|
||||
heavyMessages: 200,
|
||||
heavyTools: 64,
|
||||
heavyTokens: 32_000,
|
||||
}
|
||||
);
|
||||
assert.equal(result.admit, true);
|
||||
if (result.admit) {
|
||||
assert.notEqual(result.lease, null);
|
||||
result.lease?.release();
|
||||
}
|
||||
occupied.release();
|
||||
});
|
||||
222
tests/unit/provider-icon-url-validator.test.ts
Normal file
222
tests/unit/provider-icon-url-validator.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
// Focused tests for the shared icon-URL validator (src/shared/validation/iconUrl.ts)
|
||||
// and the data-URL acceptance in createProviderNodeSchema/updateProviderNodeSchema.
|
||||
// Mirrors the acceptance criteria: valid http(s) + valid `data:image/*;base64` URLs
|
||||
// accepted; malformed values, non-image data URLs, non-base64 image data URLs, and
|
||||
// unsafe schemes rejected.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { isValidProviderIconUrl } from "../../src/shared/validation/iconUrl.ts";
|
||||
import {
|
||||
createProviderNodeSchema,
|
||||
updateProviderNodeSchema,
|
||||
} from "../../src/shared/validation/schemas.ts";
|
||||
|
||||
// `iVBORw0KGgo=...` is a base64-encoded (truncated but structurally valid) PNG header.
|
||||
const VALID_PNG_DATA_URL = "data:image/png;base64,iVBORw0KGgo=";
|
||||
const VALID_SVG_DATA_URL =
|
||||
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=";
|
||||
const VALID_XICON_DATA_URL = "data:image/x-icon;base64,QUJDRA==";
|
||||
const VALID_JPEG_DATA_URL = "data:image/jpeg;base64,/9j/4AAQSkZJRg==";
|
||||
const VALID_HTTP = "https://example.com/logo.png";
|
||||
const VALID_HTTP_2000 = "https://example.com/" + "a".repeat(1970) + ".png";
|
||||
|
||||
// ---- shared validator ----
|
||||
test("isValidProviderIconUrl accepts empty and http(s)", () => {
|
||||
assert.equal(isValidProviderIconUrl(""), true);
|
||||
assert.equal(isValidProviderIconUrl(VALID_HTTP), true);
|
||||
assert.equal(isValidProviderIconUrl("http://example.com/logo.png"), true);
|
||||
});
|
||||
|
||||
test("isValidProviderIconUrl accepts valid data:image/*;base64 URLs", () => {
|
||||
assert.equal(isValidProviderIconUrl(VALID_PNG_DATA_URL), true);
|
||||
assert.equal(isValidProviderIconUrl(VALID_SVG_DATA_URL), true);
|
||||
assert.equal(isValidProviderIconUrl(VALID_XICON_DATA_URL), true);
|
||||
assert.equal(isValidProviderIconUrl(VALID_JPEG_DATA_URL), true);
|
||||
});
|
||||
|
||||
test("data-URL scheme / media type / base64 marker are case-insensitive (RFC 2397)", () => {
|
||||
assert.equal(isValidProviderIconUrl("DATA:image/png;BASE64,iVBORw0KGgo="), true);
|
||||
assert.equal(isValidProviderIconUrl("Data:image/png;Base64,iVBORw0KGgo="), true);
|
||||
assert.equal(isValidProviderIconUrl("data:IMAGE/PNG;base64,iVBORw0KGgo="), true);
|
||||
assert.equal(isValidProviderIconUrl("data:image/Png;base64,iVBORw0KGgo="), true);
|
||||
});
|
||||
|
||||
test("data-URL accepts valid media-type parameters before the terminal ;base64", () => {
|
||||
assert.equal(isValidProviderIconUrl("data:image/svg+xml;charset=utf-8;base64,PHN2Zy8+"), true);
|
||||
assert.equal(isValidProviderIconUrl("data:image/svg+xml;charset=UTF-8;base64,PHN2Zy8+"), true);
|
||||
assert.equal(isValidProviderIconUrl("data:image/png;foo=bar;base64,QUJD"), true);
|
||||
// Review case: percent-encoded token parameter value (`%` is an HTTP token char).
|
||||
assert.equal(isValidProviderIconUrl("data:image/png;name=foo%20bar;base64,QUJD"), true);
|
||||
assert.equal(isValidProviderIconUrl("data:image/png;name=foo%ZZ;base64,QUJD"), false);
|
||||
// Multiple parameters + RFC 2045 quoted-string values are accepted.
|
||||
assert.equal(
|
||||
isValidProviderIconUrl('data:image/png;name="foo bar";charset=utf-8;base64,QUJD'),
|
||||
true
|
||||
);
|
||||
assert.equal(isValidProviderIconUrl('data:image/png;name="a;b";base64,QUJD'), true);
|
||||
// Escaped quote inside a quoted-string value (quoted-pair).
|
||||
assert.equal(isValidProviderIconUrl('data:image/png;name="say \\"hi\\"";base64,QUJD'), true);
|
||||
});
|
||||
|
||||
test('data-URL rejects valueless parameters per RFC 2397 (attribute "=" value)', () => {
|
||||
const invalid = [
|
||||
"data:image/png;foo;base64,QUJD", // review case: valueless parameter
|
||||
"data:image/png;base64;base64,QUJD", // second ;base64 is a valueless parameter
|
||||
"data:image/png;=bar;base64,QUJD", // empty attribute
|
||||
"data:image/png;foo=;base64,QUJD", // empty value
|
||||
"data:image/png;foo=bar;baz;base64,QUJD", // valueless among valued params
|
||||
];
|
||||
for (const v of invalid) {
|
||||
assert.equal(isValidProviderIconUrl(v), false, `Should reject: ${JSON.stringify(v)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("data-URL media types use the full HTTP token alphabet (RFC 7230/6838)", () => {
|
||||
// All `tchar` code points in one subtype; plus common image subtypes that use
|
||||
// `+`, `.` and `-` (the previous grammar only allowed `[a-z0-9.+-]`).
|
||||
const tokenAlphabet = "!#$%&'*+-.^_`|~09";
|
||||
assert.equal(isValidProviderIconUrl(`data:image/${tokenAlphabet};base64,QUJD`), true);
|
||||
assert.equal(isValidProviderIconUrl("data:image/svg+xml;base64,QUJD"), true);
|
||||
assert.equal(isValidProviderIconUrl("data:image/vnd.microsoft.icon;base64,QUJD"), true);
|
||||
assert.equal(isValidProviderIconUrl("data:image/x-ms-bmp;base64,QUJD"), true);
|
||||
// Token characters rejected by the old partial regex are now accepted.
|
||||
assert.equal(isValidProviderIconUrl("data:image/x~weird*name`;base64,QUJD"), true);
|
||||
// Any valid token is a valid subtype — `png..` is a legal RFC 7230 token even
|
||||
// though it looks odd; traversal-shaped payloads are harmless here because
|
||||
// data URLs never touch a filesystem.
|
||||
assert.equal(isValidProviderIconUrl("data:image/png..;base64,QUJD"), true);
|
||||
// Non-token characters in the subtype are still rejected.
|
||||
const invalidSubtypes = [
|
||||
"data:image/png/sub;base64,QUJD", // slash inside subtype
|
||||
"data:image/pn g;base64,QUJD", // space
|
||||
"data:image/;base64,QUJD", // empty subtype
|
||||
"data:image/png\u0000;base64,QUJD", // control char
|
||||
];
|
||||
for (const v of invalidSubtypes) {
|
||||
assert.equal(isValidProviderIconUrl(v), false, `Should reject: ${JSON.stringify(v)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("data-URL media-type and parameter grammar edge cases", () => {
|
||||
assert.equal(isValidProviderIconUrl("data:image/png;base64,QUJD"), true);
|
||||
assert.equal(isValidProviderIconUrl("data:image/png;charset=utf-8;base64,QUJD"), true);
|
||||
// `;base64` must be terminal — params after it are rejected.
|
||||
assert.equal(isValidProviderIconUrl("data:image/png;base64;charset=utf-8,QUJD"), false);
|
||||
// Attribute charset must be a token.
|
||||
assert.equal(isValidProviderIconUrl("data:image/png;char set=utf-8;base64,QUJD"), false);
|
||||
assert.equal(isValidProviderIconUrl('data:image/png;charset="unterminated;base64,QUJD'), false);
|
||||
// First-comma split rule: a quoted-string value containing a literal comma is
|
||||
// conservatively rejected (documented limitation).
|
||||
assert.equal(isValidProviderIconUrl('data:image/png;name="a,b";base64,QUJD'), false);
|
||||
// Header-only data URLs and empty metadata are rejected.
|
||||
assert.equal(isValidProviderIconUrl("data:"), false);
|
||||
assert.equal(isValidProviderIconUrl("data:;base64,QUJD"), false);
|
||||
assert.equal(isValidProviderIconUrl("data:image/png;base64,"), false);
|
||||
});
|
||||
|
||||
test("data-URL payloads are strictly validated — whitespace is rejected", () => {
|
||||
const invalid = [
|
||||
"data:image/png;base64,iVBORw0K\nGgo=", // line break in payload
|
||||
"data:image/png;base64,iVBORw0K\r\nGgo=", // CRLF in payload
|
||||
"data:image/png;base64,iVBORw0K\tGgo=", // tab in payload
|
||||
"data:image/png;base64,iVB ORw", // space in payload
|
||||
"data:image/png;base64,iVBORw0K Ggo=", // space in payload
|
||||
"data:image/png;base64,iVBOR\u00A0w0KGgo=", // non-breaking space mid-payload (Unicode whitespace)
|
||||
];
|
||||
for (const v of invalid) {
|
||||
assert.equal(isValidProviderIconUrl(v), false, `Should reject: ${JSON.stringify(v)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("isValidProviderIconUrl rejects malformed values and unsafe schemes", () => {
|
||||
const invalid = [
|
||||
"not-a-url",
|
||||
"javascript:alert(1)",
|
||||
"ftp://broken",
|
||||
"file:///etc/passwd",
|
||||
"//example.com/logo.png", // scheme-relative — not explicitly http(s)
|
||||
"https://", // no host
|
||||
"https://exa mple.com/x.png", // space in host
|
||||
];
|
||||
for (const v of invalid) {
|
||||
assert.equal(isValidProviderIconUrl(v), false, `Should reject: ${JSON.stringify(v)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("isValidProviderIconUrl rejects non-image data URLs", () => {
|
||||
const invalid = [
|
||||
"data:text/html;base64,QUJD",
|
||||
"data:text/plain;base64,QUJD",
|
||||
"data:application/json;base64,e30=",
|
||||
"data:image/png", // no ;base64,payload
|
||||
"data:application/octet-stream;base64,QUJD",
|
||||
];
|
||||
for (const v of invalid) {
|
||||
assert.equal(isValidProviderIconUrl(v), false, `Should reject: ${JSON.stringify(v)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("isValidProviderIconUrl rejects non-base64 image data URLs", () => {
|
||||
const invalid = [
|
||||
"data:image/png,QUJD", // missing ;base64
|
||||
"data:image/png;base64,", // empty payload
|
||||
"data:image/png;base64,!!!!", // invalid base64 chars
|
||||
"data:image/png;base64,A", // payload not 4-char aligned
|
||||
"data:image/png;base64,AAAA=", // over-padded
|
||||
"data:image/png;base64,=QUJD=", // leading '='
|
||||
];
|
||||
for (const v of invalid) {
|
||||
assert.equal(isValidProviderIconUrl(v), false, `Should reject: ${JSON.stringify(v)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("isValidProviderIconUrl enforces length caps", () => {
|
||||
// http(s): 2000-char cap preserved.
|
||||
const tooLongHttp = "https://example.com/" + "a".repeat(2000) + ".png";
|
||||
assert.equal(isValidProviderIconUrl(tooLongHttp), false);
|
||||
assert.equal(isValidProviderIconUrl(VALID_HTTP_2000), true);
|
||||
// data URL: generous 256 KB cap.
|
||||
const tooLongData = "data:image/png;base64," + "A".repeat(256 * 1024 + 10);
|
||||
assert.equal(isValidProviderIconUrl(tooLongData), false);
|
||||
});
|
||||
|
||||
// ---- server-side schema integration ----
|
||||
test("createProviderNodeSchema accepts a valid data:image/*;base64 iconUrl", () => {
|
||||
const result = createProviderNodeSchema.safeParse({
|
||||
name: "Test",
|
||||
prefix: "test",
|
||||
apiType: "chat",
|
||||
iconUrl: VALID_PNG_DATA_URL,
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
|
||||
test("createProviderNodeSchema rejects invalid data URLs", () => {
|
||||
const invalid = [
|
||||
"data:text/html;base64,QUJD",
|
||||
"data:image/png,QUJD",
|
||||
"data:image/png;base64,!!!!",
|
||||
"data:image/png;base64,",
|
||||
"data:image/png;base64,iVB ORw",
|
||||
];
|
||||
for (const iconUrl of invalid) {
|
||||
const result = createProviderNodeSchema.safeParse({
|
||||
name: "Test",
|
||||
prefix: "test",
|
||||
apiType: "chat",
|
||||
iconUrl,
|
||||
});
|
||||
assert.equal(result.success, false, `Should reject: ${JSON.stringify(iconUrl)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("updateProviderNodeSchema accepts a valid data:image/*;base64 iconUrl", () => {
|
||||
const result = updateProviderNodeSchema.safeParse({
|
||||
name: "Test",
|
||||
prefix: "test",
|
||||
baseUrl: "https://test.com",
|
||||
iconUrl: VALID_SVG_DATA_URL,
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
95
tests/unit/services/serviceSupervisorSpawnError.test.ts
Normal file
95
tests/unit/services/serviceSupervisorSpawnError.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Regression tests for the embedded-services supervisor (ServiceSupervisor).
|
||||
*
|
||||
* Bug: when the supervised binary cannot be spawned (ENOENT / EACCES, or a
|
||||
* non-executable binary such as an ELF on Windows — EFTYPE), the child emits
|
||||
* the 'error' event — NOT 'exit' — and on Windows spawn() can even throw
|
||||
* synchronously. The supervisor had no 'error' handler, so it stayed in
|
||||
* "starting" forever while the HealthChecker kept polling the dead port every
|
||||
* healthIntervalMs (each probe firing a full ProxyFetch dispatcher+native
|
||||
* fetch pair, e.g. against a CLIProxyAPI port that will never answer on this
|
||||
* platform).
|
||||
*
|
||||
* Fix under test: the supervisor now (1) handles synchronous spawn() throws
|
||||
* and the child 'error' event → stops the poller and transitions to "error",
|
||||
* and (2) transitions to "error" and stops the poller once the health checker
|
||||
* reports FAILURE_THRESHOLD consecutive failures, including during startup,
|
||||
* instead of polling the dead endpoint forever.
|
||||
*
|
||||
* Run: node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/services/serviceSupervisorSpawnError.test.ts
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { ServiceSupervisor } from "../../../src/lib/services/ServiceSupervisor.ts";
|
||||
import type { ServiceConfig } from "../../../src/lib/services/types.ts";
|
||||
|
||||
function baseConfig(overrides: Partial<ServiceConfig> = {}): ServiceConfig {
|
||||
return {
|
||||
tool: "cliproxy",
|
||||
port: 0,
|
||||
spawnArgs: () => ({
|
||||
command: process.execPath,
|
||||
args: ["-e", "setTimeout(() => {}, 30_000)"],
|
||||
env: process.env,
|
||||
cwd: tmpdir(),
|
||||
}),
|
||||
healthUrl: () => "http://127.0.0.1:1/v1/models",
|
||||
healthIntervalMs: 50,
|
||||
stopTimeoutMs: 1_000,
|
||||
logsBufferBytes: 4_096,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ServiceSupervisor spawn-failure handling", () => {
|
||||
it("transitions to error and stops polling when the binary cannot be spawned", async () => {
|
||||
// A plain text file is not an executable: on Windows spawn() throws
|
||||
// synchronously (EFTYPE/EINVAL); on POSIX the child emits 'error'
|
||||
// (ENOENT/EACCES). Both paths must land in an explicit error state.
|
||||
const dir = await mkdtemp(join(tmpdir(), "svc-sup-spawn-"));
|
||||
const badBinary = join(dir, "not-an-executable.txt");
|
||||
await writeFile(badBinary, "this is not a runnable binary\n", "utf8");
|
||||
|
||||
const supervisor = new ServiceSupervisor(
|
||||
baseConfig({
|
||||
spawnArgs: () => ({
|
||||
command: badBinary,
|
||||
args: [],
|
||||
env: process.env,
|
||||
cwd: dir,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
const status = await supervisor.start();
|
||||
assert.equal(status.state, "error");
|
||||
assert.ok(status.lastError, "lastError should describe the spawn failure");
|
||||
assert.match(
|
||||
status.lastError!,
|
||||
/ENOENT|EACCES|EINVAL|EFTYPE|not recognized|spawn|%1|Win32/i
|
||||
);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("transitions to error after consecutive health failures instead of polling forever", async () => {
|
||||
const supervisor = new ServiceSupervisor(baseConfig());
|
||||
try {
|
||||
// The child runs but never opens a server on the health URL: the
|
||||
// HealthChecker reaches FAILURE_THRESHOLD (3 × 50ms) and the supervisor
|
||||
// must surface an explicit error instead of staying "running" with an
|
||||
// endless poller.
|
||||
await assert.rejects(supervisor.start(), /Health probe failed|Service failed to start/i);
|
||||
assert.equal(supervisor.getStatus().state, "error");
|
||||
assert.ok(supervisor.getStatus().lastError);
|
||||
} finally {
|
||||
await supervisor.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
989
tests/unit/tls-proxy-context.test.ts
Normal file
989
tests/unit/tls-proxy-context.test.ts
Normal file
@@ -0,0 +1,989 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
proxyFetch,
|
||||
resolveProxyForRequest,
|
||||
runWithProxyContext,
|
||||
runWithTlsTracking,
|
||||
setTlsClientForTest,
|
||||
} from "../../open-sse/utils/proxyFetch.ts";
|
||||
import tlsClient, {
|
||||
TlsClient,
|
||||
type TlsFetchOptions,
|
||||
type WreqSession,
|
||||
} from "../../open-sse/utils/tlsClient.ts";
|
||||
import { httpBackedChat } from "../../open-sse/services/browserBackedChat.ts";
|
||||
|
||||
type EnvState = Record<string, string | undefined>;
|
||||
|
||||
const ENV_KEYS = [
|
||||
"ENABLE_TLS_FINGERPRINT",
|
||||
"TLS_FINGERPRINT_PROVIDERS",
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
"NO_PROXY",
|
||||
"no_proxy",
|
||||
"OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK",
|
||||
"PROXY_AUTO_SELECT_ENABLED",
|
||||
] as const;
|
||||
|
||||
async function withEnv(env: EnvState, fn: () => Promise<void> | void): Promise<void> {
|
||||
const prior = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
|
||||
for (const key of ENV_KEYS) {
|
||||
if (env[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = env[key];
|
||||
}
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
for (const key of ENV_KEYS) {
|
||||
if (prior[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = prior[key];
|
||||
}
|
||||
setTlsClientForTest(null);
|
||||
}
|
||||
}
|
||||
|
||||
function fakeTlsClient(
|
||||
fetch: (url: string, options?: TlsFetchOptions) => Promise<Response>,
|
||||
) {
|
||||
return { available: true, fetch };
|
||||
}
|
||||
|
||||
test("explicit direct proxy resolution keeps a session and never rereads the environment", async () => {
|
||||
await withEnv({ HTTPS_PROXY: "http://placeholder.proxy:8080" }, async () => {
|
||||
const created: Array<Record<string, unknown>> = [];
|
||||
const client = new TlsClient(async (options) => {
|
||||
created.push(options);
|
||||
return {
|
||||
close: async () => {},
|
||||
fetch: async () => new Response("ok"),
|
||||
};
|
||||
});
|
||||
|
||||
await client.fetch("https://upstream.example", { proxy: null });
|
||||
await client.fetch("https://upstream.example", { proxy: null });
|
||||
await client.fetch("https://upstream.example");
|
||||
|
||||
assert.equal(created.length, 2);
|
||||
assert.equal(created[0]?.proxy, undefined);
|
||||
assert.equal(created[1]?.proxy, "http://placeholder.proxy:8080");
|
||||
});
|
||||
});
|
||||
|
||||
test("same proxy is isolated by stable account session scope", async () => {
|
||||
const created: Array<Record<string, unknown>> = [];
|
||||
const client = new TlsClient(async (options) => {
|
||||
created.push(options);
|
||||
return {
|
||||
close: async () => {},
|
||||
fetch: async () => new Response("ok"),
|
||||
};
|
||||
});
|
||||
|
||||
const proxy = "http://shared.proxy:8080";
|
||||
await client.fetch("https://upstream.example", { proxy, sessionScope: "account-a" });
|
||||
await client.fetch("https://upstream.example", { proxy, sessionScope: "account-a" });
|
||||
await client.fetch("https://upstream.example", { proxy, sessionScope: "account-b" });
|
||||
|
||||
assert.equal(created.length, 2);
|
||||
});
|
||||
|
||||
test("circuit failures are isolated to the exact session scope and proxy", async () => {
|
||||
const client = new TlsClient(async (options) => ({
|
||||
close: async () => {},
|
||||
fetch: async () => {
|
||||
if (options.proxy === "http://bad.proxy:8080") throw new Error("bad proxy");
|
||||
return new Response("good");
|
||||
},
|
||||
}));
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await assert.rejects(
|
||||
client.fetch("https://upstream.example", {
|
||||
proxy: "http://bad.proxy:8080",
|
||||
sessionScope: "bad-account",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const response = await client.fetch("https://upstream.example", {
|
||||
proxy: "http://good.proxy:8080",
|
||||
sessionScope: "good-account",
|
||||
});
|
||||
assert.equal(await response.text(), "good");
|
||||
});
|
||||
|
||||
test("redirect error semantics are forwarded to wreq unchanged", async () => {
|
||||
let redirect: unknown;
|
||||
const client = new TlsClient(async () => ({
|
||||
close: async () => {},
|
||||
fetch: async (_url, options) => {
|
||||
redirect = options?.redirect;
|
||||
return new Response("ok");
|
||||
},
|
||||
}));
|
||||
|
||||
await client.fetch("https://upstream.example", { redirect: "error" });
|
||||
assert.equal(redirect, "error");
|
||||
});
|
||||
|
||||
test("Request input bypasses wreq without losing method headers or body", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
},
|
||||
async () => {
|
||||
let tlsCalls = 0;
|
||||
let received: Request | null = null;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
tlsCalls++;
|
||||
return new Response("tls");
|
||||
}),
|
||||
);
|
||||
|
||||
const input = new Request("https://upstream.example/v1", {
|
||||
method: "POST",
|
||||
headers: { "x-test": "present" },
|
||||
body: "payload",
|
||||
});
|
||||
const tracked = await runWithTlsTracking("codex", () =>
|
||||
proxyFetch(input, {}, {
|
||||
undiciFetch: async (forwarded) => {
|
||||
received = forwarded as Request;
|
||||
return new Response("dispatcher");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(tlsCalls, 0);
|
||||
assert.equal(received, input);
|
||||
assert.equal(received?.method, "POST");
|
||||
assert.equal(received?.headers.get("x-test"), "present");
|
||||
assert.equal(await received?.text(), "payload");
|
||||
assert.equal(tracked.tlsFingerprintUsed, false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("non-idempotent TLS failures are never replayed", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
},
|
||||
async () => {
|
||||
let dispatcherCalls = 0;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
throw new Error("post-send transport failure");
|
||||
}),
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
runWithTlsTracking("codex", () =>
|
||||
proxyFetch(
|
||||
"https://upstream.example/v1",
|
||||
{ method: "POST", body: "{}" },
|
||||
{
|
||||
undiciFetch: async () => {
|
||||
dispatcherCalls++;
|
||||
return new Response("unexpected");
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
(error: Error & { code?: string }) =>
|
||||
error.code === "TLS_FINGERPRINT_FAILED" &&
|
||||
error.message === "TLS fingerprint request failed; request is not safe to replay",
|
||||
);
|
||||
assert.equal(dispatcherCalls, 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("safe GET TLS failure falls back through the same configured proxy", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
HTTPS_PROXY: "http://placeholder.proxy:8080",
|
||||
},
|
||||
async () => {
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
throw new Error("transport failed");
|
||||
}),
|
||||
);
|
||||
let dispatcher: unknown;
|
||||
const tracked = await runWithTlsTracking("codex", () =>
|
||||
proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async (_input, init) => {
|
||||
dispatcher = init?.dispatcher;
|
||||
return new Response("fallback");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.ok(dispatcher);
|
||||
assert.equal(await tracked.result.text(), "fallback");
|
||||
assert.equal(tracked.tlsFingerprintUsed, false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("internal TimeoutError is not classified as a caller abort", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
},
|
||||
async () => {
|
||||
const timeout = new Error("internal timeout");
|
||||
timeout.name = "TimeoutError";
|
||||
let dispatcherCalls = 0;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
throw timeout;
|
||||
}),
|
||||
);
|
||||
|
||||
const tracked = await runWithTlsTracking("codex", () =>
|
||||
proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async () => {
|
||||
dispatcherCalls++;
|
||||
return new Response("fallback");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(dispatcherCalls, 1);
|
||||
assert.equal(await tracked.result.text(), "fallback");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("control-plane direct fallback bypasses an environment proxy", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
HTTPS_PROXY: "http://placeholder.proxy:8080",
|
||||
OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK: "true",
|
||||
},
|
||||
async () => {
|
||||
const result = await runWithProxyContext(
|
||||
{ type: "http", host: "127.0.0.1", port: "9" },
|
||||
() => resolveProxyForRequest("https://upstream.example/v1"),
|
||||
{ directFallbackOnUnreachable: true },
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { source: "direct", proxyUrl: null });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("new proxied TLS transport requires an explicit provider allowlist", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: undefined,
|
||||
HTTPS_PROXY: "http://placeholder.proxy:8080",
|
||||
},
|
||||
async () => {
|
||||
let tlsCalls = 0;
|
||||
let dispatcherCalls = 0;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
tlsCalls++;
|
||||
return new Response("tls");
|
||||
}),
|
||||
);
|
||||
|
||||
const tracked = await runWithTlsTracking("codex", () =>
|
||||
proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async () => {
|
||||
dispatcherCalls++;
|
||||
return new Response("dispatcher");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(tlsCalls, 0);
|
||||
assert.equal(dispatcherCalls, 1);
|
||||
assert.equal(await tracked.result.text(), "dispatcher");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("caller abort propagates unchanged and never falls back", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
},
|
||||
async () => {
|
||||
const controller = new AbortController();
|
||||
const abortError = new Error("caller stopped");
|
||||
let dispatcherCalls = 0;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
controller.abort(abortError);
|
||||
throw abortError;
|
||||
}),
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
runWithTlsTracking("codex", () =>
|
||||
proxyFetch(
|
||||
"https://upstream.example/v1",
|
||||
{ signal: controller.signal },
|
||||
{
|
||||
undiciFetch: async () => {
|
||||
dispatcherCalls++;
|
||||
return new Response("unexpected");
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
(error) => error === abortError,
|
||||
);
|
||||
assert.equal(dispatcherCalls, 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("stateful TLS session failures never fall back even for GET", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
},
|
||||
async () => {
|
||||
let dispatcherCalls = 0;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
const error = new Error("transport failed");
|
||||
Object.defineProperty(error, "sessionHadCookies", { value: true });
|
||||
throw error;
|
||||
}),
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
runWithTlsTracking("codex", () =>
|
||||
proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async () => {
|
||||
dispatcherCalls++;
|
||||
return new Response("unexpected");
|
||||
},
|
||||
}),
|
||||
),
|
||||
(error: Error & { code?: string }) =>
|
||||
error.code === "TLS_FINGERPRINT_FAILED" &&
|
||||
error.message === "TLS fingerprint request failed; stateful session cannot be replayed",
|
||||
);
|
||||
assert.equal(dispatcherCalls, 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("TLS transport failures never expose proxy credentials", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
HTTPS_PROXY: "http://user:password@placeholder.proxy:8080",
|
||||
},
|
||||
async () => {
|
||||
const warnings: string[] = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(" "));
|
||||
try {
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
throw new Error(
|
||||
"connect failed via http://user:password@placeholder.proxy:8080",
|
||||
);
|
||||
}),
|
||||
);
|
||||
const tracked = await runWithTlsTracking("codex", () =>
|
||||
proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async () => new Response("fallback"),
|
||||
}),
|
||||
);
|
||||
assert.equal(await tracked.result.text(), "fallback");
|
||||
assert.equal(warnings.some((line) => line.includes("user:password")), false);
|
||||
assert.equal(warnings.some((line) => line.includes("placeholder.proxy")), false);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("family-pinned proxies retain dispatcher enforcement instead of using wreq", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
HTTPS_PROXY: "http://placeholder.proxy:8080?family=ipv4",
|
||||
},
|
||||
async () => {
|
||||
let tlsCalls = 0;
|
||||
let dispatcherCalls = 0;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
tlsCalls++;
|
||||
return new Response("tls");
|
||||
}),
|
||||
);
|
||||
|
||||
const tracked = await runWithTlsTracking("codex", () =>
|
||||
proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async () => {
|
||||
dispatcherCalls++;
|
||||
return new Response("dispatcher");
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(tlsCalls, 0);
|
||||
assert.equal(dispatcherCalls, 1);
|
||||
assert.equal(await tracked.result.text(), "dispatcher");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("relay contexts never route through wreq", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
},
|
||||
async () => {
|
||||
let tlsCalls = 0;
|
||||
let relayCalls = 0;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
tlsCalls++;
|
||||
return new Response("tls");
|
||||
}),
|
||||
);
|
||||
|
||||
const tracked = await runWithTlsTracking("codex", () =>
|
||||
runWithProxyContext(
|
||||
{ type: "vercel", host: "relay.example", relayAuth: "test-auth" },
|
||||
() =>
|
||||
proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async () => {
|
||||
relayCalls++;
|
||||
return new Response("relay");
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.equal(tlsCalls, 0);
|
||||
assert.equal(relayCalls, 1);
|
||||
assert.equal(await tracked.result.text(), "relay");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("wreq responses are adapted to the native Response API", async () => {
|
||||
const sourceBody = new Response("adapted").body;
|
||||
assert.ok(sourceBody);
|
||||
const client = new TlsClient(async () => ({
|
||||
close: async () => {},
|
||||
fetch: async () => ({
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: new Map([["x-source", "wreq"]]),
|
||||
body: sourceBody,
|
||||
url: "https://upstream.example/v1",
|
||||
redirected: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
const response = await client.fetch("https://upstream.example/v1", { proxy: null });
|
||||
assert.equal(response instanceof Response, true);
|
||||
assert.equal(response.headers.get("x-source"), "wreq");
|
||||
assert.equal(response.url, "https://upstream.example/v1");
|
||||
assert.equal(response.redirected, true);
|
||||
assert.equal(await response.text(), "adapted");
|
||||
});
|
||||
|
||||
test("exit waits for pending session creation and closes the late session", async () => {
|
||||
type TestSession = {
|
||||
close: () => Promise<void>;
|
||||
fetch: () => Promise<Response>;
|
||||
};
|
||||
const sessionGate = Promise.withResolvers<TestSession>();
|
||||
const creationStarted = Promise.withResolvers<void>();
|
||||
const closeStarted = Promise.withResolvers<void>();
|
||||
const closeGate = Promise.withResolvers<void>();
|
||||
let closed = 0;
|
||||
const client = new TlsClient(() => {
|
||||
creationStarted.resolve();
|
||||
return sessionGate.promise;
|
||||
});
|
||||
const request = client.fetch("https://upstream.example/v1", { proxy: null });
|
||||
await creationStarted.promise;
|
||||
|
||||
let exitSettled = false;
|
||||
const exiting = client.exit().then(() => {
|
||||
exitSettled = true;
|
||||
});
|
||||
sessionGate.resolve({
|
||||
close: async () => {
|
||||
closed++;
|
||||
closeStarted.resolve();
|
||||
await closeGate.promise;
|
||||
},
|
||||
fetch: async () => new Response("unexpected"),
|
||||
});
|
||||
await closeStarted.promise;
|
||||
assert.equal(exitSettled, false);
|
||||
|
||||
closeGate.resolve();
|
||||
await assert.rejects(request, /wreq-js transport failed/);
|
||||
await exiting;
|
||||
assert.equal(closed, 1);
|
||||
});
|
||||
|
||||
test("bounded session cache closes the least-recently-used idle session", async () => {
|
||||
const closed: string[] = [];
|
||||
const client = new TlsClient(async (options) => {
|
||||
const proxy = String(options.proxy);
|
||||
return {
|
||||
close: async () => {
|
||||
closed.push(proxy);
|
||||
},
|
||||
fetch: async () => new Response("ok"),
|
||||
};
|
||||
}, 2);
|
||||
|
||||
await client.fetch("https://upstream.example/v1", { proxy: "http://proxy-1:8080" });
|
||||
await client.fetch("https://upstream.example/v1", { proxy: "http://proxy-2:8080" });
|
||||
await client.fetch("https://upstream.example/v1", { proxy: "http://proxy-3:8080" });
|
||||
|
||||
assert.deepEqual(closed, ["http://proxy-1:8080"]);
|
||||
await client.exit();
|
||||
});
|
||||
|
||||
test("direct browser-backed TLS calls isolate sessions by pool key", async () => {
|
||||
const originalFetch = tlsClient.fetch.bind(tlsClient);
|
||||
let observedScope: string | undefined;
|
||||
tlsClient.fetch = async (_url, options) => {
|
||||
observedScope = options?.sessionScope;
|
||||
return new Response("ok", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
try {
|
||||
const result = await httpBackedChat({
|
||||
poolKey: "claude-web:account-123",
|
||||
chatUrl: "https://claude.ai/api/chat",
|
||||
chatPageUrl: "https://claude.ai/new",
|
||||
userMessage: "hello",
|
||||
chatUrlMatchDomain: "claude.ai",
|
||||
inputSelector: "#prompt",
|
||||
});
|
||||
assert.equal(result.status, 200);
|
||||
assert.equal(observedScope, "claude-web:account-123");
|
||||
} finally {
|
||||
tlsClient.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("allowlisted proxied TLS receives the exact proxy and account scope", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
HTTPS_PROXY: "http://placeholder.proxy:8080",
|
||||
},
|
||||
async () => {
|
||||
let observedOptions: TlsFetchOptions | undefined;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async (_url, options) => {
|
||||
observedOptions = options;
|
||||
return new Response("tls");
|
||||
}),
|
||||
);
|
||||
const tracked = await runWithTlsTracking(
|
||||
{ provider: "codex", sessionScope: "connection-123" },
|
||||
() =>
|
||||
proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async () => {
|
||||
throw new Error("dispatcher must not run");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(observedOptions?.proxy, "http://placeholder.proxy:8080");
|
||||
assert.equal(observedOptions?.sessionScope, "connection-123");
|
||||
assert.equal(tracked.tlsFingerprintUsed, true);
|
||||
assert.equal(await tracked.result.text(), "tls");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("proxy dispatcher failures sanitize logs and propagated errors", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "false",
|
||||
HTTPS_PROXY: "http://user:password@placeholder.proxy:8080",
|
||||
},
|
||||
async () => {
|
||||
const errors: string[] = [];
|
||||
const originalError = console.error;
|
||||
console.error = (...args: unknown[]) => errors.push(args.map(String).join(" "));
|
||||
try {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async () => {
|
||||
const error = new Error(
|
||||
"connect failed via http://user:password@placeholder.proxy:8080",
|
||||
) as Error & { code?: string };
|
||||
error.code = "ECONNREFUSED";
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
assert.ok(caught instanceof Error);
|
||||
assert.equal(caught.message, "Proxy request failed");
|
||||
assert.equal("code" in caught ? caught.code : undefined, "PROXY_UNREACHABLE");
|
||||
assert.equal(errors.some((line) => line.includes("user:password")), false);
|
||||
assert.equal(errors.some((line) => line.includes("placeholder.proxy")), false);
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("half-open circuit admits only one probe for an isolated session key", async () => {
|
||||
const originalNow = Date.now;
|
||||
const probeStarted = Promise.withResolvers<void>();
|
||||
const probeGate = Promise.withResolvers<void>();
|
||||
let probeMode = false;
|
||||
let fetchCalls = 0;
|
||||
const client = new TlsClient(async () => ({
|
||||
close: async () => {},
|
||||
fetch: async () => {
|
||||
fetchCalls++;
|
||||
if (!probeMode) throw new Error("upstream unavailable");
|
||||
probeStarted.resolve();
|
||||
await probeGate.promise;
|
||||
return new Response("recovered");
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await assert.rejects(
|
||||
client.fetch("https://upstream.example/v1", {
|
||||
proxy: null,
|
||||
sessionScope: "connection-123",
|
||||
}),
|
||||
/wreq-js transport failed/,
|
||||
);
|
||||
}
|
||||
probeMode = true;
|
||||
const afterCooldown = originalNow() + 31_000;
|
||||
Date.now = () => afterCooldown;
|
||||
|
||||
const probe = client.fetch("https://upstream.example/v1", {
|
||||
proxy: null,
|
||||
sessionScope: "connection-123",
|
||||
});
|
||||
await probeStarted.promise;
|
||||
await assert.rejects(
|
||||
client.fetch("https://upstream.example/v1", {
|
||||
proxy: null,
|
||||
sessionScope: "connection-123",
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
error.code === "TLS_CIRCUIT_OPEN",
|
||||
);
|
||||
assert.equal(fetchCalls, 4);
|
||||
probeGate.resolve();
|
||||
assert.equal(await (await probe).text(), "recovered");
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
probeGate.resolve();
|
||||
await client.exit();
|
||||
}
|
||||
});
|
||||
|
||||
test("circuit invalidation snapshots cookies before closing the failed session", async () => {
|
||||
let closed = false;
|
||||
const client = new TlsClient(async () => ({
|
||||
close: () => {
|
||||
closed = true;
|
||||
},
|
||||
getCookies: () => (closed ? {} : { session: "account-a" }),
|
||||
fetch: async () => {
|
||||
throw new Error("upstream unavailable");
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await assert.rejects(
|
||||
client.fetch("https://upstream.example/v1", {
|
||||
proxy: null,
|
||||
sessionScope: "connection-123",
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof Error &&
|
||||
"sessionHadCookies" in error &&
|
||||
error.sessionHadCookies === true,
|
||||
);
|
||||
}
|
||||
await Promise.resolve();
|
||||
assert.equal(closed, true);
|
||||
await assert.rejects(
|
||||
client.fetch("https://upstream.example/v1", {
|
||||
proxy: null,
|
||||
sessionScope: "connection-123",
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
error.code === "TLS_CIRCUIT_OPEN" &&
|
||||
"sessionHadCookies" in error &&
|
||||
error.sessionHadCookies === true,
|
||||
);
|
||||
} finally {
|
||||
await client.exit();
|
||||
}
|
||||
});
|
||||
|
||||
test("pending session creation is bounded per TLS client", async () => {
|
||||
const sessionGates = [
|
||||
Promise.withResolvers<WreqSession>(),
|
||||
Promise.withResolvers<WreqSession>(),
|
||||
];
|
||||
let creates = 0;
|
||||
const client = new TlsClient(() => {
|
||||
const gate = sessionGates[creates++];
|
||||
if (!gate) throw new Error("unexpected session creation");
|
||||
return gate.promise;
|
||||
}, 2);
|
||||
const session: WreqSession = {
|
||||
close: async () => {},
|
||||
fetch: async () => new Response("ok"),
|
||||
};
|
||||
|
||||
const first = client.fetch("https://upstream.example/v1", {
|
||||
proxy: "http://proxy-1:8080",
|
||||
sessionScope: "connection-1",
|
||||
});
|
||||
const second = client.fetch("https://upstream.example/v1", {
|
||||
proxy: "http://proxy-2:8080",
|
||||
sessionScope: "connection-2",
|
||||
});
|
||||
try {
|
||||
assert.equal(creates, 2);
|
||||
await assert.rejects(
|
||||
client.fetch("https://upstream.example/v1", {
|
||||
proxy: "http://proxy-3:8080",
|
||||
sessionScope: "connection-3",
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
error.code === "TLS_SESSION_CAPACITY",
|
||||
);
|
||||
assert.equal(creates, 2);
|
||||
|
||||
for (const gate of sessionGates) gate.resolve(session);
|
||||
assert.equal(await (await first).text(), "ok");
|
||||
assert.equal(await (await second).text(), "ok");
|
||||
} finally {
|
||||
for (const gate of sessionGates) gate.resolve(session);
|
||||
await Promise.allSettled([first, second]);
|
||||
await client.exit();
|
||||
}
|
||||
});
|
||||
|
||||
test("direct TLS fallback never auto-selects a different proxy route", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: undefined,
|
||||
PROXY_AUTO_SELECT_ENABLED: "true",
|
||||
},
|
||||
async () => {
|
||||
let tlsCalls = 0;
|
||||
let dispatcherCalls = 0;
|
||||
let autoSelectCalls = 0;
|
||||
let nativeCalls = 0;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
tlsCalls++;
|
||||
throw new Error("wreq transport failed");
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async () => {
|
||||
dispatcherCalls++;
|
||||
const error = new Error("fetch failed: ECONNREFUSED") as Error & { code?: string };
|
||||
error.code = "ECONNREFUSED";
|
||||
throw error;
|
||||
},
|
||||
findWorkingProxy: async () => {
|
||||
autoSelectCalls++;
|
||||
return "http://unexpected.proxy:8080";
|
||||
},
|
||||
nativeFetch: async () => {
|
||||
nativeCalls++;
|
||||
return new Response("native");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(tlsCalls, 1);
|
||||
assert.equal(dispatcherCalls, 2);
|
||||
assert.equal(autoSelectCalls, 0);
|
||||
assert.equal(nativeCalls, 1);
|
||||
assert.equal(await response.text(), "native");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("proxied TLS compatibility overload requires an explicit session scope", async () => {
|
||||
await withEnv(
|
||||
{
|
||||
ENABLE_TLS_FINGERPRINT: "true",
|
||||
TLS_FINGERPRINT_PROVIDERS: "codex",
|
||||
HTTPS_PROXY: "http://placeholder.proxy:8080",
|
||||
},
|
||||
async () => {
|
||||
let tlsCalls = 0;
|
||||
let dispatcherCalls = 0;
|
||||
setTlsClientForTest(
|
||||
fakeTlsClient(async () => {
|
||||
tlsCalls++;
|
||||
return new Response("unexpected");
|
||||
}),
|
||||
);
|
||||
|
||||
const tracked = await runWithTlsTracking("codex", () =>
|
||||
proxyFetch("https://upstream.example/v1", {}, {
|
||||
undiciFetch: async () => {
|
||||
dispatcherCalls++;
|
||||
return new Response("dispatcher");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(tlsCalls, 0);
|
||||
assert.equal(dispatcherCalls, 1);
|
||||
assert.equal(tracked.tlsFingerprintUsed, false);
|
||||
assert.equal(await tracked.result.text(), "dispatcher");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("circuit trip defers session close until active response streams release", async () => {
|
||||
let first = true;
|
||||
let closed = 0;
|
||||
const client = new TlsClient(async () => ({
|
||||
close: async () => {
|
||||
closed++;
|
||||
},
|
||||
fetch: async () => {
|
||||
if (first) {
|
||||
first = false;
|
||||
return {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: [],
|
||||
body: new ReadableStream<Uint8Array>({}),
|
||||
};
|
||||
}
|
||||
throw new Error("upstream unavailable");
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const activeResponse = await client.fetch("https://upstream.example/v1", {
|
||||
proxy: null,
|
||||
sessionScope: "connection-123",
|
||||
});
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await assert.rejects(
|
||||
client.fetch("https://upstream.example/v1", {
|
||||
proxy: null,
|
||||
sessionScope: "connection-123",
|
||||
}),
|
||||
/wreq-js transport failed/,
|
||||
);
|
||||
}
|
||||
assert.equal(closed, 0);
|
||||
|
||||
await activeResponse.body?.cancel();
|
||||
await Promise.resolve();
|
||||
assert.equal(closed, 1);
|
||||
} finally {
|
||||
await client.exit();
|
||||
}
|
||||
});
|
||||
|
||||
test("streaming wreq body failures are sanitized and counted by the circuit", async () => {
|
||||
const secret = "http://user:password@proxy.example:8080";
|
||||
const client = new TlsClient(async () => ({
|
||||
close: async () => {},
|
||||
fetch: async () => ({
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: [["content-type", "text/plain"]],
|
||||
body: new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
const error = new Error(`body failed through ${secret}`) as Error & {
|
||||
code?: string;
|
||||
};
|
||||
error.code = "UND_ERR_SOCKET";
|
||||
controller.error(error);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await client.fetch("https://upstream.example/v1", {
|
||||
proxy: "http://user:password@proxy.example:8080",
|
||||
sessionScope: "connection-123",
|
||||
});
|
||||
await assert.rejects(
|
||||
response.text(),
|
||||
(error: unknown) =>
|
||||
error instanceof Error &&
|
||||
error.message === "wreq-js response body failed" &&
|
||||
"code" in error &&
|
||||
error.code === "UND_ERR_SOCKET" &&
|
||||
!String(error).includes("user:password"),
|
||||
);
|
||||
assert.equal(
|
||||
client.getCircuitState(
|
||||
"http://user:password@proxy.example:8080",
|
||||
"connection-123",
|
||||
).failureCount,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
await client.exit();
|
||||
}
|
||||
});
|
||||
62
tests/unit/translator-antigravity-signature-bypass.test.ts
Normal file
62
tests/unit/translator-antigravity-signature-bypass.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { openaiToAntigravityRequest } =
|
||||
await import("../../open-sse/translator/request/openai-to-gemini.ts");
|
||||
|
||||
const body = {
|
||||
messages: [
|
||||
{ role: "user", content: "Use the terminal tool to echo hi." },
|
||||
{
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "terminal", arguments: JSON.stringify({ command: "echo hi" }) },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_1", name: "terminal", content: "hi" },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "terminal",
|
||||
description: "Run a shell command",
|
||||
parameters: { type: "object", properties: { command: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function modelParts(model: string, b: unknown) {
|
||||
const envelope = openaiToAntigravityRequest(model, b, true) as {
|
||||
request: { contents: Array<{ role: string; parts: Array<Record<string, unknown>> }> };
|
||||
};
|
||||
const modelMsg = envelope.request.contents.find((c) => c.role === "model");
|
||||
assert.ok(modelMsg, "expected an assistant (model-role) message in the translation");
|
||||
return modelMsg.parts;
|
||||
}
|
||||
|
||||
test("antigravity multi-turn tool call carries the signature bypass sentinel by default", () => {
|
||||
const parts = modelParts("gemini-3.1-pro-low", body);
|
||||
const fc = parts.find((p) => p.functionCall);
|
||||
assert.ok(fc, "expected a functionCall part");
|
||||
assert.equal(fc.thoughtSignature, "skip_thought_signature_validator");
|
||||
});
|
||||
|
||||
test("ANTIGRAVITY_ALLOW_SIGNATURE_BYPASS=0 disables the sentinel", () => {
|
||||
const prev = process.env.ANTIGRAVITY_ALLOW_SIGNATURE_BYPASS;
|
||||
process.env.ANTIGRAVITY_ALLOW_SIGNATURE_BYPASS = "0";
|
||||
try {
|
||||
const parts = modelParts("gemini-3.1-pro-low", body);
|
||||
const fc = parts.find((p) => p.functionCall);
|
||||
assert.ok(fc, "expected a functionCall part");
|
||||
assert.equal(fc.thoughtSignature, undefined);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.ANTIGRAVITY_ALLOW_SIGNATURE_BYPASS;
|
||||
else process.env.ANTIGRAVITY_ALLOW_SIGNATURE_BYPASS = prev;
|
||||
}
|
||||
});
|
||||
140
tests/unit/ui/add-compatible-provider-icon-url.test.tsx
Normal file
140
tests/unit/ui/add-compatible-provider-icon-url.test.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Field-level icon URL validation for the compatible-provider Add modal: an invalid
|
||||
// iconUrl must surface as an inline field error BEFORE a blind POST, instead of only
|
||||
// failing after the request round-trip. Mirrors the shared validator
|
||||
// (src/shared/validation/iconUrl.ts) used by both the UI and the server schema.
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const { default: AddCompatibleProviderModal } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal");
|
||||
|
||||
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function render(props: Record<string, unknown>) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AddCompatibleProviderModal
|
||||
isOpen
|
||||
mode="openai"
|
||||
onClose={() => {}}
|
||||
onCreated={() => {}}
|
||||
{...(props as any)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
containers.push({ root, el });
|
||||
return el;
|
||||
}
|
||||
|
||||
function inputByLabel(el: Element, label: string): HTMLInputElement {
|
||||
const inputs = Array.from(el.querySelectorAll<HTMLInputElement>("input"));
|
||||
const found = inputs.find((i) => {
|
||||
const labelEl = i.previousElementSibling || i.parentElement?.previousElementSibling;
|
||||
return labelEl?.textContent === label;
|
||||
});
|
||||
if (!found) throw new Error(`No input for label: ${label}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
|
||||
act(() => {
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(fn: () => boolean, timeoutMs = 2000) {
|
||||
const start = Date.now();
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Any POST that does reach the network should never happen for the invalid-input
|
||||
// cases under test — fail loudly if it does.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve({ node: {} }) } as Response)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of containers.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("AddCompatibleProviderModal — iconUrl field-level validation", () => {
|
||||
it("shows an inline error for an unsafe scheme and does NOT submit", async () => {
|
||||
const el = render({});
|
||||
const modal = el.querySelector('[role="dialog"]')!;
|
||||
|
||||
setInputValue(inputByLabel(modal, "nameLabel"), "My Node");
|
||||
setInputValue(inputByLabel(modal, "prefixLabel"), "mynode");
|
||||
setInputValue(inputByLabel(modal, "iconUrlLabel"), "javascript:alert(1)");
|
||||
|
||||
const buttons = Array.from(modal.querySelectorAll<HTMLButtonElement>("button"));
|
||||
const addBtn = buttons.find((b) => b.textContent === "add");
|
||||
act(() => addBtn!.click());
|
||||
await waitFor(() => modal.textContent?.includes("iconUrlInvalid") ?? false);
|
||||
|
||||
expect(modal.textContent).toContain("iconUrlInvalid");
|
||||
// The invalid icon must never reach the API.
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an inline error for a non-image data URL and does NOT submit", async () => {
|
||||
const el = render({});
|
||||
const modal = el.querySelector('[role="dialog"]')!;
|
||||
|
||||
setInputValue(inputByLabel(modal, "nameLabel"), "My Node");
|
||||
setInputValue(inputByLabel(modal, "prefixLabel"), "mynode");
|
||||
setInputValue(inputByLabel(modal, "iconUrlLabel"), "data:text/html;base64,QUJD");
|
||||
|
||||
const buttons = Array.from(modal.querySelectorAll<HTMLButtonElement>("button"));
|
||||
const addBtn = buttons.find((b) => b.textContent === "add");
|
||||
act(() => addBtn!.click());
|
||||
await waitFor(() => modal.textContent?.includes("iconUrlInvalid") ?? false);
|
||||
|
||||
expect(modal.textContent).toContain("iconUrlInvalid");
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a valid data:image/*;base64 iconUrl and submits", async () => {
|
||||
const el = render({});
|
||||
const modal = el.querySelector('[role="dialog"]')!;
|
||||
|
||||
setInputValue(inputByLabel(modal, "nameLabel"), "My Node");
|
||||
setInputValue(inputByLabel(modal, "prefixLabel"), "mynode");
|
||||
setInputValue(inputByLabel(modal, "iconUrlLabel"), "data:image/png;base64,iVBORw0KGgo=");
|
||||
|
||||
const buttons = Array.from(modal.querySelectorAll<HTMLButtonElement>("button"));
|
||||
const addBtn = buttons.find((b) => b.textContent === "add");
|
||||
act(() => addBtn!.click());
|
||||
await waitFor(() => (fetch as ReturnType<typeof vi.fn>).mock.calls.length > 0);
|
||||
|
||||
expect(modal.textContent).not.toContain("iconUrlInvalid");
|
||||
const call = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(call[0])).toBe("/api/provider-nodes");
|
||||
const body = JSON.parse(String(call[1].body));
|
||||
expect(body.iconUrl).toBe("data:image/png;base64,iVBORw0KGgo=");
|
||||
});
|
||||
});
|
||||
131
tests/unit/ui/edit-compatible-node-icon-url.test.tsx
Normal file
131
tests/unit/ui/edit-compatible-node-icon-url.test.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Field-level icon URL validation for the compatible-provider Edit modal: an invalid
|
||||
// iconUrl must surface as an inline field error BEFORE the onSave callback fires, and a
|
||||
// valid data:image/*;base64 iconUrl must submit. Mirrors the shared validator
|
||||
// (src/shared/validation/iconUrl.ts) used by both the UI and the server schema.
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const { default: EditCompatibleNodeModal } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal");
|
||||
|
||||
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function render(node: Record<string, unknown>, onSave?: () => Promise<void>) {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => {
|
||||
root.render(
|
||||
<EditCompatibleNodeModal
|
||||
isOpen
|
||||
node={node as any}
|
||||
onSave={onSave || (async () => {})}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
containers.push({ root, el });
|
||||
return el;
|
||||
}
|
||||
|
||||
function inputByLabel(el: Element, label: string): HTMLInputElement {
|
||||
const inputs = Array.from(el.querySelectorAll<HTMLInputElement>("input"));
|
||||
const found = inputs.find((i) => {
|
||||
const labelEl = i.previousElementSibling || i.parentElement?.previousElementSibling;
|
||||
return labelEl?.textContent === label;
|
||||
});
|
||||
if (!found) throw new Error(`No input for label: ${label}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
|
||||
act(() => {
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(fn: () => boolean, timeoutMs = 2000) {
|
||||
const start = Date.now();
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of containers.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const NODE = {
|
||||
id: "oc-1",
|
||||
name: "My Node",
|
||||
prefix: "mynode",
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
apiType: "chat",
|
||||
iconUrl: "https://example.com/logo.png",
|
||||
};
|
||||
|
||||
describe("EditCompatibleNodeModal — iconUrl field-level validation", () => {
|
||||
it("shows an inline error for an unsafe scheme and does NOT call onSave", async () => {
|
||||
const onSave = vi.fn(async () => {});
|
||||
const el = render({ ...NODE, iconUrl: "javascript:alert(1)" });
|
||||
const modal = el.querySelector('[role="dialog"]')!;
|
||||
|
||||
setInputValue(inputByLabel(modal, "iconUrlLabel"), "javascript:alert(1)");
|
||||
const buttons = Array.from(modal.querySelectorAll<HTMLButtonElement>("button"));
|
||||
const saveBtn = buttons.find((b) => b.textContent === "save");
|
||||
act(() => saveBtn!.click());
|
||||
await waitFor(() => modal.textContent?.includes("iconUrlInvalid") ?? false);
|
||||
|
||||
expect(modal.textContent).toContain("iconUrlInvalid");
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an inline error for a non-image data URL and does NOT call onSave", async () => {
|
||||
const onSave = vi.fn(async () => {});
|
||||
const el = render({ ...NODE, iconUrl: "data:text/html;base64,QUJD" });
|
||||
const modal = el.querySelector('[role="dialog"]')!;
|
||||
|
||||
setInputValue(inputByLabel(modal, "iconUrlLabel"), "data:text/html;base64,QUJD");
|
||||
const buttons = Array.from(modal.querySelectorAll<HTMLButtonElement>("button"));
|
||||
const saveBtn = buttons.find((b) => b.textContent === "save");
|
||||
act(() => saveBtn!.click());
|
||||
await waitFor(() => modal.textContent?.includes("iconUrlInvalid") ?? false);
|
||||
|
||||
expect(modal.textContent).toContain("iconUrlInvalid");
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a valid data:image/*;base64 iconUrl and calls onSave with it", async () => {
|
||||
const onSave = vi.fn(async () => {});
|
||||
const el = render({ ...NODE, iconUrl: "" }, onSave);
|
||||
const modal = el.querySelector('[role="dialog"]')!;
|
||||
|
||||
setInputValue(inputByLabel(modal, "iconUrlLabel"), "data:image/png;base64,iVBORw0KGgo=");
|
||||
const buttons = Array.from(modal.querySelectorAll<HTMLButtonElement>("button"));
|
||||
const saveBtn = buttons.find((b) => b.textContent === "save");
|
||||
act(() => saveBtn!.click());
|
||||
await waitFor(() => onSave.mock.calls.length > 0);
|
||||
|
||||
expect(modal.textContent).not.toContain("iconUrlInvalid");
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.iconUrl).toBe("data:image/png;base64,iVBORw0KGgo=");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user