mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 17:52:31 +03:00
Compare commits
113 Commits
maint/cher
...
fix/releas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7a6d2ae3d | ||
|
|
1828b6c14a | ||
|
|
99781e000d | ||
|
|
159b90125c | ||
|
|
70fca36f9d | ||
|
|
2d24e82c61 | ||
|
|
c8f4efa158 | ||
|
|
bfc846fae6 | ||
|
|
5f16478c7e | ||
|
|
c6de098630 | ||
|
|
ee088198bf | ||
|
|
792626c3c4 | ||
|
|
dc66e3be29 | ||
|
|
7d4584db56 | ||
|
|
181828625b | ||
|
|
b7bad4006b | ||
|
|
c6195cec62 | ||
|
|
8c009f55e9 | ||
|
|
14b6183693 | ||
|
|
0bb3bc093c | ||
|
|
88b05fdb92 | ||
|
|
9f4186a2b2 | ||
|
|
afd5169b69 | ||
|
|
326a975084 | ||
|
|
0d36801f20 | ||
|
|
754ba0fa86 | ||
|
|
240b9b5bc4 | ||
|
|
2afaab52a1 | ||
|
|
04483ed211 | ||
|
|
3a87712b56 | ||
|
|
382449d593 | ||
|
|
807a0d2022 | ||
|
|
54bba33e2f | ||
|
|
a54c1f73af | ||
|
|
57fb90d734 | ||
|
|
5eba045175 | ||
|
|
06727f0e74 | ||
|
|
5f75abe4a2 | ||
|
|
58f0ff1b41 | ||
|
|
05ab06f3a1 | ||
|
|
431fc02e75 | ||
|
|
c1d951e5c5 | ||
|
|
740e16c3e2 | ||
|
|
d8967efc6c | ||
|
|
3a66761cb7 | ||
|
|
5e5919dcc0 | ||
|
|
f68856695d | ||
|
|
be9f43e1ee | ||
|
|
a39f78c6f8 | ||
|
|
bc38965088 | ||
|
|
bd33b4589a | ||
|
|
c8e6b07df5 | ||
|
|
ed7a68e1a9 | ||
|
|
6f3738b009 | ||
|
|
b254890c07 | ||
|
|
09520785f8 | ||
|
|
04b4690f84 | ||
|
|
356fd5d606 | ||
|
|
61cb52399e | ||
|
|
9fb7d6a493 | ||
|
|
e117249baa | ||
|
|
a524fdeaf0 | ||
|
|
a448b146bf | ||
|
|
efbc7a7ba2 | ||
|
|
a7d2dba1eb | ||
|
|
a1833b1159 | ||
|
|
c4c39b1a4a | ||
|
|
8a17f43849 | ||
|
|
332c738844 | ||
|
|
a102a2d773 | ||
|
|
5926d35758 | ||
|
|
48d43240f4 | ||
|
|
87c145a2be | ||
|
|
4fe0fffb31 | ||
|
|
65dae70403 | ||
|
|
0b5ab6570d | ||
|
|
97a1355037 | ||
|
|
a2eab58dde | ||
|
|
fed05a3207 | ||
|
|
2c21f292cd | ||
|
|
d9df8bb512 | ||
|
|
0bb17b91c6 | ||
|
|
3d590c310b | ||
|
|
247f2606cd | ||
|
|
580162548a | ||
|
|
79b8c8351c | ||
|
|
05940f4c7f | ||
|
|
0f5699165b | ||
|
|
714a36cf99 | ||
|
|
bbe5c78f4d | ||
|
|
2d49f1c743 | ||
|
|
d11b99f6cc | ||
|
|
065fa67f63 | ||
|
|
4d8506c2c5 | ||
|
|
2c8093f73f | ||
|
|
b294c76719 | ||
|
|
439854ce65 | ||
|
|
5e4a684bad | ||
|
|
eb817932e4 | ||
|
|
788d56fa07 | ||
|
|
390690dd0a | ||
|
|
15b9cb194f | ||
|
|
58ab721fe2 | ||
|
|
038035f937 | ||
|
|
da771f8d86 | ||
|
|
7a0515038b | ||
|
|
cf7e4148c5 | ||
|
|
153f453b0b | ||
|
|
9233a9483c | ||
|
|
3b411c7da7 | ||
|
|
3ea174d531 | ||
|
|
f1fda94047 | ||
|
|
034db3c3dd |
49
.env.example
49
.env.example
@@ -1414,7 +1414,7 @@ APP_LOG_TO_FILE=true
|
||||
|
||||
# Whether call log pipeline capture stores stream chunks when enabled in settings.
|
||||
# Only applies when call_log_pipeline_enabled=true.
|
||||
# Default: true
|
||||
# Default: false (opt-in — saves disk: stream chunks are the biggest call-log artifact)
|
||||
# CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS=true
|
||||
|
||||
# Maximum call log artifact size for pipeline captures, in KB.
|
||||
@@ -1426,7 +1426,7 @@ APP_LOG_TO_FILE=true
|
||||
# bodies is retained in the database.
|
||||
# Used by: open-sse/handlers/chatCore.ts — cloneBoundedChatLogPayload()
|
||||
# CHAT_LOG_TEXT_LIMIT=65536 # Max string length before truncation (default: 64 KB)
|
||||
# CHAT_LOG_ARRAY_TAIL_ITEMS=24 # Number of array items retained from tail (default: 24)
|
||||
# CHAT_LOG_ARRAY_TAIL_ITEMS=128 # Number of array items retained from tail (default: 128)
|
||||
# CHAT_LOG_MAX_DEPTH=6 # Max nesting depth before truncation (default: 6)
|
||||
# CHAT_LOG_MAX_OBJECT_KEYS=80 # Max object keys retained (default: 80, 0 = no limit)
|
||||
|
||||
@@ -1893,7 +1893,7 @@ APP_LOG_TO_FILE=true
|
||||
|
||||
# Log request shape (content-type + content-length) for large chat payloads.
|
||||
# Used by: src/app/api/v1/chat/completions/route.ts. Set to "0" to silence.
|
||||
# Default: enabled.
|
||||
# Default: disabled (opt-in).
|
||||
# OMNIROUTE_LOG_REQUEST_SHAPE=1
|
||||
|
||||
# Write raw (untruncated) request/response JSON in call log artifacts.
|
||||
@@ -2514,3 +2514,46 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# URL the dashboard's "Support the project" button opens (payment/plans
|
||||
# page). No pricing/value lives in this repo — only the link.
|
||||
# RADAR_SUPPORTER_PLANS_URL=https://radar.omniroute.online/planos
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 27. RELEASE v3.8.50 ADDITIONS
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Heavy chat admission queue wait before returning retryable 503. Set 0 for the
|
||||
# legacy immediate rejection. Used by: src/shared/middleware/chatBodyAdmission.ts.
|
||||
# Default: 5000 (5 seconds)
|
||||
# OMNIROUTE_CHAT_ADMISSION_QUEUE_MS=5000
|
||||
|
||||
# Timeout for /api/jobs/:id/run-now while it waits for an in-flight run.
|
||||
# Used by: src/app/api/jobs/[id]/run-now/route.ts. Default: 30000 (30 seconds)
|
||||
# OMNIROUTE_RUNNOW_TIMEOUT_MS=30000
|
||||
|
||||
# Maximum request/response body size before chat-log summarization, in KiB.
|
||||
# Used by: src/lib/chatLogTruncation.ts. Default: 1024
|
||||
# CHAT_LOG_MAX_BODY_KB=1024
|
||||
|
||||
# Adobe Firefly browser renewal and durable session cache (enabled by default).
|
||||
# Used by: open-sse/services/adobeFireflySession.ts.
|
||||
# ADOBE_FIREFLY_BROWSER_REFRESH=1
|
||||
# ADOBE_FIREFLY_SESSION_DISK=1
|
||||
# Minimum spacing between submissions and the extra pause after every third success.
|
||||
# ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS=12000
|
||||
# ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS=15000
|
||||
# Chrome CDP runtime used by Adobe Firefly renewal. True headless is debug-only:
|
||||
# Adobe colligo normally rejects risk tokens minted without a headed browser.
|
||||
# ADOBE_FIREFLY_CHROME_CDP_PORT=9334
|
||||
# ADOBE_FIREFLY_CHROME_VISIBLE=0
|
||||
# ADOBE_FIREFLY_CHROME_HEADLESS=0
|
||||
# ADOBE_FIREFLY_CHROME_FORCE_RESTART=0
|
||||
# ADOBE_FIREFLY_CHROME_PING=auto
|
||||
# ADOBE_FIREFLY_LOGIN_WAIT_MS=0
|
||||
# ADOBE_FIREFLY_FORTER_WAIT_MS=45000
|
||||
# Optional absolute Chrome executable; auto-detected when unset.
|
||||
# CHROME_PATH=
|
||||
|
||||
# Telegram Mini App bridge. The update endpoint remains disabled while the bot
|
||||
# token is unset. Used by: src/lib/telegram/* and src/app/api/telegram/update/route.ts.
|
||||
# TELEGRAM_BOT_TOKEN=
|
||||
# TELEGRAM_DEFAULT_MODEL=auto/chat
|
||||
# TELEGRAM_BOT_API_BASE=https://api.telegram.org
|
||||
# TELEGRAM_WEBHOOK_TIMEOUT_MS=60000
|
||||
|
||||
49
.gitignore
vendored
49
.gitignore
vendored
@@ -72,7 +72,6 @@ yarn-error.log*
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
!.env.devin-bridge.example
|
||||
!.env.homolog.example
|
||||
# Provider API keys (never commit)
|
||||
*.api-key
|
||||
@@ -172,6 +171,7 @@ config/quality/test-impact-map.json
|
||||
# GitNexus local index
|
||||
.gitnexus
|
||||
.worktrees
|
||||
bin/omniroute.mjs
|
||||
|
||||
# Consistent with .dockerignore / .npmignore
|
||||
.omc/
|
||||
@@ -201,17 +201,12 @@ scripts/i18n/_pending-keys.json
|
||||
.codegraph/
|
||||
|
||||
# Fumadocs generated source
|
||||
/.source/
|
||||
|
||||
# Temporary local worktrees used to build unpublished npm tarballs
|
||||
/.deploy-build-*/
|
||||
.source/
|
||||
|
||||
# AI agent local settings and configs
|
||||
.agents/
|
||||
.antigravitycli/
|
||||
.claude/
|
||||
!tests/fixtures/devin-bridge/e2e-workspace/.claude/
|
||||
!tests/fixtures/devin-bridge/e2e-workspace/.claude/**
|
||||
|
||||
# PR Reviews and local feedback files
|
||||
pr_reviews*.json
|
||||
@@ -226,6 +221,26 @@ CODEX-SETUP-PROMPT.md
|
||||
# Quality ratchet — métricas efêmeras (baseline commitado em config/quality/; métricas não)
|
||||
config/quality/quality-metrics.json
|
||||
|
||||
# Electron desktop build output unpacked into the repo root.
|
||||
# `electron-builder` (squirrel-windows target) unpacks the packaged app — the
|
||||
# entire Chromium runtime, ~24k files — directly into the repository root.
|
||||
# Every rule below is ROOT-ANCHORED (leading `/`) on purpose: a bare `locales/`
|
||||
# or `resources/` would also swallow tracked sources such as the CLI
|
||||
# translations in `bin/cli/locales/*.json`.
|
||||
/OmniRoute.exe
|
||||
/Uninstall OmniRoute.exe
|
||||
/uninstallerIcon.ico
|
||||
/locales/
|
||||
/resources/
|
||||
/*.pak
|
||||
/*.dll
|
||||
/icudtl.dat
|
||||
/snapshot_blob.bin
|
||||
/v8_context_snapshot.bin
|
||||
/vk_swiftshader_icd.json
|
||||
/LICENSE.electron.txt
|
||||
/LICENSES.chromium.html
|
||||
|
||||
# Runtime logs (diretório local, nunca versionado)
|
||||
/logs/
|
||||
-home-diegosouzapw-dev-automações-bots-yt-downloader-20260504 .txt
|
||||
@@ -238,10 +253,7 @@ omniroute.md
|
||||
|
||||
# mise configuration
|
||||
mise.toml
|
||||
# release-green artifacts (.gitignore has no inline comments — a trailing
|
||||
# `# ...` becomes part of the pattern, so it must sit on its own line).
|
||||
# Already covered by /_*/ above; kept explicit for discoverability.
|
||||
_artifacts/
|
||||
_artifacts/ # release-green artifacts
|
||||
.claude-flow/
|
||||
|
||||
# ESLint file cache (npm run lint --cache / complexity ratchets)
|
||||
@@ -251,8 +263,6 @@ _artifacts/
|
||||
|
||||
# CI/local quality artifacts (eslint-results.json, quality-ratchet.md, etc.)
|
||||
.artifacts/
|
||||
# Isolated Devin bridge workspaces, evidence, and test databases
|
||||
.sandbox/
|
||||
|
||||
# Homologation E2E suite (npm run homolog) — real-environment credentials + report output
|
||||
.env.homolog
|
||||
@@ -260,12 +270,11 @@ tests/homolog/.auth/
|
||||
tests/homolog/ui/.auth/
|
||||
homolog-report/
|
||||
docker-compose.yml.bak
|
||||
.playwright-cli/
|
||||
# Playwright screenshot/log output. Today every artifact happens to land inside
|
||||
# output/**/.playwright-cli/ (covered above), but anything written directly to
|
||||
# output/ would otherwise show up as untracked.
|
||||
/output/
|
||||
|
||||
# _tasks e um repo git SEPARADO (ver AGENTS.md). _tasks/ (com barra) NAO ignora um
|
||||
# SYMLINK _tasks; /_tasks (ancorado) cobre symlink/dir na raiz (incidente 2026-08-08).
|
||||
# _tasks e um repo git SEPARADO (ver AGENTS.md). A linha _tasks/ (com barra) NAO
|
||||
# ignora um SYMLINK chamado _tasks; /_tasks (ancorado) cobre arquivo/symlink/dir na raiz
|
||||
# e impede que um git add -A recapture o symlink (incidente 2026-08-08).
|
||||
/_tasks
|
||||
|
||||
# CLI local cache/state
|
||||
.playwright-cli
|
||||
|
||||
@@ -627,7 +627,7 @@ procedures are in [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALI
|
||||
complexity) must not regress vs `quality-baseline.json`. Update via
|
||||
`npm run quality:ratchet -- --update` when a metric genuinely improves.
|
||||
- Job `test-vitest` runs `npm run test:vitest` (MCP tools, autoCombo, cache) — blocking.
|
||||
`test:vitest:ui` is advisory until UI component tests are triaged.
|
||||
`test:vitest:ui` has been blocking since PR #7127.
|
||||
|
||||
**Allowlist policy (short form):** Fix the cause; use the allowlist only for pre-existing
|
||||
violations you cannot fix in the same PR. Add a comment with justification + issue number.
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -93,7 +93,15 @@ RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
|
||||
# build from 17min to 9min on the same 32-core box. Webpack stays available as the
|
||||
# escape hatch: `--build-arg`/-e OMNIROUTE_USE_TURBOPACK=0.
|
||||
# See docs/ops/QUALITY_GATE_PLAYBOOK.md Parte 6.
|
||||
ENV OMNIROUTE_USE_TURBOPACK=1
|
||||
#
|
||||
# Declared as ARG+ENV, not a bare ENV: a bare ENV shadows any same-named ARG for
|
||||
# the rest of the stage, so `--build-arg OMNIROUTE_USE_TURBOPACK=0` was silently
|
||||
# ignored and the escape hatch above only ever worked via `-e` at runtime, never
|
||||
# at build time. Turbopack compiles in native Rust memory that lives outside the
|
||||
# V8 heap, so OMNIROUTE_BUILD_MEMORY_MB cannot bound it and a memory-constrained
|
||||
# build host gets SIGKILLed by the cgroup OOM killer with no error message.
|
||||
ARG OMNIROUTE_USE_TURBOPACK=1
|
||||
ENV OMNIROUTE_USE_TURBOPACK="${OMNIROUTE_USE_TURBOPACK}"
|
||||
|
||||
# Next.js basePath is fixed at build time; pass OMNIROUTE_BASE_PATH here when the
|
||||
# image should serve under a reverse-proxy subpath without a runtime patch.
|
||||
|
||||
69
Makefile
Normal file
69
Makefile
Normal file
@@ -0,0 +1,69 @@
|
||||
.PHONY: help install dev start build build-release lint typecheck typecheck-strict \
|
||||
test test-unit test-vitest test-coverage test-all test-integration test-e2e \
|
||||
check check-cycles check-docs env-sync clean
|
||||
|
||||
# OmniRoute — convenience wrapper around the npm scripts.
|
||||
# All targets delegate to the canonical package.json scripts (single source of truth).
|
||||
|
||||
help: ## Show this help
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
install: ## Install dependencies (auto-generates .env from .env.example)
|
||||
npm install
|
||||
|
||||
dev: ## Dev server at http://localhost:20128
|
||||
npm run dev
|
||||
|
||||
start: ## Production server (requires a prior build)
|
||||
npm run start
|
||||
|
||||
build: ## Production build (Next.js 16 standalone)
|
||||
npm run build
|
||||
|
||||
build-release: ## Release build
|
||||
npm run build:release
|
||||
|
||||
lint: ## ESLint (0 errors expected)
|
||||
npm run lint
|
||||
|
||||
typecheck: ## TypeScript check (core)
|
||||
npm run typecheck:core
|
||||
|
||||
typecheck-strict: ## Strict check (no implicit any)
|
||||
npm run typecheck:noimplicit:core
|
||||
|
||||
test: ## Unit tests (Node native runner)
|
||||
npm run test:unit
|
||||
|
||||
test-unit: ## Alias for `test`
|
||||
npm run test:unit
|
||||
|
||||
test-vitest: ## Vitest (MCP server, autoCombo, cache)
|
||||
npm run test:vitest
|
||||
|
||||
test-coverage: ## Unit tests + coverage gate (60/60/60/60)
|
||||
npm run test:coverage
|
||||
|
||||
test-all: ## All suites (unit + vitest + ecosystem + e2e)
|
||||
npm run test:all
|
||||
|
||||
test-integration: ## Integration tests
|
||||
npm run test:integration
|
||||
|
||||
test-e2e: ## E2E (Playwright)
|
||||
npm run test:e2e
|
||||
|
||||
check: ## lint + test combined
|
||||
npm run check
|
||||
|
||||
check-cycles: ## Detect circular dependencies
|
||||
npm run check:cycles
|
||||
|
||||
check-docs: ## Validate documentation (incl. fabricated-docs)
|
||||
npm run check:docs-all
|
||||
|
||||
env-sync: ## Sync .env from .env.example
|
||||
npm run env:sync
|
||||
|
||||
clean: ## Remove build artifacts
|
||||
rm -rf .build dist coverage .eslintcache
|
||||
2
changelog.d/features/9000-encrypted-reasoning-replay.md
Normal file
2
changelog.d/features/9000-encrypted-reasoning-replay.md
Normal file
@@ -0,0 +1,2 @@
|
||||
- Add a default-off connection setting for Codex, OpenAI, and OpenAI-compatible Responses API providers that preserves client-supplied `reasoning.encrypted_content` items for replay, including per-target combo routing.
|
||||
- Omit opaque encrypted reasoning values from persisted call logs while retaining compact diagnostic markers.
|
||||
@@ -1 +1 @@
|
||||
- **sse:** New-API / One-API / Sub2API aggregator balance detection for compatible nodes — with the "Aggregator Gateway" toggle on, OmniRoute queries the aggregator's `/api/user/self` to read the account balance, shows it as a dashboard badge and lets quota-preflight routing skip exhausted accounts. Gated by the `NEWAPI_AGGREGATOR_BALANCE` feature flag (default off), with a `quotaPerUnit` override for aggregators that do not use the default 500000 units/$1 rate ([#9415](https://github.com/diegosouzapw/OmniRoute/issues/9415))
|
||||
- **feat(sse):** New-API/One-API/Sub2API aggregator balance detection for compatible provider nodes — when the "Aggregator Gateway" toggle is enabled, OmniRoute queries the aggregator's `/api/user/self` endpoint to detect the account balance; the dashboard shows a balance badge and quota-preflight routing skips exhausted accounts. Gated by the `NEWAPI_AGGREGATOR_BALANCE` feature flag (default: off), with a custom `quotaPerUnit` override for aggregators that use a different rate than the default 500000 units/$1 ([#9415](https://github.com/diegosouzapw/OmniRoute/issues/9415))
|
||||
|
||||
1
changelog.d/fixes/9496-kimi-k3-responses-replay.md
Normal file
1
changelog.d/fixes/9496-kimi-k3-responses-replay.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(translator): preserve authentic K3 Responses reasoning by model across providers, keep it on the matching assistant turn, and make Kimi Coding prefer client reasoning then cached replay before its empty-marker fallback (#9496)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(providers):** new per-provider `noAuthFallbackDisabledProviders` setting lets operators disable the synthetic anonymous (no-auth) credential fallback for API-key providers whose static definition declares `anonymousFallback: true` (e.g. `opencode-go`, `opencode-zen`) — upstream endpoints now reject anonymous requests with `401 Missing API key`, so the fallback added latency and caused UI health/reconnect churn. Real keyed connections keep working and recover automatically once quota state clears; true no-auth providers (`opencode`, `mimocode`, …) are unaffected, with `blockedProviders` remaining their disable mechanism. Default behavior is unchanged ([#9675](https://github.com/diegosouzapw/OmniRoute/pull/9675))
|
||||
1
changelog.d/fixes/9695-docker-bundler-build-arg.md
Normal file
1
changelog.d/fixes/9695-docker-bundler-build-arg.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(docker):** `--build-arg OMNIROUTE_USE_TURBOPACK=0` now reaches the builder stage — a bare `ENV` was shadowing the `ARG`, so the documented webpack escape hatch was silently ignored and memory-constrained hosts were OOM-killed with no error output ([#9695](https://github.com/diegosouzapw/OmniRoute/pull/9695))
|
||||
1
changelog.d/fixes/9719-combo-connection-pins.md
Normal file
1
changelog.d/fixes/9719-combo-connection-pins.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(db): clear stale combo connection pins when provider connections are deleted (#9719)
|
||||
1
changelog.d/fixes/9730-persist-rtk-renderers.md
Normal file
1
changelog.d/fixes/9730-persist-rtk-renderers.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(compression): persist `enableRenderers` through `normalizeRtkConfig` so RTK renderer settings survive a DB round-trip ([#9730](https://github.com/diegosouzapw/OmniRoute/pull/9730))
|
||||
1
changelog.d/fixes/9737-vietnamese-locale-parity.md
Normal file
1
changelog.d/fixes/9737-vietnamese-locale-parity.md
Normal file
@@ -0,0 +1 @@
|
||||
- Restore Vietnamese locale parity after the entity-normalization sync dropped Radar, provider, and mini-playground messages.
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(api):** Model catalogs no longer expose functional gateway mirrors unless the API key permits the mirror's final public model ID ([#9788](https://github.com/diegosouzapw/OmniRoute/pull/9788)) — thanks @xz-dev
|
||||
1
changelog.d/fixes/9826-command-code-responses-usage.md
Normal file
1
changelog.d/fixes/9826-command-code-responses-usage.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(executors):** preserve Command Code usage in `/v1/responses` streams so Codex clients receive real input, output, cache, and reasoning token counts ([#9826](https://github.com/diegosouzapw/OmniRoute/pull/9826)) — thanks @MrShitFox
|
||||
1
changelog.d/fixes/9828-codex-redundant-oneof-enum.md
Normal file
1
changelog.d/fixes/9828-codex-redundant-oneof-enum.md
Normal file
@@ -0,0 +1 @@
|
||||
- **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/9834-cursor-selected-image-blobid.md
Normal file
1
changelog.d/fixes/9834-cursor-selected-image-blobid.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(cursor):** SelectedImage uses `blobIdWithData` + session blobStore, with JPEG soft-cap prep via sharp ([#9834](https://github.com/diegosouzapw/OmniRoute/pull/9834)) — thanks @yansigit
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(test):** reconcile test expectations that drifted from the code they guard on `release/v3.8.50` — auth/vision/provider schema snapshots, and three context-aware combo compatibility assertions that contradicted the same file's own stated contract (catalog-too-small targets stay available as runtime fallback rather than being dropped). The combo assertions were masked by an unresolved import that stopped `combo.ts` from loading at all, so they only become reachable once that import is repaired.
|
||||
@@ -0,0 +1 @@
|
||||
- Repair release-sweep regressions in locale and environment contracts, package metadata, scripts, dependency, size and dead-code ratchets, OpenAPI coverage, Telegram error sanitization, Openference public-credential handling, DB-module classification, resilience UI test assertions, strict CodeBuddy CN tests, Lite compression typing, and the job-registry migration number.
|
||||
@@ -0,0 +1 @@
|
||||
- Let the release-green validator finish the test-masking gate on loaded runners while preserving the existing timeout for every other full-CI gate, and report Node.js `ETIMEDOUT` errors as explicit timeout failures.
|
||||
1
changelog.d/maintenance/9839-v3850-quality-ratchets.md
Normal file
1
changelog.d/maintenance/9839-v3850-quality-ratchets.md
Normal file
@@ -0,0 +1 @@
|
||||
- Reconcile the final v3.8.50 bundle-size and file-size ratchets against the measured release tip, preserving exact direction-down ceilings and their source attribution.
|
||||
@@ -44,6 +44,7 @@
|
||||
"commander",
|
||||
"concurrently",
|
||||
"cross-env",
|
||||
"cron-parser",
|
||||
"csv-stringify",
|
||||
"ctrf",
|
||||
"dompurify",
|
||||
@@ -114,6 +115,7 @@
|
||||
"recharts",
|
||||
"safe-regex",
|
||||
"selfsigned",
|
||||
"sharp",
|
||||
"size-limit",
|
||||
"smol-toml",
|
||||
"socks",
|
||||
|
||||
@@ -1673,7 +1673,7 @@
|
||||
},
|
||||
"tests/unit/base-executor-sanitize-effort.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 48
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"tests/unit/batch-deletion.test.ts": {
|
||||
@@ -2036,11 +2036,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"tests/unit/codebuddy-cn-provider.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"tests/unit/codex-banked-reset-credits-5199.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 7
|
||||
@@ -3339,4 +3334,4 @@
|
||||
"count": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"_rebaseline_2026_08_09_9296_adobe_media_capabilities": "PR #9296 (artickc, fix/adobe-firefly-model-capabilities) own growth: src/app/api/v1/models/catalog.ts 1590->1597 (+7). The image and video catalog serializers now expose the already-normalized Adobe Firefly discovery capability data (media_capabilities, plus the existing video modality/size fields) at their only response-emission chokepoints. The discovery parser and capability normalization remain in open-sse/services/adobeFireflyModels.ts; extracting these seven serialization fields would obscure the catalog contract. Covered by tests/unit/adobe-firefly.test.ts and tests/unit/image-upscale.test.ts.",
|
||||
"_rebaseline_2026_08_08_v3850_base_drift_batch_9757": "Base drift on release/v3.8.50, not own growth: the 08-06..08-08 merge batches grew 12 already-frozen (or newly-landed) files without carrying their rebaselines — the dedicated rebaseline PR #9616 was closed as 'superseded' but its file-size entries never actually reached the base, and later merges (#8894 combos page, #9539 EditConnectionModal, #8895 models route, #9294/#9293 catalog, #9541 db/core, #8970 tokenHealthCheck, #8925 mcp schemas+server, #8890 accountFallback, #9467 chat.ts, #8931 openai-to-kiro, ProxyRegistryManager) kept growing them. All 12 values re-measured on THIS branch's tree (= pure tip + this PR's 1-line chat.ts fix, which adds zero lines). This PR's own source changes (chat.ts identifier restore, stream.ts format carve-out) do not grow any frozen file past these values.",
|
||||
"_rebaseline_2026_08_08_migration_135_collision": "fix(db): resolve migration version 135 numbering collision — #9449's 135_connection_runtime_state.sql and #8908's 135_migrate_model_capability_max_token.sql both claimed version 135 (#9449 branched before #8908 merged and never got renumbered before landing on release/v3.8.50), which threw 'Migration version collision detected' the moment ANY code touched the database — a fresh install/deploy from this tip cannot even boot. Renumbered the later-landing file to 140 (next free slot) and added the matching isSchemaAlreadyApplied('140') retroactive guard, matching the established pattern already used for the prior 135/136 -> 137/138 renumber in the same file. Own growth: src/lib/db/migrationRunner.ts 1084->1094 (+10, the new case block) — irreducible, matches the existing per-case guard pattern exactly. Covered by tests/unit/migration-135-numbering-collision.test.ts (2/2), confirmed failing (reproducing the exact live crash) against the pre-fix colliding filenames, passing after.",
|
||||
"_rebaseline_2026_08_08_9183_reasoning_cache_index_sync": "Extracted fix(responses-api): sync reasoning-cache write index with the fixed read side (from the originally-authored #9183) — chatCore.ts's write side cached every response under a hardcoded messageIndex:0, and translator/index.ts's plain-turn (non-tool-call) cache-key lookup ALSO still hardcoded messageIndex 0 at its call site (a second, previously-undiscovered instance of the same hardcoding bug, found while re-verifying this fix against the current upstream tip — the two never agreed once a conversation went past its first assistant turn, so DeepSeek/Xiaomi-mimo plain-turn reasoning replay silently missed the cache). Own growth: open-sse/handlers/chatCore.ts 5034->5042 (+8, computing messageIndex from the incoming request's message count at both the streaming and non-streaming cache-write call sites) — irreducible call-site wiring. Covered by tests/unit/reasoning-cache.test.ts (new end-to-end write/read regression test, rebaselined below) and tests/unit/translator-helper-branches.test.ts fixture updates. Other #9183 sub-fixes (output_index collision prevention, reasoning-content-alias generalization) were originally assumed already superseded by upstream's own independent fix — a live incident 2026-08-08 disproved that for the message-vs-tool-call collision case specifically (fixed separately in #9822); not re-extracted here since this PR's own scope is the narrower messageIndex sync only.",
|
||||
"_rebaseline_2026_08_02_9259_rolling_rpm": "PR #9259 (issue #8733) own growth: open-sse/services/rateLimitManager.ts baseline 1060->1167 (+107; final source 1153). The existing withRateLimit chokepoint now composes process-local rolling RPM leases with Bottleneck admission, releases pre-dispatch leases on queue timeout/abort/connection disable, preserves caller abort reasons, and wires 429/header state into the extracted rollingRpmGate.ts. The remaining growth is irreducible lifecycle wiring at the dispatch boundary plus the real watchdog test hooks needed to verify queued-wedge recovery; moving it further would obscure lease ownership and Bottleneck cleanup. Covered by the focused rate-limit manager/sliding-window suite (33/33); distributed multi-instance coordination remains explicitly out of scope.",
|
||||
"_rebaseline_2026_07_24_8470_hyperagent_sticky_thread": "PR #8470 (artickc, fix/hyperagent-tool-loop-thread-sticky) own growth: open-sse/executors/hyperagent.ts 936->1025 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 937->1026, +89, crosses the 1000 cap). Fixes a real bug where a reverse-conversion proxy (text-Intent/JSON to Claude Code native tool_calls) rewrites assistant messages between agentic tool-loop turns, breaking HyperAgent’s conversation-prefix fingerprint and cold-starting the thread mid tool-loop. Adds Anthropic tool_use/tool_result flattening to extractMessageText() plus a new rootUserFingerprint()/root-key lookup tier in resolveHyperAgentThreadBinding()/storeHyperAgentThreadAfterTurn() so the thread stays sticky across the tool loop. Cohesive additions inside the existing single-file executor; not extractable without splitting the executor mid-request-flow. Covered by tests/unit/executor-hyperagent.test.ts (19/19, +5 new cases for tool_result/tool_use flattening + root-key stickiness). Pre-merge review flagged a cross-conversation root-key collision risk (tracked in the PR’s own mandatory pre-merge checklist, not yet addressed) — unrelated to this file-size ratchet, tracked separately by /fix-prs.",
|
||||
"_rebaseline_2026_07_25_8494_capability_filter_fail_closed": "PR #8494 (fix/capability-filters-fail-closed, #8488) own growth: open-sse/services/combo.ts 3640->3693 (+53) adds a fail-closed guard after filterTargetsByRequestCompatibility() — when every eligible target is excluded by request-capability filtering (vision/tools/etc) instead of quota/health, the combo now returns an explicit `capability_mismatch` 400 (describeCapabilityFilterExhaustion, imported from combo/comboStructure.ts) rather than silently falling through to a generic no-targets error, plus a `compatFilterFailOpen` escape hatch (combo config OR settings) mirrored at both the main/auto and round-robin call sites for symmetry. combo/comboStructure.ts (previously under cap, un-frozen) grows 794->918 (+124) — new home for describeCapabilityFilterExhaustion + providerSupportsEmulatedToolCalling (#5240 emulated tool-calling exemption so fail-closed does not regress prompt-emulation-only combos like all-chatgpt-web). Irreducible orchestration wiring at the existing filter chokepoint (same precedent as #7301's universal-cooldown-retry generalization). Companion test tests/unit/combo-routing-engine.test.ts 3409->3449 (+40, fail-closed/fail-open coverage across both call sites) also rebaselined. Covered by tests/unit/8488-capability-filter-fail-closed.test.ts (new) + 95/95 passing across both files. Structural shrink of combo.ts tracked in #3501.",
|
||||
"_rebaseline_2026_07_25_8499_ts7_result_union_predicates": "PR #8499 (backryun, chore/ts7-types-executor-scattered) own growth: muse-spark-web.ts 1396->1405 (+9, irreducible). Under this workspace's `strictNullChecks: false`, the boolean-literal discriminant on `GraphqlResult` (`{ ok: true } | { ok: false; error: string }`) narrows the positive `.ok===true` branch but leaves `!result.ok` at the full union under TS7, making `.error` unreachable to the checker at the two call sites (warmup, mode-switch). Fixed by adding a single `isGraphqlFailure()` type-predicate helper (doc comment + 3-line body) reused at both call sites instead of duplicating the predicate inline — not extractable to a shared module without splitting a single-file executor's local narrowing helper out of its own file. Covered by the existing muse-spark-web executor test suite (no behavior change, pure narrowing fix).",
|
||||
@@ -159,9 +163,12 @@
|
||||
"_rebaseline_2026_06_20_4389_thinking_toolchoice": "Re-baseline base.ts 1387->1399 (#4389): tool_choice-forced thinking guard at the existing Claude wire-image injection chokepoint (effThinking gate avoids the Anthropic 400 when tool_choice forces a tool). Cohesive guard; structural shrink tracked in #3501.",
|
||||
"_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": 1000,
|
||||
"testCap": 1000,
|
||||
"testFrozen": {
|
||||
"tests/unit/adobe-firefly.test.ts": 1136,
|
||||
"tests/unit/reasoning-cache.test.ts": 1035,
|
||||
"_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).",
|
||||
@@ -350,7 +357,7 @@
|
||||
"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/chatCore.ts": 5050,
|
||||
"open-sse/handlers/imageGeneration.ts": 3101,
|
||||
"open-sse/handlers/responseSanitizer.ts": 1128,
|
||||
"open-sse/handlers/search.ts": 1536,
|
||||
@@ -359,12 +366,15 @@
|
||||
"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/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": 1060,
|
||||
"open-sse/services/compression/strategySelector.ts": 1061,
|
||||
"open-sse/services/rateLimitManager.ts": 1167,
|
||||
"open-sse/translator/response/openai-responses.ts": 1204,
|
||||
"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,
|
||||
@@ -391,7 +401,7 @@
|
||||
"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": 1094,
|
||||
"src/lib/db/migrationRunner.ts": 1101,
|
||||
"src/lib/db/models.ts": 1097,
|
||||
"src/lib/db/providers.ts": 1034,
|
||||
"src/lib/memory/retrieval.ts": 1073,
|
||||
@@ -401,8 +411,8 @@
|
||||
"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": 2520,
|
||||
"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,
|
||||
@@ -421,9 +431,6 @@
|
||||
"_rebaseline_2026_07_28_8863_firefly_detail_level": "PR #8863 (fix/adobe-firefly-gpt-detail-level-max) own growth: adobeFireflyClient.ts 2317->2322 (+5 = gpt-image detailLevel defaulting to maximal at the existing payload-build site). Covered by tests/unit/adobe-firefly.test.ts.",
|
||||
"_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped <Link> blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts.",
|
||||
"_rebaseline_2026_08_02_v3850_agentrouter_responses": "Release v3.8.50 AgentRouter/Codex compatibility reconciliation. open-sse/executors/base.ts 1562->1578: #9190 wires AgentRouter's selected Claude/OpenAI/Responses protocol through the existing executor URL, auth, identity-header and fingerprint chokepoints; the reusable alternate resolver remains outside base.ts. open-sse/utils/stream.ts 2887->2889: #9213 evaluates Responses ID and usage normalization independently so response.completed always receives finite usage.total_tokens instead of short-circuiting after an ID rewrite. tests/unit/chatcore-translation-paths.test.ts 2769->2776: #9191 updates the existing Claude-Code bridge assertions for the dynamic AgentRouter wire image. PR #9224 offsets its own chatCore growth by extracting the AgentRouter protocol decisions into chatCore/agentRouterProtocol.ts, leaving chatCore below its frozen ceiling. Covered by agentrouter executor/chatCore protocol tests, chatcore translation-path tests, and responses-commentary-passthrough tests.",
|
||||
"_rebaseline_2026_08_08_v3850_base_drift_batch_9757": "Base drift on release/v3.8.50, not own growth: the 08-06..08-08 merge batches grew 12 already-frozen (or newly-landed) files without carrying their rebaselines — the dedicated rebaseline PR #9616 was closed as 'superseded' but its file-size entries never actually reached the base, and later merges (#8894 combos page, #9539 EditConnectionModal, #8895 models route, #9294/#9293 catalog, #9541 db/core, #8970 tokenHealthCheck, #8925 mcp schemas+server, #8890 accountFallback, #9467 chat.ts, #8931 openai-to-kiro, ProxyRegistryManager) kept growing them. All 12 values re-measured on THIS branch's tree (= pure tip + this PR's 1-line chat.ts fix, which adds zero lines). This PR's own source changes (chat.ts identifier restore, stream.ts format carve-out) do not grow any frozen file past these values.",
|
||||
"_rebaseline_2026_08_08_migration_135_collision": "fix(db): resolve migration version 135 numbering collision — #9449's 135_connection_runtime_state.sql and #8908's 135_migrate_model_capability_max_token.sql both claimed version 135 (#9449 branched before #8908 merged and never got renumbered before landing on release/v3.8.50), which threw 'Migration version collision detected' the moment ANY code touched the database — a fresh install/deploy from this tip cannot even boot. Renumbered the later-landing file to 140 (next free slot) and added the matching isSchemaAlreadyApplied('140') retroactive guard, matching the established pattern already used for the prior 135/136 -> 137/138 renumber in the same file. Own growth: src/lib/db/migrationRunner.ts 1084->1094 (+10, the new case block) — irreducible, matches the existing per-case guard pattern exactly. Covered by tests/unit/migration-135-numbering-collision.test.ts (2/2), confirmed failing (reproducing the exact live crash) against the pre-fix colliding filenames, passing after.",
|
||||
"_rebaseline_2026_08_02_9259_rolling_rpm": "PR #9259 (issue #8733) own growth: open-sse/services/rateLimitManager.ts baseline 1060->1167 (+107; final source 1153). The existing withRateLimit chokepoint now composes process-local rolling RPM leases with Bottleneck admission, releases pre-dispatch leases on queue timeout/abort/connection disable, preserves caller abort reasons, and wires 429/header state into the extracted rollingRpmGate.ts. The remaining growth is irreducible lifecycle wiring at the dispatch boundary plus the real watchdog test hooks needed to verify queued-wedge recovery; moving it further would obscure lease ownership and Bottleneck cleanup. Covered by the focused rate-limit manager/sliding-window suite (33/33); distributed multi-instance coordination remains explicitly out of scope.",
|
||||
"_rebaseline_2026_07_25_dario_upstream_proxy_selector": "PR #8523 (Dario embedded service): upstream-proxy mode selector replaces the binary CLIProxyAPI toggle with Native/CLIProxyAPI/Dario/Fallback + a fallback-backend picker. ProviderDetailPageClient.tsx 798->804 (+6, new hook fields threaded through to ConnectionsListPanel), ConnectionRow.tsx 942->958 (+16, the mode <select> + conditional fallback-backend <select> replacing a single pill button), useProviderConnections.ts 954->986 (+32, upstreamProxyMode/upstreamProxyFallbackBackend state + handleSetUpstreamProxyMode, handleToggleCliproxyapiMode kept as a thin backward-compat wrapper for the existing hook-shape test). All additive UI/state for the new modes — no unrelated refactor.",
|
||||
"_rebaseline_2026_08_02_9242_token_health_transient": "PR #9242 (fix/refresh-circuit-transient): src/lib/tokenHealthCheck.ts 1021 (new file, above cap 1000). The file consolidates token-refresh health checking logic that was previously scattered across auth.ts and tokenRefresh.ts. Cohesive single-responsibility module for refresh circuit state management; not extractable without splitting the refresh state machine. Covered by tests/unit/tokenHealthCheck-transient.test.ts.",
|
||||
"_rebaseline_2026_07_28_8870_firefly_ref_cap_timeout": "PR #8870 (fix/adobe-firefly-gpt-ref-cap-timeout) own growth: adobeFireflyClient.ts 2322->2385 (+63 = gpt-image subject-ref hard cap at 2 + adaptive poll timeout budget (base 300s + 60s/ref, max 600s) + defensive .slice on referenceBlobs for gpt/nano/generic families). Fixes live 504s on multi-screenshot listing jobs (Featured Promo / Box Art) where 3–4+ subject refs stall colligo until the old 180s poll budget expires. Helpers adobeFireflyMaxImageRefs/adobeFireflyImageTimeoutMs live next to the existing payload/poll chokepoint (not extractable without splitting the wire recipe mid-PR). Covered by tests/unit/adobe-firefly.test.ts (ref-cap + timeout cases). Structural shrink tracked in #3501.",
|
||||
@@ -435,6 +442,7 @@
|
||||
"_rebaseline_2026_08_06b_v3850_sweepreds_drift": "Segunda reconciliacao de 2026-08-06 (/sweep-reds sobre o tip puro 2ddbbc61a6): 3 arquivos voltaram a passar do frozen apos os merges do mesmo dia, com atribuicao 1:1 por commit. (1) src/app/(dashboard)/dashboard/providers/page.tsx 1928->1944 e (2) open-sse/executors/base.ts 1635->1640, ambos do #9515 (feat(radar): flag-gated signed free-model catalog overlay, commit e7f6b1d130) — o overlay do Radar entra por wiring nos chokepoints ja existentes (a resolucao/verificacao do catalogo assinado mora fora destes dois arquivos); +16 e +5 linhas liquidas nao sao extraiveis sem inventar um leaf por callsite. (3) open-sse/services/accountFallback.ts 1966->1972 do #8704 (commit c4527f97bd), +6 linhas de dados em CREDITS_EXHAUSTED_SIGNALS ('has been exhausted', fixes #8631). src/sse/handlers/chat.ts 1880>1877 tambem estava violando e NAO entra aqui de proposito: e drenado por encolhimento na PR #9598, sem rebaseline. Crescimento proprio DESTA PR: src/lib/db/migrationRunner.ts 1077->1084 (+7) — o guard retroativo em isSchemaAlreadyApplied para os arquivos renumerados 137/138, exigido pela propria mensagem de erro de colisao do runner (ambas as migracoes sao ALTER TABLE ADD COLUMN puro, nao idempotente). Dois `case` + dois `return hasColumn(...)` + 3 linhas de comentario dentro do switch existente; nao extraivel.",
|
||||
"_rebaseline_2026_08_06c_v3850_sweepreds_pr2": "Segunda PR do /sweep-reds (fix/release-v3.8.50-basereds-0806b): tests/unit/provider-models-route.test.ts 1784->1787 (medido pelo gate, que conta split(\"\\n\").length) (+2 apos compressao de comentarios) — alinhamento de contrato forcado por dois merges do dia: #9106 tornou gemini-3.1-pro-high user-callable (a entry do alias entra na lista esperada do teste de discovery-retry, +1 linha de dado + 1 de comentario) e ff012ff420 adicionou onboardUser como bootstrap hop (exclusao no mock, ja comprimida a 1 linha). Nao ha o que encolher sem apagar o comentario que explica o porque.",
|
||||
"_rebaseline_2026_08_07_v3850_sweepreds_pr2_toolnamemap": "tests/unit/translator-openai-to-gemini.test.ts 1616->1619 (+3). O frozen estava EXATAMENTE no tamanho da base, entao qualquer linha nova viola. #9568 (c9a3361e5a) fez buildChangedToolNameMap emitir entradas IDENTIDADE (o Gemini minusculiza nomes de tool nas respostas, entao o tradutor de resposta precisa da chave para mapear de volta), o que passou a incluir `_toolNameMap` no envelope Antigravity de qualquer request com tools. As 3 linhas sao: a chave nova na lista esperada de Object.keys, 1 comentario explicando POR QUE ela aparece (sem ele o proximo leitor tenta remove-la de novo) e 1 assert do CONTEUDO do map — presenca de chave sozinha nao provaria a entrada identidade, que e justamente o comportamento novo. Nao ha o que extrair: e alinhamento de contrato dentro de um teste existente.",
|
||||
"_rebaseline_2026_08_08_9634_migration_139_guard": "PR #9634 (fix/release-v3850-basereds) own growth, re-measured on e0ce95c59 after rebase: src/lib/db/migrationRunner.ts 1094->1096 (+2, the isSchemaAlreadyApplied case-139 retroactive guard for the renumbered ccr migration). Irreducible, matches the per-case guard pattern exactly. Covered by tests/unit/migration-135-numbering-collision.test.ts.",
|
||||
"_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.",
|
||||
"_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\\\"tool\\\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.",
|
||||
"_rebaseline_2026_06_24_headroom_strategy": "Headroom-aware connection selection (dario technique): combo.ts 3168->3180 (+12 = a new `else if (strategy === \\\"headroom\\\")` dispatch branch in handleComboChat that delegates to orderTargetsByHeadroom + its log line, plus the import). The actual logic lives OUT of the god-file: the pure ranker rankByHeadroom/computeHeadroom is the new leaf open-sse/services/combo/headroomRanking.ts (91 LOC, <cap) and the async orderer orderTargetsByHeadroom is appended to the existing open-sse/services/combo/quotaStrategies.ts (<cap) next to its sibling reset-aware/reset-window orderers (reuses their connection-expansion machinery). headroom = 1 - max(util_5h, util_7d) from getSaturation (src/lib/quota/saturationSignals.ts), prefers the connection with the most free capacity. Only the dispatch wiring is irreducible at the existing combo strategy chokepoint (mirrors the reset-aware/reset-window/context-optimized branches); not extractable without hiding the call site. fill-first stays default; all existing strategies untouched. Covered by tests/unit/combo-headroom-ranking.test.ts (pure helper) + tests/unit/combo-headroom-strategy.test.ts (orderer, saturation injected). Structural shrink of combo.ts tracked in #3501.",
|
||||
@@ -541,7 +549,7 @@
|
||||
"src/lib/tokenHealthCheck.ts": "1053",
|
||||
"src/lib/db/apiKeys.ts": "1529",
|
||||
"src/lib/db/core.ts": "1639",
|
||||
"src/lib/db/migrationRunner.ts": "1094",
|
||||
"src/lib/db/migrationRunner.ts": "1096",
|
||||
"src/lib/db/models.ts": "1097",
|
||||
"src/lib/db/providers.ts": "1034",
|
||||
"src/lib/memory/retrieval.ts": "1073",
|
||||
@@ -560,5 +568,9 @@
|
||||
"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_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 — 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.",
|
||||
"_rebaseline_2026_08_03_9255_adobe_firefly_durable_sessions": "PR #9255 own cohesive growth: open-sse/services/adobeFireflyClient.ts 2322->2894 adds authenticated-vs-guest IMS classification, browser-risk ARP validation/rebuild, bounded 408 retry/recovery, sticky accepted-session handling, and matching image/video submit recovery at the existing Adobe upstream client chokepoints. This client was already explicitly frozen as a single self-contained upstream integration by #8006/#8510; splitting only the retry/auth helpers now would scatter one request state machine while structural shrink remains tracked in #3501. tests/unit/adobe-firefly.test.ts 871->1136 adds direct regression coverage for guest-token rejection, cookie/ARP rebuilding, 408 retries, sticky accepted ARP reuse, forced auth recovery, and cookie-to-IMS exchange. The obsolete 1179-line managed-Chrome fallback module was deleted rather than rebaselined after the packaged-safe pure-CDP path became authoritative. Focused Adobe suite: 61/61.",
|
||||
"_rebaseline_2026_08_07_9653_disconnect_grace_period": "Extracted fix(sse): grace period before finalizing a client disconnect as 499 (#9653) — a client that closes its connection right after reading a fully-completed SSE stream can race OmniRoute's own completion bookkeeping, getting persisted as a false 499/0-tokens even though it delivered the full response (live-confirmed: a real disconnect at 18236ms was corrected to 200/82814+1292 tokens). Own growth: open-sse/handlers/chatCore.ts 5030->5039 (+9, wiring createClientDisconnectGraceHandler at the existing onClientDisconnectFinalize call site) — irreducible call-site wiring, the actual grace-period logic lives in the new leaf createClientDisconnectGraceHandler (open-sse/utils/streamFailureFinalization.ts, not frozen). Re-measured to 5042 after rebasing onto a newer release/v3.8.50 tip: the file carries an unrelated +3 base drift from already-merged upstream commits between this PR's original branch point and the rebase target, not covered by this entry. Covered by tests/unit/stream-disconnect-grace-period-9653.test.ts (4/4, fake-timer driven). Other file-size gate violations present on this base tip are pre-existing/unrelated to this change (base-red #9679, re-verify current issue number at merge time)."
|
||||
}
|
||||
|
||||
@@ -101,8 +101,9 @@
|
||||
"_rebaseline_2026_07_28_v3849_release": "75.5 -> 99 (+23.5). Aperto EXIGIDO pelo modo --require-tighten do ratchet: a métrica melhorou de verdade no ciclo v3.8.49. A causa é o workflow assíncrono de tradução, que finalmente alcançou o denominador em EN — as rebaselines anteriores (v3.8.39/.44/.47) foram todas afrouxamentos registrando o atraso das traduções, e agora ele foi pago. O coletor SUBTRAI os placeholders (present - placeholder em scripts/quality/collect-metrics.mjs), então os 317 marcadores __MISSING__ que esta release introduziu para o drift de valor já estão descontados dos 99 — o número é honesto, não inflado por placeholder. Medido pelo collect-metrics do CI no run 30404226939."
|
||||
},
|
||||
"deadExports": {
|
||||
"value": 227,
|
||||
"value": 230,
|
||||
"direction": "down",
|
||||
"_rebaseline_2026_08_09_v3850_post_sweep": "227 -> 230. Measured by npm run check:dead-code on the unmodified release/v3.8.50 tip 382449d593 during the mandatory --full-ci pre-flight. The +3 is inherited cycle drift from the authorized merge sweep; this repair adds no production exports. Rebaseline records the actual tip so ci.yml quality-gate can run, while structural cleanup remains separate debt.",
|
||||
"_rebaseline_2026_07_01_v3843_release": "225->227 (+2). v3.8.43 cycle drift, surfaced in the Quality Ratchet job after eslintWarnings was rebaselined (check:dead-code runs there). 227 = measured by check:dead-code (knip) on the release tip 4635076eb. The 5 CI fixes add 0 dead exports: safeHttpHref in linkify.ts is module-local AND used (called by linkifyText); no new exports; test files are not scanned. Tighten via --update next cycle.",
|
||||
"dedicatedGate": true,
|
||||
"_rebaseline_2026_06_30_v3842_deadcode_wave": "310 -> 225. Measured by `node scripts/check/check-dead-code.mjs` on the v3.8.42 tip after the JxnLexn dead-code (#5463/#5464/#5466) + duplication (#5471..#5500) wave landed: DEAD_EXPORTS=133 + DEAD_FILES=92 = 225. The stale 310 was the v3.8.38 release snapshot never ratcheted on PR->release fast-gates (check:dead-code runs only on ci.yml PR->main, not quality.yml). Tightening to the true measured value; release-time captain rebaselines up if parallel cycle merges add dead exports.",
|
||||
@@ -177,12 +178,13 @@
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"bundleSize": {
|
||||
"value": 7666,
|
||||
"value": 8045,
|
||||
"direction": "down",
|
||||
"dedicatedGate": true,
|
||||
"_rebaseline_2026_07_07_v3846_release_close": "5601->6534 (+933). v3.8.46 release close: gzip of the 4 bin/*.mjs entrypoints (size-limit + @size-limit/file) grew from this cycle's feature/fix merges pulled transitively into the CLI entrypoints (new providers, combo pipeline strategy #6396, effort/thinking standardization #6241, catalog cache-invalidation #6408). Measured 6534 locally via `check:bundle-size --ratchet` (deterministic gzip, matches CI). Legitimate cycle growth; shrink is separate debt.",
|
||||
"_rebaseline_2026_07_19_7808_codeql_alias_resolver_hook": "6534->6762 (+228). PR #7808 (CodeQL js/incomplete-url-substring-sanitization fix): the ESM loader hook source moved out of the inline `HOOK_SOURCE` template literal in bin/aliasResolver.mjs into a real file bin/aliasResolverHook.mjs, loaded via pathToFileURL() instead of a dynamically-built `data:text/javascript,...` URL. The new file is now counted by size-limit as a 5th bin/*.mjs entrypoint. Net +228 = the hook's gzip size (previously hidden inside aliasResolver.mjs because the template literal was compressed away). Security-driven; no shrink opportunity.",
|
||||
"_rebaseline_2026_07_28_v3849_release_preflight": "6762 -> 7666 (+904). Fechamento do ciclo v3.8.49: gzip dos entrypoints bin/*.mjs (size-limit + @size-limit/file) cresceu com o que os merges do ciclo puxam transitivamente para o CLI (novos provedores — 271->290, seletor de protocolo por conexão #8861, catálogos de busca #8814, resiliência). Crescimento legítimo de ciclo, medido localmente com `npm run check:bundle-size` = 7666 (gzip determinístico, bate com o CI). Encolher é dívida separada."
|
||||
"_rebaseline_2026_07_28_v3849_release_preflight": "6762 -> 7666 (+904). Fechamento do ciclo v3.8.49: gzip dos entrypoints bin/*.mjs (size-limit + @size-limit/file) cresceu com o que os merges do ciclo puxam transitivamente para o CLI (novos provedores — 271->290, seletor de protocolo por conexão #8861, catálogos de busca #8814, resiliência). Crescimento legítimo de ciclo, medido localmente com `npm run check:bundle-size` = 7666 (gzip determinístico, bate com o CI). Encolher é dívida separada.",
|
||||
"_rebaseline_2026_08_09_v3850_release_close": "7666 -> 8045 (+379 gzip bytes, +4.9%). Release v3.8.50 close reconciliation measured twice with the real size-limit + @size-limit/file path on tip e0ce95c592. Per-entry measurements remain below their absolute budgets: omniroute.mjs 4380/15000, mcp-server.mjs 1195/5000, nodeRuntimeSupport.mjs 887/8000, reset-password.mjs 1583/6000. The growth accumulated through legitimate CLI/runtime work in this cycle, including global-install ESM alias resolution, Termux cache preparation, and MCP stdio startup hardening; no entrypoint is near its absolute ceiling. The direction:down ratchet stays blocking from this exact measured tip."
|
||||
},
|
||||
"openapiBreaking": {
|
||||
"value": 0,
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
{
|
||||
"_comment": "Catraca de test-discovery (check-test-discovery.mjs). Cada entrada e um arquivo de teste que NENHUM runner coleta (ele nunca roda) — divida congelada na auditoria 6A.1 (2026-06-09; 195 originais, 135 religados no node runner em 6A.1c). So pode DIMINUIR: religue o teste (ajustando o glob do runner ou movendo o arquivo) e remova a entrada via --update. NAO adicione novos orfaos — corrija o runner.",
|
||||
"_remaining_60": "Categorias: 33 .test.tsx de tests/unit (religaveis via vitest.config root, MAS o experimento 2026-06-09 mostrou 24 arquivos vermelhos — triagem de drift de UI na janela 2026-06-16, junto com os 14 fails do proprio test:vitest:ui atual); 9 open-sse __tests__ + 8 src __tests__ (includes de vitest.config que NENHUM script executa sem filtro); 4 golden-set + 1 benchmarks + 1 live + 1 stress (deliberadamente manuais — decidir runner/gating); 3 integration/services (gated RUN_SERVICES_INT=1, sem runner CI).",
|
||||
"_remaining_13": "13 orfaos restantes: 2 testes de API em settings + 1 snapshot de quota do DB; 4 golden-set + 1 benchmark + 1 teste live + 1 stress (deliberadamente manuais — decidir runner/gating); 3 integration/services (gated RUN_SERVICES_INT=1, sem runner CI).",
|
||||
"orphans": [
|
||||
"open-sse/services/__tests__/chatgptTlsClient.test.ts",
|
||||
"open-sse/services/__tests__/claudeTlsClient.test.ts",
|
||||
"open-sse/services/__tests__/grokTlsClient.test.ts",
|
||||
"open-sse/services/__tests__/manifestAdapter.test.ts",
|
||||
"open-sse/services/__tests__/specificityDetector.test.ts",
|
||||
"open-sse/services/__tests__/tierResolver.test.ts",
|
||||
"open-sse/services/__tests__/volumeDetector.test.ts",
|
||||
"open-sse/translator/helpers/__tests__/maxTokensHelper.test.ts",
|
||||
"open-sse/translator/helpers/__tests__/schemaCoercion.test.ts",
|
||||
"src/app/api/settings/__tests__/memory.test.ts",
|
||||
"src/app/api/settings/__tests__/settings.test.ts",
|
||||
"src/lib/db/__tests__/quotaSnapshots.test.ts",
|
||||
"src/lib/memory/__tests__/injection.test.ts",
|
||||
"src/lib/memory/__tests__/qdrant-wiring.test.ts",
|
||||
"src/lib/memory/__tests__/retrieval.test.ts",
|
||||
"src/lib/memory/__tests__/schemas.test.ts",
|
||||
"src/lib/skills/__tests__/integration.test.ts",
|
||||
"tests/benchmarks/pipeline-accuracy.test.ts",
|
||||
"tests/golden-set/compression-caveman-v2.test.ts",
|
||||
"tests/golden-set/compression-quality.test.ts",
|
||||
@@ -28,36 +14,6 @@
|
||||
"tests/integration/services/full-lifecycle.int.test.ts",
|
||||
"tests/integration/services/route-guard-services.int.test.ts",
|
||||
"tests/live/deepseek-web-live.test.ts",
|
||||
"tests/theoldllm-stress.test.ts",
|
||||
"tests/unit/AutoComboCatalog.test.tsx",
|
||||
"tests/unit/SkillsConceptCard.test.tsx",
|
||||
"tests/unit/agent-skills-page.test.tsx",
|
||||
"tests/unit/dashboard/batch/components/BatchDetailModal.test.tsx",
|
||||
"tests/unit/dashboard/batch/components/ExpirationBadge.test.tsx",
|
||||
"tests/unit/dashboard/batch/components/NewBatchWizard.test.tsx",
|
||||
"tests/unit/dashboard/batch/components/ProgressBarBicolor.test.tsx",
|
||||
"tests/unit/dashboard/batch/components/UploadFileModal.test.tsx",
|
||||
"tests/unit/dashboard/batch/components/useBatchActions.test.tsx",
|
||||
"tests/unit/dashboard/batch/concept-cards.test.tsx",
|
||||
"tests/unit/dashboard/batch/list-regression.test.tsx",
|
||||
"tests/unit/dashboard/batch/sanitization.test.tsx",
|
||||
"tests/unit/omni-skills-page.test.tsx",
|
||||
"tests/unit/shared-clipboard.test.tsx",
|
||||
"tests/unit/shared/components/AutoRoutingBanner.test.tsx",
|
||||
"tests/unit/shared/components/KiroAuthModal.test.tsx",
|
||||
"tests/unit/shared/components/ProxyConfigModal.test.tsx",
|
||||
"tests/unit/translator-friendly-advanced-section.test.tsx",
|
||||
"tests/unit/translator-friendly-compression.test.tsx",
|
||||
"tests/unit/translator-friendly-concept-card.test.tsx",
|
||||
"tests/unit/translator-friendly-integration.test.tsx",
|
||||
"tests/unit/translator-friendly-monitor-tab.test.tsx",
|
||||
"tests/unit/translator-friendly-page-client.test.tsx",
|
||||
"tests/unit/translator-friendly-pipeline-view.test.tsx",
|
||||
"tests/unit/translator-friendly-raw-json-panel.test.tsx",
|
||||
"tests/unit/translator-friendly-result-narrated.test.tsx",
|
||||
"tests/unit/translator-friendly-simple-controls.test.tsx",
|
||||
"tests/unit/translator-friendly-stream-transformer.test.tsx",
|
||||
"tests/unit/translator-friendly-test-bench.test.tsx",
|
||||
"tests/unit/translator-friendly-translate-tab.test.tsx"
|
||||
"tests/theoldllm-stress.test.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -186,10 +186,10 @@ Runs on pull requests only.
|
||||
|
||||
Runs after `build`. Blocks merge on failure.
|
||||
|
||||
| Suite | Validates | Blocking |
|
||||
| ---------------- | ------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `test:vitest` | MCP server (94 tools), autoCombo, cache — vitest runner | Yes |
|
||||
| `test:vitest:ui` | UI component tests — vitest runner | **Advisory** (`continue-on-error: true`) — failing until Fase 6A UI triage |
|
||||
| Suite | Validates | Blocking |
|
||||
| ---------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `test:vitest` | MCP server (94 tools), autoCombo, cache — vitest runner | Yes |
|
||||
| `test:vitest:ui` | UI component tests — vitest runner | **Blocking** — pre-existing failures are explicitly excluded in `vitest.config.ts`; new failures fail the job |
|
||||
|
||||
### Nightly workflows (scheduled, advisory)
|
||||
|
||||
@@ -401,7 +401,7 @@ several "obvious" merges turned out to hide debt and are **not** clean drop-ins.
|
||||
|
||||
- `check:openapi-security-tiers` (advisory) — ❌ **NOT cleanly flippable.** It exits 0 but warns that several `traffic-inspector` routes under `LOCAL_ONLY_API_PREFIXES` lack the `x-loopback-only: true` annotation. Enforcing it requires adding those annotations to `openapi.yaml` first.
|
||||
- `typecheck:noimplicit:core` (advisory) — largely subsumed by the blocking `check:type-coverage` ratchet. Flip to a ratchet or drop the redundant second `tsc` pass.
|
||||
- `test:vitest:ui` (advisory, 14 parked fails) — fix-and-block or delete; don't leave rotting.
|
||||
- `test:vitest:ui` (now **blocking**) — pre-existing failures are explicitly excluded in `vitest.config.ts` with `// #8618` tracking comments; new failures fail the job.
|
||||
- `check:secrets` (gitleaks, blocking ratchet frozen at 3 documented false-positives) — allowlist the 3 to reach 0, or demote to advisory. Overlaps GitHub native secret-scanning + `check:public-creds`.
|
||||
- `check:pr-evidence` (blocking, greps PR-body prose) — high false-positive risk; weakens Hard Rule #18 enforcement if dropped, so this is a genuine policy call.
|
||||
- `semgrep` (advisory standalone) — overlaps CodeQL for the OWASP families; wire its baseline to a ratchet or drop.
|
||||
|
||||
@@ -18,7 +18,7 @@ Unlike API-key providers, Web Cookie providers authenticate using the credential
|
||||
|
||||
Many authentication issues are caused by copying cookies from the wrong place.
|
||||
|
||||
## Do NOT copy from Cookie Storage
|
||||
## Do NOT copy from Cookie Storage
|
||||
|
||||
Most browsers expose stored cookies through:
|
||||
|
||||
@@ -36,7 +36,7 @@ Although these cookies look correct, they may be:
|
||||
|
||||
Using these values may cause authentication failures even if they appear valid.
|
||||
|
||||
## Copy from a Live Request
|
||||
## Copy from a Live Request
|
||||
|
||||
Instead, use the cookies from a successful request:
|
||||
|
||||
@@ -80,14 +80,14 @@ The exact credentials required depend on the provider.
|
||||
|
||||
Different websites store authentication differently. Some require only cookies, while others may require additional headers or tokens.
|
||||
|
||||
| Provider | Credential Format | Provider Guide |
|
||||
|----------|-------------------|----------------|
|
||||
| Claude Web | Full Cookie request header | `docs/providers/CLAUDE_WEB.md` |
|
||||
| ChatGPT Web | _(verify)_ | |
|
||||
| Gemini Web | _(verify)_ | |
|
||||
| Copilot Web | _(verify)_ | |
|
||||
| Grok Web | _(verify)_ | |
|
||||
| ... | ... | ... |
|
||||
| Provider | Credential Format | Provider Guide |
|
||||
| ----------- | -------------------------------------------------------------- | ------------------------------- |
|
||||
| Claude Web | Full Cookie request header | `docs/providers/CLAUDE_WEB.md` |
|
||||
| ChatGPT Web | Full Cookie header or `__Secure-next-auth.session-token` value | `docs/providers/CHATGPT_WEB.md` |
|
||||
| Gemini Web | _(verify)_ | |
|
||||
| Copilot Web | _(verify)_ | |
|
||||
| Grok Web | _(verify)_ | |
|
||||
| ... | ... | ... |
|
||||
|
||||
> Update this table as new Web Cookie providers are added or existing providers change their authentication requirements.
|
||||
|
||||
|
||||
@@ -147,11 +147,11 @@ The prod stack runs in parallel with the dev compose (different container names,
|
||||
|
||||
The repository ships a multi-stage Dockerfile (`Dockerfile`). Three stages are exposed; pick the right `target` for your use case.
|
||||
|
||||
| Stage | Base image | Purpose |
|
||||
| ------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `builder` | `node:24.15.0-trixie-slim` | Installs deps (`npm ci --legacy-peer-deps`) and runs `npm run build -- --webpack` |
|
||||
| `runner-base` | `node:24.15.0-trixie-slim` | Production runtime with the Next.js standalone output. **No provider CLIs bundled.** |
|
||||
| `runner-cli` | `runner-base` | Adds `git`, `docker.io`, `docker-compose` and global CLIs: `@openai/codex`, `@anthropic-ai/claude-code`, `droid`, `openclaw`. **Pick this for agentic workflows.** |
|
||||
| Stage | Base image | Purpose |
|
||||
| ------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `builder` | `node:26-trixie-slim` | Installs deps (`npm ci --legacy-peer-deps`) and runs `npm run build` (Turbopack by default — see Build-time resources below) |
|
||||
| `runner-base` | `node:26-trixie-slim` | Production runtime with the Next.js standalone output. **No provider CLIs bundled.** |
|
||||
| `runner-cli` | `runner-base` | Adds `git`, `docker.io`, `docker-compose` and global CLIs: `@openai/codex`, `@anthropic-ai/claude-code`, `droid`, `openclaw`. **Pick this for agentic workflows.** |
|
||||
|
||||
Build a specific target manually:
|
||||
|
||||
@@ -160,14 +160,50 @@ docker build --target runner-base -t omniroute:base .
|
||||
docker build --target runner-cli -t omniroute:cli .
|
||||
```
|
||||
|
||||
Defaults exported by `runner-base`: `PORT=20128`, `HOSTNAME=0.0.0.0`, `NODE_OPTIONS=--max-old-space-size=512`, `DATA_DIR=/app/data`, `OMNIROUTE_MIGRATIONS_DIR=/app/migrations`.
|
||||
### Build-time resources
|
||||
|
||||
Two build args control what the `builder` stage costs. They are build-time only —
|
||||
`OMNIROUTE_MEMORY_MB` (below) is a separate, runtime knob.
|
||||
|
||||
| Build arg | Default | Effect |
|
||||
| --------------------------- | ------- | ---------------------------------------------------------------------- |
|
||||
| `OMNIROUTE_USE_TURBOPACK` | `1` | `0` builds with webpack instead. Lower peak memory, slower. |
|
||||
| `OMNIROUTE_BUILD_MEMORY_MB` | `4096` | V8 heap ceiling (`--max-old-space-size`) for the spawned `next build`. |
|
||||
|
||||
Turbopack compiles in native Rust memory that lives **outside** the V8 heap, so
|
||||
`OMNIROUTE_BUILD_MEMORY_MB` does not bound it. On a host with a memory ceiling the
|
||||
build is then SIGKILLed by the OOM killer with no error text at all — it simply
|
||||
stops mid-`Creating an optimized production build`, which reads like a hang rather
|
||||
than an out-of-memory. If the build host is constrained, switch bundlers:
|
||||
|
||||
```bash
|
||||
docker build --target runner-base \
|
||||
--build-arg OMNIROUTE_USE_TURBOPACK=0 \
|
||||
-t omniroute:base .
|
||||
```
|
||||
|
||||
`webpackBuildWorker` is enabled, so `next build` runs a parent **and** a worker
|
||||
process and each honours `OMNIROUTE_BUILD_MEMORY_MB` separately. Size the container
|
||||
ceiling above roughly twice that value, not once.
|
||||
|
||||
Measured on this tree (`--target runner-base`, `OMNIROUTE_BUILD_MEMORY_MB=6144`):
|
||||
|
||||
| Bundler | Container ceiling | Result |
|
||||
| --------- | ----------------- | ----------------------------- |
|
||||
| Turbopack | 8 GiB / 16 GiB | OOM-killed at both, silently |
|
||||
| webpack | 8 GiB | build worker SIGKILLed |
|
||||
| webpack | 12 GiB | succeeded, peaked at 11.1 GiB |
|
||||
|
||||
### Runtime defaults
|
||||
|
||||
Defaults exported by `runner-base`: `PORT=20128`, `HOSTNAME=0.0.0.0`, `OMNIROUTE_MEMORY_MB=1024`, `NODE_OPTIONS=--max-old-space-size=1024`, `DATA_DIR=/app/data`, `OMNIROUTE_MIGRATIONS_DIR=/app/migrations`.
|
||||
|
||||
Memory behavior in Docker:
|
||||
|
||||
- `NODE_OPTIONS=--max-old-space-size=512` is baked into the image as a fallback.
|
||||
- The image sets `OMNIROUTE_MEMORY_MB=1024` and derives `NODE_OPTIONS=--max-old-space-size=1024` from it.
|
||||
- The actual server process is started by the standalone launcher, which reads `OMNIROUTE_MEMORY_MB` and appends `--max-old-space-size=<OMNIROUTE_MEMORY_MB>`.
|
||||
- Node uses the last repeated `--max-old-space-size` value, so setting `OMNIROUTE_MEMORY_MB` controls the effective Docker heap limit.
|
||||
- If `OMNIROUTE_MEMORY_MB` is unset, the launcher uses `512`.
|
||||
- Because the image always sets it, the launcher's own RAM-calibrated fallback never applies under Docker. Raise it explicitly (`-e OMNIROUTE_MEMORY_MB=2048`) on a host with headroom.
|
||||
|
||||
## Critical Environment Variables
|
||||
|
||||
@@ -180,7 +216,7 @@ Beyond the defaults documented in [ENVIRONMENT.md](../reference/ENVIRONMENT.md),
|
||||
| `REDIS_PORT` | Host-side port for the bundled Redis container | `6379` |
|
||||
| `REDIS_BIND_HOST` | Host interface the bundled Redis port is published on (loopback unless you add AUTH) | `127.0.0.1` |
|
||||
| `AUTO_UPDATE_HOST_REPO_DIR` | Host path mounted into `cli` profile at `/workspace/omniroute` for self-update workflows | `.` (current directory) |
|
||||
| `OMNIROUTE_MEMORY_MB` | Runtime Node heap ceiling for the Docker standalone server; overrides the image fallback above | `512` |
|
||||
| `OMNIROUTE_MEMORY_MB` | Runtime Node heap ceiling for the Docker standalone server; overrides the image default above | `1024` |
|
||||
| `DASHBOARD_PORT` / `API_PORT` | Override exposed ports for dashboard (20128) and API (20129) | `20128` / `20129` |
|
||||
| `OMNIROUTE_BASE_PATH` | URL subpath when the app is published behind a reverse proxy (e.g. `/omniroute`) | _(empty = root)_ |
|
||||
| `NEXT_PUBLIC_BASE_URL` | Public browser origin including the subpath (e.g. `https://host/omniroute`) | unset |
|
||||
|
||||
@@ -523,6 +523,12 @@ exhausts its bounds of `10,000` visited nodes or depth `12`.
|
||||
Each process uses a process-local guard to reserve limited heavyweight capacity before retaining
|
||||
and parsing a large request body. A heavyweight lease remains held for the lifetime of an SSE
|
||||
response.
|
||||
|
||||
When capacity is busy, a heavyweight request first waits up to
|
||||
`OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` (default `5000`, `0` disables the wait) for a slot to free up
|
||||
before answering the retryable `503`. The bounded wait exists so agent-style clients
|
||||
(OpenCode, Claude Code, Cursor) that fan out heavy sub-requests concurrently serialize the burst
|
||||
instead of burning their whole retry budget on immediate rejections and dying mid-task.
|
||||
Current heavyweight lease occupancy is not surfaced in the dashboard.
|
||||
Settings → Resilience → Request Queue → Concurrent Requests does not control this; that setting
|
||||
governs a separate provider request-queue mechanism.
|
||||
@@ -530,13 +536,18 @@ governs a separate provider request-queue mechanism.
|
||||
**Fix:**
|
||||
|
||||
1. Retry first. Clients should honor `Retry-After` and use backoff rather than immediately
|
||||
repeating the request.
|
||||
repeating the request. Note that with the default `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS=5000`
|
||||
a heavy request already waited up to 5 seconds before the `503`, so a client retry loop should
|
||||
back off beyond that instead of hammering.
|
||||
2. If normal deployment traffic repeatedly exhausts the guard, you can cautiously raise
|
||||
`OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` from its default of `1`. Increase it one step at a time,
|
||||
restart OmniRoute after each change, and observe memory headroom under representative load.
|
||||
Every additional heavyweight request can increase concurrent V8 heap use and container or
|
||||
host OOM risk. No value is safe for every deployment; validate the setting against your own
|
||||
traffic and memory limits rather than assuming that `2` is universally safe.
|
||||
3. Prefer widening the wait (`OMNIROUTE_CHAT_ADMISSION_QUEUE_MS`) over raising the in-flight
|
||||
limit when bursts are short: waiting costs latency, while an extra concurrent heavyweight
|
||||
request costs heap residency for the whole request lifetime.
|
||||
|
||||
See the [environment-variable reference](../reference/ENVIRONMENT.md#4-security--authentication)
|
||||
for the authoritative admission settings. Loosening the heavyweight classification thresholds
|
||||
|
||||
@@ -5225,6 +5225,102 @@ paths:
|
||||
"200":
|
||||
description: Sync initialized
|
||||
|
||||
# ─── Background Jobs (local-only administration) ───────────────
|
||||
|
||||
/api/jobs:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: List registered background jobs
|
||||
description: Local-only runtime administration. Returns each registered job and its latest run.
|
||||
x-internal: true
|
||||
responses:
|
||||
"200":
|
||||
description: Registered jobs
|
||||
"500":
|
||||
description: Failed to list jobs
|
||||
|
||||
/api/jobs/{id}/enable:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Enable a background job
|
||||
description: Local-only runtime administration. Enables the job and restarts its timer.
|
||||
x-internal: true
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Job enabled
|
||||
"404":
|
||||
description: Job not found
|
||||
"500":
|
||||
description: Failed to enable job
|
||||
|
||||
/api/jobs/{id}/disable:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Disable a background job
|
||||
description: Local-only runtime administration. Disables the job and stops its timer.
|
||||
x-internal: true
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Job disabled
|
||||
"404":
|
||||
description: Job not found
|
||||
"500":
|
||||
description: Failed to disable job
|
||||
|
||||
/api/jobs/{id}/run-now:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Trigger a background job
|
||||
description: >-
|
||||
Local-only runtime administration. Starts the job, or waits for an in-flight
|
||||
run before queueing the next one, subject to OMNIROUTE_RUNNOW_TIMEOUT_MS.
|
||||
x-internal: true
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Job trigger accepted
|
||||
"404":
|
||||
description: Job not found
|
||||
"500":
|
||||
description: Failed to trigger job
|
||||
|
||||
/api/jobs/{id}/runs:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Read background-job run history
|
||||
description: Local-only runtime administration. Returns newest-first run history for one job.
|
||||
x-internal: true
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Job run history
|
||||
"404":
|
||||
description: Job not found
|
||||
"500":
|
||||
description: Failed to load job runs
|
||||
|
||||
# ─── Resilience & Monitoring ────────────────────────────────────
|
||||
|
||||
/api/resilience:
|
||||
@@ -5247,6 +5343,70 @@ paths:
|
||||
"200":
|
||||
description: Updated resilience configuration
|
||||
|
||||
/api/resilience/connections:
|
||||
get:
|
||||
tags: [System]
|
||||
summary: Inspect connection resilience state
|
||||
description: >-
|
||||
Local-only operational view of per-connection cooldowns, provider circuit
|
||||
breakers, model lockouts, and recent breaker transitions. Credential columns
|
||||
are excluded by an explicit database whitelist.
|
||||
x-internal: true
|
||||
parameters:
|
||||
- name: windowMs
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 86400000
|
||||
default: 3600000
|
||||
- name: provider
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
responses:
|
||||
"200":
|
||||
description: Connection, breaker, lockout, window, and degradation metadata
|
||||
"400":
|
||||
description: Invalid query parameters
|
||||
"500":
|
||||
description: Failed to collect resilience state
|
||||
|
||||
/api/telegram/update:
|
||||
post:
|
||||
tags: [System]
|
||||
summary: Receive Telegram updates or Mini App messages
|
||||
description: >-
|
||||
Public Telegram integration endpoint. Bot updates are acknowledged after
|
||||
reply dispatch is queued. Mini App requests must include Telegram-signed
|
||||
initData, which is verified with TELEGRAM_BOT_TOKEN before chat proxying.
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
properties:
|
||||
initData:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
update_id:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: Update acknowledged or Mini App reply returned
|
||||
"400":
|
||||
description: Invalid JSON, request shape, or missing Mini App message
|
||||
"401":
|
||||
description: Invalid Mini App initData signature
|
||||
"503":
|
||||
description: Telegram integration is not configured
|
||||
|
||||
/api/resilience/reset:
|
||||
post:
|
||||
tags: [System]
|
||||
|
||||
143
docs/proposals/TELEGRAM-MINIAPP.md
Normal file
143
docs/proposals/TELEGRAM-MINIAPP.md
Normal file
@@ -0,0 +1,143 @@
|
||||
---
|
||||
title: "Feasibility — Telegram Mini App Integration"
|
||||
version: 3.8.49
|
||||
lastUpdated: 2026-08-08
|
||||
---
|
||||
|
||||
# Telegram Mini App Integration — Feasibility Analysis
|
||||
|
||||
**Status: FEASIBLE with moderate effort (estimated 2–4 dev-days for a working slice)**
|
||||
|
||||
## 1. What "Telegram Mini App" means here
|
||||
|
||||
A Telegram Mini App is an iframe-hosted web app opened inside Telegram (via
|
||||
inline buttons / bot menu buttons) that talks to a bot backend through the
|
||||
[Telegram WebApp SDK](https://core.telegram.org/bots/webapps). For OmniRoute
|
||||
the natural shape is:
|
||||
|
||||
- **Bot backend** (new): receives Telegram updates (webhook), validates the
|
||||
Mini App's `initData` signature, and proxies chat requests to OmniRoute's
|
||||
existing OpenAI-compatible `/v1/chat/completions` surface.
|
||||
- **Mini App frontend** (new): a small chat UI served by OmniRoute (Next.js
|
||||
route or `public/` static bundle), using the Telegram WebApp JS SDK.
|
||||
|
||||
## 2. Current state of the codebase (verified against `main` @ 918fba5e3)
|
||||
|
||||
### Already present — outbound notifications only
|
||||
|
||||
| Piece | Location | What it does |
|
||||
| ---------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| Telegram webhook integration | `src/lib/webhooks/integrations/telegram.ts` | Builds `sendMessage` payloads for **outbound** gateway events (model, provider, latency, error) |
|
||||
| Webhook dispatcher | `src/lib/webhookDispatcher.ts` | Routes by kind; decrypts `botToken` from DB metadata for telegram |
|
||||
| Webhook kinds | `src/lib/db/webhooks.ts` | `slack \| telegram \| discord \| custom` |
|
||||
| Webhook CRUD + test | `src/app/api/webhooks/*` | Create/update/test; telegram kind skips `url` (uses bot token + chat_id) |
|
||||
| Bot token validation | `telegram.ts:18` | `BOT_TOKEN_RE = /^\d+:[A-Za-z0-9_-]{35,}$/` |
|
||||
| Encryption requirement | `webhooks/route.ts:77` | Telegram webhooks require DB encryption enabled (bot tokens stored at rest) |
|
||||
|
||||
### Missing — what a Mini App needs that does not exist yet
|
||||
|
||||
| Gap | Detail |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Inbound Bot API listener** | No `setWebhook` registration, no `/bot<token>/getUpdates` polling, no update handling anywhere. Only the `sendMessage` direction exists. |
|
||||
| **WebApp `initData` validation** | No HMAC-SHA256 check of `initData` against the bot token (`WebAppData` hash validation from the Bot API docs). |
|
||||
| **Telegram bot library** | `package.json` has no `telegraf`/`grammy`/`telegram-bot-api` dependency. Would need to add one or hand-roll the (small) HMAC + fetch logic. |
|
||||
| **Mini App hosting surface** | `public/` exists (static assets) and Next.js routes exist; no `/miniapp` route or static bundle yet. |
|
||||
| **Session → API key mapping** | Mini App users need to authenticate to `/v1/chat/completions`. Two options: per-user generated OmniRoute API keys (via `src/lib/db/apiKeys`) or a bot-side proxy that injects a shared key. |
|
||||
|
||||
## 3. Constraints
|
||||
|
||||
### 3.1 Architectural
|
||||
|
||||
- **No existing inbound-bot layer.** The webhook system is strictly
|
||||
event→outbound. A Mini App needs a _new_ Bot API webhook endpoint
|
||||
(`POST /api/telegram/webhook/<botToken-prefix>` or a dedicated route) plus
|
||||
update dispatch. This is additive — no conflicts with the existing
|
||||
`webhooks/` subsystem, but the two must not share the `botToken` storage
|
||||
semantics blindly (webhooks store bot tokens for _outbound_; the Mini App
|
||||
needs the same token for _inbound_ signature checks — same token, new use).
|
||||
- **Public HTTPS required.** Telegram only delivers updates to an HTTPS
|
||||
endpoint with a valid cert. Self-hosted OmniRoute behind Tailscale/ngrok
|
||||
needs a public tunnel or Cloudflare Tunnel for the webhook path
|
||||
(a future webhook-URL setting). The dashboard can render the current
|
||||
public origin (`OMNIROUTE_PUBLIC_BASE_URL`) but no webhook registration
|
||||
helper exists.
|
||||
- **Encryption gate.** `webhooks/route.ts:77` already refuses telegram
|
||||
kinds without DB encryption. The Mini App bot token has the same
|
||||
sensitivity (it _is_ the HMAC secret for initData validation) — same gate
|
||||
applies, which is a _good_ constraint (no plaintext tokens).
|
||||
|
||||
### 3.2 Telegram platform
|
||||
|
||||
- **initData is the only trust anchor.** Mini App auth = verify
|
||||
`hash` field of `initData` using HMAC-SHA256(key = SHA256(bot_token),
|
||||
data = sorted `key=value` pairs minus `hash`). Must be implemented
|
||||
server-side; never trust the client.
|
||||
- **No inbound push to arbitrary users.** Telegram bots cannot initiate
|
||||
conversations. The Mini App works for users who _already_ have the bot —
|
||||
or you add a `/start` command handler + deep-link (`t.me/bot?startapp=`).
|
||||
- **Rate limits.** Bot API ~30 msg/s per bot, 20 msg/min per chat group.
|
||||
Chat responses via `sendMessage`/`answerWebAppQuery` are fine at gateway
|
||||
scale, but streaming must be emulated (send progressive edits or chunked
|
||||
messages) — no native SSE into Telegram.
|
||||
- **WebApp SDK quirks.** `Telegram.WebApp.ready()` must be called; theme
|
||||
params come from the SDK; the mini app is sandboxed iframe (no
|
||||
`window.open` to external, clipboard limited). For a chat UI this is fine.
|
||||
|
||||
### 3.3 Security / policy
|
||||
|
||||
- **Per-user key issuance is the clean model.** Rather than exposing the
|
||||
admin's own API keys, mint a scoped OmniRoute API key per Telegram user
|
||||
(`apiKeys` table + `isModelAllowedForKey` policy), or proxy with a single
|
||||
gateway key and map `user_id` → account. Recommendation: per-user keys so
|
||||
existing rate-limit / model-allowlist / policy code applies unchanged.
|
||||
- **initData expiry.** `auth_date` in initData must be checked (Telegram
|
||||
recommends < 24h; short TTLs for chat flows).
|
||||
- **Secret handling.** Bot token must stay in the encrypted DB / env —
|
||||
mirror the existing `isEncryptionEnabled()` gate.
|
||||
|
||||
## 4. Required next steps (implementation plan)
|
||||
|
||||
### Phase 0 — Spike (½–1 dev-day)
|
||||
|
||||
1. Add `grammy` or `telegraf` (or ~60 lines of hand-rolled HMAC + fetch).
|
||||
2. Implement `src/lib/telegram/initData.ts` — `verifyInitData(initData, botToken)`.
|
||||
3. Stand up a throwaway `POST /api/telegram/miniapp/webhook` route behind
|
||||
a dedicated webhook secret; register via `setWebhook` once, locally.
|
||||
|
||||
### Phase 1 — Minimal chat slice (1–2 dev-days)
|
||||
|
||||
1. **Webhook endpoint** `POST /api/telegram/bot/update` (or
|
||||
`/api/telegram/miniapp/update`): parse Update, verify initData, dispatch.
|
||||
2. **Command handler**: `/start` → reply with deep link
|
||||
`https://t.me/<bot>?startapp=<userKey>`; `startapp` param carries a
|
||||
one-time token that maps to a generated OmniRoute API key.
|
||||
3. **Chat proxy**: map `initData.user.id` → API key → call
|
||||
`handleChat` (same path as `/v1/chat/completions`) → reply via
|
||||
`sendMessage` (non-stream) or chunked edits (fake streaming).
|
||||
4. **Mini App page**: `src/app/(dashboard)/miniapp/page.tsx` (or static
|
||||
bundle in `public/miniapp/`) — Telegram WebApp SDK init + minimal chat
|
||||
UI posting to the bot webhook.
|
||||
5. **Config**: `TELEGRAM_BOT_TOKEN` env (or reuse webhook metadata),
|
||||
`OMNIROUTE_PUBLIC_BASE_URL` for webhook URL display; doc in
|
||||
`.env.example` + `ENVIRONMENT.md` (env-doc-sync check).
|
||||
|
||||
### Phase 2 — Production hardening (1 dev-day)
|
||||
|
||||
- Streaming emulation (message edits), error/backpressure mapping to Bot API
|
||||
limits, per-user key revocation (`/logout` command → revoke API key),
|
||||
usage/rate-limit surfacing (reuse `enforceApiKeyPolicy`), webhook
|
||||
registration helper in dashboard settings, i18n for the mini app UI.
|
||||
|
||||
## 5. Verdict
|
||||
|
||||
**Feasible.** The gateway already exposes the exact API a Mini App chat
|
||||
needs (`/v1/chat/completions` with per-key policy), and the outbound
|
||||
Telegram webhook shows the team already handles bot tokens safely
|
||||
(encryption gate + token format validation). The genuinely new surface is
|
||||
small: an inbound update webhook + initData HMAC verification + a thin
|
||||
chat proxy + a static Mini App page. No changes to the core SSE/relay
|
||||
pipeline are required.
|
||||
|
||||
**Primary risks:** (1) public HTTPS requirement for the webhook (tunnel
|
||||
needed on self-hosted installs), (2) no native streaming to Telegram
|
||||
(UX tradeoff), (3) initData trust must be strictly server-side.
|
||||
125
docs/providers/CHATGPT_WEB.md
Normal file
125
docs/providers/CHATGPT_WEB.md
Normal file
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: "Providers — ChatGPT Web (session credentials via Cookie Editor)"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-08
|
||||
---
|
||||
|
||||
# Providers — ChatGPT Web (Plus/Pro session credentials)
|
||||
|
||||
`chatgpt-web` (alias `cgpt-web`, display name **ChatGPT Web (Plus/Pro)**) sends OpenAI-format chat requests through an authenticated `chatgpt.com` browser session. It authenticates with the `__Secure-next-auth.session-token` cookie — **no API key required**.
|
||||
|
||||
> **New to Web Cookie providers?**
|
||||
>
|
||||
> Read **`docs/getting-started/WEB-COOKIE-GUIDE.md`** for the general setup process, limitations, and troubleshooting before following this provider-specific guide.
|
||||
|
||||
---
|
||||
|
||||
## 1. What credential does OmniRoute need?
|
||||
|
||||
Defined in `src/shared/constants/providers/web-cookie.ts` + `src/shared/providers/webSessionCredentials.ts`:
|
||||
|
||||
| Field | Value |
|
||||
| -------------------------- | ----------------------------------------------------------------------------- |
|
||||
| Provider id | `chatgpt-web` |
|
||||
| Credential name | `__Secure-next-auth.session-token` |
|
||||
| Accepts full Cookie header | ✅ yes |
|
||||
| Accepted storage keys | `cookie`, `sessionToken`, `session-token`, `__Secure-next-auth.session-token` |
|
||||
|
||||
Two paste formats both work:
|
||||
|
||||
- **Bare value** — just the token contents: `eyJhbGciOi...`
|
||||
- **Full Cookie header** — `__Secure-next-auth.session-token=eyJhbGciOi...; cf_clearance=...` (preferred — carries rotation/anti-bot cookies the executor needs)
|
||||
|
||||
---
|
||||
|
||||
## 2. Copy the cookie header with Cookie Editor
|
||||
|
||||
Cookie Editor can copy the cookies for the active `chatgpt.com` tab as an HTTP header string.
|
||||
Always compare the exported value with a live authenticated request as described in section 3.
|
||||
|
||||
### 2.1 Install and pin
|
||||
|
||||
1. Install **[Cookie-Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm)** (Moustachauve) in Chrome/Edge, or the Firefox equivalent.
|
||||
2. Pin it to the toolbar if you use it regularly.
|
||||
|
||||
### 2.2 Copy the credential
|
||||
|
||||
1. Go to **https://chatgpt.com** and make sure you're **signed in with the Plus/Pro account** you want OmniRoute to use.
|
||||
2. Open a conversation and send at least one message (forces the session token to be live/refreshed).
|
||||
3. Click the **Cookie Editor** icon to open its side panel for the active tab.
|
||||
4. Find `__Secure-next-auth.session-token`. If it's split into chunks (`__Secure-next-auth.session-token.0`, `.1`, …), select **all** of them — OmniRoute's `nextAuthCookie.ts` merges rotated chunk families.
|
||||
5. Click **Copy**, choose **Header string**, and copy the resulting `name=value; name=value` text.
|
||||
|
||||
> **If the token is missing:** confirm that you are signed in, send a message to refresh the session, and inspect the live request in section 3.
|
||||
|
||||
---
|
||||
|
||||
## 3. Verify the required data (before pasting)
|
||||
|
||||
The repo's `WEB-COOKIE-GUIDE.md` mandates a live-request check. Do it once per session:
|
||||
|
||||
1. With chatgpt.com open, press **F12** → **Network** tab.
|
||||
2. Refresh the page, then send a chat message.
|
||||
3. Click the conversation request (e.g. `/backend-api/conversation` or the SSE stream) → **Headers** → **Request Headers** → **Cookie**.
|
||||
4. Confirm it contains `__Secure-next-auth.session-token=...` — **not** just `cf_clearance` or `__cf_bm`.
|
||||
|
||||
The value you copied in step 2.3 must match what the live request sends. If they differ, re-copy from Cookie Editor.
|
||||
|
||||
---
|
||||
|
||||
## 4. Add / update the credential in OmniRoute
|
||||
|
||||
### Dashboard (typical user path)
|
||||
|
||||
1. Open the OmniRoute dashboard → **Providers** → **Add Provider**.
|
||||
2. Search **ChatGPT Web (Plus/Pro)** (id `chatgpt-web`).
|
||||
3. Paste the copied cookie header into the credential field.
|
||||
4. Click **Test Connection**.
|
||||
5. Save.
|
||||
|
||||
If requests later return 401 or 403, re-copy the header from a fresh live session. The executor merges `Set-Cookie` rotations while the connection is active, but it cannot recover a credential that is no longer accepted upstream.
|
||||
|
||||
### Bulk / session pools (many accounts)
|
||||
|
||||
For multiple ChatGPT sessions, use the bulk web-session import or session-pool endpoints:
|
||||
|
||||
- `POST /api/providers/bulk-web-session` — import many cookie credentials at once
|
||||
- `GET /api/session-pools` + `/api/session-pools/[provider]` — pool rotation across accounts
|
||||
|
||||
Each credential blob must carry the `__Secure-next-auth.session-token` value under one of the accepted storage keys (`cookie`, `sessionToken`, `session-token`, or the cookie's exact name).
|
||||
|
||||
### Renewing when the session expires
|
||||
|
||||
Web sessions can stop working after sign-out or server-side rotation. Re-run steps 2.2 through 4 whenever requests start failing with 401/403.
|
||||
|
||||
---
|
||||
|
||||
## 5. Contributing updates
|
||||
|
||||
If you changed the credential contract (new storage key, new cookie name, changed hint) or are filling the docs gap, contribute it:
|
||||
|
||||
1. Update `src/shared/providers/webSessionCredentials.ts` (credential name / placeholder / storage keys) or `src/shared/constants/providers/web-cookie.ts` (`authHint`).
|
||||
2. Update this guide (`docs/providers/CHATGPT_WEB.md`) and the provider table in `docs/getting-started/WEB-COOKIE-GUIDE.md`.
|
||||
3. Update `.env.example` + `docs/reference/ENVIRONMENT.md` if you touched env vars, then run:
|
||||
```bash
|
||||
node scripts/check/check-env-doc-sync.mjs # must pass
|
||||
```
|
||||
4. Run the provider/unit tests:
|
||||
```bash
|
||||
npm run test:unit
|
||||
# targeted: tests/unit/chatgpt-web.test.ts (stealth path)
|
||||
```
|
||||
5. Follow `CONTRIBUTING.md`, branch from the current active release tip, use a Conventional Commit message, and open the PR against that active release branch.
|
||||
|
||||
> ⚠️ **Never commit a real cookie value.** All examples above are placeholders. If a test fixture needs a token, use a fake `eyJhbGciOi...` string.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
| -------------------------------- | -------------------------------------------- | --------------------------------------------------------- |
|
||||
| Cookie not in Cookie Editor | Signed out / not HttpOnly-visible | Sign in; enable HttpOnly display in options |
|
||||
| Token missing from live request | Request is not authenticated | Sign in and send a chat message first |
|
||||
| 401 after Test Connection passed | Expired or rotated session | Re-copy from a fresh live request |
|
||||
| Chunked token fails | Only one chunk pasted | Select all `__Secure-next-auth.session-token.*` chunks |
|
||||
@@ -736,12 +736,12 @@ The logging system writes to both stdout and rotated log files. All configuratio
|
||||
| `CALL_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `call_logs` SQLite table before pruning. |
|
||||
| `ENABLE_REQUEST_LOGS` | _(unset)_ | Force detailed request logging on or off, overriding the dashboard setting. |
|
||||
| `MAX_PENDING_REQUEST_AGE_MS` | `3600000` (1 hour) | Max age for orphaned active request log entries before in-memory cleanup. |
|
||||
| `CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS` | `true` | Store stream chunks in pipeline artifacts when `call_log_pipeline_enabled=true`. |
|
||||
| `CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS` | `false` | Store stream chunks in pipeline artifacts when `call_log_pipeline_enabled=true`. Opt-in (`true`) — off by default to save disk. |
|
||||
| `CALL_LOG_PIPELINE_MAX_SIZE_KB` | `512` | Max pipeline call log artifact size in KB when `call_log_pipeline_enabled=true`. |
|
||||
| `PROXY_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `proxy_logs` SQLite table before pruning. |
|
||||
| `APP_LOG_ROTATION_CHECK_INTERVAL_MS` | `60000` (1 min) | How often `src/lib/logRotation.ts` re-checks the active log file size. |
|
||||
| `CHAT_LOG_TEXT_LIMIT` | `65536` | Max string length retained in chat log artifacts (default 64 KB). |
|
||||
| `CHAT_LOG_ARRAY_TAIL_ITEMS` | `24` | Number of array items retained from the tail when truncating chat log payloads. |
|
||||
| `CHAT_LOG_ARRAY_TAIL_ITEMS` | `128` | Number of array items retained from the tail when truncating chat log payloads. |
|
||||
| `CHAT_LOG_MAX_DEPTH` | `6` | Max nesting depth before chat log payloads are truncated. |
|
||||
| `CHAT_LOG_MAX_OBJECT_KEYS` | `80` | Max object keys retained in chat log payloads (0 = unlimited). |
|
||||
| `CHAT_DEBUG_FILE` | `false` | When true, `serializeArtifactForStorage` skips size-based truncation. Debug only. |
|
||||
@@ -976,7 +976,7 @@ changing them requires a code edit, not an env var:
|
||||
| `CURSOR_AGENT_CLI_VERSION` | _(detect / pin)_ | `open-sse/utils/cursorAgentCliVersion.ts` | Agent CLI build id (`YYYY.MM.DD-<hash>`) for `x-cursor-client-version: cli-…` on Agent Run. |
|
||||
| `CURSOR_DATA_DIR` | _(probed)_ | `open-sse/utils/cursorAgentCliVersion.ts` | Override Cursor Agent CLI data dir (`…/versions/<id>`); same var the official agent uses. |
|
||||
| `CURSOR_TOKEN` | _(unset)_ | `scripts/ad-hoc/cursor-tap.cjs` | Direct Cursor bearer token used by developer tooling. |
|
||||
| `OMNIROUTE_LOG_REQUEST_SHAPE` | enabled (`!== "0"`) | `src/app/api/v1/chat/completions/route.ts` | Log content-type/length markers for large chat payloads. Set `"0"` to silence. |
|
||||
| `OMNIROUTE_LOG_REQUEST_SHAPE` | disabled (opt-in via `"1"`) | `src/app/api/v1/chat/completions/route.ts` | Log content-type/length markers for large chat payloads when `"1"` is set. Off by default to reduce log noise. |
|
||||
| `DEBUG_RESPONSES_SSE_TO_JSON` | _(unset)_ | `open-sse/handlers/responseTranslator.ts` | Set `true` to log Responses API SSE→JSON translation details. |
|
||||
| `NEXT_PUBLIC_OMNIROUTE_E2E_MODE` | _(unset)_ | E2E test harness | Set `true` to enable E2E test mode (relaxed auth, test hooks). |
|
||||
|
||||
@@ -1389,3 +1389,31 @@ Used by `src/lib/vncSession/manifest.ts` to configure Docker-based headless Chro
|
||||
| `REDIS_BIND_HOST` | `127.0.0.1` | Bind address for the embedded Redis service. |
|
||||
| `REDIS_PORT` | `6379` | Port for the embedded Redis service. |
|
||||
| `OMNIROUTE_REDIS_BIND_HOST` | – | OmniRoute-scoped override for the embedded Redis bind address. |
|
||||
|
||||
---
|
||||
|
||||
## 24. Release v3.8.50 additions
|
||||
|
||||
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_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. |
|
||||
| `ADOBE_FIREFLY_SESSION_DISK` | enabled | `open-sse/services/adobeFireflySession.ts` | Persists repaired Adobe sessions under `DATA_DIR`; set `0` for memory-only state. |
|
||||
| `ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS` | `12000` | `open-sse/services/adobeFireflySession.ts` | Minimum spacing between Adobe Firefly generate submissions. |
|
||||
| `ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS` | `15000` | `open-sse/services/adobeFireflySession.ts` | Extra quiet period after every third successful Adobe submission. |
|
||||
| `ADOBE_FIREFLY_CHROME_CDP_PORT` | `9334` | `open-sse/services/adobeFireflyChromeRuntime.ts` | CDP port for the account-scoped Chrome runtime. |
|
||||
| `ADOBE_FIREFLY_CHROME_VISIBLE` | `0` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Set `1` to keep the Adobe renewal browser visible; the default parks a headed window off-screen. |
|
||||
| `ADOBE_FIREFLY_CHROME_HEADLESS` | `0` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Debug-only true-headless mode; Adobe colligo normally rejects the resulting risk session. |
|
||||
| `ADOBE_FIREFLY_CHROME_FORCE_RESTART` | `0` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Set `1` to restart the account-scoped Chrome runtime before renewal. |
|
||||
| `ADOBE_FIREFLY_CHROME_PING` | automatic | `open-sse/services/adobeFireflyChromeRuntime.ts` | `1` forces, and `0` disables, the in-page generate probe used to prove the renewed ARP session. |
|
||||
| `ADOBE_FIREFLY_LOGIN_WAIT_MS` | context-dependent | `open-sse/services/adobeFireflyChromeRuntime.ts` | Interactive-login wait budget: `0` on background renewal and `300000` on the explicit login flow unless overridden. |
|
||||
| `ADOBE_FIREFLY_FORTER_WAIT_MS` | `45000` | `open-sse/services/adobeFireflyChromeRuntime.ts` | Maximum wait for a fresh Forter token during session renewal. |
|
||||
| `CHROME_PATH` | auto-detect | `open-sse/services/adobeFireflyChromeRuntime.ts` | Optional absolute Chrome executable used when platform auto-detection is insufficient. |
|
||||
| `TELEGRAM_BOT_TOKEN` | _(unset)_ | `src/lib/telegram/config.ts` | BotFather token that enables the inbound webhook and signs Mini App `initData`. |
|
||||
| `TELEGRAM_DEFAULT_MODEL` | `auto/chat` | `src/lib/telegram/chatProxy.ts` | Model used for Telegram chat replies. |
|
||||
| `TELEGRAM_BOT_API_BASE` | `https://api.telegram.org` | `src/lib/telegram/config.ts` | Bot API base URL override for proxies or self-hosted Bot API servers. |
|
||||
| `TELEGRAM_WEBHOOK_TIMEOUT_MS` | `60000` | `src/lib/telegram/config.ts` | Timeout in milliseconds for outbound Bot API calls. |
|
||||
|
||||
113
docs/video-preset-generation.md
Normal file
113
docs/video-preset-generation.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Video Generation Through Preset Jobs
|
||||
|
||||
Custom provider nodes whose `/videos` surface is an **async submit → poll → fetch-result API** (instead of a synchronous generation endpoint) can be wired into the `/api/v1/videos/generations` route without any new provider code. The model row carries a `generationConfig.preset`, and the dispatcher routes the request through a single job executor that is configured entirely by declarative preset data.
|
||||
|
||||
## How dispatch works
|
||||
|
||||
1. The route parses `model` as `provider/model` and resolves the provider node's credentials (`POST /api/v1/videos/generations`).
|
||||
2. `handleVideoGeneration` (in `open-sse/handlers/videoGeneration.ts`) checks whether the provider is a **custom provider node** (no entry in the static video registry).
|
||||
3. For custom nodes it reads the custom model row via `getCustomModelVideoPreset(provider, model)`:
|
||||
- The model row has `generationConfig.preset` set (e.g. `"agnes-video-job"`) → dispatch through the **job executor** (`open-sse/handlers/videoGeneration/job.ts`).
|
||||
- The preset name does not match any known preset → **502** `Unknown video job preset: <preset>` (server-side misconfiguration).
|
||||
- No preset configured → fall back to the generic OpenAI-compatible sync handler, mirroring the images route.
|
||||
4. The job executor runs the preset pipeline: **submit** the job, **poll** for terminal status, **read** the finished video URL, and return the standard OpenAI-compatible response shape.
|
||||
|
||||
The executor is one handler family; every provider-specific detail (paths, auth, body shape, status/result fields, poll cadence) is data in the preset definition.
|
||||
|
||||
## Response contract
|
||||
|
||||
Both the sync and job paths return the same shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"created": 1234567890,
|
||||
"data": [{ "url": "https://…", "format": "mp4" }]
|
||||
}
|
||||
```
|
||||
|
||||
This is the shape the media-generation consumer reads (`data.data[0].url`), so preset-job providers are drop-in replacements for sync providers.
|
||||
|
||||
## Presets
|
||||
|
||||
Presets live in `open-sse/handlers/videoGeneration/job.ts` (`VIDEO_JOB_PRESETS`). Each preset declares:
|
||||
|
||||
| Field | Meaning |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `authHeaderName` / `authScheme` | `x-api-key` with `raw` value (Agnes, muapi) or `Authorization` with `Bearer` prefix (Sora). Missing credentials → request goes out without an auth header. |
|
||||
| `baseUrlFallback` | Default base URL. Overridden by the provider connection's `providerSpecificData.baseUrl` (or top-level `baseUrl`), which wins when set. |
|
||||
| `submit.path` / `submit.buildBody` | Where and how the job is submitted. `{model}` in the path is substituted with the encoded model id; the body is built from `model`/`prompt`/`duration` plus pass-through of every other request field. |
|
||||
| `taskIdPath` | Dot path into the submit response identifying the job (e.g. `task_id`, `request_id`, `id`). Missing job id → **502**. |
|
||||
| `poll.pathTemplate` | Poll URL template; `{taskId}` is substituted. |
|
||||
| `statusPath` / `statusDone` / `statusFailed` | Where the job status lives and which values are terminal. |
|
||||
| `resultPath` | Dot path into the poll response holding the finished video URL: a string, a string array, or an array of `{ url }` objects are all accepted. Completed job with no readable URL → **502**. |
|
||||
| `maxPolls` / `pollIntervalMs` | Poll budget (default 60 polls × 2000 ms). Exhausted → **504** `Video job timed out`. |
|
||||
|
||||
### `agnes-video-job` — Agnes Video V2.0
|
||||
|
||||
- Auth: `x-api-key: <key>` (raw).
|
||||
- Base URL fallback: `https://apihub.agnes-ai.com`.
|
||||
- Submit: `POST /v1/videos` with `{ model, prompt, ...extras }` — image, mode, `num_frames`, `frame_rate` and other provider knobs pass through untouched.
|
||||
- Job id: `task_id` from the submit response.
|
||||
- Poll: `GET /v1/videos/{taskId}`; status at `status` (`completed` / `failed`).
|
||||
- Result: `metadata.url` — the completed video URL is returned as JSON metadata, not a binary body.
|
||||
|
||||
### `muapi-video-job` — muapi.ai
|
||||
|
||||
- Auth: `x-api-key: <key>` (raw).
|
||||
- Base URL fallback: `https://api.muapi.ai`.
|
||||
- Submit: `POST /api/v1/{model}` with `{ prompt, duration?, ...extras }`.
|
||||
- Job id: `request_id` from the submit response.
|
||||
- Poll: `GET /api/v1/predictions/{taskId}/result`; status at `status` (`completed` / `failed`).
|
||||
- Result: `outputs` — an array of video URLs.
|
||||
|
||||
### `sora-job` — OpenAI Sora
|
||||
|
||||
- Auth: `Authorization: Bearer <key>`.
|
||||
- Base URL fallback: `https://api.openai.com`.
|
||||
- Submit: `POST /v1/videos` with `{ model, prompt, seconds?, ...extras }`. `seconds` is a **string** enum (`"4" | "8" | "12"`) in the Sora API, so a numeric `duration` is stringified; size mapping is intentionally not forced.
|
||||
- Job id: `id` from the submit response.
|
||||
- Poll: `GET /v1/videos/{taskId}`; status at `status` (`completed` / `failed`).
|
||||
- Result: `data` — an array whose entries are either a URL string or `{ url: "…" }`.
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Register the provider node** as an OpenAI-compatible custom provider (`providerSpecificData.baseUrl` optional — the preset's `baseUrlFallback` is used when absent).
|
||||
2. **Register a custom model** tagged with the `videos` endpoint and a `generationConfig`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "super-video-v1",
|
||||
"name": "Super Video v1",
|
||||
"source": "manual",
|
||||
"apiFormat": "chat-completions",
|
||||
"supportedEndpoints": ["videos"],
|
||||
"generationConfig": { "preset": "agnes-video-job" }
|
||||
}
|
||||
```
|
||||
|
||||
`addCustomModel` (in `src/lib/db/models.ts`) accepts `generationConfig?: { preset: string }` as its final parameter and persists it on the model row; `updateCustomModel` forwards it the same way. The provider-models API accepts `generationConfig` on create and update.
|
||||
|
||||
3. **Call the route** as usual:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8787/api/v1/videos/generations \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $API_KEY" \
|
||||
-d '{
|
||||
"model": "my-custom-provider/super-video-v1",
|
||||
"prompt": "a cat playing piano",
|
||||
"duration": 5
|
||||
}'
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause |
|
||||
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
|
||||
| `400 Unknown video provider: …` | Non-custom provider not in the static registry; preset jobs only apply to custom provider nodes. |
|
||||
| `502 Unknown video job preset: …` | `generationConfig.preset` does not match any preset in `VIDEO_JOB_PRESETS`. Fix the model row. |
|
||||
| `502 Video provider did not return a job id (…)` | Submit succeeded but the response had no readable value at `taskIdPath`. |
|
||||
| `502 Video job failed (…)` / `Video job completed but no result URL found (…)` | Poll reached a terminal `statusFailed` state, or `resultPath` held no readable URL. |
|
||||
| `504 Video job timed out after 60 polls (…)` | Job never reached a terminal status within the poll budget. |
|
||||
| Upstream 4xx/5xx passthrough | `fetchJson` returns the upstream status when the submit/poll request itself is not OK. |
|
||||
| Requests go out without auth | No `apiKey`/`accessToken` on the provider connection; the executor sends `Content-Type` only. |
|
||||
245
electron/package-lock.json
generated
245
electron/package-lock.json
generated
@@ -55,9 +55,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@electron/asar/node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -257,9 +257,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@electron/universal/node_modules/brace-expansion": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"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",
|
||||
"dependencies": {
|
||||
@@ -297,6 +297,45 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/windows-sign": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz",
|
||||
"integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
"fs-extra": "^11.1.1",
|
||||
"minimist": "^1.2.8",
|
||||
"postject": "^1.0.0-alpha.6"
|
||||
},
|
||||
"bin": {
|
||||
"electron-windows-sign": "bin/electron-windows-sign.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/windows-sign/node_modules/fs-extra": {
|
||||
"version": "11.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz",
|
||||
"integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
@@ -835,16 +874,16 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
@@ -1091,6 +1130,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-dirname": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -1260,9 +1308,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dir-compare/node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1411,6 +1459,19 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-builder-squirrel-windows": {
|
||||
"version": "26.15.3",
|
||||
"resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz",
|
||||
"integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.15.3",
|
||||
"builder-util": "26.15.3",
|
||||
"electron-winstaller": "5.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-publish": {
|
||||
"version": "26.15.3",
|
||||
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz",
|
||||
@@ -1445,6 +1506,66 @@
|
||||
"tiny-typed-emitter": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz",
|
||||
"integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
"fs-extra": "^7.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"temp": "^0.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@electron/windows-sign": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller/node_modules/fs-extra": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
"universalify": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6 <7 || >=8"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller/node_modules/jsonfile": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
|
||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller/node_modules/universalify": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
@@ -1627,9 +1748,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/filelist/node_modules/brace-expansion": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"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",
|
||||
"dependencies": {
|
||||
@@ -1792,9 +1913,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2113,9 +2234,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2359,6 +2480,20 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -2622,6 +2757,36 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/postject": {
|
||||
"version": "1.0.0-alpha.6",
|
||||
"resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
|
||||
"integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
"bin": {
|
||||
"postject": "dist/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postject/node_modules/commander": {
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
|
||||
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/proc-log": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
|
||||
@@ -2816,6 +2981,21 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "2.6.3",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
|
||||
"integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==",
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/roarr": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
|
||||
@@ -3045,9 +3225,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.20",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz",
|
||||
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
|
||||
"version": "7.5.22",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
|
||||
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
@@ -3071,6 +3251,21 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/temp": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz",
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/temp-file": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getUpstreamTimeoutConfig } from "@/shared/utils/runtimeTimeouts";
|
||||
import { resolvePublicCred } from "../utils/publicCreds.ts";
|
||||
import type { LegacyProvider } from "./providerRegistry.ts";
|
||||
import { loadProviderCredentials } from "./credentialLoader.ts";
|
||||
import { generateLegacyProviders } from "./providerRegistry.ts";
|
||||
@@ -18,6 +19,15 @@ export const FETCH_TIMEOUT_MS = upstreamTimeouts.fetchTimeoutMs;
|
||||
// idle for this duration. Override with STREAM_IDLE_TIMEOUT_MS env var.
|
||||
export const STREAM_IDLE_TIMEOUT_MS = upstreamTimeouts.streamIdleTimeoutMs;
|
||||
|
||||
// Grace period (ms) a client-disconnect finalization waits for the stream's own
|
||||
// completion bookkeeping to land before persisting a 499. See #9653 — a client
|
||||
// that closes right after reading a fully-completed SSE stream can otherwise
|
||||
// race OmniRoute's own completion callback, resulting in a false 499 with zero
|
||||
// token usage for a request that actually delivered its full response. Set
|
||||
// STREAM_DISCONNECT_GRACE_PERIOD_MS=0 to disable and restore the old
|
||||
// immediate-fail behavior.
|
||||
export const STREAM_DISCONNECT_GRACE_PERIOD_MS = upstreamTimeouts.streamDisconnectGracePeriodMs;
|
||||
|
||||
// Timeout for the first non-ping SSE event. Inherits REQUEST_TIMEOUT_MS when
|
||||
// set, unless STREAM_READINESS_TIMEOUT_MS is specified directly. This must stay
|
||||
// conservative for large prompts and slow first-byte reasoning providers.
|
||||
@@ -65,27 +75,27 @@ export const PROVIDERS: Record<string, LegacyProvider> = new Proxy(
|
||||
{} as Record<string, LegacyProvider>,
|
||||
{
|
||||
get(_, prop) {
|
||||
if (typeof prop === 'symbol') return undefined;
|
||||
if (typeof prop === "symbol") return undefined;
|
||||
return Reflect.get(initProviders(), prop, _providers);
|
||||
},
|
||||
has(_, prop) {
|
||||
if (typeof prop === 'symbol') return false;
|
||||
if (typeof prop === "symbol") return false;
|
||||
return Reflect.has(initProviders(), prop);
|
||||
},
|
||||
ownKeys() {
|
||||
return Reflect.ownKeys(initProviders());
|
||||
},
|
||||
getOwnPropertyDescriptor(_, prop) {
|
||||
if (typeof prop === 'symbol') return undefined;
|
||||
if (typeof prop === "symbol") return undefined;
|
||||
return Object.getOwnPropertyDescriptor(initProviders(), prop);
|
||||
},
|
||||
set(_, prop, value) {
|
||||
if (typeof prop === 'symbol') return false;
|
||||
if (typeof prop === "symbol") return false;
|
||||
(initProviders() as Record<string, LegacyProvider>)[prop] = value;
|
||||
return true;
|
||||
},
|
||||
deleteProperty(_, prop) {
|
||||
if (typeof prop === 'symbol') return false;
|
||||
if (typeof prop === "symbol") return false;
|
||||
return Reflect.deleteProperty(initProviders(), prop);
|
||||
},
|
||||
}
|
||||
@@ -124,6 +134,11 @@ export const OAUTH_ENDPOINTS = {
|
||||
auth: "https://github.com/login/oauth/authorize",
|
||||
deviceCode: "https://github.com/login/device/code",
|
||||
},
|
||||
openference: {
|
||||
token: "https://openference.com/oauth/token",
|
||||
auth: "https://openference.com/app/oauth/authorize",
|
||||
clientId: resolvePublicCred("openference_id"),
|
||||
},
|
||||
};
|
||||
|
||||
// Cache TTLs (seconds)
|
||||
|
||||
@@ -311,7 +311,6 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
|
||||
{ provider: "nscale", modelId: "openai/gpt-oss-20b", displayName: "openai/gpt-oss-20b", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "nscale", tos: "caution" },
|
||||
{ provider: "nscale", modelId: "meta-llama/Llama-4-Scout-17B-16E-Instruct", displayName: "meta-llama/Llama-4-Scout-17B-16E-Instruct", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "nscale", tos: "caution" },
|
||||
{ provider: "nscale", modelId: "meta-llama/Llama-3.3-70B-Instruct", displayName: "meta-llama/Llama-3.3-70B-Instruct", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "nscale", tos: "caution" },
|
||||
{ provider: "nvidia", modelId: "z-ai/glm-5.1", displayName: "GLM 5.1", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
{ provider: "nvidia", modelId: "z-ai/glm-5.2", displayName: "GLM 5.2", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
{ provider: "nvidia", modelId: "minimaxai/minimax-m2.7", displayName: "MiniMax M2.7", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
{ provider: "nvidia", modelId: "google/gemma-4-31b-it", displayName: "Gemma 4 31B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
@@ -321,7 +320,6 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
|
||||
{ provider: "nvidia", modelId: "qwen/qwen3.5-397b-a17b", displayName: "Qwen3.5-397B-A17B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
{ provider: "nvidia", modelId: "qwen/qwen3.5-122b-a10b", displayName: "Qwen3.5-122B-A10B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
{ provider: "nvidia", modelId: "stepfun-ai/step-3.5-flash", displayName: "Step 3.5 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
{ provider: "nvidia", modelId: "deepseek-ai/deepseek-v4-pro", displayName: "DeepSeek V4 Pro", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
{ provider: "nvidia", modelId: "openai/gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
{ provider: "nvidia", modelId: "openai/gpt-oss-20b", displayName: "GPT OSS 20B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
{ provider: "nvidia", modelId: "nvidia/nemotron-3-super-120b-a12b", displayName: "Nemotron 3 Super 120B A12B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" },
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
[
|
||||
"deepseek-ai/deepseek-v4-pro",
|
||||
"google/gemma-4-31b-it",
|
||||
"minimaxai/minimax-m2.7",
|
||||
"mistralai/devstral-2-123b-instruct-2512",
|
||||
@@ -13,6 +12,5 @@
|
||||
"qwen/qwen3.5-397b-a17b",
|
||||
"stepfun-ai/step-3.5-flash",
|
||||
"thinkingmachines/inkling",
|
||||
"z-ai/glm-5.1",
|
||||
"z-ai/glm-5.2"
|
||||
]
|
||||
|
||||
@@ -90,10 +90,65 @@ export function getDefaultModel(aliasOrId: string): string | null {
|
||||
return models?.[0]?.id || null;
|
||||
}
|
||||
|
||||
/** Score a registry entry by how many capability flags it defines. */
|
||||
function modelRichness(m: RegistryModel): number {
|
||||
let score = 0;
|
||||
if (m.supportsXHighEffort !== undefined) score += 10; // critical for effort routing
|
||||
if (m.supportsReasoning !== undefined) score += 5;
|
||||
if (m.contextLength !== undefined) score += 3;
|
||||
if (m.maxOutputTokens !== undefined) score += 2;
|
||||
if (m.supportsVision !== undefined) score += 2;
|
||||
if (m.toolCalling !== undefined) score += 2;
|
||||
if (m.interleavedField !== undefined) score += 1;
|
||||
if (m.unsupportedParams !== undefined) score += 1;
|
||||
return score;
|
||||
}
|
||||
|
||||
function getGlobalModel(modelId: string): RegistryModel | undefined {
|
||||
// 1. Exact match — collect all, pick the richest
|
||||
let candidates: RegistryModel[] = [];
|
||||
for (const models of Object.values(PROVIDER_MODELS)) {
|
||||
const found = models.find((m) => m.id === modelId);
|
||||
if (found) candidates.push(found);
|
||||
}
|
||||
if (candidates.length > 0) {
|
||||
return candidates.sort((a, b) => modelRichness(b) - modelRichness(a))[0];
|
||||
}
|
||||
|
||||
// 2. Strip provider prefix (e.g. moonshotai/kimi-k3-free -> kimi-k3-free)
|
||||
const basename = modelId.split("/").pop() || modelId;
|
||||
candidates = [];
|
||||
for (const models of Object.values(PROVIDER_MODELS)) {
|
||||
const found = models.find((m) => m.id === basename);
|
||||
if (found) candidates.push(found);
|
||||
}
|
||||
if (candidates.length > 0) {
|
||||
return candidates.sort((a, b) => modelRichness(b) - modelRichness(a))[0];
|
||||
}
|
||||
|
||||
// 3. Substring match for base model name (e.g. kimi-k3-free -> kimi-k3)
|
||||
// Finds the longest matching base model ID; on ties, prefers the richer entry.
|
||||
let bestMatch: RegistryModel | undefined;
|
||||
for (const models of Object.values(PROVIDER_MODELS)) {
|
||||
for (const m of models) {
|
||||
if (basename.startsWith(m.id)) {
|
||||
if (
|
||||
!bestMatch ||
|
||||
m.id.length > bestMatch.id.length ||
|
||||
(m.id.length === bestMatch.id.length && modelRichness(m) > modelRichness(bestMatch))
|
||||
) {
|
||||
bestMatch = m;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
export function getProviderModel(aliasOrId: string, modelId: string): RegistryModel | undefined {
|
||||
const models = PROVIDER_MODELS[aliasOrId];
|
||||
if (!models) return undefined;
|
||||
return models.find((model) => model.id === modelId);
|
||||
if (!models) return getGlobalModel(modelId);
|
||||
return models.find((model) => model.id === modelId) || getGlobalModel(modelId);
|
||||
}
|
||||
|
||||
export function isValidModel(
|
||||
@@ -103,26 +158,20 @@ export function isValidModel(
|
||||
): boolean {
|
||||
if (passthroughProviders.has(aliasOrId)) return true;
|
||||
const models = PROVIDER_MODELS[aliasOrId];
|
||||
if (!models) return false;
|
||||
return models.some((m) => m.id === modelId);
|
||||
if (!models) return !!getGlobalModel(modelId);
|
||||
return models.some((m) => m.id === modelId) || !!getGlobalModel(modelId);
|
||||
}
|
||||
|
||||
export function findModelName(aliasOrId: string, modelId: string): string {
|
||||
const models = PROVIDER_MODELS[aliasOrId];
|
||||
if (!models) return modelId;
|
||||
const found = models.find((m) => m.id === modelId);
|
||||
if (!models) return getGlobalModel(modelId)?.name || modelId;
|
||||
const found = models.find((m) => m.id === modelId) || getGlobalModel(modelId);
|
||||
return found?.name || modelId;
|
||||
}
|
||||
|
||||
export function getModelTargetFormat(aliasOrId: string, modelId: string): string | null {
|
||||
const models = PROVIDER_MODELS[aliasOrId];
|
||||
// Strip provider prefix if present: "openai/gpt-5.6-luna" → "gpt-5.6-luna"
|
||||
const prefix = aliasOrId + "/";
|
||||
const bareModelId =
|
||||
typeof modelId === "string" && modelId.startsWith(prefix)
|
||||
? modelId.slice(prefix.length)
|
||||
: modelId;
|
||||
const found = models?.find((m) => m.id === bareModelId);
|
||||
const found = models?.find((m) => m.id === modelId) || getGlobalModel(modelId);
|
||||
if (found?.targetFormat) return found.targetFormat;
|
||||
// #5842: OpenAI "*-pro" reasoning models (o1-pro, gpt-5.x-pro) are only served by
|
||||
// the native /v1/responses endpoint — /v1/chat/completions 404s ("only supported
|
||||
@@ -130,14 +179,17 @@ export function getModelTargetFormat(aliasOrId: string, modelId: string): string
|
||||
// covers dynamically-synced ids that post-date the catalog (same spirit as the gh
|
||||
// executor's /codex/i routing, 9router#102). Scoped to the openai alias so other
|
||||
// providers shipping *-pro ids keep their own endpoint semantics.
|
||||
if (aliasOrId === "openai" && /-pro$/i.test(bareModelId)) return "openai-responses";
|
||||
if (aliasOrId === "openai" && /-pro$/i.test(modelId)) return "openai-responses";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getModelStripTypes(aliasOrId: string, modelId: string): string[] {
|
||||
const models = PROVIDER_MODELS[aliasOrId];
|
||||
if (!models) return [];
|
||||
const found = models.find((m) => m.id === modelId);
|
||||
if (!models)
|
||||
return Array.isArray(getGlobalModel(modelId)?.strip)
|
||||
? [...getGlobalModel(modelId)!.strip!]
|
||||
: [];
|
||||
const found = models.find((m) => m.id === modelId) || getGlobalModel(modelId);
|
||||
return Array.isArray(found?.strip) ? [...found.strip] : [];
|
||||
}
|
||||
|
||||
@@ -262,7 +314,7 @@ function resolveProviderModelList(aliasOrId: string): {
|
||||
|
||||
export function supportsXHighEffort(aliasOrId: string, modelId: string): boolean {
|
||||
const { models: providerModels } = resolveProviderModelList(aliasOrId);
|
||||
const model = providerModels?.find((entry) => entry.id === modelId);
|
||||
const model = providerModels?.find((entry) => entry.id === modelId) || getGlobalModel(modelId);
|
||||
if (model?.supportsXHighEffort !== undefined) {
|
||||
return model.supportsXHighEffort !== false;
|
||||
}
|
||||
|
||||
@@ -121,6 +121,8 @@ import { chatgpt_webProvider } from "./registry/chatgpt-web/index.ts";
|
||||
import { openrouterProvider } from "./registry/openrouter/index.ts";
|
||||
import { cheaperinferenceProvider } from "./registry/cheaperinference/index.ts";
|
||||
import { openvectaProvider } from "./registry/openvecta/index.ts";
|
||||
import { openferenceProvider } from "./registry/openference/index.ts";
|
||||
import { openference_apiProvider } from "./registry/openference-api/index.ts";
|
||||
import { orcarouterProvider } from "./registry/orcarouter/index.ts";
|
||||
import { copilot_webProvider } from "./registry/copilot-web/index.ts";
|
||||
import { copilot_m365_webProvider } from "./registry/copilot-m365-web/index.ts";
|
||||
@@ -345,6 +347,8 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
openrouter: openrouterProvider,
|
||||
cheaperinference: cheaperinferenceProvider,
|
||||
openvecta: openvectaProvider,
|
||||
openference: openferenceProvider,
|
||||
"openference-api": openference_apiProvider,
|
||||
orcarouter: orcarouterProvider,
|
||||
"copilot-web": copilot_webProvider,
|
||||
"copilot-m365-web": copilot_m365_webProvider,
|
||||
|
||||
@@ -32,8 +32,6 @@ export const nvidiaProvider: RegistryEntry = {
|
||||
{ id: "qwen/qwen3.5-122b-a10b", name: "Qwen3.5-122B-A10B" },
|
||||
{ id: "stepfun-ai/step-3.5-flash", name: "Step 3.5 Flash" },
|
||||
{ id: "stepfun-ai/step-3.7-flash", name: "Step 3.7 Flash" },
|
||||
{ id: "deepseek-ai/deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true },
|
||||
{ id: "deepseek-ai/deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true },
|
||||
// Sweep 2026-06-19: verified present in the live NVIDIA NIM /v1/models catalog.
|
||||
{ id: "moonshotai/kimi-k2.6", name: "Kimi K2.6" },
|
||||
{ id: "openai/gpt-oss-120b", name: "GPT OSS 120B", toolCalling: false },
|
||||
|
||||
18
open-sse/config/providers/registry/openference-api/index.ts
Normal file
18
open-sse/config/providers/registry/openference-api/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
|
||||
|
||||
/**
|
||||
* Openference API key — OpenAI-compatible gateway (https://openference.com/).
|
||||
*
|
||||
* Bearer API keys (`sk-…`) hit the same api.openference.com/v1/* surface as OAuth
|
||||
* JWTs. Live model discovery uses NAMED_OPENAI_STYLE_PROVIDERS; the seed below is
|
||||
* the offline fallback when the live fetch fails.
|
||||
*/
|
||||
export const openference_apiProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
|
||||
id: "openference-api",
|
||||
alias: "ofa",
|
||||
baseUrl: "https://api.openference.com/v1/chat/completions",
|
||||
responsesBaseUrl: "https://api.openference.com/v1/responses",
|
||||
passthroughModels: true,
|
||||
models: [{ id: "GLM-5.2", name: "GLM 5.2", contextLength: 850000 }],
|
||||
});
|
||||
25
open-sse/config/providers/registry/openference/index.ts
Normal file
25
open-sse/config/providers/registry/openference/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { resolvePublicCred, type RegistryEntry } from "../../shared.ts";
|
||||
|
||||
/**
|
||||
* Openference — OpenAI-compatible AI inference gateway (https://openference.com/).
|
||||
*
|
||||
* OAuth access tokens are ES256 JWTs accepted as Bearer credentials on
|
||||
* api.openference.com/v1/*. Live model discovery uses NAMED_OPENAI_STYLE_PROVIDERS;
|
||||
* seed models below are the offline fallback when the live fetch fails.
|
||||
*/
|
||||
export const openferenceProvider: RegistryEntry = {
|
||||
id: "openference",
|
||||
alias: "of",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://api.openference.com/v1/chat/completions",
|
||||
responsesBaseUrl: "https://api.openference.com/v1/responses",
|
||||
authType: "oauth",
|
||||
authHeader: "bearer",
|
||||
passthroughModels: true,
|
||||
oauth: {
|
||||
clientIdDefault: resolvePublicCred("openference_id"),
|
||||
tokenUrl: "https://openference.com/oauth/token",
|
||||
},
|
||||
models: [{ id: "GLM-5.2", name: "GLM 5.2", contextLength: 850000 }],
|
||||
};
|
||||
35
open-sse/executors/azure-ai.ts
Normal file
35
open-sse/executors/azure-ai.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { DefaultExecutor } from "./default.ts";
|
||||
import type { ProviderCredentials } from "./base.ts";
|
||||
import { applyAzureParamRules } from "./azureParamRules.ts";
|
||||
|
||||
/**
|
||||
* Azure AI Foundry (`azure-ai`).
|
||||
*
|
||||
* URL building, auth headers and the `responses` vs `chat` apiType switch all
|
||||
* live in `DefaultExecutor`, keyed on the `azure-ai` provider id — this subclass
|
||||
* inherits them unchanged and adds only the Azure request-param rules.
|
||||
*
|
||||
* Before this existed, `azure-ai` fell through to the bare `DefaultExecutor`
|
||||
* while `azure-openai` had the rules inline, so the same Azure deployment
|
||||
* behaved differently depending on which connection served it: `azure-openai`
|
||||
* succeeded and `azure-ai` returned HTTP 400 for `max_tokens` /
|
||||
* `reasoning_effort`.
|
||||
*/
|
||||
export class AzureAiExecutor extends DefaultExecutor {
|
||||
constructor() {
|
||||
super("azure-ai");
|
||||
}
|
||||
|
||||
override transformRequest(
|
||||
model: string,
|
||||
body: unknown,
|
||||
stream: boolean,
|
||||
credentials: ProviderCredentials
|
||||
): unknown {
|
||||
return applyAzureParamRules(
|
||||
model,
|
||||
body,
|
||||
super.transformRequest(model, body, stream, credentials)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { DefaultExecutor } from "./default.ts";
|
||||
import type { ProviderCredentials } from "./base.ts";
|
||||
import { stripTrailingSlashes } from "../utils/urlSanitize.ts";
|
||||
import { applyAzureParamRules } from "./azureParamRules.ts";
|
||||
|
||||
const DEFAULT_API_VERSION = "2024-12-01-preview";
|
||||
const GPT5_OR_REASONING_DEPLOYMENT = /(?:^|[/_-])(?:gpt-5|o(?:1|3|4))(?:[._-]|$)/i;
|
||||
|
||||
function normalizeAzureBaseUrl(rawBaseUrl?: string | null): string {
|
||||
const normalized = stripTrailingSlashes((rawBaseUrl || "").trim());
|
||||
@@ -57,37 +57,10 @@ export class AzureOpenAIExecutor extends DefaultExecutor {
|
||||
stream: boolean,
|
||||
credentials: ProviderCredentials
|
||||
): unknown {
|
||||
const transformed = super.transformRequest(model, body, stream, credentials);
|
||||
if (!GPT5_OR_REASONING_DEPLOYMENT.test(model)) return transformed;
|
||||
if (!transformed || typeof transformed !== "object" || Array.isArray(transformed)) {
|
||||
return transformed;
|
||||
}
|
||||
|
||||
const original =
|
||||
body && typeof body === "object" && !Array.isArray(body)
|
||||
? (body as Record<string, unknown>)
|
||||
: null;
|
||||
const normalized = { ...(transformed as Record<string, unknown>) };
|
||||
|
||||
if (original?.max_completion_tokens !== undefined) {
|
||||
normalized.max_completion_tokens = original.max_completion_tokens;
|
||||
} else if (
|
||||
normalized.max_completion_tokens === undefined &&
|
||||
original?.max_tokens !== undefined
|
||||
) {
|
||||
normalized.max_completion_tokens = original.max_tokens;
|
||||
}
|
||||
delete normalized.max_tokens;
|
||||
|
||||
if (normalized.temperature !== undefined && normalized.temperature !== 1) {
|
||||
delete normalized.temperature;
|
||||
}
|
||||
|
||||
const hasTools = Array.isArray(normalized.tools) && normalized.tools.length > 0;
|
||||
if (hasTools || normalized.reasoning_effort === "none") {
|
||||
delete normalized.reasoning_effort;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
return applyAzureParamRules(
|
||||
model,
|
||||
body,
|
||||
super.transformRequest(model, body, stream, credentials)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
76
open-sse/executors/azureParamRules.ts
Normal file
76
open-sse/executors/azureParamRules.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Azure Chat Completions param rules, shared by every Azure wire path.
|
||||
*
|
||||
* Azure's newer deployments reject a handful of stock OpenAI Chat Completions
|
||||
* params and return HTTP 400 rather than ignoring them:
|
||||
*
|
||||
* - `max_tokens` -> "Unsupported parameter: 'max_tokens' is not supported
|
||||
* with this model. Use 'max_completion_tokens' instead."
|
||||
* - `temperature` -> only the default (1) is accepted.
|
||||
* - `reasoning_effort` -> "Function tools with reasoning_effort are not
|
||||
* supported ... Please use /v1/responses instead."
|
||||
*
|
||||
* This logic previously lived inline in `AzureOpenAIExecutor`, so it only
|
||||
* covered the `azure-openai` provider. `azure-ai` (Azure AI Foundry) routes
|
||||
* through `DefaultExecutor` and inherited none of it, which meant an identical
|
||||
* deployment 400'd on one connection and succeeded on the other. Extracted here
|
||||
* so both executors apply exactly the same rules.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Deployments that require `max_completion_tokens` instead of `max_tokens`.
|
||||
*
|
||||
* Matches the GPT-5 family and the o1/o3/o4 reasoning series at a token
|
||||
* boundary, so a deployment named `my-gpt-5-prod` matches while an unrelated
|
||||
* `piston-o4-legacy`-style name does not match by accident. `gpt-chat-latest`
|
||||
* is listed explicitly: it is a moving alias that currently resolves to a
|
||||
* GPT-5-era model and rejects `max_tokens`, but carries no version number for
|
||||
* the boundary pattern to key on.
|
||||
*/
|
||||
export const AZURE_COMPLETION_TOKEN_DEPLOYMENT =
|
||||
/(?:^|[/_-])(?:gpt-5|o(?:1|3|4))(?:[._-]|$)|^gpt-chat-latest$/i;
|
||||
|
||||
/**
|
||||
* Apply the Azure param rules to an already-translated Chat Completions body.
|
||||
*
|
||||
* `originalBody` is the pre-translation request, consulted only to recover a
|
||||
* caller-supplied token budget that translation may have moved or dropped.
|
||||
* Returns `transformed` untouched when the deployment is unaffected or the body
|
||||
* is not a plain object, and never mutates either input.
|
||||
*/
|
||||
export function applyAzureParamRules(
|
||||
model: string,
|
||||
originalBody: unknown,
|
||||
transformed: unknown
|
||||
): unknown {
|
||||
if (!AZURE_COMPLETION_TOKEN_DEPLOYMENT.test(model)) return transformed;
|
||||
if (!transformed || typeof transformed !== "object" || Array.isArray(transformed)) {
|
||||
return transformed;
|
||||
}
|
||||
|
||||
const original =
|
||||
originalBody && typeof originalBody === "object" && !Array.isArray(originalBody)
|
||||
? (originalBody as Record<string, unknown>)
|
||||
: null;
|
||||
const normalized = { ...(transformed as Record<string, unknown>) };
|
||||
|
||||
if (original?.max_completion_tokens !== undefined) {
|
||||
normalized.max_completion_tokens = original.max_completion_tokens;
|
||||
} else if (normalized.max_completion_tokens === undefined && original?.max_tokens !== undefined) {
|
||||
normalized.max_completion_tokens = original.max_tokens;
|
||||
}
|
||||
delete normalized.max_tokens;
|
||||
|
||||
if (normalized.temperature !== undefined && normalized.temperature !== 1) {
|
||||
delete normalized.temperature;
|
||||
}
|
||||
|
||||
// Azure 400s on reasoning_effort as soon as tools are present, which is every
|
||||
// agentic client (Claude Code, Cursor agent) on every turn.
|
||||
const hasTools = Array.isArray(normalized.tools) && normalized.tools.length > 0;
|
||||
if (hasTools || normalized.reasoning_effort === "none") {
|
||||
delete normalized.reasoning_effort;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
@@ -2,16 +2,19 @@
|
||||
// Extracted verbatim from base.ts. Deps are config/services only (no host import → no cycle).
|
||||
import { PROVIDER_CLAUDE } from "../../services/systemTransforms.ts";
|
||||
import { isClaudeCodeCompatible } from "../../services/provider.ts";
|
||||
import { supportsClaudeMaxEffort, supportsXHighEffort } from "../../config/providerModels.ts";
|
||||
import {
|
||||
supportsClaudeMaxEffort,
|
||||
supportsXHighEffort,
|
||||
getProviderModel,
|
||||
} from "../../config/providerModels.ts";
|
||||
|
||||
/**
|
||||
* Sanitize reasoning_effort for providers that don't accept all values.
|
||||
*
|
||||
* The claude→openai translator passes output_config.effort through verbatim
|
||||
* (including max) and only performs form conversion; provider-aware effort
|
||||
* policy is owned here. Combined with runtime alias remapping (e.g.
|
||||
* claude-opus-4-6 → mimo/mimo-v2.5-pro), this routes a client's effort value
|
||||
* to OpenAI-shape providers that don't accept it:
|
||||
* The claude→openai translator may emit reasoning_effort=max/xhigh when the
|
||||
* client sends output_config.effort=max on a Claude-shape request. Combined with
|
||||
* runtime alias remapping (e.g. claude-opus-4-6 → mimo/mimo-v2.5-pro), this
|
||||
* routes xhigh to OpenAI-shape providers that don't accept the value:
|
||||
*
|
||||
* xiaomi-mimo : low|medium|high only — 400 literal_error on xhigh
|
||||
* mistral : devstral models reject reasoning_effort entirely
|
||||
@@ -140,9 +143,11 @@ export function mapNvidiaGlm52ReasoningParams(
|
||||
}
|
||||
|
||||
export function supportsMaxEffortForProvider(provider: string, model: string): boolean {
|
||||
const resolvedModelId = getProviderModel(provider, model)?.id || model;
|
||||
|
||||
const isClaude =
|
||||
(provider === PROVIDER_CLAUDE || isClaudeCodeCompatible(provider)) &&
|
||||
supportsClaudeMaxEffort(model);
|
||||
supportsClaudeMaxEffort(resolvedModelId);
|
||||
// opencode-go proxies DeepSeek with the native DeepSeek API contract, which
|
||||
// accepts {high, max} literally. Without this opt-in, max would be
|
||||
// normalized to xhigh (the OmniRoute-internal top tier) and rejected by the
|
||||
@@ -151,11 +156,12 @@ export function supportsMaxEffortForProvider(provider: string, model: string): b
|
||||
// Ollama Cloud also accepts literal max (for example GLM 5.2 supports
|
||||
// low|medium|high|max|none) and rejects xhigh.
|
||||
const isOpencodeGoDeepSeek =
|
||||
(provider === "opencode-go" || provider === "opencode-zen") &&
|
||||
model.toLowerCase().includes("deepseek");
|
||||
provider === "opencode-go" && resolvedModelId.toLowerCase().includes("deepseek");
|
||||
const isOllamaCloud = provider === "ollama-cloud";
|
||||
const isMoonshotK3 =
|
||||
(provider === "moonshot" || provider === "kimi") && /^kimi-k3(?:$|-)/i.test(model);
|
||||
// Kimi K3 only accepts literal max and rejects xhigh natively. Apply this mapping
|
||||
// regardless of provider so that OpenAI-compatible proxies (e.g. TokenRouter)
|
||||
// correctly pass max instead of the internal xhigh top tier.
|
||||
const isMoonshotK3 = /^kimi-k3(?:$|-)/i.test(resolvedModelId);
|
||||
return isClaude || isOpencodeGoDeepSeek || isOllamaCloud || isMoonshotK3;
|
||||
}
|
||||
|
||||
@@ -253,16 +259,6 @@ export function sanitizeReasoningEffortForProvider(
|
||||
const effortStr = typeof c.effort === "string" ? c.effort.toLowerCase() : "";
|
||||
const modelStr = model || "";
|
||||
|
||||
// Oh My Pi exposes `minimal`, while Codex's Responses API starts at `low`.
|
||||
// Normalize every carrier before the Codex executor sends the upstream request.
|
||||
if (provider === "codex" && effortStr === "minimal") {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: normalized reasoning_effort minimal → low`
|
||||
);
|
||||
return writeEffortValue(b, "low", c);
|
||||
}
|
||||
|
||||
const githubOptIn =
|
||||
provider === "github" && GITHUB_REASONING_EFFORT_OPT_IN_PATTERN.test(modelStr);
|
||||
const rejecting =
|
||||
@@ -298,27 +294,48 @@ export function sanitizeReasoningEffortForProvider(
|
||||
}
|
||||
|
||||
const supportsXHigh = supportsXHighEffort(provider, modelStr);
|
||||
const shouldDowngradeXHigh = effortStr === "xhigh" && !supportsXHigh;
|
||||
const supportsXHighForMax = supportsXHigh;
|
||||
const supportsMax = supportsMaxEffortForProvider(provider, modelStr);
|
||||
const shouldNormalizeMaxToXHigh = effortStr === "max" && !supportsMax && supportsXHighForMax;
|
||||
const shouldDowngradeMax = effortStr === "max" && !supportsMax && !supportsXHighForMax;
|
||||
|
||||
if (shouldNormalizeMaxToXHigh) {
|
||||
// ── xhigh handling ──────────────────────────────────────────────────────
|
||||
// xhigh is OmniRoute-internal. Map it to the best effort the model accepts.
|
||||
if (effortStr === "xhigh") {
|
||||
if (supportsXHigh) return body; // model accepts xhigh natively
|
||||
if (supportsMax) {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: mapped reasoning_effort xhigh → max`
|
||||
);
|
||||
return writeEffortValue(b, "max", c);
|
||||
}
|
||||
// Model explicitly rejects xhigh — gracefully degrade to high (its highest standard tier)
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: normalized reasoning_effort max → xhigh`
|
||||
);
|
||||
return writeEffortValue(b, "xhigh", c);
|
||||
}
|
||||
|
||||
if (shouldDowngradeXHigh || shouldDowngradeMax) {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: downgraded reasoning_effort ${effortStr} → high`
|
||||
`${provider}/${modelStr}: downgraded reasoning_effort xhigh → high`
|
||||
);
|
||||
return writeEffortValue(b, "high", c);
|
||||
}
|
||||
|
||||
// ── max handling ────────────────────────────────────────────────────────
|
||||
// NEW DEFAULT: pass max through unchanged. Most reasoning-capable APIs
|
||||
// accept max natively. Only degrade when we KNOW the model rejects it
|
||||
// (registry has supportsXHighEffort explicitly set to false AND it's not
|
||||
// in the supportsMax whitelist). Unknown models pass through — trust the
|
||||
// upstream, and if it 400s the user gets a clear signal. This prevents
|
||||
// new models from being unusable for weeks until they're whitelisted (#8057).
|
||||
if (effortStr === "max") {
|
||||
if (supportsMax) return body; // explicitly known to accept max
|
||||
if (!supportsXHigh) {
|
||||
// Model is explicitly flagged as rejecting xhigh (and not in supportsMax) —
|
||||
// it likely only accepts standard tiers. Degrade to its highest: high.
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: downgraded reasoning_effort max → high (model rejects max/xhigh)`
|
||||
);
|
||||
return writeEffortValue(b, "high", c);
|
||||
}
|
||||
// Default: pass max through unchanged — trust the upstream
|
||||
return body;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -2822,8 +2822,7 @@ export class ChatGptWebExecutor extends BaseExecutor {
|
||||
const modelSlug = resolvedModel.slug;
|
||||
const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(
|
||||
(body || {}) as Record<string, unknown>,
|
||||
messages as Array<{ role: string; content: unknown }>,
|
||||
{ hardened: isThinkingCapableModel(model, modelSlug) }
|
||||
messages as Array<{ role: string; content: unknown }>
|
||||
);
|
||||
|
||||
if (!credentials.apiKey) {
|
||||
|
||||
@@ -408,12 +408,13 @@ export class CliproxyapiExecutor extends BaseExecutor {
|
||||
|
||||
input.log?.info?.("CPA", `CLIProxyAPI → ${url} (model: ${input.model}, shape: ${shape})`);
|
||||
|
||||
// _toolNameMap is an in-memory channel to chatCore for response-side
|
||||
// tool name restoration; never send it over the wire.
|
||||
// _toolNameMap and _namespaceToolIdentityMap are in-memory channels to
|
||||
// chatCore for response-side tool name restoration; never send them over
|
||||
// the wire.
|
||||
const wireBody =
|
||||
transformedBody && typeof transformedBody === "object"
|
||||
? JSON.stringify(transformedBody, (key, value) =>
|
||||
key === "_toolNameMap" ? undefined : value
|
||||
key === "_toolNameMap" || key === "_namespaceToolIdentityMap" ? undefined : value
|
||||
)
|
||||
: JSON.stringify(transformedBody);
|
||||
|
||||
|
||||
@@ -1,5 +1,82 @@
|
||||
import { DefaultExecutor } from "./default.ts";
|
||||
import type { ProviderCredentials } from "./base.ts";
|
||||
import type { ExecuteInput, ExecutorExecuteResult, ProviderCredentials } from "./base.ts";
|
||||
|
||||
const SENSITIVE_CONTENT_REJECTION =
|
||||
"抱歉,系统检测到您当前输入的信息存在敏感内容,我无法响应您的请求,请检查后重新输入";
|
||||
const LARGE_TOOL_METADATA_BYTES = 64 * 1024;
|
||||
|
||||
function responseFromResult(result: ExecutorExecuteResult): Response {
|
||||
return result instanceof Response ? result : result.response;
|
||||
}
|
||||
|
||||
function credentialsFromResult(
|
||||
result: ExecutorExecuteResult,
|
||||
fallback: ProviderCredentials
|
||||
): ProviderCredentials {
|
||||
if (result instanceof Response || !result.headers) return fallback;
|
||||
|
||||
const authorization = Object.entries(result.headers).find(
|
||||
([name]) => name.toLowerCase() === "authorization"
|
||||
)?.[1];
|
||||
if (!authorization?.startsWith("Bearer ")) return fallback;
|
||||
|
||||
return {
|
||||
...fallback,
|
||||
accessToken: authorization.slice("Bearer ".length),
|
||||
expiresAt: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function compactToolDescriptions(body: unknown): unknown | null {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
||||
|
||||
const request = body as Record<string, unknown>;
|
||||
if (!Array.isArray(request.tools) || request.tools.length === 0) return null;
|
||||
|
||||
const originalTools = request.tools;
|
||||
try {
|
||||
const serializedTools = JSON.stringify(originalTools);
|
||||
if (new TextEncoder().encode(serializedTools).byteLength < LARGE_TOOL_METADATA_BYTES) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let tools: unknown[] | null = null;
|
||||
originalTools.forEach((tool, index) => {
|
||||
if (!tool || typeof tool !== "object" || Array.isArray(tool)) return;
|
||||
|
||||
const declaration = tool as Record<string, unknown>;
|
||||
if (
|
||||
declaration.type !== "function" ||
|
||||
!declaration.function ||
|
||||
typeof declaration.function !== "object" ||
|
||||
Array.isArray(declaration.function)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolFunction = declaration.function as Record<string, unknown>;
|
||||
if (!Object.prototype.hasOwnProperty.call(toolFunction, "description")) return;
|
||||
|
||||
const compactFunction = { ...toolFunction };
|
||||
delete compactFunction.description;
|
||||
tools ??= originalTools.slice();
|
||||
tools[index] = { ...declaration, function: compactFunction };
|
||||
});
|
||||
|
||||
return tools ? { ...request, tools } : null;
|
||||
}
|
||||
|
||||
async function isSensitiveContentRejection(response: Response): Promise<boolean> {
|
||||
if (response.status !== 400) return false;
|
||||
const responseText = await response
|
||||
.clone()
|
||||
.text()
|
||||
.catch(() => "");
|
||||
return responseText.includes(SENSITIVE_CONTENT_REJECTION);
|
||||
}
|
||||
|
||||
/**
|
||||
* CodeBuddyCnExecutor — talks to https://copilot.tencent.com/v2/chat/completions
|
||||
@@ -21,6 +98,26 @@ export class CodeBuddyCnExecutor extends DefaultExecutor {
|
||||
super("codebuddy-cn");
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput): Promise<ExecutorExecuteResult> {
|
||||
const result = await super.execute(input);
|
||||
if (!(await isSensitiveContentRejection(responseFromResult(result)))) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const compactBody = compactToolDescriptions(input.body);
|
||||
if (!compactBody) return result;
|
||||
|
||||
input.log?.debug?.(
|
||||
"CODEBUDDY_CN",
|
||||
"Upstream rejected an oversized tool request as sensitive content; retrying with compact tool descriptions"
|
||||
);
|
||||
return super.execute({
|
||||
...input,
|
||||
body: compactBody,
|
||||
credentials: credentialsFromResult(result, input.credentials),
|
||||
});
|
||||
}
|
||||
|
||||
transformRequest(
|
||||
model: string,
|
||||
body: unknown,
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "../config/codexIdentity.ts";
|
||||
import { getAccessToken } from "../services/tokenRefresh.ts";
|
||||
import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts";
|
||||
import { applyResponsesInputPolicy } from "../services/responsesInputPolicy.ts";
|
||||
import { normalizeCodexVerbosity } from "../services/codexVerbosity.ts";
|
||||
import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts";
|
||||
import { CORS_HEADERS } from "../utils/cors.ts";
|
||||
@@ -222,90 +223,6 @@ function convertSystemToDeveloperRole(body: Record<string, unknown>): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip server-generated item IDs from the input array.
|
||||
*
|
||||
* The Codex /codex/responses endpoint does not persist response items even when
|
||||
* store=true is sent. When proxy clients (e.g. OpenClaw) include response items
|
||||
* from previous turns in the input array, those items carry server-assigned IDs
|
||||
* (prefixed with "rs_", "fc_", "resp_", "msg_"). The Codex backend tries to
|
||||
* validate these IDs against its persistence store and returns 404 when the items
|
||||
* are not found (because store was effectively false).
|
||||
*
|
||||
* This function:
|
||||
* 1. Removes bare string references ("rs_abc123") from the input array
|
||||
* 2. Removes object items with type "item_reference" (explicit stored-item refs)
|
||||
* 3. Strips the "id" field from any object in input whose id matches a
|
||||
* server-generated prefix (rs_, fc_, resp_, msg_) — so the content is
|
||||
* preserved but the backend won't try to look it up
|
||||
*/
|
||||
export function stripStoredItemReferences(body: Record<string, unknown>): void {
|
||||
if (Array.isArray(body.input) && body.input.length === 0) {
|
||||
body.input = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "continue" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (!Array.isArray(body.input)) return;
|
||||
|
||||
const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/;
|
||||
let strippedCount = 0;
|
||||
|
||||
body.input = body.input.filter((item) => {
|
||||
// Bare string references: "rs_abc123", "resp_abc123"
|
||||
if (typeof item === "string" && SERVER_ID_PATTERN.test(item)) {
|
||||
strippedCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Object references: { type: "item_reference", id: "rs_..." }
|
||||
if (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
!Array.isArray(item) &&
|
||||
(item as Record<string, unknown>).type === "item_reference"
|
||||
) {
|
||||
strippedCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reasoning blobs (encrypted_content) are unusable with store=false since
|
||||
// previous_response_id is deleted — strip them to avoid wasting context
|
||||
// tokens (O(n^2) growth across agentic turns).
|
||||
if (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
!Array.isArray(item) &&
|
||||
(item as Record<string, unknown>).type === "reasoning"
|
||||
) {
|
||||
strippedCount++;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Object items with server-generated IDs: strip the id field but keep the item.
|
||||
// e.g. { id: "rs_...", type: "reasoning", summary: [...] } → keep content, remove id
|
||||
// e.g. { id: "fc_...", type: "function_call", ... } → keep content, remove id
|
||||
if (item && typeof item === "object" && !Array.isArray(item)) {
|
||||
const record = item as Record<string, unknown>;
|
||||
if (typeof record.id === "string" && SERVER_ID_PATTERN.test(record.id)) {
|
||||
delete record.id;
|
||||
strippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (strippedCount > 0) {
|
||||
console.debug(
|
||||
`[Codex] stripStoredItemReferences: sanitized ${strippedCount} server-generated ID(s) from input`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function stripOrphanedCodexFunctionCallOutputs(body: Record<string, unknown>): void {
|
||||
if (!Array.isArray(body.input)) return;
|
||||
@@ -1296,7 +1213,7 @@ export class CodexExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
// Issue #1832 & #1853: Map messages to input for clients like Cursor 5.5 that use responses/compact but send messages instead of input.
|
||||
// This MUST run before convertSystemToDeveloperRole and stripStoredItemReferences.
|
||||
// This MUST run before convertSystemToDeveloperRole.
|
||||
if (!body.input && Array.isArray(body.messages)) {
|
||||
body.input = body.messages.map((msg: ResponsesMessageInput) => ({
|
||||
type: "message",
|
||||
@@ -1419,11 +1336,6 @@ export class CodexExecutor extends BaseExecutor {
|
||||
preserveCustomTools: nativeCodexPassthrough,
|
||||
});
|
||||
|
||||
// Strip stored response item references (rs_, resp_, msg_ IDs) from input.
|
||||
// The /codex/responses endpoint does not persist responses even with store=true,
|
||||
// so any references to previous response items would cause 404 errors.
|
||||
stripStoredItemReferences(body);
|
||||
|
||||
// Issue #806: Even for native passthrough, some clients (purist completions) might indiscriminately inject
|
||||
// a `messages` or `prompt` array which the strict Codex Responses schema rejects.
|
||||
delete body.messages;
|
||||
@@ -1515,6 +1427,11 @@ export class CodexExecutor extends BaseExecutor {
|
||||
delete body.session_id;
|
||||
delete body.conversation_id;
|
||||
|
||||
applyResponsesInputPolicy(
|
||||
body,
|
||||
credentials?.providerSpecificData?.preserveEncryptedReasoning === true
|
||||
);
|
||||
|
||||
if (nativeCodexPassthrough) {
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,114 @@ 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 }
|
||||
@@ -138,7 +246,9 @@ export function normalizeCodexTools(
|
||||
// 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 = stripUnsupportedRegexPatterns(parameters);
|
||||
const sanitizedParameters = stripRedundantOneOfConstEnum(
|
||||
stripUnsupportedRegexPatterns(parameters)
|
||||
);
|
||||
|
||||
// Rewrite in-place to Responses format
|
||||
for (const key of Object.keys(tool)) {
|
||||
|
||||
@@ -48,6 +48,21 @@ function recordOrEmpty(value: unknown): JsonRecord {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `arguments` field for an assistant tool-call part that Command
|
||||
* Code's /alpha/generate schema REQUIRES (rejects a missing field with
|
||||
* `missing required field 'arguments'`). Valid source values round-trip:
|
||||
* - object arguments -> JSON string of the object
|
||||
* - string arguments -> the string as-is (already valid JSON)
|
||||
* - missing / empty / invalid JSON -> "{}" (a valid empty-object string)
|
||||
*/
|
||||
function toolCallArgumentsString(value: unknown): string {
|
||||
const parsed = recordOrEmpty(value);
|
||||
if (isRecord(value)) return JSON.stringify(parsed);
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
return JSON.stringify(parsed);
|
||||
}
|
||||
|
||||
function normalizeContentText(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
return asRecordArray(content)
|
||||
@@ -244,11 +259,15 @@ function convertMessages(
|
||||
const id = stringValue(call.id) || "";
|
||||
if (!id || !pairedToolCallIds.has(id)) continue;
|
||||
const fn = isRecord(call.function) ? call.function : {};
|
||||
const parsedInput = recordOrEmpty(fn.arguments);
|
||||
parts.push({
|
||||
type: "tool-call",
|
||||
toolCallId: id,
|
||||
toolName: stringValue(fn.name) || "",
|
||||
input: recordOrEmpty(fn.arguments),
|
||||
input: parsedInput,
|
||||
// /alpha/generate requires this field on assistant tool-call parts;
|
||||
// a missing one is rejected with `missing required field 'arguments'`.
|
||||
arguments: toolCallArgumentsString(fn.arguments),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -420,7 +439,61 @@ type AggregateState = {
|
||||
usage: JsonRecord | null;
|
||||
};
|
||||
|
||||
function firstRecord(record: JsonRecord, keys: readonly string[]): JsonRecord {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (isRecord(value)) return value;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function firstNumber(record: JsonRecord, keys: readonly string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const value = numberValue(record[key]);
|
||||
if (value !== undefined) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Keep earlier finish-step usage when the terminal finish event omits it. */
|
||||
function mergeCommandCodeUsage(previous: JsonRecord | null, next: unknown): JsonRecord | null {
|
||||
if (!isRecord(next)) return previous;
|
||||
|
||||
const merged: JsonRecord = { ...(previous || {}), ...next };
|
||||
for (const key of [
|
||||
"inputTokenDetails",
|
||||
"input_token_details",
|
||||
"input_tokens_details",
|
||||
"prompt_tokens_details",
|
||||
"outputTokenDetails",
|
||||
"output_token_details",
|
||||
"output_tokens_details",
|
||||
"completion_tokens_details",
|
||||
"reasoningTokenDetails",
|
||||
"reasoning_token_details",
|
||||
]) {
|
||||
const before = isRecord(previous?.[key]) ? previous[key] : {};
|
||||
const after = isRecord(next[key]) ? next[key] : {};
|
||||
if (Object.keys(before).length > 0 || Object.keys(after).length > 0) {
|
||||
merged[key] = { ...before, ...after };
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function rememberCommandCodeUsage(state: AggregateState, event: JsonRecord): void {
|
||||
const usage =
|
||||
event.type === "finish-step"
|
||||
? (event.usage ?? event.totalUsage)
|
||||
: (event.totalUsage ?? event.usage);
|
||||
state.usage = mergeCommandCodeUsage(state.usage, usage);
|
||||
}
|
||||
|
||||
function applyEventToAggregate(event: JsonRecord, state: AggregateState): void {
|
||||
// Some Command Code protocol revisions attach usage to the terminal payload
|
||||
// without preserving the event type. Capture it before event-specific handling.
|
||||
rememberCommandCodeUsage(state, event);
|
||||
|
||||
switch (event.type) {
|
||||
case "text-delta":
|
||||
state.content += stringValue(event.text) || "";
|
||||
@@ -440,9 +513,10 @@ function applyEventToAggregate(event: JsonRecord, state: AggregateState): void {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "finish-step":
|
||||
break;
|
||||
case "finish":
|
||||
state.finishReason = mapFinishReason(event.finishReason);
|
||||
state.usage = isRecord(event.totalUsage) ? event.totalUsage : null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -460,30 +534,72 @@ function applyEventToAggregateOrThrow(event: JsonRecord, state: AggregateState):
|
||||
|
||||
function usageFromCommandCode(usage: JsonRecord | null) {
|
||||
if (!usage) return undefined;
|
||||
const details = isRecord(usage.inputTokenDetails) ? usage.inputTokenDetails : {};
|
||||
const cacheRead = numberValue(details.cacheReadTokens) || 0;
|
||||
const noCache = numberValue(details.noCacheTokens) || 0;
|
||||
const inputDetails = firstRecord(usage, [
|
||||
"inputTokenDetails",
|
||||
"input_token_details",
|
||||
"input_tokens_details",
|
||||
"prompt_tokens_details",
|
||||
]);
|
||||
const outputDetails = firstRecord(usage, [
|
||||
"outputTokenDetails",
|
||||
"output_token_details",
|
||||
"output_tokens_details",
|
||||
"completion_tokens_details",
|
||||
]);
|
||||
const reasoningDetails = firstRecord(usage, [
|
||||
"reasoningTokenDetails",
|
||||
"reasoning_token_details",
|
||||
"reasoning_tokens_details",
|
||||
]);
|
||||
const cacheRead =
|
||||
firstNumber(usage, [
|
||||
"cachedInputTokens",
|
||||
"cached_input_tokens",
|
||||
"cacheReadInputTokens",
|
||||
"cache_read_input_tokens",
|
||||
"cacheReadTokens",
|
||||
"cache_read_tokens",
|
||||
"cached_tokens",
|
||||
]) ??
|
||||
firstNumber(inputDetails, [
|
||||
"cachedTokens",
|
||||
"cached_tokens",
|
||||
"cacheReadTokens",
|
||||
"cache_read_tokens",
|
||||
]);
|
||||
const noCache = firstNumber(inputDetails, ["noCacheTokens", "no_cache_tokens"]);
|
||||
// Command Code's totalUsage.inputTokens is the FULL prompt total and already
|
||||
// includes the cached portion (noCacheTokens + cacheReadTokens = inputTokens),
|
||||
// so we must NOT add cacheRead back — that would double-count. There is no
|
||||
// cache-write field in the upstream payload, so cache creation stays unset.
|
||||
const inputTokens = numberValue(usage.inputTokens) || 0;
|
||||
const prompt = inputTokens;
|
||||
const completion = numberValue(usage.outputTokens) || 0;
|
||||
const prompt =
|
||||
firstNumber(usage, ["inputTokens", "input_tokens", "promptTokens", "prompt_tokens"]) ??
|
||||
(noCache ?? 0) + (cacheRead ?? 0);
|
||||
const reasoning =
|
||||
firstNumber(usage, ["reasoningTokens", "reasoning_tokens"]) ??
|
||||
firstNumber(outputDetails, ["reasoningTokens", "reasoning_tokens"]) ??
|
||||
firstNumber(reasoningDetails, ["reasoningTokens", "reasoning_tokens"]);
|
||||
const textOutput = firstNumber(outputDetails, ["textTokens", "text_tokens"]);
|
||||
const completion =
|
||||
firstNumber(usage, [
|
||||
"outputTokens",
|
||||
"output_tokens",
|
||||
"completionTokens",
|
||||
"completion_tokens",
|
||||
]) ?? (textOutput ?? 0) + (reasoning ?? 0);
|
||||
const total = firstNumber(usage, ["totalTokens", "total_tokens"]) ?? prompt + completion;
|
||||
const result: JsonRecord = {
|
||||
prompt_tokens: prompt,
|
||||
prompt_tokens_details: { cached_tokens: cacheRead ?? 0 },
|
||||
completion_tokens: completion,
|
||||
total_tokens: prompt + completion,
|
||||
completion_tokens_details: { reasoning_tokens: reasoning ?? 0 },
|
||||
total_tokens: total,
|
||||
};
|
||||
// Surface the cache breakdown as informational fields so logUsage prints
|
||||
// `| cache_read=X | no_cache=Y` and appendRequestLog persists them. These are
|
||||
// NOT added to prompt_tokens (already included) — metering stays accurate.
|
||||
if (cacheRead > 0) result.cache_read_input_tokens = cacheRead;
|
||||
if (noCache > 0) result.no_cache_tokens = noCache;
|
||||
// Keep reasoning_token_details (reasoningTokens) when present so stream.ts's
|
||||
// extractUsage can surface it as reasoning_tokens.
|
||||
const reasoningDetails = isRecord(usage.reasoningTokenDetails) ? usage.reasoningTokenDetails : {};
|
||||
const reasoning = numberValue(reasoningDetails.reasoningTokens);
|
||||
if (cacheRead !== undefined && cacheRead > 0) result.cache_read_input_tokens = cacheRead;
|
||||
if (noCache !== undefined && noCache > 0) result.no_cache_tokens = noCache;
|
||||
if (reasoning !== undefined && reasoning > 0) result.reasoning_tokens = reasoning;
|
||||
return result;
|
||||
}
|
||||
@@ -523,6 +639,7 @@ function createStreamResponse(
|
||||
|
||||
const emitEvent = (event: unknown) => {
|
||||
if (!isRecord(event) || closed) return;
|
||||
rememberCommandCodeUsage(state, event);
|
||||
if (!sentRole) {
|
||||
sentRole = true;
|
||||
controller.enqueue(sse(chatCompletionChunk(id, model, { role: "assistant" })));
|
||||
@@ -562,9 +679,10 @@ function createStreamResponse(
|
||||
}
|
||||
case "reasoning-end":
|
||||
break;
|
||||
case "finish-step":
|
||||
break;
|
||||
case "finish": {
|
||||
state.finishReason = mapFinishReason(event.finishReason);
|
||||
state.usage = isRecord(event.totalUsage) ? event.totalUsage : null;
|
||||
controller.enqueue(sse(chatCompletionChunk(id, model, {}, state.finishReason)));
|
||||
// Emit a standards-compliant usage-only chunk (choices: []) before
|
||||
// [DONE] when upstream reported usage. stream.ts's extractUsage
|
||||
|
||||
@@ -515,7 +515,6 @@ export function messagesToPrompt(
|
||||
historyWindow = 0
|
||||
): string {
|
||||
if (messages.length === 0) return "";
|
||||
|
||||
const systemParts: string[] = [];
|
||||
const conversation: Array<{ role: string; text: string }> = [];
|
||||
const callNameById = new Map<string, string>();
|
||||
@@ -527,8 +526,9 @@ export function messagesToPrompt(
|
||||
} else if (m.role === "user" || m.role === "assistant") {
|
||||
if (text) conversation.push({ role: m.role, text });
|
||||
if (m.role === "user") lastUserContent = text;
|
||||
const calls = Array.isArray((m as { tool_calls?: unknown }).tool_calls)
|
||||
? (m as { tool_calls: Array<{ id?: string; function?: { name?: string } }> }).tool_calls
|
||||
const toolCalls = (m as { tool_calls?: unknown }).tool_calls;
|
||||
const calls = Array.isArray(toolCalls)
|
||||
? (toolCalls as Array<{ id?: string; function?: { name?: string } }>)
|
||||
: [];
|
||||
for (const c of calls) {
|
||||
if (c?.id && typeof c.function?.name === "string") callNameById.set(c.id, c.function.name);
|
||||
|
||||
@@ -61,12 +61,11 @@ import {
|
||||
} from "@/lib/providers/validation/urlHelpers";
|
||||
import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts";
|
||||
import { resolveZaiUrl } from "./default/zaiFormatOverride.ts";
|
||||
import { normalizePoolConfig } from "./default/poolConfig.ts";
|
||||
import { acquireNvidiaConcurrencySlot } from "./default/nvidiaConcurrencyGate.ts";
|
||||
import { resolveAlibabaProviderBaseUrl } from "@/shared/constants/alibabaProviderRegions";
|
||||
import { usesCcWireImage } from "../services/ccWireImageBuiltins.ts";
|
||||
|
||||
import type { PoolConfig } from "../services/sessionPool/types.ts";
|
||||
|
||||
const NVIDIA_TOOL_CALL_ID_PATTERN = /^[A-Za-z0-9]{9}$/;
|
||||
|
||||
function normalizeNvidiaToolCallId(id: unknown): unknown {
|
||||
@@ -146,7 +145,7 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
super(provider, PROVIDERS[provider] || PROVIDERS.openai);
|
||||
const registryEntry = getRegistryEntry(provider);
|
||||
if (registryEntry?.poolConfig) {
|
||||
this.poolConfig = registryEntry.poolConfig as PoolConfig;
|
||||
this.poolConfig = normalizePoolConfig(registryEntry.poolConfig) ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
33
open-sse/executors/default/poolConfig.ts
Normal file
33
open-sse/executors/default/poolConfig.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { PoolConfig } from "../../services/sessionPool/types.ts";
|
||||
|
||||
export function normalizePoolConfig(value: Record<string, unknown>): PoolConfig | null {
|
||||
const {
|
||||
minSessions,
|
||||
maxSessions,
|
||||
cooldownBase,
|
||||
cooldownMax,
|
||||
cooldownJitter,
|
||||
requestTimeout,
|
||||
requestJitter,
|
||||
} = value;
|
||||
if (
|
||||
typeof minSessions !== "number" ||
|
||||
typeof maxSessions !== "number" ||
|
||||
typeof cooldownBase !== "number" ||
|
||||
typeof cooldownMax !== "number" ||
|
||||
typeof cooldownJitter !== "number" ||
|
||||
typeof requestTimeout !== "number" ||
|
||||
typeof requestJitter !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
minSessions,
|
||||
maxSessions,
|
||||
cooldownBase,
|
||||
cooldownMax,
|
||||
cooldownJitter,
|
||||
requestTimeout,
|
||||
requestJitter,
|
||||
};
|
||||
}
|
||||
@@ -137,8 +137,23 @@ interface DuckDuckGoModelCapabilities {
|
||||
reasoningEffort: string | null;
|
||||
}
|
||||
|
||||
type DuckDuckGoRequestMessage = Record<string, unknown> & {
|
||||
role: string;
|
||||
content: unknown;
|
||||
};
|
||||
|
||||
let durablePublicKey: JsonWebKey | null = null;
|
||||
|
||||
export function normalizeDuckDuckGoMessages(value: unknown): DuckDuckGoRequestMessage[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((message) => {
|
||||
if (!message || typeof message !== "object" || Array.isArray(message)) return [];
|
||||
const record = message as Record<string, unknown>;
|
||||
if (typeof record.role !== "string") return [];
|
||||
return [{ ...record, role: record.role, content: record.content }];
|
||||
});
|
||||
}
|
||||
|
||||
function extractDuckDuckGoContent(data: unknown): string {
|
||||
if (!data || typeof data !== "object") return "";
|
||||
const record = data as Record<string, unknown>;
|
||||
@@ -251,11 +266,14 @@ export function normalizeDuckDuckGoModel(model: string | undefined): string {
|
||||
}
|
||||
|
||||
function getDuckDuckGoModelCapabilities(model: string): DuckDuckGoModelCapabilities {
|
||||
// Per duckchat/v1/models (2026-07-22): claude-haiku-4-5 and gpt-oss-120b take a "low"
|
||||
// reasoningEffort on the free tier; the others omit it (duck.ai applies its own default).
|
||||
// `reasoningEffort` is REQUIRED on every duckchat/v1/chat request. Omitting it
|
||||
// returns 400 ERR_BAD_REQUEST — A/B verified live against duck.ai with an
|
||||
// otherwise byte-identical payload (200 with the field, 400 without, repeated).
|
||||
// The live duck.ai bundle always sends one, so there is no "let the server
|
||||
// pick a default" path any more.
|
||||
if (model === "claude-haiku-4-5") return { reasoningEffort: "low" };
|
||||
if (model === "tinfoil/gpt-oss-120b") return { reasoningEffort: "low" };
|
||||
return { reasoningEffort: null };
|
||||
return { reasoningEffort: "none" };
|
||||
}
|
||||
|
||||
function extractDuckDuckGoFeVersion(html: string): string | null {
|
||||
@@ -353,7 +371,6 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
private warmed = false;
|
||||
private seeded = false;
|
||||
private feVersion = DEFAULT_FE_VERSION;
|
||||
private pendingVqdHash1: string | null = null;
|
||||
private readonly cookieJar = new Map<string, string>();
|
||||
@@ -440,14 +457,12 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
|
||||
const { model, body, stream, signal, upstreamExtraHeaders } = input;
|
||||
const upstreamModel = normalizeDuckDuckGoModel(model);
|
||||
const bodyObj = (body || {}) as Record<string, unknown>;
|
||||
const rawMessages = Array.isArray((body as { messages?: unknown[] } | null)?.messages)
|
||||
? ((body as { messages: unknown[] }).messages as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const rawMessages = normalizeDuckDuckGoMessages(bodyObj.messages);
|
||||
const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(
|
||||
bodyObj,
|
||||
rawMessages
|
||||
);
|
||||
const messages = effectiveMessages as Array<Record<string, unknown>>;
|
||||
const messages = effectiveMessages;
|
||||
const isStreaming = stream !== false;
|
||||
const upstreamHeaders = upstreamExtraHeaders || {};
|
||||
|
||||
@@ -561,7 +576,12 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
await this.warmSession(mergedSignal);
|
||||
await this.seedChallengeChain(upstreamModel, mergedSignal);
|
||||
// NOTE: the throwaway "seed" chat POST that used to run here has been removed.
|
||||
// It existed to coax a usable challenge out of the upstream while the solver
|
||||
// was broken; now that the solver reproduces a real browser's probe vectors
|
||||
// exactly, the first real request succeeds on its own. Keeping it only doubled
|
||||
// the chat calls per user request against an IP-rate-limited endpoint, which
|
||||
// showed up as spurious 429 ERR_RATE_LIMIT.
|
||||
const vqdHeaders = await this.acquireAuthHeaders(mergedSignal);
|
||||
if (!vqdHeaders.vqd4 && !vqdHeaders.vqdHash1) {
|
||||
clearTimeout(timeout);
|
||||
@@ -770,41 +790,6 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
private async seedChallengeChain(model: string, signal: AbortSignal): Promise<void> {
|
||||
if (this.seeded || signal.aborted) return;
|
||||
this.seeded = true;
|
||||
const seedMessages = [{ role: "user", content: "hi" }];
|
||||
const previousPending = this.pendingVqdHash1;
|
||||
try {
|
||||
const vqdHeaders = await this.acquireAuthHeaders(signal);
|
||||
if (!vqdHeaders.vqd4 && !vqdHeaders.vqdHash1) {
|
||||
this.pendingVqdHash1 = previousPending;
|
||||
return;
|
||||
}
|
||||
const response = await fetch(CHAT_URL, {
|
||||
method: "POST",
|
||||
headers: mergeHeadersCaseInsensitive(this.buildRequestHeaders(), {
|
||||
Accept: "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
"x-ddg-journey-id": randomUUID().replaceAll("-", ""),
|
||||
"x-fe-signals": makeDuckDuckGoFeSignals(),
|
||||
"x-fe-version": this.feVersion,
|
||||
...(vqdHeaders.vqd4 ? { "x-vqd-4": vqdHeaders.vqd4 } : {}),
|
||||
...(vqdHeaders.vqdHash1 ? { "x-vqd-hash-1": vqdHeaders.vqdHash1 } : {}),
|
||||
}),
|
||||
body: JSON.stringify(buildDuckDuckGoPayload(model, seedMessages, false)),
|
||||
signal,
|
||||
});
|
||||
this.rememberResponseCookies(response);
|
||||
if (response.ok) this.rememberChallengeHeader(response);
|
||||
else this.pendingVqdHash1 = previousPending;
|
||||
await response.body?.cancel().catch(() => {});
|
||||
} catch (error) {
|
||||
void error;
|
||||
this.pendingVqdHash1 = previousPending;
|
||||
}
|
||||
}
|
||||
|
||||
private async processResponse(
|
||||
response: Response,
|
||||
streaming: boolean,
|
||||
|
||||
@@ -5,12 +5,38 @@ import { createHash } from "node:crypto";
|
||||
import vm from "node:vm";
|
||||
import { parseFragment, serialize } from "parse5";
|
||||
|
||||
// WARNING: the contents of this template literal are NOT TypeScript — they are plain
|
||||
// script-mode JavaScript executed via `vm.runInContext`. `vm.runInContext` compiles in
|
||||
// script (non-module) mode, so an `export` keyword anywhere in here is a hard
|
||||
// SyntaxError that kills the whole solver. A refactor that mass-added `export` to the
|
||||
// five `function` declarations below silently broke every DuckDuckGo chat request
|
||||
// (solve threw -> unsolved challenge sent -> HTTP 418 ERR_CHALLENGE). Do not add
|
||||
// `export`/`import` to this string; `duckduckgo-challenge-split.test.ts` guards this.
|
||||
export const CHALLENGE_STUBS = String.raw`
|
||||
var __ua = __DDG_REAL_UA__;
|
||||
var __HTML_LOOKUP = __DDG_HTML_LOOKUP__;
|
||||
export function __makeHtmlElement(tag) {
|
||||
// Browser-fidelity shims for the DDG "am I a real browser" probes.
|
||||
// In a browser every built-in stringifies as native code; under a plain vm
|
||||
// context the user-land re-declarations below would otherwise leak their source.
|
||||
function __nativeFn(fn, name){
|
||||
Object.defineProperty(fn, 'name', { value: name, configurable: true });
|
||||
fn.toString = function(){ return 'function ' + name + '() { [native code] }'; };
|
||||
return fn;
|
||||
}
|
||||
__nativeFn(parseInt, 'parseInt');
|
||||
__nativeFn(parseFloat, 'parseFloat');
|
||||
__nativeFn(isNaN, 'isNaN');
|
||||
__nativeFn(encodeURIComponent, 'encodeURIComponent');
|
||||
__nativeFn(decodeURIComponent, 'decodeURIComponent');
|
||||
// NOTE: do NOT seal Math. Real Chromium reports Object.isSealed(Math) === false,
|
||||
// and at least one challenge variant probes exactly that; sealing it here made
|
||||
// the vector differ from the browser by one and failed the challenge.
|
||||
function __makeHtmlElement(tag) {
|
||||
var state = { _innerHTML: '', _qsaCount: 0, _cssText: '' };
|
||||
var el = {
|
||||
// Instantiate against the real per-tag constructor so
|
||||
// document.createElement('div') instanceof HTMLDivElement holds.
|
||||
var el = Object.create(__ctorForTag(tag).prototype);
|
||||
Object.assign(el, {
|
||||
tagName: String(tag).toUpperCase(), nodeName: String(tag).toUpperCase(), nodeType: 1,
|
||||
children: [], childNodes: [], classList: [], dataset: {},
|
||||
offsetWidth: 1, offsetHeight: 1, clientWidth: 1, clientHeight: 1, scrollHeight: 1, scrollWidth: 1,
|
||||
@@ -19,9 +45,9 @@ export function __makeHtmlElement(tag) {
|
||||
getAttribute: function(a){ if(a==='srcdoc') return state._srcdoc||''; return null; },
|
||||
hasAttribute: function(){ return false; }, appendChild: function(c){ return c; }, removeChild: function(c){ return c; },
|
||||
addEventListener: function(){}, removeEventListener: function(){}, querySelector: function(){ return null; },
|
||||
querySelectorAll: function(s){ if (s === '*') { var arr = []; arr.length = state._qsaCount; return arr; } return []; },
|
||||
querySelectorAll: function(s){ if (s === '*') { return __makeNodeList(state._qsaCount); } return __makeNodeList(0); },
|
||||
cloneNode: function(){ return __makeHtmlElement(tag); }
|
||||
};
|
||||
});
|
||||
Object.defineProperty(el, 'style', { value: new Proxy({}, { set: function(t, k, v){ t[k] = v; if (k === 'cssText') state._cssText = String(v); return true; }, get: function(t, k){ if (k === 'cssText') return state._cssText; return t[k] || ''; } }), enumerable: true, configurable: true });
|
||||
Object.defineProperty(el, 'innerHTML', { get: function(){ return state._innerHTML; }, set: function(v){ var key = String(v); var entry = __HTML_LOOKUP && __HTML_LOOKUP[key]; if (entry) { state._innerHTML = String(entry.html); state._qsaCount = entry.count|0; } else { state._innerHTML = key; state._qsaCount = 0; } }, enumerable: true, configurable: true });
|
||||
Object.defineProperty(el, 'outerHTML', { get: function(){ return '<' + tag + '>' + state._innerHTML + '</' + tag + '>'; }, enumerable: true });
|
||||
@@ -30,7 +56,7 @@ export function __makeHtmlElement(tag) {
|
||||
Object.defineProperty(el, 'contentDocument', { get: function(){ return __ifDoc; }, enumerable: true });
|
||||
return el;
|
||||
}
|
||||
export function __mkObj(name, base) {
|
||||
function __mkObj(name, base) {
|
||||
base = base || {};
|
||||
return new Proxy(base, {
|
||||
get: function(t, k) {
|
||||
@@ -54,18 +80,105 @@ export function __mkObj(name, base) {
|
||||
has: function(t, k){ return k in t; }, set: function(t, k, v){ t[k] = v; return true; }
|
||||
});
|
||||
}
|
||||
export function __parseCssDisplay(cssText){ if(!cssText) return ''; var m = String(cssText).match(/(?:^|;)\\s*display\\s*:\\s*([^;]+)/i); return m ? String(m[1]).trim() : ''; }
|
||||
export function __getComputedStyle(el){ var cssText = el && el.style && el.style.cssText || ''; var display = __parseCssDisplay(cssText); return { getPropertyValue: function(name){ if(String(name).toLowerCase()==='display') return display; return ''; }, cssText: cssText, display: display }; }
|
||||
function __parseCssDisplay(cssText){ if(!cssText) return ''; var m = String(cssText).match(/(?:^|;)\s*display\s*:\s*([^;]+)/i); return m ? String(m[1]).trim() : ''; }
|
||||
function __getComputedStyle(el){ var cssText = el && el.style && el.style.cssText || ''; var display = __parseCssDisplay(cssText); return { getPropertyValue: function(name){ if(String(name).toLowerCase()==='display') return display; return ''; }, cssText: cssText, display: display }; }
|
||||
var __ifMeta = __mkObj('meta', { getAttribute: function(a){ return a==='content' ? "default-src 'none'; script-src 'unsafe-inline';" : null; }, hasAttribute: function(a){ return a==='content'; }, tagName: 'META', nodeName: 'META' });
|
||||
var __ifDoc = __mkObj('iframeDoc', { querySelector: function(s){ if (s && s.indexOf('Content-Security-Policy') !== -1) return __ifMeta; if (s === 'meta') return __ifMeta; return null; }, querySelectorAll: function(s){ if (s && s.indexOf('Content-Security-Policy') !== -1) return [__ifMeta]; if (s === 'meta') return [__ifMeta]; return []; }, getElementsByTagName: function(t){ return t && t.toLowerCase()==='meta' ? [__ifMeta] : []; }, body: __mkObj('iframeBody'), head: __mkObj('iframeHead'), documentElement: __mkObj('iframeRoot'), createElement: function(){ return __mkObj('elem', {setAttribute:function(){}, appendChild:function(){}, removeChild:function(){}, getAttribute:function(){return null;}, hasAttribute:function(){return false;}}); }, cookie: '', readyState: 'complete' });
|
||||
var __iframeEl = __mkObj('iframe', { contentDocument: __ifDoc, contentWindow: __mkObj('iframeWin', { document: __ifDoc, top: undefined, parent: undefined }), document: __ifDoc, getAttribute: function(a){ if (a==='sandbox') return 'allow-scripts allow-same-origin'; if (a==='srcdoc') return ''; if (a==='id') return 'jsa'; return null; }, hasAttribute: function(a){ return a==='sandbox'||a==='id'; }, tagName: 'IFRAME', nodeName: 'IFRAME', id: 'jsa' });
|
||||
var document = __mkObj('document', { querySelector: function(s){ if (s === '#jsa') return __iframeEl; if (s && s.indexOf('Content-Security-Policy') !== -1) return __ifMeta; return null; }, querySelectorAll: function(s){ if (s === '#jsa') return [__iframeEl]; if (s && s.indexOf('Content-Security-Policy') !== -1) return [__ifMeta]; return []; }, getElementById: function(id){ return id==='jsa' ? __iframeEl : null; }, getElementsByTagName: function(t){ if(t&&t.toLowerCase()==='iframe') return [__iframeEl]; return []; }, getElementsByClassName: function(){ return []; }, body: __mkObj('body', {appendChild:function(){}, removeChild:function(){}, querySelector:function(s){return s==='#jsa'?__iframeEl:null;}, querySelectorAll:function(s){return s==='#jsa'?[__iframeEl]:[];}}), head: __mkObj('head'), documentElement: __mkObj('root'), createElement: function(tag){ return __makeHtmlElement(tag||'div'); }, createTextNode: function(t){ return {nodeType:3, nodeValue:String(t||''), textContent:String(t||'')}; }, cookie: '', readyState: 'complete', title: '', addEventListener: function(){}, removeEventListener: function(){} });
|
||||
// document.body keeps a LIVE children collection: challenges append a node and
|
||||
// assert body.children.length grew by exactly 1, then remove it again.
|
||||
var __bodyKids = [];
|
||||
Object.defineProperty(__bodyKids, 'constructor', { value: HTMLCollection, enumerable: false, configurable: true });
|
||||
var __body = __mkObj('body', {
|
||||
appendChild: function(c){ __bodyKids.push(c); return c; },
|
||||
removeChild: function(c){ var i = __bodyKids.indexOf(c); if (i !== -1) __bodyKids.splice(i, 1); return c; },
|
||||
contains: function(c){ return __bodyKids.indexOf(c) !== -1; },
|
||||
querySelector: function(s){ return s === '#jsa' ? __iframeEl : null; },
|
||||
querySelectorAll: function(s){ return s === '#jsa' ? [__iframeEl] : __makeNodeList(0); },
|
||||
children: __bodyKids, childNodes: __bodyKids,
|
||||
tagName: 'BODY', nodeName: 'BODY', nodeType: 1
|
||||
});
|
||||
var document = __mkObj('document', { querySelector: function(s){ if (s === '#jsa') return __iframeEl; if (s && s.indexOf('Content-Security-Policy') !== -1) return __ifMeta; return null; }, querySelectorAll: function(s){ if (s === '#jsa') return [__iframeEl]; if (s && s.indexOf('Content-Security-Policy') !== -1) return [__ifMeta]; return __makeNodeList(__bodyKids.length + 3); }, getElementById: function(id){ return id==='jsa' ? __iframeEl : null; }, getElementsByTagName: function(t){ if(t&&t.toLowerCase()==='iframe') return [__iframeEl]; return []; }, getElementsByClassName: function(){ return []; }, body: __body, head: __mkObj('head'), documentElement: __mkObj('root'), createElement: function(tag){ return __makeHtmlElement(tag||'div'); }, createTextNode: function(t){ return {nodeType:3, nodeValue:String(t||''), textContent:String(t||'')}; }, cookie: '', readyState: 'complete', title: '', addEventListener: function(){}, removeEventListener: function(){} });
|
||||
var window = __mkObj('window', { document: document, __DDG_BE_VERSION__: 1, __DDG_FE_CHAT_HASH__: 1, navigator: __mkObj('navigator', { userAgent: __ua, webdriver: false, language: 'en-US', languages: ['en-US','en'], platform: 'Linux x86_64', vendor: 'Google Inc.', appVersion: '5.0 (X11)', cookieEnabled: true, onLine: true, hardwareConcurrency: 8, deviceMemory: 8 }), innerWidth: 1280, innerHeight: 800, outerWidth: 1280, outerHeight: 800, devicePixelRatio: 1, screen: __mkObj('screen', { width:1920, height:1080, availWidth:1920, availHeight:1080, colorDepth:24, pixelDepth:24 }), location: __mkObj('location', { href:'https://duck.ai/', origin:'https://duck.ai', host:'duck.ai', hostname:'duck.ai', protocol:'https:', pathname:'/' }), performance: __mkObj('perf', { now: function(){ return 0; }, timeOrigin: 0 }), history: __mkObj('history', { length: 1, state: null }), addEventListener: function(){}, removeEventListener: function(){}, dispatchEvent: function(){return true;}, setTimeout: function(fn){ try{fn();}catch(e){} return 0; }, clearTimeout: function(){}, hasOwnProperty: function(k){ if (k==='__DDG_BE_VERSION__'||k==='__DDG_FE_CHAT_HASH__') return true; return Object.prototype.hasOwnProperty.call(this,k); } });
|
||||
window.top = window; window.self = window; window.window = window; window.parent = window; window.globalThis = window;
|
||||
// Object.prototype.toString.call(window) must be "[object Window]".
|
||||
try { window[Symbol.toStringTag] = 'Window'; } catch (e) {}
|
||||
// In a browser a sloppy-mode function called with no receiver gets the global
|
||||
// object, and challenges assert (function(){return this;})() === window.
|
||||
// In a vm context that is the context's own global, so alias it to window.
|
||||
try {
|
||||
var __g = (function(){ return this; })();
|
||||
if (__g && __g !== window) {
|
||||
Object.defineProperty(__g, Symbol.toStringTag, { value: 'Window', configurable: true });
|
||||
// Copy by VALUE, not via accessors. Two reasons:
|
||||
// 1) the var top/self/navigator/... declarations further down are hoisted,
|
||||
// so those names already exist on the vm global and an "in" guard would
|
||||
// skip them, leaving window.navigator undefined;
|
||||
// 2) accessors closing over the window binding would recurse once it is
|
||||
// rebound to __g below.
|
||||
// The stub window is static, so a value copy is equivalent.
|
||||
var __winStub = window;
|
||||
for (var __k in __winStub) {
|
||||
try { __g[__k] = __winStub[__k]; } catch (e) {}
|
||||
}
|
||||
// hasOwnProperty is probed for the __DDG_* markers; keep the stub's version.
|
||||
try { __g.hasOwnProperty = function(k){ return __winStub.hasOwnProperty(k); }; } catch (e) {}
|
||||
window = __g;
|
||||
window.top = window; window.self = window; window.window = window; window.parent = window; window.globalThis = window;
|
||||
}
|
||||
} catch (e) {}
|
||||
var top = window, self = window, parent = window, navigator = window.navigator, location = window.location, screen = window.screen, performance = window.performance, history = window.history;
|
||||
var __R = null, __E = null;
|
||||
export function __HTMLClass(name){ var c = function(){}; c.prototype = __mkObj(name+'.proto'); return c; }
|
||||
var HTMLElement = __HTMLClass('HTMLElement'), HTMLDivElement = __HTMLClass('HTMLDivElement'), HTMLIFrameElement = __HTMLClass('HTMLIFrameElement'), HTMLDocument = __HTMLClass('HTMLDocument'), Document = __HTMLClass('Document'), Element = __HTMLClass('Element'), Node = __HTMLClass('Node'), Window = __HTMLClass('Window'), Event = __HTMLClass('Event'), MouseEvent = __HTMLClass('MouseEvent'), KeyboardEvent = __HTMLClass('KeyboardEvent'), TouchEvent = __HTMLClass('TouchEvent'), XMLHttpRequest = __HTMLClass('XMLHttpRequest'), WebSocket = __HTMLClass('WebSocket'), Image = __HTMLClass('Image'), FormData = __HTMLClass('FormData'), Blob = __HTMLClass('Blob'), File = __HTMLClass('File'), FileReader = __HTMLClass('FileReader'), URL = __HTMLClass('URL'), URLSearchParams = __HTMLClass('URLSearchParams'), Headers = __HTMLClass('Headers'), Request = __HTMLClass('Request'), Response = __HTMLClass('Response');
|
||||
// Real DOM constructor chain. Some DDG challenge variants assert
|
||||
// HTMLDivElement.prototype instanceof HTMLElement and
|
||||
// HTMLElement.prototype instanceof Element, so these cannot be flat
|
||||
// unrelated stubs — the prototype links have to be real.
|
||||
function __DomClass(name, parent){
|
||||
var c = function(){};
|
||||
if (parent) c.prototype = Object.create(parent.prototype);
|
||||
c.prototype.constructor = c;
|
||||
Object.defineProperty(c, 'name', { value: name, configurable: true });
|
||||
c.toString = function(){ return 'function ' + name + '() { [native code] }'; };
|
||||
return c;
|
||||
}
|
||||
var EventTarget = __DomClass('EventTarget', null);
|
||||
var Node = __DomClass('Node', EventTarget);
|
||||
var Element = __DomClass('Element', Node);
|
||||
var HTMLElement = __DomClass('HTMLElement', Element);
|
||||
var HTMLDivElement = __DomClass('HTMLDivElement', HTMLElement);
|
||||
var HTMLIFrameElement = __DomClass('HTMLIFrameElement', HTMLElement);
|
||||
var HTMLLIElement = __DomClass('HTMLLIElement', HTMLElement);
|
||||
var HTMLUnknownElement = __DomClass('HTMLUnknownElement', HTMLElement);
|
||||
var Document = __DomClass('Document', Node);
|
||||
var HTMLDocument = __DomClass('HTMLDocument', Document);
|
||||
var NodeList = __DomClass('NodeList', null);
|
||||
var HTMLCollection = __DomClass('HTMLCollection', null);
|
||||
// Map a tag name to the constructor a browser would use, so
|
||||
// document.createElement('div') instanceof HTMLDivElement holds.
|
||||
function __ctorForTag(tag){
|
||||
var t = String(tag||'div').toLowerCase();
|
||||
if (t === 'div') return HTMLDivElement;
|
||||
if (t === 'iframe') return HTMLIFrameElement;
|
||||
if (t === 'li') return HTMLLIElement;
|
||||
return HTMLElement;
|
||||
}
|
||||
// A NodeList-like: array-shaped but NOT a real Array, with .constructor.name
|
||||
// === 'NodeList' — challenges check both !Array.isArray(x) and the ctor name.
|
||||
function __makeNodeList(length){
|
||||
var nl = Object.create(NodeList.prototype);
|
||||
var n = length|0;
|
||||
for (var i = 0; i < n; i++) nl[i] = __makeHtmlElement('div');
|
||||
Object.defineProperty(nl, 'length', { value: n, enumerable: false, configurable: true });
|
||||
nl.item = function(i){ return this[i] || null; };
|
||||
nl.forEach = function(fn, thisArg){ for (var i = 0; i < n; i++) fn.call(thisArg, this[i], i, this); };
|
||||
nl[Symbol.iterator] = function(){ var i = 0, self = this; return { next: function(){ return i < n ? { value: self[i++], done: false } : { value: undefined, done: true }; } }; };
|
||||
return nl;
|
||||
}
|
||||
function __HTMLClass(name){ var c = function(){}; c.prototype = __mkObj(name+'.proto'); return c; }
|
||||
// NOTE: HTMLElement / HTMLDivElement / HTMLIFrameElement / Element / Node /
|
||||
// Document / HTMLDocument / NodeList are defined above via __DomClass with a
|
||||
// REAL prototype chain — do not redeclare them here or the instanceof probes break.
|
||||
var Window = __HTMLClass('Window'), Event = __HTMLClass('Event'), MouseEvent = __HTMLClass('MouseEvent'), KeyboardEvent = __HTMLClass('KeyboardEvent'), TouchEvent = __HTMLClass('TouchEvent'), XMLHttpRequest = __HTMLClass('XMLHttpRequest'), WebSocket = __HTMLClass('WebSocket'), Image = __HTMLClass('Image'), FormData = __HTMLClass('FormData'), Blob = __HTMLClass('Blob'), File = __HTMLClass('File'), FileReader = __HTMLClass('FileReader'), URL = __HTMLClass('URL'), URLSearchParams = __HTMLClass('URLSearchParams'), Headers = __HTMLClass('Headers'), Request = __HTMLClass('Request'), Response = __HTMLClass('Response');
|
||||
var fetch = function(){ return Promise.resolve(__mkObj('resp', {ok:true, status:200, json:function(){return Promise.resolve({});}, text:function(){return Promise.resolve('');}})); };
|
||||
var getComputedStyle = __getComputedStyle;
|
||||
`;
|
||||
@@ -90,9 +203,16 @@ export function buildHtmlLookup(js: string): Record<string, { html: string; coun
|
||||
if (seen.has(html)) continue;
|
||||
seen.add(html);
|
||||
const fragment = parseFragment(html);
|
||||
// `count` backs `element.querySelectorAll('*').length` for an element whose
|
||||
// innerHTML is `html`. `querySelectorAll('*')` on a container returns its
|
||||
// DESCENDANTS, and `countHtmlElements` already excludes the `#document-fragment`
|
||||
// root, so the fragment's element count IS the descendant count. The former
|
||||
// `- 1` undercounted by one (verified against a real browser: for
|
||||
// `<li><div></li><li></div` Chromium reports 3, this returned 2), which
|
||||
// corrupted every probe that multiplies by that length.
|
||||
lookup[html] = {
|
||||
html: serialize(fragment),
|
||||
count: Math.max(0, countHtmlElements(fragment) - 1),
|
||||
count: countHtmlElements(fragment),
|
||||
};
|
||||
}
|
||||
return lookup;
|
||||
@@ -102,14 +222,33 @@ export function sha256Base64(value: string): string {
|
||||
return createHash("sha256").update(value, "utf8").digest("base64");
|
||||
}
|
||||
|
||||
// Shape of the object a DDG challenge program resolves to.
|
||||
type DuckDuckGoChallengeResult = {
|
||||
client_hashes?: unknown;
|
||||
meta?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Origin the solved challenge claims to come from. The duck.ai frontend stamps
|
||||
* `meta.origin` with its own origin and the upstream cross-checks it.
|
||||
*/
|
||||
export const DUCKDUCKGO_CHALLENGE_ORIGIN = "https://duck.ai";
|
||||
|
||||
/**
|
||||
* `meta.stack` mimics the frontend's captured Error stack. The upstream only
|
||||
* requires a plausible stack that points at the duck.ai bundle — verified by
|
||||
* ablation: a generic bundle path is accepted, omitting the field is not.
|
||||
*/
|
||||
function buildChallengeStack(origin: string, bundlePath: string): string {
|
||||
const url = `${origin}${bundlePath}`;
|
||||
return `Error\nat l (${url}:2:1695625)\nat async ${url}:2:1519117`;
|
||||
}
|
||||
|
||||
export async function solveDuckDuckGoChallenge(
|
||||
challenge: string,
|
||||
userAgent: string
|
||||
userAgent: string,
|
||||
options: { origin?: string; bundlePath?: string } = {}
|
||||
): Promise<string> {
|
||||
// SECURITY NOTE: This function executes base64-decoded JavaScript from duck.ai via vm.runInContext.
|
||||
// The challenge code is upstream-supplied (supply-chain surface). It is sandboxed with a 5s timeout
|
||||
@@ -121,14 +260,31 @@ export async function solveDuckDuckGoChallenge(
|
||||
);
|
||||
const context = vm.createContext({});
|
||||
vm.runInContext(stubs, context, { timeout: 5000 });
|
||||
const startedAt = Date.now();
|
||||
const result = (await vm.runInContext(js, context, {
|
||||
timeout: 5000,
|
||||
})) as DuckDuckGoChallengeResult;
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const clientHashes = Array.isArray(result.client_hashes) ? result.client_hashes : [];
|
||||
if (clientHashes.length === 0)
|
||||
throw new Error("DuckDuckGo challenge returned empty client_hashes");
|
||||
clientHashes[0] = userAgent;
|
||||
result.client_hashes = clientHashes.map((hash) => sha256Base64(String(hash)));
|
||||
|
||||
// The real frontend augments the challenge's own `meta` with origin / stack /
|
||||
// duration before sending it back. Omitting them yields 418 ERR_CHALLENGE even
|
||||
// when every client_hash is correct (confirmed by capturing a real browser's
|
||||
// x-vqd-hash-1 header, which always carries all three).
|
||||
const origin = options.origin ?? DUCKDUCKGO_CHALLENGE_ORIGIN;
|
||||
const bundlePath = options.bundlePath ?? "/dist/duckai-dist/entry.duckai.js";
|
||||
const meta = (result.meta ?? {}) as Record<string, unknown>;
|
||||
result.meta = {
|
||||
...meta,
|
||||
origin,
|
||||
stack: buildChallengeStack(origin, bundlePath),
|
||||
duration: String(elapsedMs),
|
||||
};
|
||||
|
||||
return Buffer.from(JSON.stringify(result), "utf8").toString("base64");
|
||||
}
|
||||
|
||||
|
||||
@@ -80,16 +80,7 @@ export class GeminiBusinessExecutor extends BaseExecutor {
|
||||
// Extract cookies from credentials — check apiKey/cookie first, then
|
||||
// try each __Secure-1PSID* key in providerSpecificData individually.
|
||||
// A user with only __Secure-1PSID (no PSIDTS) is still valid.
|
||||
const directCookie =
|
||||
readCredentialString(credentials?.apiKey) || readCredentialString(credentials?.cookie);
|
||||
const psid = readProviderSpecificString(credentials?.providerSpecificData, [
|
||||
"__Secure-1PSID",
|
||||
"cookie",
|
||||
]);
|
||||
const psidts = readProviderSpecificString(credentials?.providerSpecificData, [
|
||||
"__Secure-1PSIDTS",
|
||||
]);
|
||||
const cookie = directCookie || [psid, psidts].filter(Boolean).join("; ");
|
||||
const cookie = resolveGeminiBusinessCookie(credentials);
|
||||
|
||||
if (!cookie) {
|
||||
return makeErrorResult(
|
||||
@@ -380,6 +371,15 @@ function readProviderSpecificString(providerSpecificData: unknown, keys: string[
|
||||
return "";
|
||||
}
|
||||
|
||||
export function resolveGeminiBusinessCookie(credentials: unknown): string {
|
||||
if (!credentials || typeof credentials !== "object") return "";
|
||||
const data = credentials as Record<string, unknown>;
|
||||
const directCookie = readCredentialString(data.apiKey) || readCredentialString(data.cookie);
|
||||
const psid = readProviderSpecificString(data.providerSpecificData, ["__Secure-1PSID", "cookie"]);
|
||||
const psidts = readProviderSpecificString(data.providerSpecificData, ["__Secure-1PSIDTS"]);
|
||||
return directCookie || [psid, psidts].filter(Boolean).join("; ");
|
||||
}
|
||||
|
||||
function extractTextContent(content: unknown): string {
|
||||
if (typeof content === "string") return content.trim();
|
||||
if (Array.isArray(content)) {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { ChatGptWebExecutor } from "./chatgpt-web.ts";
|
||||
import { BlackboxWebExecutor } from "./blackbox-web.ts";
|
||||
import { MuseSparkWebExecutor } from "./muse-spark-web.ts";
|
||||
import { AzureOpenAIExecutor } from "./azure-openai.ts";
|
||||
import { AzureAiExecutor } from "./azure-ai.ts";
|
||||
import { CommandCodeExecutor } from "./commandCode.ts";
|
||||
import { GitlabExecutor } from "./gitlab.ts";
|
||||
import { NlpCloudExecutor } from "./nlpcloud.ts";
|
||||
@@ -89,6 +90,7 @@ const executors = {
|
||||
glmt: new GlmExecutor("glmt"),
|
||||
cu: new CursorExecutor(), // Alias for cursor
|
||||
"azure-openai": new AzureOpenAIExecutor(),
|
||||
"azure-ai": new AzureAiExecutor(),
|
||||
"command-code": new CommandCodeExecutor(),
|
||||
cmd: new CommandCodeExecutor(), // Alias
|
||||
gitlab: new GitlabExecutor(),
|
||||
@@ -263,6 +265,7 @@ export { ChatGptWebExecutor } from "./chatgpt-web.ts";
|
||||
export { BlackboxWebExecutor } from "./blackbox-web.ts";
|
||||
export { MuseSparkWebExecutor } from "./muse-spark-web.ts";
|
||||
export { AzureOpenAIExecutor } from "./azure-openai.ts";
|
||||
export { AzureAiExecutor } from "./azure-ai.ts";
|
||||
export { CommandCodeExecutor } from "./commandCode.ts";
|
||||
export { GitlabExecutor } from "./gitlab.ts";
|
||||
export { NlpCloudExecutor } from "./nlpcloud.ts";
|
||||
|
||||
@@ -108,13 +108,9 @@ export function mapModel(model: string): string {
|
||||
const TOKEN_SEED = "oldllm-client-2026";
|
||||
const UA_PREFIX = CHROME_UA.slice(0, 20); // "Mozilla/5.0 (Windows"
|
||||
|
||||
type TheOldLlmProxy = {
|
||||
type?: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
} | null;
|
||||
type TheOldLlmProxy = Awaited<
|
||||
ReturnType<typeof import("../../src/lib/db/proxies").resolveProxyForProvider>
|
||||
>;
|
||||
|
||||
interface TheOldLlmFetchDependencies {
|
||||
resolveProxy: () => Promise<TheOldLlmProxy>;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { assembleStreamingResponseHeaders } from "./chatCore/streamingResponseHe
|
||||
import { storeStreamingSemanticCacheResponse } from "./chatCore/streamingSemanticCacheStore.ts";
|
||||
import { assembleStreamingPipeline } from "./chatCore/streamingPipeline.ts";
|
||||
import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts";
|
||||
import { applyResponsesInputPolicy } from "../services/responsesInputPolicy.ts";
|
||||
import {
|
||||
getHeaderValueCaseInsensitive,
|
||||
isNoMemoryRequested,
|
||||
@@ -170,6 +171,7 @@ import {
|
||||
ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE,
|
||||
STREAM_RECOVERY,
|
||||
DEFAULT_MAX_TOKENS,
|
||||
STREAM_DISCONNECT_GRACE_PERIOD_MS,
|
||||
} from "../config/constants.ts";
|
||||
import { createRecoverableStream, makeContinuationBody } from "../services/streamRecovery.ts";
|
||||
import {
|
||||
@@ -207,7 +209,6 @@ import { stageTrace } from "./chatCore/stageTrace.ts";
|
||||
import { attachCompressionUsageReceiptAfterAnalytics as attachCompressionUsageReceiptAfterAnalyticsFor } from "./chatCore/compressionUsageReceipt.ts";
|
||||
import { prepareUpstreamBody } from "./chatCore/upstreamBody.ts";
|
||||
import { getQuotaScopeLabelForProvider } from "../services/antigravityQuotaFamily.ts";
|
||||
|
||||
import {
|
||||
getCallLogPipelineCaptureStreamChunks,
|
||||
getCallLogPipelineMaxSizeBytes,
|
||||
@@ -367,9 +368,7 @@ import {
|
||||
isTpmExhausted,
|
||||
isRpmExhausted,
|
||||
} from "../services/geminiRateLimitTracker.ts";
|
||||
|
||||
import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts";
|
||||
|
||||
/**
|
||||
* Core chat handler - shared between SSE and Worker
|
||||
* Returns { success, response, status, error } for caller to handle fallback
|
||||
@@ -389,10 +388,8 @@ import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts";
|
||||
* @param {boolean} options.isCombo - Whether this request is from a combo
|
||||
* @param {string} options.connectionId - Connection ID for settings lookup
|
||||
*/
|
||||
|
||||
// extractSystemRoleMessages extracted to chatCore/claudeSystemRole.ts (#3501); re-exported above so
|
||||
// existing importers (e.g. tests/unit/system-role-extraction.test.ts) keep resolving it from here.
|
||||
|
||||
export async function handleChatCore({
|
||||
body,
|
||||
modelInfo,
|
||||
@@ -428,7 +425,6 @@ export async function handleChatCore({
|
||||
/* fail open */
|
||||
}
|
||||
}
|
||||
|
||||
// Per-request model-routing metadata (first extracted slice of the request-setup phase).
|
||||
const { apiFormat, customModelTargetFormat, requestedModel } = resolveChatCoreRequestSetup(
|
||||
modelInfo,
|
||||
@@ -442,7 +438,6 @@ export async function handleChatCore({
|
||||
// (not Math.random) purely to satisfy CodeQL js/insecure-randomness — this id
|
||||
// is a log-correlation token, not a security secret.
|
||||
const traceId = globalThis.crypto.randomUUID().slice(0, 6);
|
||||
|
||||
// Emit request.started event for real-time dashboard
|
||||
setImmediate(() => {
|
||||
emit("request.started", {
|
||||
@@ -526,7 +521,6 @@ export async function handleChatCore({
|
||||
`long-running goal mode enabled: readinessMax=${agentGoalPolicy.readinessMaxTimeoutMs}ms streamRecovery=${agentGoalPolicy.streamRecoveryEnabled}`
|
||||
);
|
||||
}
|
||||
|
||||
let effectiveServiceTier: EffectiveServiceTier = "standard";
|
||||
// Codex service-tier resolvers extracted to chatCore/serviceTier.ts (#3501); bind the per-request
|
||||
// provider/credentials once and delegate so the existing call sites stay byte-identical.
|
||||
@@ -555,7 +549,6 @@ export async function handleChatCore({
|
||||
})
|
||||
).catch(() => {});
|
||||
};
|
||||
|
||||
// Key-health updater extracted to chatCore/keyHealth.ts (#3501); bind the per-request log once
|
||||
// and delegate so the existing call sites stay byte-identical.
|
||||
const recordKeyHealthStatus = (
|
||||
@@ -563,11 +556,9 @@ export async function handleChatCore({
|
||||
creds: Record<string, unknown> | null | undefined,
|
||||
transport?: string
|
||||
): void => recordKeyHealthStatusFor(status, creds, log, transport);
|
||||
|
||||
const persistCodexQuotaState = async (headers: Record<string, string> | null, status = 0) => {
|
||||
const currentConnectionId = getCurrentConnectionId();
|
||||
if (provider !== "codex" || !currentConnectionId || !headers) return;
|
||||
|
||||
try {
|
||||
const existingProviderData =
|
||||
credentials?.providerSpecificData && typeof credentials.providerSpecificData === "object"
|
||||
@@ -582,28 +573,23 @@ export async function handleChatCore({
|
||||
status,
|
||||
});
|
||||
if (!built) return;
|
||||
|
||||
if (built.exhaustionLog) {
|
||||
log?.debug?.("CODEX", built.exhaustionLog);
|
||||
}
|
||||
|
||||
// Invalidate the preflight cache for this connection so the next
|
||||
// isModelAvailable check fetches fresh quota data.
|
||||
if (status === 429) {
|
||||
invalidateCodexQuotaCache(currentConnectionId);
|
||||
}
|
||||
|
||||
await updateProviderConnection(currentConnectionId, {
|
||||
providerSpecificData: built.nextProviderData,
|
||||
});
|
||||
|
||||
credentials.providerSpecificData = built.nextProviderData;
|
||||
} catch (err) {
|
||||
const errMessage = err instanceof Error ? err.message : String(err);
|
||||
log?.debug?.("CODEX", `Failed to persist codex quota state: ${errMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Phase 9.2: Idempotency check ──
|
||||
// Resolve the idempotency key once here and reuse it at the Phase 9.2 save site below,
|
||||
// rather than re-deriving it. (#3821-review LEDGER-6)
|
||||
@@ -622,13 +608,11 @@ export async function handleChatCore({
|
||||
if (idempotencyHit) {
|
||||
return idempotencyHit;
|
||||
}
|
||||
|
||||
// T07: Inject connectionId into credentials so executors can rotate API keys
|
||||
// using providerSpecificData.extraApiKeys (API Key Round-Robin feature)
|
||||
if (connectionId && credentials && !credentials.connectionId) {
|
||||
credentials.connectionId = connectionId;
|
||||
}
|
||||
|
||||
// Endpoint/format resolution extracted to chatCore/requestFormat.ts (#3501); pure derivation
|
||||
// from the inbound request, destructured so every downstream use stays byte-identical.
|
||||
const {
|
||||
@@ -1071,6 +1055,13 @@ export async function handleChatCore({
|
||||
return cacheHit;
|
||||
}
|
||||
|
||||
if (targetFormat === FORMATS.OPENAI_RESPONSES && body && typeof body === "object") {
|
||||
applyResponsesInputPolicy(
|
||||
body as Record<string, unknown>,
|
||||
credentials?.providerSpecificData?.preserveEncryptedReasoning === true
|
||||
);
|
||||
}
|
||||
|
||||
body = sanitizeChatRequestBody(body, sourceFormat, targetFormat);
|
||||
// Per-request opt-out: clients that manage their own context send
|
||||
// `x-omniroute-no-memory: true` to skip memory+skills injection (a null owner
|
||||
@@ -2264,8 +2255,19 @@ export async function handleChatCore({
|
||||
// the latter is a Kiro/Claude passthrough alias channel with string values,
|
||||
// while namespace identities carry `{namespace, name}` for the #7936 response
|
||||
// seam. Extract first because Kiro merge may reuse `_toolNameMap` below.
|
||||
//
|
||||
// #9780 — prefer the dedicated channel: on a pivot the openai->claude/gemini
|
||||
// step publishes its own alias map on `_toolNameMap`, so that property alone
|
||||
// yields aliases here. The `_toolNameMap` read stays as the fallback for the
|
||||
// non-pivot producers (executors/base.ts, cliproxyapi.ts, antigravity).
|
||||
const namespaceIdentityMap = translatedBody._namespaceToolIdentityMap;
|
||||
const requestToolIdentityMap =
|
||||
translatedBody._toolNameMap instanceof Map ? translatedBody._toolNameMap : null;
|
||||
namespaceIdentityMap instanceof Map
|
||||
? namespaceIdentityMap
|
||||
: translatedBody._toolNameMap instanceof Map
|
||||
? translatedBody._toolNameMap
|
||||
: null;
|
||||
delete translatedBody._namespaceToolIdentityMap;
|
||||
delete translatedBody._toolNameMap;
|
||||
|
||||
// Kiro: sanitize tool schemas before dispatch. Kiro returns 400 "Improperly
|
||||
@@ -4333,9 +4335,14 @@ export async function handleChatCore({
|
||||
try {
|
||||
const firstChoice = translatedResponse?.choices?.[0];
|
||||
const msg = firstChoice?.message;
|
||||
// The response being cached now will be replayed as history on the *next*
|
||||
// turn, where the read side (translator/index.ts) keys the lookup by the
|
||||
// message's real position in that future `messages` array — i.e. right
|
||||
// after everything the client sent this turn.
|
||||
const bodyMessages = (body as { messages?: unknown[] } | null | undefined)?.messages;
|
||||
cacheReasoningFromAssistantMessage(msg, provider, model, {
|
||||
requestId: skillRequestId,
|
||||
messageIndex: 0,
|
||||
messageIndex: Array.isArray(bodyMessages) ? bodyMessages.length : 0,
|
||||
});
|
||||
} catch {
|
||||
// Cache capture is non-critical — never block the response
|
||||
@@ -4760,12 +4767,15 @@ export async function handleChatCore({
|
||||
// with tool_calls so it can be replayed on subsequent turns (DeepSeek V4, Kimi K2, etc.)
|
||||
if (normalizedStreamStatus === 200 && streamResponseBody) {
|
||||
try {
|
||||
const body = streamResponseBody as Record<string, unknown>;
|
||||
const choices = body.choices as { message?: Record<string, unknown> }[] | undefined;
|
||||
const streamBody = streamResponseBody as Record<string, unknown>;
|
||||
const choices = streamBody.choices as { message?: Record<string, unknown> }[] | undefined;
|
||||
const msg = choices?.[0]?.message;
|
||||
// See the non-streaming capture above: messageIndex must match the
|
||||
// position this message will occupy in the *next* turn's history.
|
||||
const bodyMessages = (body as { messages?: unknown[] } | null | undefined)?.messages;
|
||||
cacheReasoningFromAssistantMessage(msg, provider, model, {
|
||||
requestId: skillRequestId,
|
||||
messageIndex: 0,
|
||||
messageIndex: Array.isArray(bodyMessages) ? bodyMessages.length : 0,
|
||||
});
|
||||
} catch {
|
||||
// Cache capture is non-critical — never block the stream
|
||||
@@ -4908,13 +4918,20 @@ export async function handleChatCore({
|
||||
});
|
||||
const handleStreamFailure = streamFailureFinalizers.handleStreamFailure;
|
||||
onPipelineStreamError = streamFailureFinalizers.onPipelineStreamError;
|
||||
onClientDisconnectFinalize = (event) =>
|
||||
handleStreamFailure({
|
||||
status: 499,
|
||||
message: `Client disconnected: ${event.reason}`,
|
||||
code: "client_disconnected",
|
||||
type: "client_disconnected",
|
||||
});
|
||||
// #9653: gives a genuine, race-delayed completion a chance to land (see
|
||||
// createClientDisconnectGraceHandler's doc comment) before persisting a false
|
||||
// 499/0-tokens for a request that actually delivered its full response.
|
||||
onClientDisconnectFinalize = streamFailure.createClientDisconnectGraceHandler({
|
||||
isStreamCompletionRecorded: () => streamCompletionRecorded,
|
||||
gracePeriodMs: STREAM_DISCONNECT_GRACE_PERIOD_MS,
|
||||
finalize: (event) =>
|
||||
handleStreamFailure({
|
||||
status: 499,
|
||||
message: `Client disconnected: ${event.reason}`,
|
||||
code: "client_disconnected",
|
||||
type: "client_disconnected",
|
||||
}),
|
||||
});
|
||||
|
||||
// For providers using Responses API format, translate stream back to openai (Chat Completions) format
|
||||
// UNLESS client is Droid CLI which expects openai-responses format back
|
||||
@@ -5025,7 +5042,6 @@ export async function handleChatCore({
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) {
|
||||
if (!expiresAt) return false;
|
||||
const expiresAtMs = new Date(expiresAt).getTime();
|
||||
|
||||
@@ -3,11 +3,11 @@ import {
|
||||
getChatLogMaxDepth,
|
||||
getChatLogArrayTailItems,
|
||||
getChatLogMaxObjectKeys,
|
||||
getChatLogMaxBodyBytes,
|
||||
} from "@/lib/logEnv";
|
||||
import { estimateSizeFast } from "../../utils/estimateSize.ts";
|
||||
|
||||
export const MEMORY_EXTRACTION_TEXT_LIMIT = 64 * 1024;
|
||||
const MAX_LOG_BODY_CHARS = 8 * 1024; // 8KB cap for logged request/response bodies
|
||||
|
||||
export function capMemoryExtractionText(value: string): string {
|
||||
if (value.length <= MEMORY_EXTRACTION_TEXT_LIMIT) return value;
|
||||
@@ -60,9 +60,10 @@ export function cloneBoundedChatLogPayload(value: unknown, depth = 0): unknown {
|
||||
|
||||
/**
|
||||
* Truncate a large object for logging. If its JSON representation exceeds
|
||||
* MAX_LOG_BODY_CHARS, return a lightweight summary instead of the full clone.
|
||||
* This prevents persistAttemptLogs from holding multi-MB references to
|
||||
* translatedBody across 17 call sites per request.
|
||||
* the configured max body size (getChatLogMaxBodyBytes()), return a
|
||||
* lightweight summary instead of the full clone. This prevents
|
||||
* persistAttemptLogs from holding multi-MB references to translatedBody
|
||||
* across 17 call sites per request.
|
||||
*
|
||||
* When the summarized object carries a `tools` definition, re-attach it
|
||||
* (bounded via `cloneBoundedChatLogPayload`) so the request-details view can
|
||||
@@ -75,8 +76,9 @@ export function cloneBoundedChatLogPayload(value: unknown, depth = 0): unknown {
|
||||
export function truncateForLog(value: unknown): Record<string, unknown> | null | undefined {
|
||||
if (value === null || value === undefined) return value as null | undefined;
|
||||
if (typeof value !== "object") return value as unknown as Record<string, unknown>;
|
||||
const estimatedSize = estimateSizeFast(value);
|
||||
if (estimatedSize <= MAX_LOG_BODY_CHARS) return value as Record<string, unknown>;
|
||||
const maxBodyBytes = getChatLogMaxBodyBytes();
|
||||
const estimatedSize = estimateSizeFast(value, maxBodyBytes);
|
||||
if (estimatedSize <= maxBodyBytes) return value as Record<string, unknown>;
|
||||
// Object is too large — return a summary instead of a deep clone
|
||||
const obj = value as Record<string, unknown>;
|
||||
const summary: Record<string, unknown> = {
|
||||
|
||||
@@ -1524,7 +1524,7 @@ async function handleFalAIImageGeneration({
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const images = await normalizeProviderImagePayload(payload, body, log);
|
||||
const images = await normalizeProviderImagePayload(payload, body, log, "b64_json");
|
||||
return saveImageSuccessResult({
|
||||
provider,
|
||||
model,
|
||||
@@ -1714,7 +1714,7 @@ async function handleStabilityAIImageGeneration({
|
||||
payload = { image: buffer.toString("base64") };
|
||||
}
|
||||
|
||||
const images = await normalizeProviderImagePayload(payload, body, log);
|
||||
const images = await normalizeProviderImagePayload(payload, body, log, "b64_json");
|
||||
return saveImageSuccessResult({
|
||||
provider,
|
||||
model,
|
||||
@@ -1833,7 +1833,7 @@ async function handleBlackForestLabsImageGeneration({
|
||||
})
|
||||
: initialPayload;
|
||||
|
||||
const images = await normalizeProviderImagePayload(finalPayload, body, log);
|
||||
const images = await normalizeProviderImagePayload(finalPayload, body, log, "url");
|
||||
return saveImageSuccessResult({
|
||||
provider,
|
||||
model,
|
||||
@@ -1908,7 +1908,7 @@ async function handleRecraftImageGeneration({
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const images = await normalizeProviderImagePayload(payload, body, log);
|
||||
const images = await normalizeProviderImagePayload(payload, body, log, "url");
|
||||
return saveImageSuccessResult({
|
||||
provider,
|
||||
model,
|
||||
@@ -2200,7 +2200,7 @@ function shouldIncludeStabilityMask(model) {
|
||||
]).has(model);
|
||||
}
|
||||
|
||||
async function normalizeProviderImagePayload(payload, body, log) {
|
||||
async function normalizeProviderImagePayload(payload, body, log, defaultFormat) {
|
||||
const candidates = [];
|
||||
|
||||
const pushCandidate = (value) => {
|
||||
@@ -2226,7 +2226,7 @@ async function normalizeProviderImagePayload(payload, body, log) {
|
||||
|
||||
const normalized = [];
|
||||
for (const candidate of candidates) {
|
||||
const item = await normalizeProviderImageCandidate(candidate, body);
|
||||
const item = await normalizeProviderImageCandidate(candidate, body, defaultFormat);
|
||||
if (item) normalized.push(item);
|
||||
}
|
||||
|
||||
@@ -2240,8 +2240,8 @@ async function normalizeProviderImagePayload(payload, body, log) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function normalizeProviderImageCandidate(candidate, body) {
|
||||
const wantsBase64 = body?.response_format === "b64_json";
|
||||
async function normalizeProviderImageCandidate(candidate, body, defaultFormat) {
|
||||
const wantsBase64 = body?.response_format === "b64_json" || defaultFormat === "b64_json";
|
||||
let url = null;
|
||||
let b64 = null;
|
||||
|
||||
|
||||
@@ -15,14 +15,15 @@ import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGenerat
|
||||
import {
|
||||
AdobeFireflyError,
|
||||
adobeFireflyGenerateImage,
|
||||
adobeFireflyImageTimeoutMs,
|
||||
resolveAdobeAccessToken,
|
||||
resolveAdobeSourceImageReferences,
|
||||
resolveAdobeSourceImageIds,
|
||||
resolveAdobeImageModel,
|
||||
} from "../../../services/adobeFireflyClient.ts";
|
||||
import { getAdobeReferenceUploadLimit } from "../../../services/adobeFireflyModels.ts";
|
||||
import { isAdobeFireflyUpscaleModel } from "../../../services/adobeFireflyUpscale.ts";
|
||||
import { handleAdobeFireflyImageUpscale } from "../../imageUpscale/adobeFirefly.ts";
|
||||
import { ensureAdobeFireflySession } from "../../../services/adobeFireflySession.ts";
|
||||
|
||||
function normalizePositiveNumber(value: unknown, fallback: number): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
export async function handleAdobeFireflyImageGeneration({
|
||||
model,
|
||||
@@ -50,25 +51,22 @@ export async function handleAdobeFireflyImageGeneration({
|
||||
images?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
credentials: { apiKey?: string; accessToken?: string };
|
||||
credentials: {
|
||||
apiKey?: string;
|
||||
accessToken?: string;
|
||||
connectionId?: string;
|
||||
providerSpecificData?: {
|
||||
cookie?: unknown;
|
||||
access_token?: unknown;
|
||||
accessToken?: unknown;
|
||||
browserSessionKey?: unknown;
|
||||
} | null;
|
||||
};
|
||||
log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void };
|
||||
fetchImpl?: typeof fetch;
|
||||
}) {
|
||||
const startTime = Date.now();
|
||||
const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
|
||||
|
||||
// Topaz upscalers share adobe-firefly but use /v2/3p-images/upsample (no prompt).
|
||||
if (isAdobeFireflyUpscaleModel(model)) {
|
||||
return handleAdobeFireflyImageUpscale({
|
||||
model,
|
||||
provider,
|
||||
body: body as Record<string, unknown>,
|
||||
credentials,
|
||||
log,
|
||||
fetchImpl,
|
||||
});
|
||||
}
|
||||
|
||||
if (!prompt) {
|
||||
return saveImageErrorResult({
|
||||
provider,
|
||||
@@ -80,7 +78,17 @@ export async function handleAdobeFireflyImageGeneration({
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await resolveAdobeAccessToken(credentials, fetchImpl);
|
||||
// Durable session: JWT + Cookie once → auto-rebuild ARP from forter/arkose,
|
||||
// cache, optional Playwright warm-up. Submit path rotates ARP on 408.
|
||||
const session = await ensureAdobeFireflySession({
|
||||
credentials,
|
||||
fetchImpl,
|
||||
log,
|
||||
});
|
||||
const accessToken = session.accessToken;
|
||||
const sessionCookie = session.cookie || undefined;
|
||||
const arpSessionId = session.arpSessionId;
|
||||
const timeoutMs = normalizePositiveNumber(body.timeout_ms, 180_000);
|
||||
const seed =
|
||||
typeof body.seed === "number"
|
||||
? body.seed
|
||||
@@ -88,44 +96,26 @@ export async function handleAdobeFireflyImageGeneration({
|
||||
? Number(body.seed)
|
||||
: undefined;
|
||||
|
||||
// Keep the raw credential blob for Cookie + sherlockToken (x-arp-session-id).
|
||||
// JWT may be embedded in the same paste as cookies (HAR / multi-line).
|
||||
const psd = (credentials as { providerSpecificData?: { cookie?: string } })
|
||||
?.providerSpecificData;
|
||||
const sessionCookie =
|
||||
(typeof psd?.cookie === "string" && psd.cookie.trim()) ||
|
||||
(typeof credentials?.apiKey === "string" && credentials.apiKey.trim()) ||
|
||||
(typeof credentials?.accessToken === "string" && credentials.accessToken.includes(";")
|
||||
? credentials.accessToken
|
||||
: undefined);
|
||||
// Cap uploads by model family (matches MediaViewModel GetSourceImageLimit).
|
||||
const { id: resolvedId } = resolveAdobeImageModel(model);
|
||||
const maxRefs = resolvedId.includes("nano-banana") || resolvedId.includes("gpt-image") ? 4 : 2;
|
||||
|
||||
const { spec } = resolveAdobeImageModel(model);
|
||||
const references = await resolveAdobeSourceImageReferences({
|
||||
const sourceImageIds = await resolveAdobeSourceImageIds({
|
||||
accessToken,
|
||||
body,
|
||||
max: getAdobeReferenceUploadLimit(spec, "image"),
|
||||
max: maxRefs,
|
||||
sessionCookie,
|
||||
arpSessionId,
|
||||
prompt,
|
||||
fetchImpl,
|
||||
log,
|
||||
});
|
||||
|
||||
const explicitTimeout =
|
||||
typeof body.timeout_ms === "number"
|
||||
? body.timeout_ms
|
||||
: typeof body.timeout_ms === "string" && body.timeout_ms.trim()
|
||||
? Number(body.timeout_ms)
|
||||
: undefined;
|
||||
const timeoutMs = adobeFireflyImageTimeoutMs({
|
||||
timeoutMs: explicitTimeout,
|
||||
refCount: references.length,
|
||||
});
|
||||
|
||||
log?.info?.(
|
||||
"IMAGE",
|
||||
`${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` +
|
||||
(references.length ? ` | refs: ${references.length}` : "") +
|
||||
` | pollTimeoutMs=${timeoutMs}`
|
||||
(sourceImageIds.length ? ` | refs: ${sourceImageIds.length}` : "") +
|
||||
` | session=${session.source}`
|
||||
);
|
||||
|
||||
const result = await adobeFireflyGenerateImage({
|
||||
@@ -137,8 +127,11 @@ export async function handleAdobeFireflyImageGeneration({
|
||||
quality: body.quality,
|
||||
seed: Number.isFinite(seed as number) ? (seed as number) : undefined,
|
||||
negativePrompt: typeof body.negative_prompt === "string" ? body.negative_prompt : undefined,
|
||||
references: references.length ? references : undefined,
|
||||
sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined,
|
||||
sessionCookie,
|
||||
arpSessionId,
|
||||
sessionFingerprint: session.fingerprint,
|
||||
sessionBrowserKey: session.browserSessionKey,
|
||||
timeoutMs,
|
||||
fetchImpl,
|
||||
log,
|
||||
|
||||
@@ -40,7 +40,12 @@ export async function handleResponsesCore({
|
||||
const customToolNames = collectResponsesCustomToolNames(body?.tools, inputItems);
|
||||
|
||||
// Convert Responses API format to Chat Completions format
|
||||
const convertedBody = convertResponsesApiFormat(body, credentials, modelInfo?.provider);
|
||||
const convertedBody = convertResponsesApiFormat(
|
||||
body,
|
||||
credentials,
|
||||
modelInfo?.provider,
|
||||
modelInfo?.model
|
||||
);
|
||||
|
||||
// Ensure stream is enabled
|
||||
convertedBody.stream = true;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Handles POST /v1/videos/generations requests. Proxies to upstream video
|
||||
* generation providers (ComfyUI AnimateDiff/SVD, SD WebUI AnimateDiff, and
|
||||
* more — see the per-format handlers below). Response format (OpenAI-like):
|
||||
* { "created": 1234567890, "data": [{ "b64_json": "...", "format": "mp4" }] }
|
||||
* { "created": 1234567890, "data": [{ "url": "https://…", "format": "mp4" }] }
|
||||
*/
|
||||
|
||||
import { getVideoProvider, parseVideoModel } from "../config/videoRegistry.ts";
|
||||
@@ -18,6 +18,16 @@ import { handleNovitaVideoGeneration } from "./videoGeneration/novitaHandler.ts"
|
||||
import { handleXaiVideoGeneration } from "./videoGeneration/xaiGrokImagineHandler.ts";
|
||||
import { handleSegmindVideoGeneration } from "./videoGeneration/providers/segmind.ts";
|
||||
import { handleAdobeFireflyVideoGeneration } from "./videoGeneration/adobeFireflyHandler.ts";
|
||||
import { handleOpenAIVideoGeneration } from "./videoGeneration/openai.ts";
|
||||
import { getVideoJobPreset, handleVideoJobGeneration } from "./videoGeneration/job.ts";
|
||||
import {
|
||||
extractRunwayFailureMessage,
|
||||
normalizeRunwayVideoResult,
|
||||
resolvePositiveInteger,
|
||||
resolveRunwayDuration,
|
||||
resolveRunwayPromptImage,
|
||||
resolveRunwayRatio,
|
||||
} from "./videoGeneration/runwayHelpers.ts";
|
||||
import { getExecutor } from "../executors/index.ts";
|
||||
import { getKieTaskId, isJsonObject, parseKieResultJson } from "../utils/kieTask.ts";
|
||||
import {
|
||||
@@ -33,13 +43,94 @@ import {
|
||||
resolveComfyUiBaseUrl,
|
||||
} from "../utils/comfyuiClient.ts";
|
||||
import { saveCallLog } from "@/lib/usageDb";
|
||||
import { getAllCustomModels } from "@/lib/db/models";
|
||||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import {
|
||||
FetchTimeoutError,
|
||||
fetchWithTimeout,
|
||||
getConfiguredTimeout,
|
||||
} from "@/shared/utils/fetchTimeout";
|
||||
|
||||
/**
|
||||
* Resolve the base URL for OpenAI-compatible video generation endpoints.
|
||||
* Prefers providerSpecificData.baseUrl (from custom node config), falls back to
|
||||
* top-level credentials.baseUrl, then to the provided fallback.
|
||||
*/
|
||||
export function resolveVideoBaseUrl(
|
||||
credentials:
|
||||
{ baseUrl?: unknown; providerSpecificData?: { baseUrl?: unknown } | null } | null | undefined,
|
||||
fallback: string
|
||||
): string {
|
||||
const psd = credentials?.providerSpecificData;
|
||||
const psdBaseUrl =
|
||||
psd && typeof psd === "object" && typeof psd.baseUrl === "string" && psd.baseUrl.trim()
|
||||
? psd.baseUrl.trim()
|
||||
: null;
|
||||
const topLevelBaseUrl =
|
||||
typeof credentials?.baseUrl === "string" && credentials.baseUrl.trim()
|
||||
? credentials.baseUrl.trim()
|
||||
: null;
|
||||
const nodeBaseUrl = psdBaseUrl || topLevelBaseUrl;
|
||||
|
||||
if (!nodeBaseUrl) return fallback;
|
||||
|
||||
// Trim trailing slashes
|
||||
let normalized = nodeBaseUrl;
|
||||
while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
|
||||
if (normalized.endsWith("/videos/generations")) return normalized;
|
||||
const stripped = normalized.replace(/\/videos\/generations$/, "");
|
||||
return `${stripped}/videos/generations`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read generationConfig.preset from the custom model row for the given
|
||||
* provider/model id. Returns null when the model has no preset configured (or
|
||||
* the registry is unreadable), so callers can fall back to the sync path.
|
||||
*/
|
||||
async function getCustomModelVideoPreset(
|
||||
providerId: string,
|
||||
modelId: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const customModelsMap = (await getAllCustomModels()) as Record<
|
||||
string,
|
||||
Array<Record<string, unknown>>
|
||||
>;
|
||||
const models = customModelsMap[providerId];
|
||||
if (!Array.isArray(models)) return null;
|
||||
for (const model of models) {
|
||||
if (!model || typeof model !== "object" || model.id !== modelId) continue;
|
||||
const generationConfig = model.generationConfig;
|
||||
if (
|
||||
generationConfig &&
|
||||
typeof generationConfig === "object" &&
|
||||
typeof (generationConfig as Record<string, unknown>).preset === "string"
|
||||
) {
|
||||
return (generationConfig as Record<string, unknown>).preset as string;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle video generation request
|
||||
*/
|
||||
export async function handleVideoGeneration({ body, credentials, log }) {
|
||||
const { provider, model } = parseVideoModel(body.model);
|
||||
|
||||
/**
|
||||
* Handle video generation request
|
||||
*/
|
||||
export async function handleVideoGeneration({ body, credentials, log, resolvedProvider = null }) {
|
||||
let { provider, model } = parseVideoModel(body.model);
|
||||
if (resolvedProvider) {
|
||||
provider = resolvedProvider;
|
||||
model = body.model.startsWith(provider + "/")
|
||||
? body.model.slice(provider.length + 1)
|
||||
: body.model;
|
||||
}
|
||||
|
||||
if (!provider) {
|
||||
return {
|
||||
@@ -51,11 +142,59 @@ export async function handleVideoGeneration({ body, credentials, log }) {
|
||||
|
||||
const providerConfig = getVideoProvider(provider);
|
||||
if (!providerConfig) {
|
||||
return {
|
||||
success: false,
|
||||
status: 400,
|
||||
error: `Unknown video provider: ${provider}`,
|
||||
if (!resolvedProvider) {
|
||||
return {
|
||||
success: false,
|
||||
status: 400,
|
||||
error: `Unknown video provider: ${provider}`,
|
||||
};
|
||||
}
|
||||
// Custom provider node. When the custom model row carries a
|
||||
// generationConfig.preset (e.g. "agnes-video-job"), dispatch through the
|
||||
// submit → poll job pipeline; otherwise mirror the images route and use the
|
||||
// generic OpenAI-compatible handler with a synthetic config.
|
||||
const presetName = await getCustomModelVideoPreset(provider, model);
|
||||
if (presetName !== null) {
|
||||
if (!getVideoJobPreset(presetName)) {
|
||||
return {
|
||||
success: false,
|
||||
status: 502,
|
||||
error: `Unknown video job preset: ${presetName}`,
|
||||
};
|
||||
}
|
||||
if (log)
|
||||
log.info("VIDEO", `Custom model ${provider}/${model} — using job preset ${presetName}`);
|
||||
return handleVideoJobGeneration({
|
||||
model,
|
||||
presetName,
|
||||
body,
|
||||
credentials,
|
||||
log,
|
||||
});
|
||||
}
|
||||
if (log)
|
||||
log.info("VIDEO", `Custom model ${provider}/${model} — using OpenAI-compatible handler`);
|
||||
const syntheticConfig = {
|
||||
id: provider,
|
||||
baseUrl: resolveVideoBaseUrl(
|
||||
credentials,
|
||||
"http://generative.language.googleapis.com/v1beta/openai/videos/generations"
|
||||
),
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
format: "openai-video",
|
||||
};
|
||||
return handleOpenAIVideoGeneration({
|
||||
model,
|
||||
body,
|
||||
credentials,
|
||||
provider,
|
||||
providerConfig: syntheticConfig,
|
||||
log,
|
||||
});
|
||||
}
|
||||
if (providerConfig.format === "openai-video") {
|
||||
return handleOpenAIVideoGeneration({ model, provider, providerConfig, body, credentials, log });
|
||||
}
|
||||
|
||||
if (providerConfig.format === "vertex-veo") {
|
||||
@@ -158,7 +297,10 @@ export async function handleVideoGeneration({ body, credentials, log }) {
|
||||
log,
|
||||
});
|
||||
}
|
||||
|
||||
if (resolvedProvider) {
|
||||
// Custom provider with no matching built-in format — use OpenAI-compatible fallback
|
||||
return handleOpenAIVideoGeneration({ model, provider, providerConfig, body, credentials, log });
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
status: 400,
|
||||
@@ -832,148 +974,6 @@ const RUNWAY_TERMINAL_FAILURE_STATUSES = new Set([
|
||||
"DELETED",
|
||||
]);
|
||||
|
||||
function resolveRunwayPromptImage(body) {
|
||||
const directCandidates = [
|
||||
body.promptImage,
|
||||
body.prompt_image,
|
||||
body.image,
|
||||
body.image_url,
|
||||
body.imageUrl,
|
||||
body.provider_options?.promptImage,
|
||||
body.provider_options?.prompt_image,
|
||||
];
|
||||
|
||||
for (const candidate of directCandidates) {
|
||||
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
||||
if (candidate && typeof candidate === "object") return candidate;
|
||||
if (Array.isArray(candidate) && candidate.length > 0) return candidate;
|
||||
}
|
||||
|
||||
const arrayCandidates = [
|
||||
body.imageUrls,
|
||||
body.image_urls,
|
||||
body.provider_options?.imageUrls,
|
||||
body.provider_options?.image_urls,
|
||||
];
|
||||
for (const candidate of arrayCandidates) {
|
||||
if (Array.isArray(candidate) && candidate.length > 0) return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveRunwayRatio(body) {
|
||||
const aspectRatio = typeof body.aspect_ratio === "string" ? body.aspect_ratio : body.aspectRatio;
|
||||
if (aspectRatio === "1280:720" || aspectRatio === "720:1280") return aspectRatio;
|
||||
if (aspectRatio === "16:9") return "1280:720";
|
||||
if (aspectRatio === "9:16") return "720:1280";
|
||||
|
||||
const size = typeof body.size === "string" ? body.size : "";
|
||||
const [widthRaw, heightRaw] = size.split("x");
|
||||
const width = Number(widthRaw);
|
||||
const height = Number(heightRaw);
|
||||
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
|
||||
return width >= height ? "1280:720" : "720:1280";
|
||||
}
|
||||
|
||||
return "1280:720";
|
||||
}
|
||||
|
||||
function resolveRunwayDuration(body) {
|
||||
if (Number.isFinite(body.duration)) {
|
||||
return clampRunwayDuration(body.duration);
|
||||
}
|
||||
|
||||
if (Number.isFinite(body.frames) && Number.isFinite(body.fps) && Number(body.fps) > 0) {
|
||||
return clampRunwayDuration(Number(body.frames) / Number(body.fps));
|
||||
}
|
||||
|
||||
return 5;
|
||||
}
|
||||
|
||||
function clampRunwayDuration(value) {
|
||||
const duration = Math.round(Number(value));
|
||||
if (!Number.isFinite(duration)) return 5;
|
||||
return Math.min(10, Math.max(2, duration));
|
||||
}
|
||||
|
||||
function resolvePositiveInteger(value, fallback) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return fallback;
|
||||
return Math.floor(numeric);
|
||||
}
|
||||
|
||||
function extractRunwayOutputUrls(task) {
|
||||
const rawOutput = Array.isArray(task?.output)
|
||||
? task.output
|
||||
: Array.isArray(task?.result)
|
||||
? task.result
|
||||
: [];
|
||||
|
||||
return rawOutput
|
||||
.map((entry) => {
|
||||
if (typeof entry === "string") return entry;
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
return entry.url || entry.uri || entry.videoUrl || entry.video_url || null;
|
||||
})
|
||||
.filter((value) => typeof value === "string" && value.length > 0);
|
||||
}
|
||||
|
||||
function extractRunwayFailureMessage(task) {
|
||||
const directCandidates = [
|
||||
task?.failure,
|
||||
task?.failureReason,
|
||||
task?.error,
|
||||
task?.errorMessage,
|
||||
task?.message,
|
||||
];
|
||||
for (const candidate of directCandidates) {
|
||||
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
||||
}
|
||||
|
||||
if (task?.failure && typeof task.failure === "object") {
|
||||
const nestedCandidates = [
|
||||
task.failure.message,
|
||||
task.failure.reason,
|
||||
task.failure.error,
|
||||
task.failure.code,
|
||||
];
|
||||
for (const candidate of nestedCandidates) {
|
||||
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function normalizeRunwayVideoResult(task, body) {
|
||||
const urls = extractRunwayOutputUrls(task);
|
||||
if (urls.length === 0) {
|
||||
throw new Error(
|
||||
`Runway task completed without output URLs: ${JSON.stringify(task).slice(0, 400)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (body.response_format === "url") {
|
||||
return urls.map((url) => ({ url, format: "mp4" }));
|
||||
}
|
||||
|
||||
const videos = [];
|
||||
for (const url of urls) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Runway output fetch failed (${response.status})`);
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
videos.push({
|
||||
b64_json: Buffer.from(arrayBuffer).toString("base64"),
|
||||
format: "mp4",
|
||||
});
|
||||
}
|
||||
|
||||
return videos;
|
||||
}
|
||||
|
||||
async function handleHaiperVideoGeneration({
|
||||
model,
|
||||
provider,
|
||||
|
||||
@@ -9,11 +9,10 @@ import { sanitizeErrorMessage } from "../../utils/error.ts";
|
||||
import {
|
||||
AdobeFireflyError,
|
||||
adobeFireflyGenerateVideo,
|
||||
resolveAdobeAccessToken,
|
||||
resolveAdobeSourceImageReferences,
|
||||
resolveAdobeSourceImageIds,
|
||||
resolveAdobeVideoModel,
|
||||
} from "../../services/adobeFireflyClient.ts";
|
||||
import { getAdobeReferenceUploadLimit } from "../../services/adobeFireflyModels.ts";
|
||||
import { ensureAdobeFireflySession } from "../../services/adobeFireflySession.ts";
|
||||
|
||||
function normalizePositiveNumber(value: unknown, fallback: number): number {
|
||||
const n = Number(value);
|
||||
@@ -32,7 +31,17 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
provider: string;
|
||||
providerConfig?: { baseUrl?: string };
|
||||
body: Record<string, unknown>;
|
||||
credentials?: { apiKey?: string; accessToken?: string } | null;
|
||||
credentials?: {
|
||||
apiKey?: string;
|
||||
accessToken?: string;
|
||||
connectionId?: string;
|
||||
providerSpecificData?: {
|
||||
cookie?: unknown;
|
||||
access_token?: unknown;
|
||||
accessToken?: unknown;
|
||||
browserSessionKey?: unknown;
|
||||
} | null;
|
||||
} | null;
|
||||
log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void };
|
||||
fetchImpl?: typeof fetch;
|
||||
}) {
|
||||
@@ -47,7 +56,14 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await resolveAdobeAccessToken(credentials, fetchImpl);
|
||||
const session = await ensureAdobeFireflySession({
|
||||
credentials,
|
||||
fetchImpl,
|
||||
log,
|
||||
});
|
||||
const accessToken = session.accessToken;
|
||||
const sessionCookie = session.cookie || undefined;
|
||||
const arpSessionId = session.arpSessionId;
|
||||
const timeoutMs = normalizePositiveNumber(body.timeout_ms, 300_000);
|
||||
const seed =
|
||||
typeof body.seed === "number"
|
||||
@@ -55,22 +71,16 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
: typeof body.seed === "string" && String(body.seed).trim()
|
||||
? Number(body.seed)
|
||||
: undefined;
|
||||
// Keep raw paste for Cookie + sherlockToken (x-arp-session-id).
|
||||
const psd = (credentials as { providerSpecificData?: { cookie?: string } })
|
||||
?.providerSpecificData;
|
||||
const sessionCookie =
|
||||
(typeof psd?.cookie === "string" && psd.cookie.trim()) ||
|
||||
(typeof credentials?.apiKey === "string" && credentials.apiKey.trim()) ||
|
||||
(typeof credentials?.accessToken === "string" && credentials.accessToken.includes(";")
|
||||
? credentials.accessToken
|
||||
: undefined);
|
||||
|
||||
const { spec } = resolveAdobeVideoModel(String(model));
|
||||
const references = await resolveAdobeSourceImageReferences({
|
||||
// Kling i2v / Veo ref / Sora frame: upload reference images first.
|
||||
const { id: videoModelId } = resolveAdobeVideoModel(String(model));
|
||||
const maxFrames = videoModelId.includes("kling") || videoModelId.includes("sora") ? 2 : 3;
|
||||
const sourceImageIds = await resolveAdobeSourceImageIds({
|
||||
accessToken,
|
||||
body,
|
||||
max: getAdobeReferenceUploadLimit(spec, "image"),
|
||||
max: maxFrames,
|
||||
sessionCookie,
|
||||
arpSessionId,
|
||||
prompt,
|
||||
fetchImpl,
|
||||
log,
|
||||
@@ -79,7 +89,8 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
log?.info?.(
|
||||
"VIDEO",
|
||||
`${provider}/${model} (adobe-firefly) | prompt: "${prompt.slice(0, 60)}${prompt.length > 60 ? "..." : ""}"` +
|
||||
(references.length ? ` | refs: ${references.length}` : "")
|
||||
(sourceImageIds.length ? ` | frames: ${sourceImageIds.length}` : "") +
|
||||
` | session=${session.source}`
|
||||
);
|
||||
|
||||
const result = await adobeFireflyGenerateVideo({
|
||||
@@ -99,8 +110,11 @@ export async function handleAdobeFireflyVideoGeneration({
|
||||
? body.negativePrompt
|
||||
: undefined,
|
||||
generateAudio: body.generate_audio !== false && body.generateAudio !== false,
|
||||
references: references.length ? references : undefined,
|
||||
sourceImageIds: sourceImageIds.length ? sourceImageIds : undefined,
|
||||
sessionCookie,
|
||||
arpSessionId,
|
||||
sessionFingerprint: session.fingerprint,
|
||||
sessionBrowserKey: session.browserSessionKey,
|
||||
timeoutMs,
|
||||
fetchImpl,
|
||||
log,
|
||||
|
||||
418
open-sse/handlers/videoGeneration/job.ts
Normal file
418
open-sse/handlers/videoGeneration/job.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* Async job/poll video generation for custom OpenAI-compatible provider nodes
|
||||
* whose /videos surface is a submit → poll → fetch-result API (e.g. Agnes
|
||||
* Video V2.0, muapi.ai, OpenAI Sora). Presets are declarative data — the
|
||||
* handler here is one family; everything else is per-preset config.
|
||||
*
|
||||
* Response shape stays OpenAI-like: { created, data: [{ url, format: "mp4" }] } so the
|
||||
* /v1/videos/generations route returns the same contract as the synchronous
|
||||
* path.
|
||||
*/
|
||||
|
||||
import {
|
||||
fetchWithTimeout,
|
||||
FetchTimeoutError,
|
||||
getConfiguredTimeout,
|
||||
} from "@/shared/utils/fetchTimeout";
|
||||
import { sanitizeErrorMessage } from "../../utils/error.ts";
|
||||
import { sleep } from "../../utils/sleep.ts";
|
||||
|
||||
interface LogLike {
|
||||
info?: (tag: string, msg: string, meta?: unknown) => void;
|
||||
warn?: (tag: string, msg: string, meta?: unknown) => void;
|
||||
error?: (tag: string, msg: string, meta?: unknown) => void;
|
||||
}
|
||||
|
||||
interface CredentialsLike {
|
||||
providerSpecificData?: { baseUrl?: unknown } | null;
|
||||
baseUrl?: unknown;
|
||||
apiKey?: unknown;
|
||||
accessToken?: unknown;
|
||||
}
|
||||
|
||||
/** Dot-path reader restricted to plain objects/arrays (no prototypes). */
|
||||
function readPath(value: unknown, path: string): unknown {
|
||||
if (!path) return value;
|
||||
let current: unknown = value;
|
||||
for (const segment of path.split(".")) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
if (typeof current !== "object") return undefined;
|
||||
if (Array.isArray(current)) {
|
||||
const index = Number(segment);
|
||||
if (!Number.isInteger(index) || index < 0 || index >= current.length) return undefined;
|
||||
current = current[index];
|
||||
continue;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(current, segment)) return undefined;
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** Non-empty string from a dot path, or null. */
|
||||
function readStringPath(value: unknown, path: string): string | null {
|
||||
const found = readPath(value, path);
|
||||
return typeof found === "string" && found.trim() ? found : null;
|
||||
}
|
||||
|
||||
function isDoneStatus(
|
||||
status: unknown,
|
||||
done: string[],
|
||||
failed: string[]
|
||||
): "done" | "failed" | "pending" {
|
||||
if (typeof status !== "string") return "pending";
|
||||
if (failed.includes(status)) return "failed";
|
||||
if (done.includes(status)) return "done";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
export type VideoJobPreset = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
/** auth header name plus value scheme */
|
||||
authHeaderName: "x-api-key" | "Authorization";
|
||||
authScheme: "bearer" | "raw";
|
||||
baseUrlFallback: string;
|
||||
submit: {
|
||||
method: "POST";
|
||||
/** may contain {model} — substituted before POST */
|
||||
path: string;
|
||||
buildBody: (params: {
|
||||
model?: string;
|
||||
prompt?: string;
|
||||
duration?: number;
|
||||
extras: Record<string, unknown>;
|
||||
}) => Record<string, unknown>;
|
||||
};
|
||||
/** dot path into the submit response identifying the job */
|
||||
taskIdPath: string;
|
||||
poll: {
|
||||
/** contains {taskId} */
|
||||
pathTemplate: string;
|
||||
};
|
||||
statusPath: string;
|
||||
statusDone: string[];
|
||||
statusFailed: string[];
|
||||
/** dot path into the poll response holding the finished video URL/array */
|
||||
resultPath: string;
|
||||
maxPolls: number;
|
||||
pollIntervalMs: number;
|
||||
};
|
||||
|
||||
// #9820: declarative presets for the shipping async job/poll video providers.
|
||||
const VIDEO_JOB_PRESETS: Record<string, VideoJobPreset> = {
|
||||
"agnes-video-job": {
|
||||
id: "agnes-video-job",
|
||||
displayName: "Agnes Video V2.0",
|
||||
authHeaderName: "x-api-key",
|
||||
authScheme: "raw",
|
||||
// Real default, matching the Agnes Video V2.0 reference: POST /v1/videos with
|
||||
// x-api-key auth; GET /v1/videos/{task_id} returns status/progress/metadata.
|
||||
baseUrlFallback: "https://apihub.agnes-ai.com",
|
||||
submit: {
|
||||
method: "POST",
|
||||
path: "/v1/videos",
|
||||
buildBody: ({ model, prompt, extras }) => ({
|
||||
model,
|
||||
prompt,
|
||||
// passthrough of image/mode/num_frames/frame_rate/… — the generic
|
||||
// route body uses .catchall, so provider-specific knobs survive.
|
||||
...extras,
|
||||
}),
|
||||
},
|
||||
taskIdPath: "task_id",
|
||||
poll: { pathTemplate: "/v1/videos/{taskId}" },
|
||||
statusPath: "status",
|
||||
statusDone: ["completed"],
|
||||
statusFailed: ["failed"],
|
||||
resultPath: "metadata.url",
|
||||
maxPolls: 60,
|
||||
pollIntervalMs: 2000,
|
||||
},
|
||||
"muapi-video-job": {
|
||||
id: "muapi-video-job",
|
||||
displayName: "muapi.ai",
|
||||
authHeaderName: "x-api-key",
|
||||
authScheme: "raw",
|
||||
// muapi.ai video/audio surface is Replicate-style: POST /api/v1/{model}
|
||||
// returns { request_id }; poll GET /api/v1/predictions/{id}/result.
|
||||
baseUrlFallback: "https://api.muapi.ai",
|
||||
submit: {
|
||||
method: "POST",
|
||||
path: "/api/v1/{model}",
|
||||
buildBody: (params) => {
|
||||
const { prompt, duration, extras } = params;
|
||||
return {
|
||||
prompt,
|
||||
...(typeof duration === "number" ? { duration } : {}),
|
||||
...extras,
|
||||
};
|
||||
},
|
||||
},
|
||||
taskIdPath: "request_id",
|
||||
poll: { pathTemplate: "/api/v1/predictions/{taskId}/result" },
|
||||
statusPath: "status",
|
||||
statusDone: ["completed"],
|
||||
statusFailed: ["failed"],
|
||||
resultPath: "outputs",
|
||||
maxPolls: 60,
|
||||
pollIntervalMs: 2000,
|
||||
},
|
||||
"sora-job": {
|
||||
id: "sora-job",
|
||||
displayName: "OpenAI Sora",
|
||||
authHeaderName: "Authorization",
|
||||
authScheme: "bearer",
|
||||
baseUrlFallback: "https://api.openai.com",
|
||||
submit: {
|
||||
method: "POST",
|
||||
path: "/v1/videos",
|
||||
buildBody: (params) => {
|
||||
const { model, prompt, duration, extras } = params;
|
||||
// seconds is a STRING enum ("4"|"8"|"12") in the Sora API; absolute
|
||||
// size mapping is intentionally not forced here.
|
||||
return {
|
||||
model,
|
||||
prompt,
|
||||
...(typeof duration === "number" ? { seconds: String(duration) } : {}),
|
||||
...extras,
|
||||
};
|
||||
},
|
||||
},
|
||||
taskIdPath: "id",
|
||||
poll: { pathTemplate: "/v1/videos/{taskId}" },
|
||||
statusPath: "status",
|
||||
statusDone: ["completed"],
|
||||
statusFailed: ["failed"],
|
||||
resultPath: "data",
|
||||
maxPolls: 60,
|
||||
pollIntervalMs: 2000,
|
||||
},
|
||||
};
|
||||
|
||||
/** Resolve a configured job preset; null when the preset is unknown/none. */
|
||||
export function getVideoJobPreset(presetName: unknown): VideoJobPreset | null {
|
||||
if (typeof presetName !== "string") return null;
|
||||
const preset = VIDEO_JOB_PRESETS[presetName];
|
||||
return preset ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a video-generation job via the submit→poll preset pipeline.
|
||||
* Returns the same shape as the sync handlers: { success, data?: …, status?, error? }.
|
||||
*/
|
||||
export async function handleVideoJobGeneration({
|
||||
model,
|
||||
presetName,
|
||||
body,
|
||||
credentials,
|
||||
log,
|
||||
maxPolls: maxPollsOverride,
|
||||
pollIntervalMs: pollIntervalOverride,
|
||||
}: {
|
||||
model: string;
|
||||
presetName: string;
|
||||
body: Record<string, unknown>;
|
||||
credentials?: unknown;
|
||||
log?: {
|
||||
info?: (tag: string, msg: string, meta?: unknown) => void;
|
||||
error?: (tag: string, msg: string) => void;
|
||||
};
|
||||
maxPolls?: number;
|
||||
pollIntervalMs?: number;
|
||||
}) {
|
||||
const preset = getVideoJobPreset(presetName);
|
||||
if (!preset) {
|
||||
return {
|
||||
success: false,
|
||||
status: 400,
|
||||
error: `Unknown video job preset: ${presetName}`,
|
||||
};
|
||||
}
|
||||
|
||||
const baseUrl = resolveJobBaseUrl(credentials, preset.baseUrlFallback);
|
||||
log?.info?.("VIDEO", `Job preset ${presetName} submitting ${model}`);
|
||||
log?.info?.("VIDEO", JSON.stringify({ baseUrl }));
|
||||
|
||||
const bodyForPreset = preset.submit.buildBody({
|
||||
model: model,
|
||||
prompt: typeof body.prompt === "string" ? body.prompt : undefined,
|
||||
duration: typeof body.duration === "number" ? body.duration : undefined,
|
||||
// passthrough of the remainder — the API keeps catchall extras
|
||||
extras: Object.fromEntries(
|
||||
Object.entries(body ?? {}).filter(
|
||||
([key]) => key !== "model" && key !== "prompt" && key !== "duration"
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
const submitPath = preset.submit.path.replace("{model}", encodeURIComponent(model));
|
||||
const submitUrl = `${baseUrl}${submitPath}`; // baseUrl never ends with "/"
|
||||
const submitResult = await fetchJson(submitUrl, {
|
||||
method: preset.submit.method,
|
||||
headers: buildJobHeaders(preset, credentials),
|
||||
body: JSON.stringify(bodyForPreset),
|
||||
log,
|
||||
});
|
||||
if (submitResult.ok === false) {
|
||||
return { success: false, status: submitResult.status, error: submitResult.error };
|
||||
}
|
||||
|
||||
const taskId = readStringPath(submitResult.data, preset.taskIdPath);
|
||||
if (!taskId) {
|
||||
return {
|
||||
success: false,
|
||||
status: 502,
|
||||
error: `Video provider did not return a job id (${presetName})`,
|
||||
};
|
||||
}
|
||||
|
||||
// Poll loop.
|
||||
const maxPolls = maxPollsOverride ?? preset.maxPolls;
|
||||
const pollInterval = pollIntervalOverride ?? preset.pollIntervalMs;
|
||||
|
||||
for (let attempt = 1; attempt <= maxPolls; attempt += 1) {
|
||||
await sleep(pollInterval);
|
||||
const pollUrl = `${baseUrl}${preset.poll.pathTemplate.replace("{taskId}", encodeURIComponent(taskId))}`;
|
||||
const pollResult = await fetchJson(pollUrl, {
|
||||
method: "GET",
|
||||
headers: buildJobHeaders(preset, credentials),
|
||||
log,
|
||||
});
|
||||
if (pollResult.ok === false) {
|
||||
return { success: false, status: pollResult.status, error: pollResult.error };
|
||||
}
|
||||
|
||||
const status = readPath(pollResult.data, preset.statusPath);
|
||||
const jobState = isDoneStatus(status, preset.statusDone, preset.statusFailed);
|
||||
if (jobState === "done") {
|
||||
const url = readResultUrl(pollResult.data, preset.resultPath);
|
||||
if (!url) {
|
||||
return {
|
||||
success: false,
|
||||
status: 502,
|
||||
error: `Video job completed but no result URL found (${presetName})`,
|
||||
};
|
||||
}
|
||||
log?.info?.("VIDEO", `Job completed after ${attempt} poll(s)`);
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
data: [{ url, format: "mp4" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (jobState === "failed") {
|
||||
return {
|
||||
success: false,
|
||||
status: 502,
|
||||
error: `Video job failed (${presetName})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
status: 504,
|
||||
error: `Video job timed out after ${maxPolls} polls (${presetName})`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJobHeaders(preset: VideoJobPreset, credentials?: unknown): Record<string, string> {
|
||||
const creds = credentials as CredentialsLike | null | undefined;
|
||||
const apiKey =
|
||||
typeof creds?.apiKey === "string" && creds.apiKey
|
||||
? creds.apiKey
|
||||
: typeof creds?.accessToken === "string" && creds.accessToken
|
||||
? creds.accessToken
|
||||
: "";
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (!apiKey) return headers;
|
||||
if (preset.authScheme === "raw") {
|
||||
headers[preset.authHeaderName] = apiKey;
|
||||
} else {
|
||||
headers[preset.authHeaderName] = `Bearer ${apiKey}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function resolveJobBaseUrl(credentials: unknown, fallback: string): string {
|
||||
const creds = credentials as CredentialsLike | null | undefined;
|
||||
const psdBaseUrl =
|
||||
creds?.providerSpecificData?.baseUrl != null &&
|
||||
typeof creds.providerSpecificData.baseUrl === "string" &&
|
||||
creds.providerSpecificData.baseUrl.trim()
|
||||
? (creds.providerSpecificData.baseUrl as string).trim()
|
||||
: null;
|
||||
const topLevelBaseUrl =
|
||||
creds?.baseUrl != null && typeof creds.baseUrl === "string" && creds.baseUrl.trim()
|
||||
? (creds.baseUrl as string).trim()
|
||||
: null;
|
||||
const nodeBaseUrl = psdBaseUrl || topLevelBaseUrl;
|
||||
if (!nodeBaseUrl) return fallback.replace(/\/+$/, "");
|
||||
let normalized = nodeBaseUrl;
|
||||
while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function fetchJson(
|
||||
url: string,
|
||||
{
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
log,
|
||||
}: {
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
log?: LogLike;
|
||||
}
|
||||
): Promise<{ ok: true; data: unknown } | { ok: false; status: number; error: string }> {
|
||||
try {
|
||||
const response = await fetchWithTimeout(url, {
|
||||
method,
|
||||
headers,
|
||||
...(body !== undefined ? { body } : {}),
|
||||
timeoutMs: getConfiguredTimeout(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("VIDEO", `Upstream ${response.status} for ${url}: ${errorText.slice(0, 200)}`);
|
||||
return { ok: false, status: response.status, error: errorText };
|
||||
}
|
||||
const data = await response.json();
|
||||
return { ok: true, data };
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const isTimeout =
|
||||
err instanceof FetchTimeoutError || (err instanceof Error && err.name === "AbortError");
|
||||
log?.error?.(
|
||||
"VIDEO",
|
||||
`${isTimeout ? "Timeout" : "Request error"} for ${url}: ${sanitizeErrorMessage(message)}`
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
status: isTimeout ? 504 : 502,
|
||||
error: `Video provider error: ${sanitizeErrorMessage(message)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function readResultUrl(data: unknown, resultPath: string): string | null {
|
||||
const found = readPath(data, resultPath);
|
||||
if (typeof found === "string" && found.trim()) return found.trim();
|
||||
if (Array.isArray(found)) {
|
||||
const first = found[0];
|
||||
// muapi-style: resultPath "outputs" resolves to ["https://…"].
|
||||
if (typeof first === "string" && first.trim()) return first.trim();
|
||||
// sora-style: resultPath "data" resolves to [{ url: "https://…" }].
|
||||
if (first && typeof first === "object" && !Array.isArray(first)) {
|
||||
const urlEntry = (first as Record<string, unknown>).url;
|
||||
if (typeof urlEntry === "string" && urlEntry.trim()) return urlEntry.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
156
open-sse/handlers/videoGeneration/openai.ts
Normal file
156
open-sse/handlers/videoGeneration/openai.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
fetchWithTimeout,
|
||||
FetchTimeoutError,
|
||||
getConfiguredTimeout,
|
||||
} from "@/shared/utils/fetchTimeout";
|
||||
import { saveCallLog } from "@/lib/usageDb";
|
||||
import { sanitizeErrorMessage } from "../../utils/error.ts";
|
||||
|
||||
interface LogLike {
|
||||
info?: (tag: string, msg: string, meta?: unknown) => void;
|
||||
error?: (tag: string, msg: string) => void;
|
||||
}
|
||||
|
||||
interface CredentialsLike {
|
||||
providerSpecificData?: { baseUrl?: unknown } | null;
|
||||
baseUrl?: unknown;
|
||||
apiKey?: unknown;
|
||||
accessToken?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the video generation endpoint URL from credentials and fallback.
|
||||
* Handles baseUrl from providerSpecificData or top-level credentials.
|
||||
*/
|
||||
function resolveVideoEndpoint(credentials: unknown, fallback: string): string {
|
||||
const creds = credentials as CredentialsLike | null | undefined;
|
||||
const psdBaseUrl =
|
||||
creds?.providerSpecificData?.baseUrl != null &&
|
||||
typeof creds.providerSpecificData.baseUrl === "string" &&
|
||||
creds.providerSpecificData.baseUrl.trim()
|
||||
? creds.providerSpecificData.baseUrl.trim()
|
||||
: null;
|
||||
const topLevelBaseUrl =
|
||||
creds?.baseUrl != null && typeof creds.baseUrl === "string" && creds.baseUrl.trim()
|
||||
? creds.baseUrl.trim()
|
||||
: null;
|
||||
const nodeBaseUrl = psdBaseUrl || topLevelBaseUrl;
|
||||
let n = nodeBaseUrl;
|
||||
while (n.endsWith("/")) n = n.slice(0, -1);
|
||||
if (n.endsWith("/videos/generations")) return n;
|
||||
return `${n}/videos/generations`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the video generation endpoint with timeout and error handling.
|
||||
*/
|
||||
async function fetchVideoEndpoint(
|
||||
url: string,
|
||||
{ headers, body, log }: { headers: Record<string, string>; body: string; log?: LogLike }
|
||||
) {
|
||||
try {
|
||||
const response = await fetchWithTimeout(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
timeoutMs: getConfiguredTimeout(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("VIDEO", `Upstream ${response.status} for ${url}: ${errorText}`);
|
||||
return { success: false, status: response.status, error: errorText };
|
||||
}
|
||||
const data = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
data: { created: data.created || Math.floor(Date.now() / 1000), data: data.data || [] },
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err?.message;
|
||||
const isTimeout = err instanceof FetchTimeoutError || err?.name === "AbortError";
|
||||
log?.error?.(
|
||||
"VIDEO",
|
||||
`${isTimeout ? "Timeout" : "Request error"} for ${url}: ${sanitizeErrorMessage(message || err)}`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
status: isTimeout ? 504 : 502,
|
||||
error: `Video provider error: ${sanitizeErrorMessage(message || err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OpenAI-compatible video generation.
|
||||
* This handler is dispatched for custom providers with format "openai-video".
|
||||
*/
|
||||
export async function handleOpenAIVideoGeneration({
|
||||
model,
|
||||
provider,
|
||||
providerConfig,
|
||||
body,
|
||||
credentials,
|
||||
log,
|
||||
}: {
|
||||
model: string;
|
||||
provider: string;
|
||||
providerConfig: { baseUrl: string; authHeader: string };
|
||||
body: unknown;
|
||||
credentials: unknown;
|
||||
log?: LogLike;
|
||||
}) {
|
||||
const startTime = Date.now();
|
||||
const creds = credentials as CredentialsLike | null | undefined;
|
||||
const apiToken = creds?.apiKey || creds?.accessToken;
|
||||
const endpoint = resolveVideoEndpoint(credentials, providerConfig.baseUrl);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...(providerConfig.authHeader === "x-api-key"
|
||||
? { "x-api-key": String(apiToken) }
|
||||
: { Authorization: `Bearer ${apiToken}` }),
|
||||
};
|
||||
const bodyObj = body as Record<string, unknown>;
|
||||
const upstreamBody = {
|
||||
model,
|
||||
prompt: (bodyObj.prompt ?? "") as string,
|
||||
...(typeof bodyObj.duration === "number" && { duration: bodyObj.duration }),
|
||||
};
|
||||
const logRequestBody = {
|
||||
model: bodyObj.model,
|
||||
prompt:
|
||||
typeof bodyObj.prompt === "string"
|
||||
? bodyObj.prompt.slice(0, 200)
|
||||
: String(bodyObj.prompt ?? ""),
|
||||
duration: bodyObj.duration,
|
||||
};
|
||||
log?.info?.("VIDEO", `OpenAI-compatible video generation: ${provider}/${model} -> ${endpoint}`, {
|
||||
body: logRequestBody,
|
||||
});
|
||||
|
||||
const fetchResult = await fetchVideoEndpoint(endpoint, {
|
||||
headers,
|
||||
body: JSON.stringify(upstreamBody),
|
||||
log,
|
||||
});
|
||||
|
||||
if (!fetchResult.success) {
|
||||
return { success: false, status: fetchResult.status, error: fetchResult.error };
|
||||
}
|
||||
|
||||
// Save call log for billing/tracking
|
||||
await saveCallLog({
|
||||
provider,
|
||||
model: String(bodyObj.model),
|
||||
endpoint: "video",
|
||||
status: fetchResult.status,
|
||||
durationMs: Date.now() - startTime,
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
requestId: null,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: fetchResult.data,
|
||||
};
|
||||
}
|
||||
125
open-sse/handlers/videoGeneration/runwayHelpers.ts
Normal file
125
open-sse/handlers/videoGeneration/runwayHelpers.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
export function resolveRunwayPromptImage(body) {
|
||||
const directCandidates = [
|
||||
body.promptImage,
|
||||
body.prompt_image,
|
||||
body.image,
|
||||
body.image_url,
|
||||
body.imageUrl,
|
||||
body.provider_options?.promptImage,
|
||||
body.provider_options?.prompt_image,
|
||||
];
|
||||
|
||||
for (const candidate of directCandidates) {
|
||||
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
||||
if (candidate && typeof candidate === "object") return candidate;
|
||||
if (Array.isArray(candidate) && candidate.length > 0) return candidate;
|
||||
}
|
||||
|
||||
const arrayCandidates = [
|
||||
body.imageUrls,
|
||||
body.image_urls,
|
||||
body.provider_options?.imageUrls,
|
||||
body.provider_options?.image_urls,
|
||||
];
|
||||
for (const candidate of arrayCandidates) {
|
||||
if (Array.isArray(candidate) && candidate.length > 0) return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveRunwayRatio(body) {
|
||||
const aspectRatio = typeof body.aspect_ratio === "string" ? body.aspect_ratio : body.aspectRatio;
|
||||
if (aspectRatio === "1280:720" || aspectRatio === "720:1280") return aspectRatio;
|
||||
if (aspectRatio === "16:9") return "1280:720";
|
||||
if (aspectRatio === "9:16") return "720:1280";
|
||||
|
||||
const size = typeof body.size === "string" ? body.size : "";
|
||||
const [widthRaw, heightRaw] = size.split("x");
|
||||
const width = Number(widthRaw);
|
||||
const height = Number(heightRaw);
|
||||
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
|
||||
return width >= height ? "1280:720" : "720:1280";
|
||||
}
|
||||
|
||||
return "1280:720";
|
||||
}
|
||||
|
||||
export function resolveRunwayDuration(body) {
|
||||
if (Number.isFinite(body.duration)) return clampRunwayDuration(body.duration);
|
||||
if (Number.isFinite(body.frames) && Number.isFinite(body.fps) && Number(body.fps) > 0) {
|
||||
return clampRunwayDuration(Number(body.frames) / Number(body.fps));
|
||||
}
|
||||
return 5;
|
||||
}
|
||||
|
||||
function clampRunwayDuration(value) {
|
||||
const duration = Math.round(Number(value));
|
||||
if (!Number.isFinite(duration)) return 5;
|
||||
return Math.min(10, Math.max(2, duration));
|
||||
}
|
||||
|
||||
export function resolvePositiveInteger(value, fallback) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return fallback;
|
||||
return Math.floor(numeric);
|
||||
}
|
||||
|
||||
function extractRunwayOutputUrls(task) {
|
||||
const rawOutput = Array.isArray(task?.output)
|
||||
? task.output
|
||||
: Array.isArray(task?.result)
|
||||
? task.result
|
||||
: [];
|
||||
return rawOutput
|
||||
.map((entry) => {
|
||||
if (typeof entry === "string") return entry;
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
return entry.url || entry.uri || entry.videoUrl || entry.video_url || null;
|
||||
})
|
||||
.filter((value) => typeof value === "string" && value.length > 0);
|
||||
}
|
||||
|
||||
export function extractRunwayFailureMessage(task) {
|
||||
const directCandidates = [
|
||||
task?.failure,
|
||||
task?.failureReason,
|
||||
task?.error,
|
||||
task?.errorMessage,
|
||||
task?.message,
|
||||
];
|
||||
for (const candidate of directCandidates) {
|
||||
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
||||
}
|
||||
if (task?.failure && typeof task.failure === "object") {
|
||||
const nestedCandidates = [
|
||||
task.failure.message,
|
||||
task.failure.reason,
|
||||
task.failure.error,
|
||||
task.failure.code,
|
||||
];
|
||||
for (const candidate of nestedCandidates) {
|
||||
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function normalizeRunwayVideoResult(task, body) {
|
||||
const urls = extractRunwayOutputUrls(task);
|
||||
if (urls.length === 0) {
|
||||
throw new Error(
|
||||
`Runway task completed without output URLs: ${JSON.stringify(task).slice(0, 400)}`
|
||||
);
|
||||
}
|
||||
if (body.response_format === "url") return urls.map((url) => ({ url, format: "mp4" }));
|
||||
|
||||
const videos = [];
|
||||
for (const url of urls) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Runway output fetch failed (${response.status})`);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
videos.push({ b64_json: Buffer.from(arrayBuffer).toString("base64"), format: "mp4" });
|
||||
}
|
||||
return videos;
|
||||
}
|
||||
@@ -54,21 +54,8 @@ export function buildAccountSemaphoreKey({
|
||||
return `${String(provider)}:${String(accountKey)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective positive cap, or null when the semaphore is bypassed (unset/<=0).
|
||||
*
|
||||
* Narrowing companion of {@link isBypassed}: that one returns a plain boolean, so
|
||||
* TypeScript cannot narrow `number | null` to `number` in its else-branch (a
|
||||
* `x is null | undefined` predicate would be unsound — 0 bypasses too). Callers
|
||||
* that need the VALUE after the guard go through here instead of casting.
|
||||
*/
|
||||
function resolveActiveCap(maxConcurrency?: number | null): number | null {
|
||||
if (maxConcurrency == null || maxConcurrency <= 0) return null;
|
||||
return maxConcurrency;
|
||||
}
|
||||
|
||||
function isBypassed(maxConcurrency?: number | null): boolean {
|
||||
return resolveActiveCap(maxConcurrency) === null;
|
||||
return maxConcurrency == null || maxConcurrency <= 0;
|
||||
}
|
||||
|
||||
function createNoopReleaseFn(): () => void {
|
||||
@@ -205,8 +192,7 @@ export function acquire(
|
||||
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
|
||||
}: AcquireAccountSemaphoreOptions = {}
|
||||
): Promise<() => void> {
|
||||
const activeCap = resolveActiveCap(maxConcurrency);
|
||||
if (activeCap === null) {
|
||||
if (isBypassed(maxConcurrency)) {
|
||||
return Promise.resolve(createNoopReleaseFn());
|
||||
}
|
||||
|
||||
@@ -214,7 +200,9 @@ export function acquire(
|
||||
return Promise.reject(makeAbortError(signal));
|
||||
}
|
||||
|
||||
const gate = ensureGate(semaphoreKey, activeCap);
|
||||
// isBypassed() above already excluded null/<=0 — ensureGate requires a plain
|
||||
// number, but a boolean-returning helper isn't a type predicate TS can narrow on.
|
||||
const gate = ensureGate(semaphoreKey, maxConcurrency as number);
|
||||
clearCleanupTimer(gate);
|
||||
|
||||
if (gate.running < gate.maxConcurrency && !isBlocked(gate)) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1200
open-sse/services/adobeFireflyChromeRuntime.ts
Normal file
1200
open-sse/services/adobeFireflyChromeRuntime.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
97
open-sse/services/adobeFireflyReferences.ts
Normal file
97
open-sse/services/adobeFireflyReferences.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { AdobeFireflyError } from "./adobeFireflyClient.ts";
|
||||
import type { AdobeFireflyVideoModelSpec } from "./adobeFireflyClient.ts";
|
||||
|
||||
export interface AdobeSourceImageReference {
|
||||
source: string;
|
||||
usage?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export function normalizeAdobeReferenceBlobs(
|
||||
modelSpec: AdobeFireflyVideoModelSpec,
|
||||
references: unknown
|
||||
): Array<{ id: string; usage: string; order?: number }> {
|
||||
if (!Array.isArray(references)) return [];
|
||||
|
||||
const maxReferences = modelSpec.referenceMode === "image" ? 3 : 2;
|
||||
if (references.length > maxReferences) {
|
||||
throw new AdobeFireflyError(
|
||||
`Adobe Firefly model accepts at most ${maxReferences} ${
|
||||
modelSpec.referenceMode === "image" ? "asset" : "frame"
|
||||
} image references`,
|
||||
400,
|
||||
"bad_image"
|
||||
);
|
||||
}
|
||||
|
||||
return references.map((reference, index) => {
|
||||
if (!reference || typeof reference !== "object") {
|
||||
throw new AdobeFireflyError("Invalid Adobe Firefly reference image", 400, "bad_image");
|
||||
}
|
||||
const value = reference as Record<string, unknown>;
|
||||
const id = typeof value.id === "string" ? value.id.trim() : "";
|
||||
if (!id) {
|
||||
throw new AdobeFireflyError("Adobe Firefly reference image id is required", 400, "bad_image");
|
||||
}
|
||||
|
||||
const expectedUsage = modelSpec.referenceMode === "image" ? "asset" : "frame";
|
||||
const usage = typeof value.usage === "string" ? value.usage.trim() : expectedUsage;
|
||||
if (usage !== expectedUsage) {
|
||||
throw new AdobeFireflyError(
|
||||
`Adobe Firefly model does not support image references with usage '${usage}'`,
|
||||
400,
|
||||
"bad_image"
|
||||
);
|
||||
}
|
||||
|
||||
return expectedUsage === "frame" ? { id, usage, order: index + 1 } : { id, usage };
|
||||
});
|
||||
}
|
||||
|
||||
export function extractAdobeSourceImageReferences(
|
||||
body: unknown,
|
||||
max = 4
|
||||
): AdobeSourceImageReference[] {
|
||||
if (!body || typeof body !== "object") return [];
|
||||
const inputs = (body as Record<string, unknown>).adobe_reference_inputs;
|
||||
if (!Array.isArray(inputs)) return [];
|
||||
|
||||
const references: AdobeSourceImageReference[] = [];
|
||||
for (const input of inputs) {
|
||||
if (!input || typeof input !== "object") continue;
|
||||
const value = input as Record<string, unknown>;
|
||||
if (
|
||||
value.type !== undefined &&
|
||||
value.type !== "input_image" &&
|
||||
value.type !== "image" &&
|
||||
value.type !== "image_url"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const imageUrl = value.image_url;
|
||||
const source =
|
||||
typeof value.source === "string"
|
||||
? value.source.trim()
|
||||
: typeof imageUrl === "string"
|
||||
? imageUrl.trim()
|
||||
: imageUrl &&
|
||||
typeof imageUrl === "object" &&
|
||||
typeof (imageUrl as Record<string, unknown>).url === "string"
|
||||
? String((imageUrl as Record<string, unknown>).url).trim()
|
||||
: typeof value.url === "string"
|
||||
? value.url.trim()
|
||||
: "";
|
||||
if (!source || (!source.startsWith("data:image/") && !/^https?:\/\//i.test(source))) continue;
|
||||
|
||||
const usage =
|
||||
typeof value.usage === "string" && value.usage.trim() ? value.usage.trim() : undefined;
|
||||
const order =
|
||||
typeof value.order === "number" && Number.isInteger(value.order) && value.order > 0
|
||||
? value.order
|
||||
: undefined;
|
||||
references.push({ source, ...(usage ? { usage } : {}), ...(order ? { order } : {}) });
|
||||
if (references.length >= max) break;
|
||||
}
|
||||
return references;
|
||||
}
|
||||
54
open-sse/services/adobeFireflySecurity.ts
Normal file
54
open-sse/services/adobeFireflySecurity.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
const ADOBE_JWT_IN_TEXT_REGEX =
|
||||
/eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}/;
|
||||
const ADOBE_JWT_IN_TEXT_GLOBAL_REGEX =
|
||||
/eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}/g;
|
||||
const ADOBE_JWT_EXACT_REGEX =
|
||||
/^eyJ[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}\.[A-Za-z0-9_-]{1,4096}$/;
|
||||
const FIREFLY_3P_HOST_SUFFIX = "firefly-3p.ff.adobe.io";
|
||||
|
||||
export function decodeAdobeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
try {
|
||||
let raw = String(token || "")
|
||||
.trim()
|
||||
.replace(/^bearer\s+/i, "")
|
||||
.trim();
|
||||
const match = raw.match(ADOBE_JWT_IN_TEXT_REGEX);
|
||||
if (match) raw = match[0];
|
||||
const part = raw.split(".")[1];
|
||||
if (!part) return null;
|
||||
const json = Buffer.from(part.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
|
||||
const value: unknown = JSON.parse(json);
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function findAllAdobeJwts(value: string): string[] {
|
||||
return value.match(ADOBE_JWT_IN_TEXT_GLOBAL_REGEX) ?? [];
|
||||
}
|
||||
|
||||
export function isExactAdobeJwt(value: string): boolean {
|
||||
return ADOBE_JWT_EXACT_REGEX.test(value);
|
||||
}
|
||||
|
||||
export function stripAdobeJwts(value: string, replacement = ""): string {
|
||||
return value.replace(ADOBE_JWT_IN_TEXT_GLOBAL_REGEX, replacement);
|
||||
}
|
||||
|
||||
function hostnameMatches(hostname: string, expected: string): boolean {
|
||||
const normalized = hostname.toLowerCase().replace(/\.$/, "");
|
||||
return normalized === expected || normalized.endsWith(`.${expected}`);
|
||||
}
|
||||
|
||||
export function isAdobeFireflyApiUrl(rawUrl: string): boolean {
|
||||
try {
|
||||
return hostnameMatches(new URL(rawUrl).hostname, FIREFLY_3P_HOST_SUFFIX);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isAdobeLoginCookieDomain(domain: string): boolean {
|
||||
return hostnameMatches(domain.replace(/^\./, ""), "adobelogin.com");
|
||||
}
|
||||
1002
open-sse/services/adobeFireflySession.ts
Normal file
1002
open-sse/services/adobeFireflySession.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -156,9 +156,10 @@ export function resolveAdobeCreativityLevel(opts: {
|
||||
return clampLevel(normalizeExplicitCreativity(Number(explicit)));
|
||||
}
|
||||
|
||||
const percent = typeof opts.creativityPercent === "number" && Number.isFinite(opts.creativityPercent)
|
||||
? Math.max(0, Math.min(100, opts.creativityPercent))
|
||||
: 0;
|
||||
const percent =
|
||||
typeof opts.creativityPercent === "number" && Number.isFinite(opts.creativityPercent)
|
||||
? Math.max(0, Math.min(100, opts.creativityPercent))
|
||||
: 0;
|
||||
return clampLevel(percent / 100);
|
||||
}
|
||||
|
||||
@@ -265,11 +266,7 @@ export async function adobeFireflyUpscaleImage(opts: {
|
||||
|
||||
const blobId = String(opts.blobId || "").trim();
|
||||
if (!blobId) {
|
||||
throw new AdobeFireflyError(
|
||||
"Adobe Firefly upscale requires a source image",
|
||||
400,
|
||||
"bad_image"
|
||||
);
|
||||
throw new AdobeFireflyError("Adobe Firefly upscale requires a source image", 400, "bad_image");
|
||||
}
|
||||
|
||||
const factor = normalizeFactor(opts.upsamplerFactor, spec.factors);
|
||||
|
||||
@@ -39,7 +39,7 @@ export function preferAntigravityConnectionsWithStoredProject<T extends Record<s
|
||||
): T[] {
|
||||
if (!Array.isArray(connections) || connections.length === 0) return connections;
|
||||
const hasStoredProject = (connection: T): boolean => {
|
||||
if (typeof connection.projectId === "string" && connection.projectId) return true;
|
||||
if (typeof connection.projectId === "string" && connection.projectId.trim()) return true;
|
||||
let psd = connection.providerSpecificData;
|
||||
if (typeof psd === "string") {
|
||||
try {
|
||||
@@ -48,12 +48,9 @@ export function preferAntigravityConnectionsWithStoredProject<T extends Record<s
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return Boolean(
|
||||
psd &&
|
||||
typeof psd === "object" &&
|
||||
typeof (psd as Record<string, unknown>).projectId === "string" &&
|
||||
(psd as Record<string, unknown>).projectId
|
||||
);
|
||||
if (!psd || typeof psd !== "object") return false;
|
||||
const projectId = (psd as Record<string, unknown>).projectId;
|
||||
return typeof projectId === "string" && projectId.trim().length > 0;
|
||||
};
|
||||
const withStoredProject = connections.filter(hasStoredProject);
|
||||
return withStoredProject.length > 0 ? withStoredProject : connections;
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
/**
|
||||
* Re-export from `antigravityProjectPersist.ts` plus a connection-preference helper.
|
||||
*
|
||||
* The persistence layer for a runtime-discovered Antigravity projectId lives in
|
||||
* the sibling file `antigravityProjectPersist.ts` (named by its core function).
|
||||
* This module adds `preferAntigravityConnectionsWithStoredProject()`, used by the
|
||||
* quota-strategy engine to give priority to connections whose projectId has
|
||||
* already been discovered and persisted.
|
||||
*/
|
||||
|
||||
import { persistDiscoveredAntigravityProjectId } from "./antigravityProjectPersist.ts";
|
||||
|
||||
export { persistDiscoveredAntigravityProjectId };
|
||||
|
||||
export function preferAntigravityConnectionsWithStoredProject(
|
||||
connections: Array<Record<string, unknown>>
|
||||
): Array<Record<string, unknown>> {
|
||||
return connections.filter(
|
||||
(conn) => conn != null && typeof conn.projectId === "string" && conn.projectId.trim().length > 0
|
||||
);
|
||||
/**
|
||||
* Prefer Antigravity connections with a discovered/stored `projectId` for
|
||||
* reset-aware quota routing.
|
||||
*
|
||||
* This is a preference, not a hard requirement: when no candidate has a stored
|
||||
* projectId, retain the full pool rather than making freshly-added accounts unusable.
|
||||
*/
|
||||
function hasStoredProjectId(connection: Record<string, unknown>): boolean {
|
||||
if (typeof connection.projectId === "string" && connection.projectId.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
const providerSpecificData = connection.providerSpecificData;
|
||||
if (providerSpecificData && typeof providerSpecificData === "object") {
|
||||
const nested = (providerSpecificData as Record<string, unknown>).projectId;
|
||||
if (typeof nested === "string" && nested.trim().length > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function preferAntigravityConnectionsWithStoredProject<T extends Record<string, unknown>>(
|
||||
connections: T[]
|
||||
): T[] {
|
||||
const withStoredProject = connections.filter(hasStoredProjectId);
|
||||
return withStoredProject.length > 0 ? withStoredProject : connections;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function normalizeClaudeAdaptiveThinking<T extends Record<string, unknown
|
||||
delete nextThinking.budget_tokens;
|
||||
delete nextThinking.max_tokens;
|
||||
|
||||
return { ...record, thinking: nextThinking } as T;
|
||||
return { ...body, thinking: nextThinking };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,7 +84,7 @@ export function normalizeClaudeDisabledThinkingEffort<T extends Record<string, u
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
...body,
|
||||
output_config: { ...outputConfig, effort: disabledEffortCap },
|
||||
} as T;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3020,7 +3020,8 @@ async function handleRoundRobinCombo({
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: "Service temporarily unavailable: all targets were skipped by pre-dispatch filters",
|
||||
message:
|
||||
"Service temporarily unavailable: all targets were skipped by pre-dispatch filters",
|
||||
type: "service_unavailable",
|
||||
code: "ALL_TARGETS_SKIPPED",
|
||||
},
|
||||
|
||||
@@ -138,9 +138,7 @@ function normalizeRuntimeStep(
|
||||
: {}),
|
||||
weight,
|
||||
label,
|
||||
// `prompt` is a per-step pipeline input and only exists on a model step —
|
||||
// #8894 widened the union with ComboProviderWildcardStep, which has no prompt.
|
||||
prompt: (step.kind === "model" ? step.prompt : null) || null,
|
||||
prompt: step.kind === "model" ? step.prompt || null : null,
|
||||
} satisfies ResolvedComboTarget;
|
||||
}
|
||||
|
||||
@@ -485,13 +483,6 @@ function estimateRequestInputTokens(body: Record<string, unknown>): number {
|
||||
}
|
||||
|
||||
function valueContainsImagePart(value: unknown): boolean {
|
||||
// Delegates to the unified media detector (open-sse/utils/mediaParts.ts) —
|
||||
// single source of truth shared with the vision-bridge guardrail. The
|
||||
// detector keeps this filter's legacy permissive matches (image-ish `type`
|
||||
// in any casing, bare `image_url`/`input_image` keys, source.media_type
|
||||
// image/*, bare data:image strings, recursion capped at depth 8) via
|
||||
// "image_indicator" parts. containsMediaKind short-circuits on the first
|
||||
// hit — this runs on every request, so no full-part collection here.
|
||||
return containsMediaKind([{ content: [value] }], "image");
|
||||
}
|
||||
|
||||
@@ -615,6 +606,10 @@ export type CompatFilterOptions = {
|
||||
failOpen?: boolean;
|
||||
};
|
||||
|
||||
export function hasHardCapabilityFailure(reasons: string[]): boolean {
|
||||
return reasons.some((reason) => HARD_COMPAT_REASONS.has(reason));
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize a capability-filter exhaustion for a 400-class combo error (#8488).
|
||||
* Returns null when the empty pool is not attributable to hard requirements.
|
||||
@@ -718,9 +713,7 @@ export function filterTargetsByRequestCompatibility(
|
||||
|
||||
if (compatible.length === targets.length) return targets;
|
||||
if (compatible.length === 0) {
|
||||
const hardRejected = rejected.some((entry) =>
|
||||
entry.reasons.some((r) => HARD_COMPAT_REASONS.has(r))
|
||||
);
|
||||
const hardRejected = rejected.some((entry) => hasHardCapabilityFailure(entry.reasons));
|
||||
const failOpen = options?.failOpen === true;
|
||||
|
||||
log.debug?.(
|
||||
|
||||
@@ -51,9 +51,9 @@ export function extractFusionPanelSpec(
|
||||
panel.push(step.comboName);
|
||||
return;
|
||||
}
|
||||
// #8894 widened ComboStep with ComboProviderWildcardStep, which carries a
|
||||
// modelPattern instead of a model. getComboModelString() already resolves any
|
||||
// step shape (and returns null for the ones with no concrete model id).
|
||||
// Provider-wildcard steps have no concrete model to dispatch — fusion is a
|
||||
// fixed-size panel of literal models/combo-refs, not a wildcard-expanding
|
||||
// strategy (see file header). Skip rather than push an undefined model.
|
||||
const modelStr = getComboModelString(step);
|
||||
if (modelStr) panel.push(modelStr);
|
||||
});
|
||||
|
||||
@@ -89,9 +89,7 @@ async function getQuotaAwareConnectionsForTarget(
|
||||
? (connections as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
if (provider === "antigravity" || provider === "agy") {
|
||||
activeConnections = preferAntigravityConnectionsWithStoredProject(
|
||||
activeConnections
|
||||
) as Array<Record<string, unknown>>;
|
||||
activeConnections = preferAntigravityConnectionsWithStoredProject(activeConnections);
|
||||
}
|
||||
if (
|
||||
!resetAwareConnectionCache.has(provider) &&
|
||||
|
||||
@@ -221,6 +221,14 @@ const LITE_SCHEMA: EngineConfigField[] = [
|
||||
label: "Preserve system prompt",
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
key: "compressToolResults",
|
||||
type: "boolean",
|
||||
label: "Proactively truncate long tool results",
|
||||
description:
|
||||
"Truncates tool results over 2,000 characters during Lite compression. Emergency overflow protection may still trim content when the context exceeds the model budget.",
|
||||
defaultValue: true,
|
||||
},
|
||||
];
|
||||
|
||||
function validateLiteConfig(config: Record<string, unknown>): EngineValidationResult {
|
||||
@@ -231,6 +239,7 @@ function validateLiteConfig(config: Record<string, unknown>): EngineValidationRe
|
||||
) {
|
||||
errors.push("preserveSystemPrompt must be a boolean");
|
||||
}
|
||||
validateBoolean(config, "compressToolResults", errors);
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
@@ -253,9 +262,17 @@ export const liteEngine: CompressionEngine = {
|
||||
},
|
||||
apply(body, options) {
|
||||
const adapter = adaptBodyForCompression(body);
|
||||
const stepCompressToolResults = options?.stepConfig?.compressToolResults;
|
||||
const result = applyLiteCompression(adapter.body, {
|
||||
...options,
|
||||
preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false,
|
||||
// buildStepOptions() already merges global config.lite with explicit step.config
|
||||
// (step wins) into stepConfig, so consume that single effective value instead of
|
||||
// AND-ing root and step values — an explicit step `true` must override a global `false`.
|
||||
compressToolResults:
|
||||
typeof stepCompressToolResults === "boolean"
|
||||
? stepCompressToolResults
|
||||
: (options?.config?.lite?.compressToolResults ?? true),
|
||||
});
|
||||
return adapter.adapted ? { ...result, body: adapter.restore(result.body) } : result;
|
||||
},
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
* - Only replace blocks ≥ minChars (default 600).
|
||||
* - `stackable: true`, `stackPriority: 4` (runs just after session-dedup(3)).
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
deleteAllCcrBlocks,
|
||||
@@ -292,8 +291,10 @@ function rehydrateEntry(hash: string, principalId: string, now: number): CcrEntr
|
||||
|
||||
// Re-admit through the same budgets a fresh store would face. If the block no longer
|
||||
// fits, it stays on disk and is served straight from the row instead of being cached.
|
||||
const { principalId: owner, bytes } = entry;
|
||||
if (enforcePrincipalBudget(owner, bytes) && enforceGlobalBudget(owner, bytes)) {
|
||||
if (
|
||||
enforcePrincipalBudget(entry.principalId, entry.bytes) &&
|
||||
enforceGlobalBudget(entry.principalId, entry.bytes)
|
||||
) {
|
||||
const key = buildStoreKey(hash, principalId === ANON ? undefined : principalId);
|
||||
ccrStore.set(key, entry);
|
||||
ccrTotalBytes += entry.bytes;
|
||||
|
||||
@@ -17,6 +17,7 @@ interface LiteCompressionOptions {
|
||||
model?: string;
|
||||
supportsVision?: boolean | null;
|
||||
preserveSystemPrompt?: boolean;
|
||||
compressToolResults?: boolean;
|
||||
}
|
||||
|
||||
function trimTrailingHorizontalWhitespace(line: string): string {
|
||||
@@ -253,9 +254,11 @@ export function applyLiteCompression(
|
||||
current = r2.body;
|
||||
if (r2.applied) techniquesApplied.push("system-dedup");
|
||||
|
||||
const r3 = compressToolResults(current);
|
||||
current = r3.body;
|
||||
if (r3.applied) techniquesApplied.push("tool-compress");
|
||||
if (options?.compressToolResults !== false) {
|
||||
const r3 = compressToolResults(current);
|
||||
current = r3.body;
|
||||
if (r3.applied) techniquesApplied.push("tool-compress");
|
||||
}
|
||||
|
||||
const r4 = removeRedundantContent(current, options);
|
||||
current = r4.body;
|
||||
|
||||
@@ -14,6 +14,8 @@ export function resolveStepDetailConfig(
|
||||
config: CompressionConfig | undefined
|
||||
) {
|
||||
switch (engine) {
|
||||
case "lite":
|
||||
return config?.lite ?? {};
|
||||
case "headroom":
|
||||
return config?.headroom ?? {};
|
||||
case "session-dedup":
|
||||
|
||||
@@ -349,6 +349,7 @@ function runCompression(
|
||||
const result = applyLiteCompression(compressionBody, {
|
||||
...options,
|
||||
preserveSystemPrompt: options?.config?.preserveSystemPrompt !== false,
|
||||
...options?.config?.lite,
|
||||
});
|
||||
return adapter.adapted ? { ...result, body: adapter.restore(result.body) } : result;
|
||||
}
|
||||
|
||||
@@ -157,6 +157,12 @@ export interface LiveZoneConfig {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Lite detail settings for proactive request-time transformations. */
|
||||
export interface LiteConfig {
|
||||
/** Truncate tool-result strings over 2,000 characters before provider dispatch. */
|
||||
compressToolResults: boolean;
|
||||
}
|
||||
|
||||
export interface CompressionPipelineStep {
|
||||
engine: CompressionEngineId;
|
||||
intensity?: CavemanIntensity | RtkIntensity;
|
||||
@@ -218,6 +224,8 @@ export interface CompressionConfig {
|
||||
languageConfig?: CompressionLanguageConfig;
|
||||
aggressive?: AggressiveConfig;
|
||||
ultra?: UltraConfig;
|
||||
/** Lite proactive transformation detail settings. */
|
||||
lite?: LiteConfig;
|
||||
/** Headroom SmartCrusher detail settings (minRows gate). */
|
||||
headroom?: HeadroomConfig;
|
||||
/** Session Dedup detail settings (minBlockChars / fuzzy, #8388). */
|
||||
@@ -395,6 +403,7 @@ export const DEFAULT_COMPRESSION_CONFIG: CompressionConfig = {
|
||||
ultraEngine: "heuristic",
|
||||
ultraSlmPrewarm: false,
|
||||
liveZone: { enabled: false },
|
||||
lite: { compressToolResults: true },
|
||||
codexResponsesConfig: { ...DEFAULT_CODEX_RESPONSES_CONFIG },
|
||||
};
|
||||
|
||||
|
||||
@@ -121,10 +121,6 @@ export function getFirecrawlBaseUrl(connection?: Record<string, unknown>): strin
|
||||
export async function fetchFirecrawlQuota(
|
||||
connectionId: string,
|
||||
connection?: Record<string, unknown>
|
||||
// FirecrawlQuota, not the base QuotaInfo: every return here is a full credit
|
||||
// breakdown (remainingCredits / planCredits / extraCreditsInferred / overPlan),
|
||||
// and the narrower annotation made the custom-base literal below an excess-
|
||||
// property error. FirecrawlQuota extends QuotaInfo, so callers are unaffected.
|
||||
): Promise<FirecrawlQuota | null> {
|
||||
const cached = quotaCache.get(connectionId);
|
||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||
|
||||
@@ -64,6 +64,8 @@ const REASONING_REPLAY_MODEL_PATTERNS = [
|
||||
];
|
||||
|
||||
const DEEPSEEK_V4_MODEL_PATTERN = /deepseek[-/]v4[-.](flash|pro)/i;
|
||||
const K3_REASONING_REPLAY_MODEL_PATTERN = /(?:^|\/)(?:kimi-)?k3(?:$|-)/i;
|
||||
const NATIVE_K27_REASONING_REPLAY_MODEL_PATTERN = /(?:^|\/)kimi-k2\.7-code(?:$|-)/i;
|
||||
|
||||
export function isDeepSeekReasoningModel(params: {
|
||||
provider: string;
|
||||
@@ -94,6 +96,14 @@ export function requiresReasoningReplay(params: {
|
||||
if (normalizedInterleavedField === "reasoning_content") return true;
|
||||
if (normalizedInterleavedField === "reasoning_details") return false;
|
||||
|
||||
if (K3_REASONING_REPLAY_MODEL_PATTERN.test(normalizedModel)) return true;
|
||||
if (
|
||||
(normalizedProvider === "moonshot" || normalizedProvider === "kimi") &&
|
||||
NATIVE_K27_REASONING_REPLAY_MODEL_PATTERN.test(normalizedModel)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// DeepSeek legacy reasoner family has an inverse contract: do not replay.
|
||||
if (/deepseek-reasoner/i.test(normalizedModel) || /deepseek-r1/i.test(normalizedModel)) {
|
||||
return false;
|
||||
|
||||
55
open-sse/services/responsesInputPolicy.ts
Normal file
55
open-sse/services/responsesInputPolicy.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const SERVER_ITEM_ID_PATTERN = /^(rs|fc|resp|msg)_/;
|
||||
|
||||
/**
|
||||
* Applies the persistence-independent policy for replayed Responses input items.
|
||||
* Stored references can only be resolved by the upstream that created them, so
|
||||
* they are always removed. Self-contained encrypted reasoning is retained only
|
||||
* when the selected connection explicitly opts in.
|
||||
*/
|
||||
export function applyResponsesInputPolicy(
|
||||
body: Record<string, unknown>,
|
||||
preserveEncryptedReasoning = false
|
||||
): void {
|
||||
if (Array.isArray(body.input) && body.input.length === 0) {
|
||||
body.input = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "continue" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (!Array.isArray(body.input)) return;
|
||||
|
||||
body.input = body.input.filter((item) => {
|
||||
if (typeof item === "string" && SERVER_ITEM_ID_PATTERN.test(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const record =
|
||||
item && typeof item === "object" && !Array.isArray(item) ? (item as JsonRecord) : null;
|
||||
if (!record) return true;
|
||||
|
||||
if (record.type === "item_reference") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
record.type === "reasoning" &&
|
||||
(!preserveEncryptedReasoning ||
|
||||
typeof record.encrypted_content !== "string" ||
|
||||
record.encrypted_content.trim().length === 0)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof record.id === "string" && SERVER_ITEM_ID_PATTERN.test(record.id)) {
|
||||
delete record.id;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import { refreshGoogleToken } from "./tokenRefresh/providers/google.ts";
|
||||
import { ensureAntigravityProjectAssigned } from "./antigravityProjectBootstrap.ts";
|
||||
import { persistDiscoveredAntigravityProjectId } from "./antigravityProjectPersist.ts";
|
||||
import { refreshCodexToken } from "./tokenRefresh/providers/codex.ts";
|
||||
import { refreshOpenferenceToken } from "./tokenRefresh/providers/openference.ts";
|
||||
import { refreshKiroToken } from "./tokenRefresh/providers/kiro.ts";
|
||||
import { refreshQoderToken } from "./tokenRefresh/providers/qoder.ts";
|
||||
import { refreshGitHubToken } from "./tokenRefresh/providers/github.ts";
|
||||
@@ -62,6 +63,7 @@ export {
|
||||
refreshClaudeOAuthToken,
|
||||
refreshGoogleToken,
|
||||
refreshCodexToken,
|
||||
refreshOpenferenceToken,
|
||||
refreshKiroToken,
|
||||
refreshQoderToken,
|
||||
refreshGitHubToken,
|
||||
@@ -339,10 +341,7 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig:
|
||||
!(credentials.projectId || credentials.providerSpecificData?.projectId)
|
||||
) {
|
||||
try {
|
||||
const discovered = await ensureAntigravityProjectAssigned(
|
||||
result.accessToken,
|
||||
fetch
|
||||
);
|
||||
const discovered = await ensureAntigravityProjectAssigned(result.accessToken, fetch);
|
||||
if (discovered) {
|
||||
result.projectId = discovered;
|
||||
result.providerSpecificData = {
|
||||
@@ -362,7 +361,8 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig:
|
||||
});
|
||||
}
|
||||
} catch (discoveryError) {
|
||||
const msg = discoveryError instanceof Error ? discoveryError.message : String(discoveryError);
|
||||
const msg =
|
||||
discoveryError instanceof Error ? discoveryError.message : String(discoveryError);
|
||||
log?.warn?.("TOKEN", `Antigravity projectId discovery failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
@@ -376,6 +376,9 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig:
|
||||
case "codex":
|
||||
return await refreshCodexToken(credentials.refreshToken, log, proxyConfig);
|
||||
|
||||
case "openference":
|
||||
return await refreshOpenferenceToken(credentials.refreshToken, log, proxyConfig);
|
||||
|
||||
case "qoder":
|
||||
return await refreshQoderToken(credentials.refreshToken, log, proxyConfig);
|
||||
|
||||
@@ -439,6 +442,7 @@ export function supportsTokenRefresh(provider) {
|
||||
"agy",
|
||||
"claude",
|
||||
"codex",
|
||||
"openference",
|
||||
"qoder",
|
||||
"github",
|
||||
"kiro",
|
||||
|
||||
92
open-sse/services/tokenRefresh/providers/openference.ts
Normal file
92
open-sse/services/tokenRefresh/providers/openference.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// @ts-nocheck
|
||||
import { OAUTH_ENDPOINTS } from "../../../config/constants.ts";
|
||||
import { runWithProxyContext } from "../../../utils/proxyFetch.ts";
|
||||
import { buildFormParams } from "../shared.ts";
|
||||
|
||||
/**
|
||||
* Specialized refresh for Openference OAuth tokens.
|
||||
* Openference uses rotating (one-time-use) oar_* refresh tokens.
|
||||
*/
|
||||
export async function refreshOpenferenceToken(refreshToken, log, proxyConfig: unknown = null) {
|
||||
try {
|
||||
const response = await runWithProxyContext(proxyConfig, () =>
|
||||
fetch(OAUTH_ENDPOINTS.openference.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: buildFormParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: OAUTH_ENDPOINTS.openference.clientId,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
|
||||
let errorCode = null;
|
||||
try {
|
||||
const parsed = JSON.parse(errorText);
|
||||
errorCode =
|
||||
parsed?.error?.code || (typeof parsed?.error === "string" ? parsed.error : null);
|
||||
} catch {
|
||||
// not JSON, ignore
|
||||
}
|
||||
|
||||
if (
|
||||
errorCode === "invalid_grant" ||
|
||||
errorCode === "token_expired" ||
|
||||
errorCode === "invalid_token"
|
||||
) {
|
||||
log?.error?.(
|
||||
"TOKEN_REFRESH",
|
||||
"Openference refresh token already used or invalid. Re-authentication required.",
|
||||
{
|
||||
status: response.status,
|
||||
errorCode,
|
||||
}
|
||||
);
|
||||
return { error: "unrecoverable_refresh_error", code: errorCode };
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
const code = errorCode || "unauthorized";
|
||||
log?.error?.(
|
||||
"TOKEN_REFRESH",
|
||||
"Openference OAuth token endpoint returned 401. Re-authentication required.",
|
||||
{
|
||||
status: response.status,
|
||||
errorCode: code,
|
||||
}
|
||||
);
|
||||
return { error: "unrecoverable_refresh_error", code };
|
||||
}
|
||||
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Openference token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Openference token", {
|
||||
hasNewAccessToken: !!tokens.access_token,
|
||||
hasNewRefreshToken: !!tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", `Network error refreshing Openference token: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,102 @@ async function getPath() {
|
||||
return _path || null;
|
||||
}
|
||||
|
||||
type UsageRecord = Record<string, unknown>;
|
||||
|
||||
function usageRecord(value: unknown): UsageRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as UsageRecord)
|
||||
: {};
|
||||
}
|
||||
|
||||
function usageNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function usageDetails(record: UsageRecord, ...keys: string[]): UsageRecord {
|
||||
for (const key of keys) {
|
||||
const value = usageRecord(record[key]);
|
||||
if (Object.keys(value).length > 0) return value;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Normalize Chat Completions and Responses usage into the Responses API shape. */
|
||||
function normalizeResponsesUsage(previous: unknown, raw: unknown): UsageRecord | null {
|
||||
const source = usageRecord(raw);
|
||||
if (Object.keys(source).length === 0) return usageRecord(previous);
|
||||
|
||||
const before = usageRecord(previous);
|
||||
const beforeInputDetails = usageDetails(before, "input_tokens_details", "prompt_tokens_details");
|
||||
const beforeOutputDetails = usageDetails(
|
||||
before,
|
||||
"output_tokens_details",
|
||||
"completion_tokens_details"
|
||||
);
|
||||
const inputDetails = usageDetails(
|
||||
source,
|
||||
"input_tokens_details",
|
||||
"prompt_tokens_details",
|
||||
"inputTokenDetails",
|
||||
"input_token_details"
|
||||
);
|
||||
const outputDetails = usageDetails(
|
||||
source,
|
||||
"output_tokens_details",
|
||||
"completion_tokens_details",
|
||||
"outputTokenDetails",
|
||||
"output_token_details",
|
||||
"reasoningTokenDetails",
|
||||
"reasoning_token_details"
|
||||
);
|
||||
|
||||
const inputTokens =
|
||||
usageNumber(source.input_tokens) ??
|
||||
usageNumber(source.prompt_tokens) ??
|
||||
usageNumber(source.inputTokens) ??
|
||||
usageNumber(source.promptTokens) ??
|
||||
usageNumber(before.input_tokens) ??
|
||||
usageNumber(before.prompt_tokens) ??
|
||||
0;
|
||||
const cachedTokens =
|
||||
usageNumber(source.cache_read_input_tokens) ??
|
||||
usageNumber(source.cached_input_tokens) ??
|
||||
usageNumber(source.cachedInputTokens) ??
|
||||
usageNumber(source.cached_tokens) ??
|
||||
usageNumber(inputDetails.cached_tokens) ??
|
||||
usageNumber(inputDetails.cachedTokens) ??
|
||||
usageNumber(inputDetails.cacheReadTokens) ??
|
||||
usageNumber(beforeInputDetails.cached_tokens) ??
|
||||
0;
|
||||
const outputTokens =
|
||||
usageNumber(source.output_tokens) ??
|
||||
usageNumber(source.completion_tokens) ??
|
||||
usageNumber(source.outputTokens) ??
|
||||
usageNumber(source.completionTokens) ??
|
||||
usageNumber(before.output_tokens) ??
|
||||
usageNumber(before.completion_tokens) ??
|
||||
0;
|
||||
const reasoningTokens =
|
||||
usageNumber(source.reasoning_tokens) ??
|
||||
usageNumber(source.reasoningTokens) ??
|
||||
usageNumber(outputDetails.reasoning_tokens) ??
|
||||
usageNumber(outputDetails.reasoningTokens) ??
|
||||
usageNumber(beforeOutputDetails.reasoning_tokens) ??
|
||||
0;
|
||||
const totalTokens =
|
||||
usageNumber(source.total_tokens) ??
|
||||
usageNumber(source.totalTokens) ??
|
||||
inputTokens + outputTokens;
|
||||
|
||||
return {
|
||||
input_tokens: inputTokens,
|
||||
input_tokens_details: { cached_tokens: cachedTokens },
|
||||
output_tokens: outputTokens,
|
||||
output_tokens_details: { reasoning_tokens: reasoningTokens },
|
||||
total_tokens: totalTokens,
|
||||
};
|
||||
}
|
||||
|
||||
// Create log directory for responses (Node.js only)
|
||||
export function createResponsesLogger(model, logsDir = null) {
|
||||
// Skip logging in worker environment (no fs)
|
||||
@@ -477,10 +573,11 @@ export function createResponsesApiTransformStream(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.usage) {
|
||||
state.usage = normalizeResponsesUsage(state.usage, parsed.usage);
|
||||
}
|
||||
|
||||
if (!parsed.choices?.length) {
|
||||
if (parsed.usage) {
|
||||
state.usage = parsed.usage;
|
||||
}
|
||||
// #6906: trailing usage-only chunk after finish_reason already deferred
|
||||
// completion — send it now with the usage just captured above.
|
||||
if (state.awaitingTrailingUsage && !state.completedSent) {
|
||||
|
||||
@@ -84,6 +84,10 @@ export function hasValidContent(msg: ClaudeMessage): boolean {
|
||||
return msg.content.some(
|
||||
(block) =>
|
||||
(block.type === "text" && block.text?.trim()) ||
|
||||
(block.type === "thinking" && block.thinking?.trim()) ||
|
||||
(block.type === "redacted_thinking" &&
|
||||
typeof block.data === "string" &&
|
||||
block.data.trim()) ||
|
||||
block.type === "tool_use" ||
|
||||
block.type === "tool_result" ||
|
||||
// #7777: media-only user turns are real content — dropping them
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user