diff --git a/.env.example b/.env.example index 858324ad9f..919db23428 100644 --- a/.env.example +++ b/.env.example @@ -384,6 +384,22 @@ NEXT_PUBLIC_CLOUD_URL= #OMNIROUTE_CODEWHISPERER_BASE_URL=https://codewhisperer.us-east-1.amazonaws.com #OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/quota #OMNIROUTE_OPENCODE_GO_QUOTA_URL=https://api.z.ai/api/monitor/usage/quota/limit +#OMNIROUTE_OPENCODE_GO_DASHBOARD_URL=https://opencode.ai/workspace +#OMNIROUTE_OLLAMA_CLOUD_USAGE_URL=https://ollama.com/settings + +# OpenCode Go dashboard quota scraping. Prefer configuring these per connection +# in Dashboard → Providers → OpenCode Go. Env vars are useful for headless +# deployments or shared server defaults. The cookie is sensitive. +#OPENCODE_GO_WORKSPACE_ID=wrk_... +#OMNIROUTE_OPENCODE_GO_WORKSPACE_ID=wrk_... +#OPENCODE_GO_AUTH_COOKIE=auth=... +#OMNIROUTE_OPENCODE_GO_AUTH_COOKIE=auth=... + +# Ollama Cloud quota scraping. Prefer configuring this per connection in +# Dashboard → Providers → Ollama Cloud. The cookie is sensitive. +#OLLAMA_USAGE_COOKIE=__Secure-session=... +#OLLAMA_CLOUD_USAGE_COOKIE=__Secure-session=... +#OMNIROUTE_OLLAMA_USAGE_COOKIE=__Secure-session=... # ═══════════════════════════════════════════════════════════════════════════════ # 8. OUTBOUND PROXY (Upstream Provider Calls) @@ -795,6 +811,11 @@ KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0" # rather than a secret. Only override if AWS ever starts enforcing this field. # Used by: src/lib/oauth/constants/oauth.ts (KIRO_CONFIG.socialClientId). # KIRO_OAUTH_CLIENT_ID=kiro-cli +# Enable full per-frame message CRC validation for Kiro streams. Off by default +# because it is O(frame bytes) on the main thread; use only for debugging +# suspected corrupted-stream issues. +# Used by: open-sse/executors/kiro.ts +# KIRO_VERIFY_FULL_CRC=false QODER_USER_AGENT="Qoder-Cli" QWEN_USER_AGENT="QwenCode/0.15.11 (linux; x64)" CURSOR_USER_AGENT="Cursor/3.4" @@ -1001,6 +1022,11 @@ APP_LOG_TO_FILE=true # Default: 100000 # CALL_LOGS_TABLE_MAX_ROWS=100000 +# Maximum age for orphaned active request log entries before the in-memory +# pending-request reaper removes them. Accepts milliseconds. +# Default: 3600000 (1 hour) +# MAX_PENDING_REQUEST_AGE_MS=3600000 + # Whether call log pipeline capture stores stream chunks when enabled in settings. # Only applies when call_log_pipeline_enabled=true. # Default: true diff --git a/.github/workflows/nightly-release-green.yml b/.github/workflows/nightly-release-green.yml new file mode 100644 index 0000000000..c1d5128a74 --- /dev/null +++ b/.github/workflows/nightly-release-green.yml @@ -0,0 +1,137 @@ +name: Nightly Release-Green + +# Solution D — continuous, NON-BLOCKING drift signal for the active release branch. +# +# WHY: the full gate (ci.yml) only runs on the release PR (PR → main), so reds +# accrue silently on release/** and explode — in layers — at release time. This +# nightly reproduces the release-equivalent validation on the active release branch +# HEAD and, when there are HARD failures, opens/updates a single tracking issue. +# +# It is NOT a required status check and never touches a contributor PR — it only +# reports. Ratchet drift (eslint warnings / cognitive-complexity / file-size) is +# expected mid-cycle and is reported but never raises the alarm on its own; only +# real defects (typecheck / lint errors / unit / vitest / db-rules / public-creds / +# package-artifact) flip the issue open. + +on: + schedule: + - cron: "23 5 * * *" # 05:23 UTC daily — off-peak, distinct from other nightlies + workflow_dispatch: + inputs: + branch: + description: "Release branch to validate (default: highest release/vX.Y.Z)" + required: false + type: string + +permissions: + contents: read + issues: write + +concurrency: + group: nightly-release-green + cancel-in-progress: true + +jobs: + release-green: + name: Validate active release branch + runs-on: ubuntu-latest + env: + JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation + API_KEY_SECRET: ci-nightly-api-key-secret-long + DISABLE_SQLITE_AUTO_BACKUP: "true" + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Resolve active release branch + id: branch + env: + INPUT_BRANCH: ${{ github.event.inputs.branch }} + run: | + set -euo pipefail + if [ -n "${INPUT_BRANCH:-}" ]; then + TARGET="$INPUT_BRANCH" + else + # highest release/vX.Y.Z by semver among remote branches + TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \ + | sed 's#origin/##' \ + | sort -t/ -k2 -V \ + | tail -1) + fi + if [ -z "$TARGET" ]; then echo "No release/v* branch found"; exit 1; fi + # Strict format guard — reject anything that isn't release/vX.Y.Z (blocks + # ref/command injection via the workflow_dispatch input). + if ! printf '%s' "$TARGET" | grep -qE '^release/v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Refusing non-canonical branch name: $TARGET"; exit 1 + fi + echo "target=$TARGET" >> "$GITHUB_OUTPUT" + echo "Active release branch: $TARGET" + + - name: Checkout the release branch + env: + TARGET: ${{ steps.branch.outputs.target }} + run: | + set -euo pipefail + git checkout "$TARGET" + git log -1 --oneline + + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: npm + + - uses: ./.github/actions/npm-ci-retry + + - name: Release-green validation (full) + id: validate + run: | + set +e + node scripts/quality/validate-release-green.mjs --json --with-build \ + 1> release-green.json 2> release-green.log + echo "exit=$?" >> "$GITHUB_OUTPUT" + echo "------- report -------" + cat release-green.log + + - name: Open / update tracking issue on HARD failure + if: steps.validate.outputs.exit != '0' + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ steps.branch.outputs.target }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + TITLE="🔴 Release branch not green: ${TARGET}" + { + echo "The nightly **release-green** validation found HARD failures on \`${TARGET}\`." + echo "These are real defects that would block the release PR — fix them in the" + echo "originating PR branch (via co-authorship), not by demanding it from contributors." + echo "" + echo "**Run:** ${RUN_URL}" + echo "" + echo '```' + sed -n '/──────── verdict ────────/,$p' release-green.log || tail -40 release-green.log + echo '```' + echo "" + echo "_Ratchet drift (eslint warnings / cognitive-complexity / file-size) listed above is expected mid-cycle and is rebaselined at release — it is NOT a contributor concern and did not, on its own, open this issue._" + } > issue-body.md + + EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ + --search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "") + if [ -n "$EXISTING" ]; then + gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file issue-body.md + echo "Updated existing issue #$EXISTING" + else + gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file issue-body.md + fi + + - name: Upload report artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: release-green-report + path: | + release-green.json + release-green.log + if-no-files-found: ignore diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index 19dca23be2..6aae7a8238 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -240,10 +240,18 @@ export function resolveOmniRoutePluginOptions( opts?: OmniRoutePluginOptions ): Required> & Pick { - const providerId = opts?.providerId ?? OMNIROUTE_PROVIDER_KEY; + const rawProviderId = opts?.providerId ?? OMNIROUTE_PROVIDER_KEY; + // OC 1.17.8+ native-adapter gate rejects providerID not in + // {openai, anthropic, opencode*}. Silently prefix so existing + // configs (providerId: "omniroute") keep working. + const providerId = rawProviderId.startsWith("opencode-") + ? rawProviderId + : `opencode-${rawProviderId}`; const displayName = opts?.displayName ?? - (providerId === OMNIROUTE_PROVIDER_KEY ? "OmniRoute" : `OmniRoute (${providerId})`); + (providerId === `opencode-${OMNIROUTE_PROVIDER_KEY}` + ? "OmniRoute" + : `OmniRoute (${providerId})`); const modelCacheTtl = typeof opts?.modelCacheTtl === "number" && opts.modelCacheTtl > 0 ? opts.modelCacheTtl diff --git a/@omniroute/opencode-plugin/tests/auth.test.ts b/@omniroute/opencode-plugin/tests/auth.test.ts index 76c071a61c..f393f0470f 100644 --- a/@omniroute/opencode-plugin/tests/auth.test.ts +++ b/@omniroute/opencode-plugin/tests/auth.test.ts @@ -13,12 +13,12 @@ import { createOmniRouteAuthHook } from "../src/index.js"; test("createOmniRouteAuthHook: default providerId is 'omniroute'", () => { const hook = createOmniRouteAuthHook(); - assert.equal(hook.provider, "omniroute"); + assert.equal(hook.provider, "opencode-omniroute"); }); test("createOmniRouteAuthHook: custom providerId binds to hook.provider (multi-instance)", () => { const hook = createOmniRouteAuthHook({ providerId: "omniroute-preprod" }); - assert.equal(hook.provider, "omniroute-preprod"); + assert.equal(hook.provider, "opencode-omniroute-preprod"); }); test("createOmniRouteAuthHook: methods[0] is type 'api' with label including displayName", () => { @@ -30,7 +30,7 @@ test("createOmniRouteAuthHook: methods[0] is type 'api' with label including dis assert.equal(m.label, "OmniRoute API Key"); const custom = createOmniRouteAuthHook({ providerId: "omniroute-preprod" }); - assert.equal(custom.methods[0].label, "OmniRoute (omniroute-preprod) API Key"); + assert.equal(custom.methods[0].label, "OmniRoute (opencode-omniroute-preprod) API Key"); }); test("createOmniRouteAuthHook: prompts[0] uses key='apiKey' per @opencode-ai/plugin contract", () => { diff --git a/@omniroute/opencode-plugin/tests/combos.test.ts b/@omniroute/opencode-plugin/tests/combos.test.ts index ff209a9a67..04102c0055 100644 --- a/@omniroute/opencode-plugin/tests/combos.test.ts +++ b/@omniroute/opencode-plugin/tests/combos.test.ts @@ -447,14 +447,14 @@ test("models() returns combo entries merged into the map", async () => { // 3 raw models + 1 combo = 4 entries assert.equal(Object.keys(out).length, 4); - assert.ok(out["omniroute/claude-primary"]); - assert.ok(out["omniroute/claude-secondary"]); - assert.ok(out["omniroute/gemini-3-flash"]); - assert.ok(out["omniroute/claude-tier"]); + assert.ok(out["opencode-omniroute/claude-primary"]); + assert.ok(out["opencode-omniroute/claude-secondary"]); + assert.ok(out["opencode-omniroute/gemini-3-flash"]); + assert.ok(out["opencode-omniroute/claude-tier"]); - const combo = out["omniroute/claude-tier"]; + const combo = out["opencode-omniroute/claude-tier"]; assert.equal(combo.name, "Claude Tier"); - assert.equal(combo.providerID, "omniroute"); + assert.equal(combo.providerID, "opencode-omniroute"); // LCD over claude-primary (200k, reasoning) + claude-secondary (100k, no reasoning) assert.equal(combo.limit.context, 100_000); assert.equal(combo.capabilities.reasoning, false); @@ -478,11 +478,11 @@ test("models(): combo with unknown member ids degrades to all-false LCD posture" { fetcher: modelsFetcher, combosFetcher } ); const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never }); - assert.ok(out["omniroute/phantom-combo"]); + assert.ok(out["opencode-omniroute/phantom-combo"]); // With zero resolvable members, LCD = all-false (defensive posture). - assert.equal(out["omniroute/phantom-combo"].capabilities.toolcall, false); - assert.equal(out["omniroute/phantom-combo"].capabilities.reasoning, false); - assert.equal(out["omniroute/phantom-combo"].limit.context, 0); + assert.equal(out["opencode-omniroute/phantom-combo"].capabilities.toolcall, false); + assert.equal(out["opencode-omniroute/phantom-combo"].capabilities.reasoning, false); + assert.equal(out["opencode-omniroute/phantom-combo"].limit.context, 0); }); test("models(): hidden combos are excluded from the map", async () => { @@ -505,8 +505,8 @@ test("models(): hidden combos are excluded from the map", async () => { { fetcher: modelsFetcher, combosFetcher } ); const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never }); - assert.ok(out["omniroute/visible"]); - assert.ok(!out["omniroute/hidden"], "hidden combo must be omitted"); + assert.ok(out["opencode-omniroute/visible"]); + assert.ok(!out["opencode-omniroute/hidden"], "hidden combo must be omitted"); }); test("models(): combo name exactly matches raw model id → raw deleted, raw deleted, no warn", async () => { @@ -530,8 +530,8 @@ test("models(): combo name exactly matches raw model id → raw deleted, raw del }); // Raw model replaced by combo of the same key; combo now lives at the bare slug. - assert.ok(out["omniroute/claude-primary"], "combo surfaces under prefixed key"); - assert.equal(out["omniroute/claude-primary"].name, "claude-primary"); + assert.ok(out["opencode-omniroute/claude-primary"], "combo surfaces under prefixed key"); + assert.equal(out["opencode-omniroute/claude-primary"].name, "claude-primary"); // No collision warning fires — dedup makes keys disjoint. const collisionWarns = warnings.filter((w) => { @@ -565,8 +565,8 @@ test("models(): two combos with same slug → second gets disambiguator suffix", const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never }); // First combo gets the bare slug; second gets disambiguated. - assert.ok(out["omniroute/claude"], "first combo at prefixed slug"); - assert.ok(out["omniroute/claude-uuid"], "second combo disambiguated by id prefix"); + assert.ok(out["opencode-omniroute/claude"], "first combo at prefixed slug"); + assert.ok(out["opencode-omniroute/claude-uuid"], "second combo disambiguated by id prefix"); }); test("models(): combos fetch fails → falls back to models-only, warn emitted, no throw", async () => { @@ -583,8 +583,8 @@ test("models(): combos fetch fails → falls back to models-only, warn emitted, // Catalog includes the models but NOT any combo entries. assert.equal(Object.keys(out).length, 2); - assert.ok(out["omniroute/claude-primary"]); - assert.ok(out["omniroute/claude-secondary"]); + assert.ok(out["opencode-omniroute/claude-primary"]); + assert.ok(out["opencode-omniroute/claude-secondary"]); // Soft-fail warning surfaced. const softFail = warnings.find((w) => { @@ -609,7 +609,7 @@ test("models(): combos cached + reused within TTL (one combo fetch per TTL windo const second = await hook.models!({} as never, { auth: apiAuth("sk-z") as never }); assert.equal(combosFetcher.callCount(), 1, "combos fetched only once within TTL"); assert.equal(modelsFetcher.callCount(), 1, "models fetched only once within TTL"); - assert.ok(second["omniroute/claude-tier"]); + assert.ok(second["opencode-omniroute/claude-tier"]); }); test("models(): combos refetched after TTL expiry (same key as models)", async () => { @@ -701,7 +701,7 @@ test("models(): nested combo-ref context is the min of nested + raw members", as { fetcher: modelsFetcher, combosFetcher } ); const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never }); - const masterLight = out["omniroute/master-light"]; + const masterLight = out["opencode-omniroute/master-light"]; assert.ok(masterLight, "MASTER-LIGHT entry must exist"); assert.equal( masterLight.limit.context, diff --git a/@omniroute/opencode-plugin/tests/config-shim.test.ts b/@omniroute/opencode-plugin/tests/config-shim.test.ts index 11dc76c032..16cb3253cf 100644 --- a/@omniroute/opencode-plugin/tests/config-shim.test.ts +++ b/@omniroute/opencode-plugin/tests/config-shim.test.ts @@ -203,7 +203,7 @@ function makeInput(initialProvider: Record = {}): Config { test("config: with valid auth.json + apiKey + baseURL → mutates input.provider[id] with stripped models block", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-test-1", baseURL: "https://or.example.com/v1" }, + "opencode-omniroute": { type: "api", key: "sk-test-1", baseURL: "https://or.example.com/v1" }, }); const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]); const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]); @@ -217,8 +217,8 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider await hook(input); const provider = (input as { provider: Record }).provider; - const entry = provider.omniroute; - assert.ok(entry, "input.provider.omniroute set"); + const entry = provider["opencode-omniroute"]; + assert.ok(entry, "input.provider['opencode-omniroute'] set"); assert.equal(entry.npm, "@ai-sdk/openai-compatible"); assert.equal(entry.name, "OmniRoute"); assert.equal(entry.options.baseURL, "https://or.example.com/v1"); @@ -227,7 +227,7 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider // Stripped per-model shape: name + cap flags + modalities + (optional) // cost. OC's SDK static schema accepts only `limit.{context,output}` — // `limit.input` is NOT in the SDK shape and gets dropped silently. - const claude = entry.models["omniroute/claude-sonnet-4-6"]; + const claude = entry.models["opencode-omniroute/claude-sonnet-4-6"]; assert.ok(claude, "claude model surfaced"); assert.equal(claude.name, "claude-sonnet-4-6"); assert.equal(claude.attachment, true); @@ -248,7 +248,7 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider // Combo surfaces under bare key + LCD'd // (gemini's reasoning=false → combo reasoning=false). - const combo = entry.models["omniroute/claude-tier"]; + const combo = entry.models["opencode-omniroute/claude-tier"]; assert.ok(combo, "combo surfaced under bare key"); assert.equal(combo.name, "Claude Tier"); assert.equal(combo.reasoning, false, "LCD: any member reasoning=false → combo reasoning=false"); @@ -323,7 +323,7 @@ test("config: existing input.provider[id] → no overwrite (respect manual overr models: { "manual-model": { name: "manual-model" } }, }; const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, + "opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, }); const fetcher = stubModelsFetcher([MODEL_CLAUDE]); const combosFetcher = stubCombosFetcher([]); @@ -333,11 +333,11 @@ test("config: existing input.provider[id] → no overwrite (respect manual overr { providerId: "omniroute" }, { readAuthJson, fetcher, combosFetcher, logger } ); - const input = makeInput({ omniroute: manual }); + const input = makeInput({ "opencode-omniroute": manual }); await hook(input); const provider = (input as { provider: Record }).provider; - assert.equal(provider.omniroute, manual, "manual override preserved by reference"); + assert.equal(provider["opencode-omniroute"], manual, "manual override preserved by reference"); assert.equal(fetcher.callCount(), 0, "no fetch — short-circuited before I/O"); assert.equal(readAuthJson.callCount(), 0, "no auth.json read either"); assert.ok( @@ -352,7 +352,7 @@ test("config: existing input.provider[id] → no overwrite (respect manual overr test("config: fetchers throw → warn + emit stub entry with models: {}", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, + "opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, }); const fetcher = throwingModelsFetcher(); const combosFetcher = throwingCombosFetcher(); @@ -368,8 +368,9 @@ test("config: fetchers throw → warn + emit stub entry with models: {}", async const input = makeInput(); await hook(input); - const entry = (input as { provider: Record }).provider - .omniroute; + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; assert.ok(entry, "stub provider entry published even when fetchers fail"); assert.equal(entry.npm, "@ai-sdk/openai-compatible"); assert.deepEqual(entry.models, {}, "models stub is empty object"); @@ -392,7 +393,7 @@ test("config: fetchers throw → warn + emit stub entry with models: {}", async test("config: combos fetcher throws → emit models-only catalog (no combos in models block)", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, + "opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, }); const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]); const combosFetcher = throwingCombosFetcher(); @@ -405,12 +406,16 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m const input = makeInput(); await hook(input); - const entry = (input as { provider: Record }).provider - .omniroute; + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; assert.ok(entry); const ids = Object.keys(entry.models).sort(); - assert.deepEqual(ids, ["omniroute/claude-sonnet-4-6", "omniroute/gemini-3-flash"]); - assert.equal(entry.models["omniroute/claude-tier"], undefined, "no combo entry"); + assert.deepEqual(ids, [ + "opencode-omniroute/claude-sonnet-4-6", + "opencode-omniroute/gemini-3-flash", + ]); + assert.equal(entry.models["opencode-omniroute/claude-tier"], undefined, "no combo entry"); assert.ok( logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")), "combos-fetch breadcrumb emitted" @@ -423,7 +428,7 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m test("config: baseURL from auth.json takes precedence when opts.baseURL absent", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" }, + "opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" }, }); const fetcher = stubModelsFetcher([MODEL_CLAUDE]); const combosFetcher = stubCombosFetcher([]); @@ -437,14 +442,15 @@ test("config: baseURL from auth.json takes precedence when opts.baseURL absent", await hook(input); assert.equal(fetcher.callsBy()[0][0], "https://creds.example/v1"); - const entry = (input as { provider: Record }).provider - .omniroute; + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; assert.equal(entry.options.baseURL, "https://creds.example/v1"); }); test("config: opts.baseURL wins over auth.json's stored baseURL", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" }, + "opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" }, }); const fetcher = stubModelsFetcher([MODEL_CLAUDE]); const combosFetcher = stubCombosFetcher([]); @@ -458,14 +464,15 @@ test("config: opts.baseURL wins over auth.json's stored baseURL", async () => { await hook(input); assert.equal(fetcher.callsBy()[0][0], "https://opts.example/v1"); - const entry = (input as { provider: Record }).provider - .omniroute; + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; assert.equal(entry.options.baseURL, "https://opts.example/v1"); }); test("config: no baseURL resolvable (no opts, no auth.json baseURL) → no-op", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-test" }, // NO baseURL on the credential + "opencode-omniroute": { type: "api", key: "sk-test" }, // NO baseURL on the credential }); const fetcher = stubModelsFetcher([MODEL_CLAUDE]); const combosFetcher = stubCombosFetcher([]); @@ -493,12 +500,12 @@ test("config: no baseURL resolvable (no opts, no auth.json baseURL) → no-op", test("config: multi-instance — two plugins with different providerIds publish to their own keys without collision", async () => { const readAuthJson = stubReadAuthJson({ - "omniroute-prod": { + "opencode-omniroute-prod": { type: "api", key: "sk-prod", baseURL: "https://prod.example/v1", }, - "omniroute-preprod": { + "opencode-omniroute-preprod": { type: "api", key: "sk-preprod", baseURL: "https://preprod.example/v1", @@ -522,15 +529,18 @@ test("config: multi-instance — two plugins with different providerIds publish await hookB(input); const provider = (input as { provider: Record }).provider; - assert.ok(provider["omniroute-prod"], "prod block present"); - assert.ok(provider["omniroute-preprod"], "preprod block present"); - assert.equal(provider["omniroute-prod"].options.apiKey, "sk-prod"); - assert.equal(provider["omniroute-preprod"].options.apiKey, "sk-preprod"); - assert.equal(provider["omniroute-prod"].options.baseURL, "https://prod.example/v1"); - assert.equal(provider["omniroute-preprod"].options.baseURL, "https://preprod.example/v1"); + assert.ok(provider["opencode-omniroute-prod"], "prod block present"); + assert.ok(provider["opencode-omniroute-preprod"], "preprod block present"); + assert.equal(provider["opencode-omniroute-prod"].options.apiKey, "sk-prod"); + assert.equal(provider["opencode-omniroute-preprod"].options.apiKey, "sk-preprod"); + assert.equal(provider["opencode-omniroute-prod"].options.baseURL, "https://prod.example/v1"); + assert.equal( + provider["opencode-omniroute-preprod"].options.baseURL, + "https://preprod.example/v1" + ); assert.notEqual( - provider["omniroute-prod"], - provider["omniroute-preprod"], + provider["opencode-omniroute-prod"], + provider["opencode-omniroute-preprod"], "blocks are distinct references" ); }); @@ -542,7 +552,7 @@ test("config: multi-instance — two plugins with different providerIds publish test("config + provider share cache: second call uses cached fetch result (single fetch per TTL)", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" }, + "opencode-omniroute": { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" }, }); const fetcher = stubModelsFetcher([MODEL_CLAUDE]); const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]); @@ -574,7 +584,7 @@ test("config + provider share cache: second call uses cached fetch result (singl test("provider → config order also dedupes (cache populated by provider, consumed by config)", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-reverse", baseURL: "https://or.example/v1" }, + "opencode-omniroute": { type: "api", key: "sk-reverse", baseURL: "https://or.example/v1" }, }); const fetcher = stubModelsFetcher([MODEL_CLAUDE]); const combosFetcher = stubCombosFetcher([]); @@ -654,7 +664,7 @@ test("buildStaticProviderEntry: stripped per-model shape matches sibling @omniro } // Sanity: claude entry has all expected stripped fields. - const claude = block.models["omniroute/claude-sonnet-4-6"]; + const claude = block.models["opencode-omniroute/claude-sonnet-4-6"]; assert.equal(typeof claude.name, "string"); assert.equal(typeof claude.attachment, "boolean"); assert.equal(typeof claude.reasoning, "boolean"); @@ -679,8 +689,8 @@ test("buildStaticProviderEntry: hidden combos are excluded", () => { "https://or.example/v1", "sk-test" ); - assert.equal(block.models["omniroute/claude-tier"], undefined); - assert.ok(block.models["omniroute/claude-sonnet-4-6"]); + assert.equal(block.models["opencode-omniroute/claude-tier"], undefined); + assert.ok(block.models["opencode-omniroute/claude-sonnet-4-6"]); }); // ──────────────────────────────────────────────────────────────────────────── @@ -696,7 +706,7 @@ test("buildStaticProviderEntry: emits modalities.input from raw.input_modalities "https://or.example/v1", "sk-test" ); - const claude = block.models["omniroute/claude-sonnet-4-6"]; + const claude = block.models["opencode-omniroute/claude-sonnet-4-6"]; assert.deepEqual(claude.modalities?.input, ["text", "image"]); assert.deepEqual(claude.modalities?.output, ["text"]); }); @@ -710,7 +720,7 @@ test("buildStaticProviderEntry: never emits limit.input (OC SDK rejects it)", () "https://or.example/v1", "sk-test" ); - const claude = block.models["omniroute/claude-sonnet-4-6"]; + const claude = block.models["opencode-omniroute/claude-sonnet-4-6"]; assert.equal((claude.limit as Record).input, undefined); assert.equal(typeof claude.limit?.context, "number"); assert.equal(typeof claude.limit?.output, "number"); @@ -738,7 +748,7 @@ test("buildStaticProviderEntry: emits cost when enrichment carries pricing", () "sk-test", enrichment ); - const claude = block.models["omniroute/claude-sonnet-4-6"]; + const claude = block.models["opencode-omniroute/claude-sonnet-4-6"]; assert.equal(claude.cost?.input, 3); assert.equal(claude.cost?.output, 15); assert.equal(claude.cost?.cache_read, 0.3); @@ -759,8 +769,8 @@ test("buildStaticProviderEntry: emits release_date when raw carries it; omits wh "https://or.example/v1", "sk-test" ); - assert.equal(block.models["omniroute/claude-with-date"].release_date, "2026-02-19"); - assert.equal(block.models["omniroute/gemini-3-flash"].release_date, undefined); + assert.equal(block.models["opencode-omniroute/claude-with-date"].release_date, "2026-02-19"); + assert.equal(block.models["opencode-omniroute/gemini-3-flash"].release_date, undefined); }); test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)", () => { @@ -789,7 +799,7 @@ test("buildStaticProviderEntry: combo modalities = intersection of members (LCD) "https://or.example/v1", "sk-test" ); - const combo = block.models["omniroute/mixed-tier"]; + const combo = block.models["opencode-omniroute/mixed-tier"]; assert.ok(combo, "combo emitted under slug key"); // claude has text+image, text-only has text → intersection drops image. assert.deepEqual(combo.modalities?.input, ["text"]); @@ -813,7 +823,7 @@ test("OmniRoutePlugin factory exposes config hook alongside auth + provider", as test("config: auth.json entry of wrong type (oauth) → no-op", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "oauth", refresh: "r", access: "a", expires: 0 }, + "opencode-omniroute": { type: "oauth", refresh: "r", access: "a", expires: 0 }, }); const fetcher = stubModelsFetcher([MODEL_CLAUDE]); const combosFetcher = stubCombosFetcher([]); @@ -850,7 +860,7 @@ test("config: readAuthJson throws → treat as missing file (silent fallback)", test("config: initialises input.provider when undefined", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk", baseURL: "https://or.example/v1" }, + "opencode-omniroute": { type: "api", key: "sk", baseURL: "https://or.example/v1" }, }); const fetcher = stubModelsFetcher([MODEL_CLAUDE]); const combosFetcher = stubCombosFetcher([]); @@ -865,7 +875,7 @@ test("config: initialises input.provider when undefined", async () => { await hook(input); const provider = (input as { provider?: Record }).provider; assert.ok(provider, "provider bag initialised"); - assert.ok(provider!.omniroute); + assert.ok(provider!["opencode-omniroute"]); }); // ──────────────────────────────────────────────────────────────────────────── @@ -875,7 +885,7 @@ test("config: initialises input.provider when undefined", async () => { test("config: enrichment fetched + name overlaid on raw-model entries", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, + "opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, }); const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]); const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]); @@ -894,19 +904,20 @@ test("config: enrichment fetched + name overlaid on raw-model entries", async () const input = makeInput(); await hook(input); - const entry = (input as { provider: Record }).provider - .omniroute; + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; assert.ok(entry); - assert.equal(entry.models["omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); - assert.equal(entry.models["omniroute/gemini-3-flash"].name, "Gemini 3 Flash"); + assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); + assert.equal(entry.models["opencode-omniroute/gemini-3-flash"].name, "Gemini 3 Flash"); // Combo names still come from /api/combos — enrichment overlay does NOT touch combos. - assert.equal(entry.models["omniroute/claude-tier"].name, "Claude Tier"); + assert.equal(entry.models["opencode-omniroute/claude-tier"].name, "Claude Tier"); assert.equal(enrichmentFetcher.callCount(), 1); }); test("config: features.enrichment=false skips enrichment fetch + keeps raw-id names", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, + "opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, }); const fetcher = stubModelsFetcher([MODEL_CLAUDE]); const combosFetcher = stubCombosFetcher([]); @@ -924,12 +935,13 @@ test("config: features.enrichment=false skips enrichment fetch + keeps raw-id na const input = makeInput(); await hook(input); - const entry = (input as { provider: Record }).provider - .omniroute; + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; assert.ok(entry); assert.equal(enrichmentFetcher.callCount(), 0, "enrichment fetch suppressed by feature flag"); assert.equal( - entry.models["omniroute/claude-sonnet-4-6"].name, + entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id retained" ); @@ -937,7 +949,7 @@ test("config: features.enrichment=false skips enrichment fetch + keeps raw-id na test("config: enrichment fetcher throws → soft-fail (warn + raw-id static catalog)", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, + "opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, }); const fetcher = stubModelsFetcher([MODEL_CLAUDE]); const combosFetcher = stubCombosFetcher([]); @@ -951,11 +963,12 @@ test("config: enrichment fetcher throws → soft-fail (warn + raw-id static cata const input = makeInput(); await hook(input); - const entry = (input as { provider: Record }).provider - .omniroute; + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; assert.ok(entry, "static block still published on enrichment failure"); assert.equal( - entry.models["omniroute/claude-sonnet-4-6"].name, + entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id retained" ); @@ -999,7 +1012,7 @@ const MODEL_NV_LLAMA: OmniRouteRawModelEntry = { test("config: usableOnly=false → no filter (existing behavior)", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, + "opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, }); const fetcher = stubModelsFetcher([MODEL_CC_OPUS, MODEL_NV_LLAMA]); const combosFetcher = stubCombosFetcher([]); @@ -1021,8 +1034,9 @@ test("config: usableOnly=false → no filter (existing behavior)", async () => { const input = makeInput(); await hook(input); - const entry = (input as { provider: Record }).provider - .omniroute; + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; assert.ok(entry.models["cc/claude-opus-4-7"], "claude kept"); assert.ok(entry.models["nvidia/llama-3-70b"], "nvidia kept (filter off)"); assert.equal(providersFetcher.callCount(), 0, "providers fetch not called when feature off"); @@ -1030,7 +1044,7 @@ test("config: usableOnly=false → no filter (existing behavior)", async () => { test("config: usableOnly=true → drops models for non-usable providers, keeps usable + unknown", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, + "opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, }); const fetcher = stubModelsFetcher([ MODEL_CC_OPUS, @@ -1070,8 +1084,9 @@ test("config: usableOnly=true → drops models for non-usable providers, keeps u const input = makeInput(); await hook(input); - const entry = (input as { provider: Record }).provider - .omniroute; + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; assert.ok(entry.models["cc/claude-opus-4-7"], "claude kept (active)"); assert.equal(entry.models["nvidia/llama-3-70b"], undefined, "nvidia dropped (error status)"); assert.ok(entry.models["agentrouter/synthetic-1"], "unknown prefix kept (subtract-filter)"); @@ -1080,7 +1095,7 @@ test("config: usableOnly=true → drops models for non-usable providers, keeps u test("config: usableOnly=true + providers fetch fails → soft-fail keeps everything", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, + "opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, }); const fetcher = stubModelsFetcher([MODEL_CC_OPUS, MODEL_NV_LLAMA]); const combosFetcher = stubCombosFetcher([]); @@ -1101,8 +1116,9 @@ test("config: usableOnly=true + providers fetch fails → soft-fail keeps everyt const input = makeInput(); await hook(input); - const entry = (input as { provider: Record }).provider - .omniroute; + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; assert.ok(entry.models["cc/claude-opus-4-7"]); assert.ok(entry.models["nvidia/llama-3-70b"], "soft-fail keeps both"); assert.ok( @@ -1113,7 +1129,7 @@ test("config: usableOnly=true + providers fetch fails → soft-fail keeps everyt test("config: diskCache hydrates stale snapshot when /v1/models throws", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, + "opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, }); const fetcher = throwingModelsFetcher(); const combosFetcher = stubCombosFetcher([]); @@ -1150,14 +1166,15 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async ( const input = makeInput(); await hook(input); - const entry = (input as { provider: Record }).provider - .omniroute; + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; assert.ok( - entry.models["omniroute/claude-sonnet-4-6"], + entry.models["opencode-omniroute/claude-sonnet-4-6"], "stale snapshot hydrated into static block" ); assert.equal( - entry.models["omniroute/claude-sonnet-4-6"].name, + entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6 (cached)", "stale enrichment also reused" ); @@ -1170,7 +1187,7 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async ( test("config: cached rawEnrichment from earlier provider hook is reused (no refetch)", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" }, + "opencode-omniroute": { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" }, }); const fetcher = stubModelsFetcher([MODEL_CLAUDE]); const combosFetcher = stubCombosFetcher([]); @@ -1202,9 +1219,10 @@ test("config: cached rawEnrichment from earlier provider hook is reused (no refe await configHook(input); assert.equal(enrichmentFetcher.callCount(), 1, "config reused cached enrichment"); - const entry = (input as { provider: Record }).provider - .omniroute; - assert.equal(entry.models["omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; + assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); }); // ───────────────────────────────────────────────────────────────────── @@ -1215,7 +1233,7 @@ test("config: cached rawEnrichment from earlier provider hook is reused (no refe test("config: providerTag (default-on) prepends ' - ' to enriched raw-model names", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, + "opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, }); const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]); const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]); @@ -1250,18 +1268,25 @@ test("config: providerTag (default-on) prepends ' - ' to enriched raw- const input = makeInput(); await hook(input); - const entry = (input as { provider: Record }).provider - .omniroute; + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; assert.ok(entry); - assert.equal(entry.models["omniroute/claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6"); - assert.equal(entry.models["omniroute/gemini-3-flash"].name, "Gemini-cli - Gemini 3 Flash"); + assert.equal( + entry.models["opencode-omniroute/claude-sonnet-4-6"].name, + "Claude - Claude Sonnet 4.6" + ); + assert.equal( + entry.models["opencode-omniroute/gemini-3-flash"].name, + "Gemini-cli - Gemini 3 Flash" + ); // Combos stay untouched — `Combo: ` prefix already conveys multi-upstream. - assert.equal(entry.models["omniroute/claude-tier"].name, "Claude Tier"); + assert.equal(entry.models["opencode-omniroute/claude-tier"].name, "Claude Tier"); }); test("config: providerTag=false suppresses the suffix", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, + "opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, }); const fetcher = stubModelsFetcher([MODEL_CLAUDE]); const combosFetcher = stubCombosFetcher([]); @@ -1279,10 +1304,11 @@ test("config: providerTag=false suppresses the suffix", async () => { const input = makeInput(); await hook(input); - const entry = (input as { provider: Record }).provider - .omniroute; + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; assert.equal( - entry.models["omniroute/claude-sonnet-4-6"].name, + entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6", "enriched name kept, provider tag suppressed" ); @@ -1290,7 +1316,7 @@ test("config: providerTag=false suppresses the suffix", async () => { test("config: providerTag falls back to UPPER(alias) when providerDisplayName missing", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, + "opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, }); const fetcher = stubModelsFetcher([MODEL_CLAUDE]); const combosFetcher = stubCombosFetcher([]); @@ -1311,14 +1337,15 @@ test("config: providerTag falls back to UPPER(alias) when providerDisplayName mi const input = makeInput(); await hook(input); - const entry = (input as { provider: Record }).provider - .omniroute; - assert.equal(entry.models["omniroute/claude-sonnet-4-6"].name, "CC - Claude Sonnet 4.6"); + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; + assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "CC - Claude Sonnet 4.6"); }); test("config: providerTag skipped entirely when neither providerDisplayName nor providerAlias set", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, + "opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, }); const fetcher = stubModelsFetcher([MODEL_CLAUDE]); const combosFetcher = stubCombosFetcher([]); @@ -1337,14 +1364,15 @@ test("config: providerTag skipped entirely when neither providerDisplayName nor const input = makeInput(); await hook(input); - const entry = (input as { provider: Record }).provider - .omniroute; - assert.equal(entry.models["omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); + const entry = (input as { provider: Record }).provider[ + "opencode-omniroute" + ]; + assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); }); test("config: providerTag is idempotent — second hook call doesn't double-suffix", async () => { const readAuthJson = stubReadAuthJson({ - omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, + "opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" }, }); const fetcher = stubModelsFetcher([MODEL_CLAUDE]); const combosFetcher = stubCombosFetcher([]); @@ -1363,16 +1391,24 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff const inputA = makeInput(); await hook(inputA); - const entryA = (inputA as { provider: Record }).provider - .omniroute; - assert.equal(entryA.models["omniroute/claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6"); + const entryA = (inputA as { provider: Record }).provider[ + "opencode-omniroute" + ]; + assert.equal( + entryA.models["opencode-omniroute/claude-sonnet-4-6"].name, + "Claude - Claude Sonnet 4.6" + ); // Second invocation (cache hit) — name must still be single-suffixed. const inputB = makeInput(); await hook(inputB); - const entryB = (inputB as { provider: Record }).provider - .omniroute; - assert.equal(entryB.models["omniroute/claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6"); + const entryB = (inputB as { provider: Record }).provider[ + "opencode-omniroute" + ]; + assert.equal( + entryB.models["opencode-omniroute/claude-sonnet-4-6"].name, + "Claude - Claude Sonnet 4.6" + ); }); // ──────────────────────────────────────────────────────────────────────────── @@ -1424,7 +1460,7 @@ test("buildStaticProviderEntry: nested combo-ref context is the bottleneck acros ); // Pre-fix: Parent would advertise 200_000 (only raw-big counted). // Post-fix: Parent should advertise 8_000 (TinyCombo bottleneck). - const parent = block.models["omniroute/parent"]; + const parent = block.models["opencode-omniroute/parent"]; assert.ok(parent, "Parent combo must be in the static catalog"); assert.equal(parent.limit?.context, 8_000); }); diff --git a/@omniroute/opencode-plugin/tests/features.test.ts b/@omniroute/opencode-plugin/tests/features.test.ts index 20c54b61d5..73ee37a72b 100644 --- a/@omniroute/opencode-plugin/tests/features.test.ts +++ b/@omniroute/opencode-plugin/tests/features.test.ts @@ -376,7 +376,7 @@ test("provider hook: enrichment fetcher called when features.enrichment !== fals ); const out = await hook.models!({} as never, { auth: apiAuth("sk") as never }); assert.equal(called, 1, "enrichment fetcher called once"); - const m = out["omniroute/claude-sonnet-4-6"]; + const m = out["opencode-omniroute/claude-sonnet-4-6"]; assert.equal(m.name, "Claude Sonnet 4.6", "enrichment name overlay applied"); assert.equal(m.cost.input, 3, "enrichment pricing applied"); assert.equal(m.cost.output, 15); @@ -401,7 +401,11 @@ test("provider hook: enrichment fetcher NOT called when features.enrichment:fals ); const out = await hook.models!({} as never, { auth: apiAuth("sk") as never }); assert.equal(called, 0, "enrichment fetcher NOT called when gated off"); - assert.equal(out["omniroute/claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id preserved"); + assert.equal( + out["opencode-omniroute/claude-sonnet-4-6"].name, + "claude-sonnet-4-6", + "raw id preserved" + ); }); test("provider hook: compression metadata fetcher NOT called by default (opt-in)", async () => { @@ -459,7 +463,7 @@ test("provider hook: compression metadata fetcher called when opted in", async ( ); const out = await hook.models!({} as never, { auth: apiAuth("sk") as never }); assert.equal(called, 1, "compression metadata fetcher called"); - const combo = out["omniroute/claude-primary"]; + const combo = out["opencode-omniroute/claude-primary"]; assert.ok(combo, "combo entry present"); assert.match( combo.name, @@ -473,7 +477,7 @@ test("provider hook: compression metadata fetcher called when opted in", async ( // ───────────────────────────────────────────────────────────────────────── const stubAuthJson = (apiKey: string) => async () => ({ - omniroute: { type: "api" as const, key: apiKey }, + "opencode-omniroute": { type: "api" as const, key: apiKey }, }); test("config hook: MCP auto-emit OFF by default (no mcp entry)", async () => { @@ -488,7 +492,7 @@ test("config hook: MCP auto-emit OFF by default (no mcp entry)", async () => { ); const input: { provider?: Record; mcp?: Record } = {}; await hook(input as never); - assert.ok(input.provider?.omniroute, "provider block written"); + assert.ok(input.provider?.["opencode-omniroute"], "provider block written"); assert.equal(input.mcp, undefined, "no mcp block written"); }); @@ -508,7 +512,7 @@ test("config hook: features.mcpAutoEmit:true writes mcp entry with provider apiK ); const input: { provider?: Record; mcp?: Record } = {}; await hook(input as never); - const entry = input.mcp?.omniroute as + const entry = input.mcp?.["opencode-omniroute"] as | { type: string; url: string; enabled: boolean; headers: Record } | undefined; assert.ok(entry, "mcp entry written"); @@ -538,7 +542,7 @@ test("config hook: features.mcpToken overrides provider apiKey in mcp Bearer", a ); const input: { provider?: Record; mcp?: Record } = {}; await hook(input as never); - const entry = input.mcp?.omniroute as { headers: Record }; + const entry = input.mcp?.["opencode-omniroute"] as { headers: Record }; assert.equal( entry.headers.Authorization, "Bearer sk-mcp-narrower", @@ -561,11 +565,11 @@ test("config hook: existing operator mcp. wins (no overwrite)", asyn } ); const input: { provider?: Record; mcp?: Record } = { - mcp: { omniroute: { type: "custom-user-entry", url: "https://manual.example/mcp" } }, + mcp: { "opencode-omniroute": { type: "custom-user-entry", url: "https://manual.example/mcp" } }, }; await hook(input as never); assert.deepEqual( - input.mcp?.omniroute, + input.mcp?.["opencode-omniroute"], { type: "custom-user-entry", url: "https://manual.example/mcp" }, "operator override preserved" ); @@ -580,7 +584,7 @@ test("config hook: features.mcpAutoEmit:true with /v1 in baseURL → strips corr }, { readAuthJson: async () => ({ - "omniroute-preprod": { type: "api" as const, key: "sk-preprod" }, + "opencode-omniroute-preprod": { type: "api" as const, key: "sk-preprod" }, }), fetcher: async () => SAMPLE_RAW, combosFetcher: async () => [], @@ -589,7 +593,7 @@ test("config hook: features.mcpAutoEmit:true with /v1 in baseURL → strips corr ); const input: { provider?: Record; mcp?: Record } = {}; await hook(input as never); - const entry = input.mcp?.["omniroute-preprod"] as { url: string }; + const entry = input.mcp?.["opencode-omniroute-preprod"] as { url: string }; assert.equal( entry.url, "https://or-preprod.example.com/api/mcp/stream", diff --git a/@omniroute/opencode-plugin/tests/multi-instance.test.ts b/@omniroute/opencode-plugin/tests/multi-instance.test.ts index a7852a95b9..352d6ad045 100644 --- a/@omniroute/opencode-plugin/tests/multi-instance.test.ts +++ b/@omniroute/opencode-plugin/tests/multi-instance.test.ts @@ -38,8 +38,8 @@ test("multi-instance: two plugin invocations bind to their own providerId", asyn baseURL: "https://b.example/v1", }); - assert.equal(a.auth?.provider, "omniroute-prod"); - assert.equal(b.auth?.provider, "omniroute-preprod"); + assert.equal(a.auth?.provider, "opencode-omniroute-prod"); + assert.equal(b.auth?.provider, "opencode-omniroute-preprod"); }); test("multi-instance: hook objects + nested arrays are independent references", async () => { @@ -70,8 +70,8 @@ test("multi-instance: identical opts twice still yield independent objects", asy assert.notEqual(first.auth, second.auth); assert.notEqual(first.auth?.methods, second.auth?.methods); // Same provider id is fine — what matters is no shared mutable state. - assert.equal(first.auth?.provider, "twin"); - assert.equal(second.auth?.provider, "twin"); + assert.equal(first.auth?.provider, "opencode-twin"); + assert.equal(second.auth?.provider, "opencode-twin"); }); test("multi-instance: mutating instance A's auth.methods does not affect instance B", async () => { @@ -132,5 +132,5 @@ test("multi-instance: invalid opts on one instance does not poison the other", a providerId: "recovered", baseURL: "https://ok.example/v1", }); - assert.equal(ok.auth?.provider, "recovered"); + assert.equal(ok.auth?.provider, "opencode-recovered"); }); diff --git a/@omniroute/opencode-plugin/tests/provider.test.ts b/@omniroute/opencode-plugin/tests/provider.test.ts index 2e4893216d..e4849205ac 100644 --- a/@omniroute/opencode-plugin/tests/provider.test.ts +++ b/@omniroute/opencode-plugin/tests/provider.test.ts @@ -75,7 +75,7 @@ const apiAuth = (key: string, baseURL?: string): unknown => test("createOmniRouteProviderHook: default providerId is 'omniroute'", () => { const hook = createOmniRouteProviderHook(undefined, { combosFetcher: async () => [] }); - assert.equal(hook.id, "omniroute"); + assert.equal(hook.id, "opencode-omniroute"); }); test("createOmniRouteProviderHook: custom providerId binds to hook.id (multi-instance)", () => { @@ -87,8 +87,8 @@ test("createOmniRouteProviderHook: custom providerId binds to hook.id (multi-ins { providerId: "omniroute-local" }, { combosFetcher: async () => [] } ); - assert.equal(a.id, "omniroute-preprod"); - assert.equal(b.id, "omniroute-local"); + assert.equal(a.id, "opencode-omniroute-preprod"); + assert.equal(b.id, "opencode-omniroute-local"); }); test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it", async () => { @@ -101,7 +101,7 @@ test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it assert.equal(fetcher.callCount(), 1); assert.deepEqual(fetcher.callsBy()[0], ["https://or.example.com/v1", "sk-abc"]); assert.equal(Object.keys(out).length, 3); - assert.ok(out["omniroute/claude-primary"]); + assert.ok(out["opencode-omniroute/claude-primary"]); }); test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => { @@ -152,13 +152,13 @@ test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => { { fetcher, combosFetcher: async () => [] } ); const out = await hook.models!({} as never, { auth: apiAuth("sk-abc") as never }); - const claude = out["omniroute/claude-primary"]; + const claude = out["opencode-omniroute/claude-primary"]; assert.ok(claude, "claude-primary present"); // `mapRawModelToModelV2` stamps the provider prefix on the id so OC's // static-catalog reader resolves `(providerID, modelID)` from the key. - assert.equal(claude.id, "omniroute/claude-primary"); + assert.equal(claude.id, "opencode-omniroute/claude-primary"); assert.equal(claude.name, "claude-primary"); - assert.equal(claude.providerID, "omniroute"); + assert.equal(claude.providerID, "opencode-omniroute"); assert.equal(claude.api.id, "openai-compatible"); assert.equal(claude.api.url, "https://or.example.com/v1"); assert.equal(claude.api.npm, "@ai-sdk/openai-compatible"); diff --git a/@omniroute/opencode-plugin/tests/scaffold.test.ts b/@omniroute/opencode-plugin/tests/scaffold.test.ts index 42e1f09404..e860171f75 100644 --- a/@omniroute/opencode-plugin/tests/scaffold.test.ts +++ b/@omniroute/opencode-plugin/tests/scaffold.test.ts @@ -26,7 +26,7 @@ test("scaffold: default export is v1 plugin shape { id, server: OmniRoutePlugin test("resolveOmniRoutePluginOptions: defaults", () => { const r = resolveOmniRoutePluginOptions(); - assert.equal(r.providerId, "omniroute"); + assert.equal(r.providerId, "opencode-omniroute"); assert.equal(r.displayName, "OmniRoute"); assert.equal(r.modelCacheTtl, 300_000); assert.equal(r.baseURL, undefined); @@ -34,8 +34,8 @@ test("resolveOmniRoutePluginOptions: defaults", () => { test("resolveOmniRoutePluginOptions: custom providerId derives displayName", () => { const r = resolveOmniRoutePluginOptions({ providerId: "omniroute-preprod" }); - assert.equal(r.providerId, "omniroute-preprod"); - assert.equal(r.displayName, "OmniRoute (omniroute-preprod)"); + assert.equal(r.providerId, "opencode-omniroute-preprod"); + assert.equal(r.displayName, "OmniRoute (opencode-omniroute-preprod)"); }); test("resolveOmniRoutePluginOptions: explicit displayName wins", () => { diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e77461fb0..89ce6ab1ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,62 @@ --- +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — 2026-06-21 ### ✨ New Features @@ -29,6 +85,7 @@ ### 🔧 Bug Fixes +- **fix(telemetry): back off the live-WS event bridge so a missing sidecar stops spamming ProxyFetch errors** — in single-port deployments the live-dashboard sidecar (port 20129) is not running, but `forwardDashboardEventToLiveWs` POSTed to it on every compression event. Since the global `fetch` is `proxyFetch`, each `ECONNREFUSED` logged a `[ProxyFetch] Undici dispatcher failed` warning (~272× in 42 min). The forwarder now backs off after consecutive failures (60s cooldown, lazy recovery) and clears the backoff on success, so a missing sidecar no longer floods the logs. ([#4604](https://github.com/diegosouzapw/OmniRoute/issues/4604) — thanks @FikFikk) - **fix(api): resolve a compatible provider node by base type, not only exact id** — connection→node resolution now matches on the bare derived node type when the exact id isn't found and the match is unambiguous (ambiguous → 404), via a pure `providerNodeSelect` helper. ([#4576](https://github.com/diegosouzapw/OmniRoute/pull/4576) — thanks @aleksesipenko / @diegosouzapw) - **fix(cli): supervisor restarts on spontaneous exit-0 (OOM cgroup) + waits for port before respawn** — a child that exits 0 because the cgroup OOM-killer reaped it is now restarted (not treated as a clean shutdown), the restart reset window widened 30s→60s, and the supervisor waits for the port to be free before respawning. ([#4578](https://github.com/diegosouzapw/OmniRoute/pull/4578) — thanks @oyi77 / @diegosouzapw) - **fix(combo): attribute lockout decay & success telemetry to the dynamically-selected connection** — on the combo success path the actual connection chosen by dynamic account-selection is read from the `X-OmniRoute-Selected-Connection-Id` response header (instead of the often-empty static `target.connectionId`), so model-lockout decay, `recordProviderSuccess`, LKGP and success/failure telemetry attribute to the right connection on both the priority and round-robin paths. The pre-screen "unavailable" snapshot is also no longer a permanent skip — availability is re-checked on each retry since connection cooldowns can expire mid-request. ([#4550](https://github.com/diegosouzapw/OmniRoute/pull/4550) — thanks @Chewji9875) diff --git a/bin/cli/commands/logs.mjs b/bin/cli/commands/logs.mjs index c8cd5edb1e..ebc87ab3b2 100644 --- a/bin/cli/commands/logs.mjs +++ b/bin/cli/commands/logs.mjs @@ -1,5 +1,6 @@ import { writeFileSync, appendFileSync, existsSync, unlinkSync } from "node:fs"; import { t } from "../i18n.mjs"; +import { getBaseUrl, buildHeaders } from "../api.mjs"; export function registerLogs(program) { program @@ -9,7 +10,7 @@ export function registerLogs(program) { .option("--filter ", t("logs.filter")) .option("--lines ", t("logs.lines"), "100") .option("--timeout ", t("logs.timeout"), "30000") - .option("--base-url ", t("logs.baseUrl"), "http://localhost:20128") + .option("--base-url ", t("logs.baseUrl")) .option("--request-id ", t("logs.requestId")) .option("--api-key ", t("logs.apiKey")) .option("--combo ", t("logs.combo")) @@ -19,7 +20,14 @@ export function registerLogs(program) { .option("--export ", t("logs.export")) .action(async (opts, cmd) => { const globalOpts = cmd.optsWithGlobals(); - const exitCode = await runLogsCommand({ ...opts, output: globalOpts.output }); + // `--context` and `--output` are global options, so forward them explicitly: + // runLogsCommand resolves the base URL via getBaseUrl({ context }), and without + // this a user's `--context` would be silently dropped. + const exitCode = await runLogsCommand({ + ...opts, + context: globalOpts.context, + output: globalOpts.output, + }); if (exitCode !== 0) process.exit(exitCode); }); } @@ -67,7 +75,10 @@ function buildLogFilter(opts) { } export async function runLogsCommand(opts = {}) { - const baseUrl = opts.baseUrl || opts["base-url"] || "http://localhost:20128"; + // Resolve the base URL the same way every other CLI command does: an explicit + // --base-url wins, otherwise fall back to the active context / env / localhost. + // Without this, `logs` always hit localhost and ignored a connected remote. + const baseUrl = opts.baseUrl || opts["base-url"] || getBaseUrl({ context: opts.context }); const follow = opts.follow ?? false; const timeout = parseInt(String(opts.timeout || "30000"), 10); const isJson = opts.output === "json"; @@ -82,8 +93,21 @@ export async function runLogsCommand(opts = {}) { // Pass only level filters to the stream (server-side); other filters are client-side const levelFilters = opts.filter ? opts.filter.split(",").map((f) => f.trim()) : []; + // Authenticate the log stream. The /api/cli-tools/logs endpoint requires the + // management token; build the same headers (scoped context token + CLI token) + // that apiFetch uses, so `logs` works against authenticated/remote servers. + // NOTE: --api-key here is a client-side log *filter* (see buildLogFilter), not + // an auth credential, so it is deliberately not forwarded to buildHeaders. + const headers = await buildHeaders({ baseUrl, context: opts.context }); + const { createLogStream } = await import("../../../src/lib/cli-helper/log-streamer.js"); - const { stream, stop } = createLogStream({ baseUrl, filters: levelFilters, follow, timeout }); + const { stream, stop } = createLogStream({ + baseUrl, + filters: levelFilters, + follow, + timeout, + headers, + }); const reader = stream.getReader(); const decoder = new TextDecoder(); diff --git a/bin/cli/data-dir.mjs b/bin/cli/data-dir.mjs index ed9264dd90..c96fc71e5f 100644 --- a/bin/cli/data-dir.mjs +++ b/bin/cli/data-dir.mjs @@ -1,3 +1,4 @@ +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -17,11 +18,24 @@ function safeHomeDir() { } } -export function resolveDataDir() { - const configured = normalizeConfiguredPath(process.env.DATA_DIR); - if (configured) return configured; +export function getLegacyDotDataDir(homeDir = safeHomeDir()) { + return path.join(homeDir, `.${APP_NAME}`); +} +export function getDefaultDataDir() { const homeDir = safeHomeDir(); + const legacyDir = getLegacyDotDataDir(homeDir); + + if (fs.existsSync(legacyDir)) { + try { + if (fs.statSync(legacyDir).isDirectory()) { + return legacyDir; + } + } catch { + // Ignore stat errors and continue to the platform default. + } + } + if (process.platform === "win32") { const appData = process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"); return path.join(appData, APP_NAME); @@ -30,7 +44,14 @@ export function resolveDataDir() { const xdgConfigHome = normalizeConfiguredPath(process.env.XDG_CONFIG_HOME); if (xdgConfigHome) return path.join(xdgConfigHome, APP_NAME); - return path.join(homeDir, `.${APP_NAME}`); + return legacyDir; +} + +export function resolveDataDir() { + const configured = normalizeConfiguredPath(process.env.DATA_DIR); + if (configured) return configured; + + return getDefaultDataDir(); } export function resolveStoragePath(dataDir = resolveDataDir()) { diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index 7a3d4bccc5..77f7418d65 100755 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -13,10 +13,10 @@ import { existsSync, readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { homedir, platform } from "node:os"; import updateNotifier from "update-notifier"; import { isNativeBinaryCompatible } from "../scripts/build/native-binary-compat.mjs"; import { getNodeRuntimeSupport, getNodeRuntimeWarning } from "./nodeRuntimeSupport.mjs"; +import { getDefaultDataDir } from "./cli/data-dir.mjs"; import { shouldProvisionStorageKey } from "./cli/utils/storageKeyProvision.mjs"; // Register tsx so dynamic imports of .ts source files (referenced as .js per @@ -41,26 +41,25 @@ if (process.argv.includes("--mcp")) { function loadEnvFile() { const envPaths = []; + const loadedEnvPaths = []; + const seenEnvPaths = new Set(); + const addEnvPath = (envPath) => { + if (seenEnvPaths.has(envPath)) return; + seenEnvPaths.add(envPath); + envPaths.push(envPath); + }; if (process.env.DATA_DIR) { - envPaths.push(join(process.env.DATA_DIR, ".env")); + addEnvPath(join(process.env.DATA_DIR, ".env")); } - const home = homedir(); - if (home) { - if (platform() === "win32") { - const appData = process.env.APPDATA || join(home, "AppData", "Roaming"); - envPaths.push(join(appData, "omniroute", ".env")); - } else { - envPaths.push(join(home, ".omniroute", ".env")); - } - } + addEnvPath(join(getDefaultDataDir(), ".env")); - envPaths.push(join(process.cwd(), ".env")); + addEnvPath(join(process.cwd(), ".env")); // Skip the repo-checkout .env when explicitly requested (used by isolation tests // that need a deterministic environment without the development repo's defaults). if (process.env.OMNIROUTE_CLI_SKIP_REPO_ENV !== "1") { - envPaths.push(join(ROOT, ".env")); + addEnvPath(join(ROOT, ".env")); } for (const envPath of envPaths) { @@ -79,13 +78,16 @@ function loadEnvFile() { } } } - console.log(` \x1b[2m📋 Loaded env from ${envPath}\x1b[0m`); - return; + loadedEnvPaths.push(envPath); } } catch { // Ignore errors reading env files. } } + + for (const envPath of loadedEnvPaths) { + console.log(` \x1b[2m📋 Loaded env from ${envPath}\x1b[0m`); + } } loadEnvFile(); diff --git a/config/quality/complexity-baseline.json b/config/quality/complexity-baseline.json index 3b7ac3041a..677745792a 100644 --- a/config/quality/complexity-baseline.json +++ b/config/quality/complexity-baseline.json @@ -1,6 +1,7 @@ { "_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.", - "count": 1915, + "count": 1916, + "_rebaseline_2026_06_23_v3834_release": "Reconciliacao release-volatil 1915->1916 (+1) no fechamento do ciclo v3.8.34. check:complexity NAO roda no fast-path PR->release (so release->main), entao o ramo acumula sem rebaselinar; surfou no full CI do release PR (run em c98e7ff6d). O +1 e drift de condicional NOVO de merge de contribuidor do ciclo (features quota/usage/opencode-go/M365). Verificado que o commit de release-finalize NAO adiciona complexity: toca CHANGELOG/baseline/mirrors/3 testes + 1 linha de regex em opencodeOllamaUsage.ts (sem novo ramo) + reorder de dados no reka registry — local mede 1916 com ou sem essa mudanca. Mesma familia dos rebaselines anteriores — crescimento de feature legitimo, nao regressao; reducao estrutural fica como debt (#3501).", "_rebaseline_2026_06_21_v3833_4537_nested_combo": "Reconciliacao 1913->1915 (+2) do PR #4537 (nestedComboMode execute — black-box combo-ref execution). O +2 vem do branch novo de dispatch nested em handleComboChat (combo.ts: normalizeNestedComboMode + executeModeUnits/hasExecutableComboRef + o ramo simpleExecuteStrategies que roda resolveComboRuntimeUnits com recursion caps depth/cycle/budget) — ramos condicionais no chokepoint de dispatch do combo. Medido com `node scripts/check/check-complexity.mjs` no estado merged. Crescimento de feature legitimo recem-TDD'd, nao regressao; reducao estrutural fica como debt (#3501).", "_rebaseline_2026_06_21_v3833_reviewprs_r4_owner": "Reconciliacao release-volatil 1911->1913 (+2) apos o lote de PRs do owner desta rodada (#4560 RTK cache_control, #4552 Cursor auto-import macOS, #4551 Codex /responses probe, #4554 Cursor Composer decode + o stack #3501 #4538/#4544/#4548). O fast-path do release nao roda check:complexity (so release->main). As 3 extracoes chatCore #3501 (#4538 recupera 4 leaves, #4544 failureUsage, #4548 claudeSystemRole+upstreamExecuteHeaders) sao PURAS/complexity-neutras (movem codigo p/ leaves sob o teto, chatCore ENCOLHE); o +2 vem dos condicionais NOVOS das features .32-portadas (#4554 decode de bloco visivel do Composer + #4551 probe do endpoint real Codex /responses). Medido com `node scripts/check/check-complexity.mjs` no tip 4b34a75fe. Crescimento de feature legitimo recem-TDD'd, nao regressao; reducao estrutural fica como debt (#3501).", "_rebaseline_2026_06_21_v3833_reviewprs_r4": "Reconciliacao release-volatil 1906->1911 (+5) apos o lote /review-prs r4 (5 PRs de contribuidores mergeados em release/v3.8.33: #4557 health-polls, #4556 mobile-table, #4545 provider-wildcard, #4558 isHidden, #4489 sticky-weighted) + 1 merge concorrente de sessao paralela (#4565 quota perf). O fast-path do release nao roda check:complexity (so release->main), entao o ramo acumula sem rebaselinar. Condicionais NOVOS legitimos: #4545 (expandProviderWildcardsInCombo/Collection + wildcardMatch glob/registry branching em providerWildcard.ts/wildcardRouter.ts), #4489 (eligibility pass sticky-weighted: resolveWeightedStepGroups + isTargetSelectableForWeighted + renormalizacao em combo.ts), #4558 (filtro isHidden em buildAutoCandidates/virtualFactory) e #4565 (guardas de skip de quota_snapshots idle). Medido com `node scripts/check/check-complexity.mjs` no tip 39b2bbfea. Mesma familia dos rebaselines anteriores — crescimento de feature legitimo recem-TDD'd, nao regressao; reducao estrutural fica como debt (#3501).", diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index ff96f92b8a..46108fa893 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -13,6 +13,7 @@ "_rebaseline_2026_06_21_qg9_chatcore_service_tier": "QG v2 Fase 9 T5 (#3501) — chatCore.ts 5137->5110 (shrink -27). The two inline Codex service-tier resolvers (resolveEffectiveServiceTier / resolveReportedServiceTier, ~36 LOC) were extracted byte-identically into the new pure leaf open-sse/handlers/chatCore/serviceTier.ts; the handler now keeps a `let effectiveServiceTier` + two thin binding closures that pass provider/credentials?.providerSpecificData to the extracted functions, so every call site stays unchanged. The orphaned getCodexRequestDefaults/normalizeCodexServiceTier/CodexServiceTier imports moved to the leaf. Ratcheted the frozen value down to lock the freed budget. Covered by tests/unit/chatcore-service-tier.test.ts.", "_rebaseline_2026_06_21_4489_sticky_weighted_limit": "PR #4489 (adivekar-utexas) own growth: src/app/(dashboard)/dashboard/combos/page.tsx 4385->4386 (+1 = the new Sticky Weighted Limit input in the weighted advanced section, a single FieldLabelWithHelp + number block gated to strategy===weighted, analogous to the existing stickyRoundRobinLimit field for round-robin) and open-sse/services/combo.ts ->2761 (sticky-weighted feature: weightedStickyTargets state wiring + the isTargetSelectableForWeighted availability pre-filter + exhaustion-aware sticky clearing/migration in handleComboChat + the round-robin sticky pre-filter in handleRoundRobinCombo + the 4 gemini-review fixes — provider guard, startsWith-separator key match, stepGroups dedup, stale-sticky cleanup). Frozen to the merge-tree measurement (release/v3.8.33 base 2649 + the feature) since CI measures the merged tree, not the v3.8.32-based branch tip. Cohesive routing logic at the existing combo dispatch chokepoints; not a movable block. Structural shrink of combo.ts tracked in #3501. Covered by tests/unit/combo-strategy-fallbacks.test.ts (sticky-weighted batching/fallback-migration/stale-clear/nested-availability + RR sticky-clear) and tests/unit/combo-config.test.ts (stickyWeightedLimit schema).", "_rebaseline_2026_06_20_reviewprs_mine_r2_filesize": "Reconciliacao file-size pos-lote /review-prs 'apenas minhas' r2: dois frozen cresceram cumulativamente sem bump (cada PR media OK na sua base, mas o crescimento empilhou acima do frozen no tip de merge; o fast-path do release nao roda check:file-size, so release->main). (1) src/shared/constants/pricing.ts 1620->1623 (+3 = linhas de pricing Claude Code (cc) do #4440, sobre o 1620 que o #4447 ja setara para gpt-4.1-mini/nano + o3/o4-mini). (2) open-sse/executors/base.ts 1399->1407 (+8 = handling granular de reasoning_effort para Claude no Copilot do #4443). Ambos dados/wiring coesos nos chokepoints existentes; nao extraiveis. Cobertos por tests/unit (claude-code pricing / base-executor-sanitize-effort + github-claude-reasoning-effort-granular).", + "_rebaseline_2026_06_22_4647_opencode_go_deepseek": "PR #4647 (DevEstacion/opencode-go DeepSeek V4 Pro effort variants) review feedback: open-sse/executors/base.ts 1407->1414 (+7 = supportsMaxEffortForProvider now opt-ins opencode-go+deepseek so the literal 'max' effort survives the post-transformReasoningEffortForProvider pass — without this, max was silently rewritten to xhigh (OmniRoute's internal top tier) and the opencode-go upstream rejected it. The check is scoped to opencode-go deliberately to preserve the OpenRouter-DeepSeek inverse invariant (pi#4055, asserted by base-executor-sanitize-effort test:OpenRouter DeepSeek normalizes max -> xhigh). The +5 explanatory comment is required: a naive maintainer could otherwise broaden the check to all deepseek models and break the OpenRouter contract. Cohesive at the existing supportsMaxEffortForProvider chokepoint, next to the Claude/CC-compatible check; not extractable. Covered by tests/unit/base-executor-sanitize-effort.test.ts (3 new opencode-go deepseek cases).", "_rebaseline_2026_06_20_4023_web_cookie_noauth_validation": "PR #4023 (oyi77) own growth: src/lib/providers/validation.ts 4450->4518 (+68 = a new validateWebCookieProvider that probes the provider's /models endpoint — 401/403 => AUTH_007 SESSION_EXPIRED, any other status => valid session, empty cookie => invalid, provider-not-in-registry => unsupported — plus a local STANDARD_USER_AGENT const for the probe). Cohesive validator at the validateProviderApiKey dispatch; not extractable. Covered by tests/unit/provider-validation-web-cookie-auth007.test.ts. Heavily curated on merge — the PR's branch was badly stale-based (squash-base-stale), so its tree was DESTRUCTIVE: providers/index.ts deleted live providers openadapter/dit/tokenrouter (added by #4313) and the executor/base.ts edits reverted release fixes (#4037 duckduckgo host, theoldllm gpt5 models, base.ts fetch-start-timeout). Only the purely-additive validation feature was kept (validation.ts validateWebCookieProvider + errorCodes AUTH_007 + the test). Dropped: 5 malformed new registry entries (used non-RegistryEntry fields defaultModel/auth + referenced non-existent executors -> tsc TS2353), the destructive providers/index.ts + executor reverts, the unrelated pr-*.sh automation scripts, and evals/types.ts (belongs to the deferred evals modularization #4422). Also removed the PR's fragile 'Phase 2' executor probe (ran a live upstream chat during validation + classified any 'auth'-containing error as SESSION_EXPIRED) and rewrote the test to install its fetch mock before module load (the original mocked too late and silently hit live chatgpt.com).", "_rebaseline_2026_06_20_1308_model_lockout_honors_reset": "port from 9router#1308 own growth: open-sse/services/accountFallback.ts 1731->1752 (+21 = the new exported pure helper selectLockoutCooldownMs + its doc comment — picks the parsed upstream reset as the model-lockout exactCooldownMs when it exceeds the base cooldown, e.g. Antigravity \"Resets in 160h\", else preserves the existing 0/base behavior) and open-sse/executors/antigravity.ts 1680->1686 (this PR +1 = parseRetryFromErrorMessage regex `reset` -> `resets?` so plural \"Resets in 160h27m24s\" matches, plus a comment line; frozen set to the SUM 1686 with the concurrent #1944 which adds +5 at the disjoint passthroughFields region of the same file, so either merge order passes — pair-file rule). The combo lockout call sites in combo.ts now pass selectLockoutCooldownMs(cooldownMs, mlSettings) instead of always base/exponential, so an exhausted model honors the real upstream reset instead of being retried within minutes. Both edits are cohesive at the existing lockout/parse chokepoints; the helper is its own pure function (not extractable further). Covered by tests/unit/combo-model-lockout-honors-reset-1308.test.ts.", "_rebaseline_2026_06_20_1944_antigravity_strip_output_config": "port from 9router#1944: open-sse/executors/antigravity.ts frozen set to the measured cumulative 1687 of two concurrent PRs that touch disjoint regions of this file, so either merge order passes (pair-file rule). #1944 adds +6 at the envelope passthroughFields destructuring (~line 759: drop output_config/output_format — Anthropic/Claude-Code-only fields that Google's Cloud Code envelope rejects with `400 Unknown name \"output_config\"`, which broke every Claude model on Antigravity); #1308 adds +1 at parseRetryFromErrorMessage (~line 889: regex reset->resets?). Base 1680 + 6 + 1 = 1687 (re-measured on the real merge tip — the earlier 1686 estimate was off by one). Both edits are cohesive at their chokepoints; not extractable. Covered by tests/unit/antigravity-strip-output-config-1944.test.ts.", @@ -108,17 +109,19 @@ "open-sse/translator/request/openai-to-kiro.ts": 807, "open-sse/config/providerRegistry.ts": 4731, "open-sse/executors/antigravity.ts": 1696, - "open-sse/executors/base.ts": 1407, + "open-sse/executors/base.ts": 1414, "open-sse/executors/chatgpt-web.ts": 2870, "open-sse/executors/claude-web.ts": 1057, "open-sse/executors/codex.ts": 1449, "open-sse/executors/cursor.ts": 1453, - "open-sse/executors/deepseek-web.ts": 1117, + "open-sse/executors/deepseek-web.ts": 1125, + "_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.", "open-sse/executors/duckduckgo-web.ts": 925, "open-sse/executors/grok-web.ts": 1871, "open-sse/executors/muse-spark-web.ts": 1284, "open-sse/executors/perplexity-web.ts": 1013, - "open-sse/handlers/audioSpeech.ts": 965, + "_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).", + "open-sse/handlers/audioSpeech.ts": 1061, "open-sse/handlers/chatCore.ts": 5125, "open-sse/handlers/imageGeneration.ts": 3777, "open-sse/handlers/responseSanitizer.ts": 1103, @@ -132,7 +135,8 @@ "open-sse/services/batchProcessor.ts": 828, "open-sse/services/browserBackedChat.ts": 850, "open-sse/services/claudeCodeCompatible.ts": 1202, - "open-sse/services/combo.ts": 2991, + "_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.", + "open-sse/services/combo.ts": 3036, "open-sse/services/rateLimitManager.ts": 1035, "open-sse/services/tokenRefresh.ts": 1997, "open-sse/services/usage.ts": 3450, @@ -235,11 +239,13 @@ "tests/unit/image-generation-handler.test.ts": 1996, "tests/unit/model-sync-route.test.ts": 1016, "tests/unit/models-catalog-route.test.ts": 1507, - "tests/unit/oauth-providers-config.test.ts": 855, + "_rebaseline_pr4561_qwen_oauth_url": "Reconcile #4561 (port decolua/9router#683) already-merged growth: oauth-providers-config.test.ts 855->867 (+12, qwen.ai URL regression-pin test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.", + "tests/unit/oauth-providers-config.test.ts": 867, "tests/unit/perplexity-web.test.ts": 959, "tests/unit/provider-models-route.test.ts": 1618, "tests/unit/provider-validation-specialty.test.ts": 2752, - "tests/unit/providers-page-utils.test.ts": 1004, + "_rebaseline_pr4613_compatible_provider_groups": "Reconcile #4613 already-merged growth: providers-page-utils.test.ts 1004->1052 (+48, buildCompatibleProviderGroups partition unit test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.", + "tests/unit/providers-page-utils.test.ts": 1052, "tests/unit/reasoning-cache.test.ts": 980, "tests/unit/route-edge-coverage.test.ts": 1234, "tests/unit/search-handler-extended.test.ts": 1124, diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index ae748b46d7..bb556f9878 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -2,9 +2,10 @@ "_comment": "Catraca de qualidade. 'down' = nao pode aumentar; 'up' = nao pode cair. Atualize via 'npm run quality:ratchet -- --update' (somente quando melhora). Cada valor e um numero REAL medido, nunca um chute. Cobertura entra na Fase 4 a partir de um run de cobertura mergeada no CI.", "metrics": { "eslintWarnings": { - "value": 3900, + "value": 3907, "direction": "down", - "_rebaseline_2026_06_22_v3833_release": "Cumulative cycle drift surfaced by the release PR full CI (the Quality Ratchet does NOT run on PR→release fast-gates, so warnings accrued across dozens of parallel-session merges this cycle — #4537/#4489/#4545/#4558/#4530/#4532/etc.). 3867→3900 (+33). Verified my own release-finalize changes (geminiHelper dedup net-shrink, auth.ts maxCooldownMs wiring) contribute 0 warnings via eslint --format json on the touched files. No coverage/openapi/i18n regressions." + "_rebaseline_2026_06_22_v3834_release": "v3.8.34 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR→release fast-gates, so warnings accrued across this cycle's parallel-session merges — #4583-4586/#4588-4593/#4606-4621/#4644/#4647/#4696/etc.). 3900→3907 (+7). Verified my release-finalize working tree touches ONLY CHANGELOG.md (git status: 0 code changes), so all +7 is inherited contributor drift. No coverage/openapi/i18n regressions.", + "_rebaseline_2026_06_22_v3833_release": "Cumulative cycle drift surfaced by the release PR full CI. 3867→3900 (+33)." }, "eslintErrors": { "value": 0, @@ -81,9 +82,10 @@ "tightenSlack": 10 }, "openapiCoverage.pct": { - "value": 38.4, + "value": 37.8, "direction": "up", - "eps": 0.5 + "eps": 0.5, + "_rebaseline_2026_06_23_v3834_release": "38.4 -> 37.8 (-0.6, beyond the 0.5 eps so it failed the ratchet). v3.8.34 cycle drift: contributor PRs added API routes (e.g. quota/usage/opencode-go endpoints) faster than openapi.yaml coverage; the openapi-coverage ratchet does NOT run on PR->release fast-gates so it surfaced only on the release PR. Verified my release-finalize working tree touches no routes / openapi paths (only version bump in openapi.yaml). Measured by CI quality:collect (run 28000387577) = 37.8. Raising coverage by documenting the new routes is tracked as follow-up doc debt." }, "i18nUiCoverage.pct": { "value": 78.4, @@ -96,10 +98,11 @@ "dedicatedGate": true }, "cognitiveComplexity": { - "value": 797, + "value": 801, "direction": "down", "dedicatedGate": true, - "_rebaseline_2026_06_22_v3833_release": "793→797 (+4) — pre-existing cycle drift on origin/release/v3.8.33 surfaced by the release PR full CI (cognitive-complexity does NOT run on PR→release fast-gates). Verified my release-finalize prod changes add 0 new violations to the COUNT: geminiHelper convertOpenAIContentToParts was already a violation (merging the duplicate #912 handler is intra-function, count unchanged) and auth.ts markAccountUnavailable only gained args (no new control flow). The +4 comes from this cycle's parallel-session combo logic (#4537 nestedComboMode / #4489 sticky-weighted / #4581 lockout-decay). Structural shrink tracked in #3501." + "_rebaseline_2026_06_22_v3834_release": "797→801 (+4) — v3.8.34 cycle drift surfaced by the release-green pre-flight (cognitive-complexity does NOT run on PR→release fast-gates). Verified my release-finalize working tree touches ONLY CHANGELOG.md (git status: 0 code changes), so all +4 is inherited contributor drift from this cycle's parallel-session merges. Structural shrink tracked in #3501.", + "_rebaseline_2026_06_22_v3833_release": "793→797 (+4) — pre-existing cycle drift on origin/release/v3.8.33." }, "typeCoveragePct": { "value": 92.17, @@ -118,9 +121,10 @@ "dedicatedGate": true }, "zizmorFindings": { - "value": 152, + "value": 155, "direction": "down", - "dedicatedGate": true + "dedicatedGate": true, + "_rebaseline_2026_06_23_v3834_release": "152 -> 155 (+3). The 3 new unpinned-uses are in .github/workflows/nightly-release-green.yml (added by #4622 this cycle): actions/checkout@v7, actions/setup-node@v6, actions/upload-artifact@v4 — the SAME deliberate @vN convention as ci.yml's own checkout@v7/setup-node@v6 and every other workflow (see _scanner_harden_workflows_2026_06_16 + _zizmor_rebaseline_2026_06_20_ci_build_artifact_reuse). SHA-pinning only this workflow would violate the convention. The workflow-lint ratchet does NOT run on PR->release fast-gates, so it surfaced only on the release PR; measured locally via `npm run check:workflows -- --ratchet` = 155. No new template-injection/artipacked/cache-poisoning." }, "vulnCount": { "value": 10, diff --git a/docs/compression/2026-06-21-compression-phase3-request-header-design.md b/docs/compression/2026-06-21-compression-phase3-request-header-design.md new file mode 100644 index 0000000000..cc55cc5228 --- /dev/null +++ b/docs/compression/2026-06-21-compression-phase3-request-header-design.md @@ -0,0 +1,229 @@ +--- +title: "Compression Config Panel — Phase 3: Per-Request Header" +version: 3.8.33 +lastUpdated: 2026-06-21 +--- + +# Compression Config Panel — Phase 3: Per-Request Header + +**Status:** approved direction (2026-06-21), pending spec review +**Base branch:** `release/v3.8.33` (Phase 1 #4432, Phase 2 #4521 merged) +**Goal:** Let a client override the resolved compression plan for a single request via the +`x-omniroute-compression` HTTP header, taking precedence over every operator-configured +layer (routing override, active profile, auto-trigger, Default). The compression resolver +is already header-aware in shape; Phase 3 adds the header parsing, threads the value to the +top of the resolver, and surfaces the resolved plan back to the client via a response header. + +Phase 1 (#4432) built the `engines` map, `deriveDefaultPlan`, `resolveCompressionPlan` +(header/active-combo-aware in signature), and persisted `activeComboId`. Phase 2 (#4521) +wired the active-profile selector and lifted active-combo resolution into `resolveBasePlan`. +Phase 3 is the final piece of the original three-phase plan. + +--- + +## 1. Background — current state + +The resolver is **partially** header-aware but the header never reaches the top of the +decision. From the code map: + +- **`open-sse/services/compression/resolveCompressionPlan.ts`** already accepts + `ResolveCtx.header` and interprets `off` / `default` / `engine:` / `` via a + private `headerToPlan` helper. **But** no caller ever passes `header` today, so the branch + is dormant. +- **`open-sse/services/compression/strategySelector.ts`** is the real entry point + (`selectCompressionStrategy`, `selectCompressionPlan`, `getEffectiveMode`, `applyCompression`). + Its `resolveBasePlan` only calls `resolveCompressionPlan` in **two** of five precedence + paths (routing-combo override and the `enginesExplicit` derived-default). The + **active-profile** and **auto-trigger** paths short-circuit and `return` *before* those + calls. So merely threading `header` into the existing `resolveCompressionPlan` calls would + **not** give the header top precedence — it would be silently ignored whenever an active + profile is set (the common case after Phase 2). The header must be evaluated at the **top** + of `resolveBasePlan`. +- **`open-sse/handlers/chatCore/headers.ts`** already has the parsing precedent: + `isNoMemoryRequested` (`x-omniroute-no-memory`, PR #4290) + the case-insensitive + `getHeaderValueCaseInsensitive` reader. +- **`src/lib/db/compressionCombos.ts`**: a combo's `id` is a `uuidv4()` (except the seeded + `default-caveman`), and its `name` is `TEXT NOT NULL` with **no UNIQUE constraint** + (migration 042). So a header value that names a combo is far more usable as the **name** + than the opaque UUID `id`. +- **`open-sse/handlers/chatCore.ts`** builds the response headers (`X-OmniRoute-Model`, + `X-OmniRoute-Cache`, …) via `buildStreamingResponseHeaders` + `attachOmniRouteMetaHeaders` + on the main response path. There is currently **no** response header reporting the applied + compression plan. + +--- + +## 2. The header contract + +`x-omniroute-compression: ` — mirrors the `x-omniroute-no-memory` / `no-cache` +convention. Parsed alongside the other omniroute request headers. Keyword values and the +`engine:` prefix are case-insensitive. Values: + +| Value | Meaning | +|---|---| +| `off` | No compression for this request. | +| `default` | The **panel-derived Default** plan, deterministically — ignores active profile, routing override, **and** auto-trigger. | +| `engine:` | A single engine, when that engine is enabled in config (e.g. `engine:rtk`). | +| `` | A named combo. Matched **by name (case-insensitive) first, then by exact id**. | + +**Decision A — combo matched by name:** because the stored `id` is a UUID, the ergonomic +header value is the combo's **name** (e.g. `my-fast-combo`). Names are not unique in the DB, +so the contract is documented as **first-match-wins**; clients wanting determinism can pass +the exact `id`. + +**Decision B — an explicit header value is authoritative:** any valid value +(`off` / `default` / `engine:` / ``) **bypasses auto-trigger**. For example, +`default` on a very large prompt keeps the panel Default rather than auto-escalating. The +mental model is "the header decides, full stop." + +**Invalid / unknown value → ignored.** Resolution falls through to the normal operator +precedence; the request is **never** rejected. A debug log line under the `COMPRESSION` +channel records the unrecognized value for observability. + +--- + +## 3. Architecture + +### 3.1 Resolution model (per request, most-specific wins) + +``` +x-omniroute-compression header (per-request) <- NEW top of precedence + -> routing-combo override (comboOverrides[comboId]) (per-route) + -> active profile (activeComboId) (global, Phase 2) + -> auto-trigger (large prompt -> autoTriggerMode) + -> Default = derived from panel engines map + -> off (master disabled, or zero engines on) +``` + +The header is evaluated at the **top** of `resolveBasePlan`. A valid value returns its plan +immediately; an unknown value falls through to the existing precedence unchanged. + +### 3.2 The resolver `source` + +`resolveBasePlan` (and the public `selectCompressionPlan`) return the existing +`DerivedPlan` (`{ mode, stackedPipeline }`) extended with an **optional** `source` field — +which precedence layer decided the plan: + +`request-header` | `routing-override` | `active-profile` | `auto-trigger` | `default` | `off` + +`mode` answers *what* compression runs; `source` answers *who* decided. The field is +optional so Phase 1/2 callers and snapshots are unaffected. chatCore reads `plan.source` +(+ `plan.mode`) to build the response header. + +### 3.3 Header parsing helper + +A new pure function in `open-sse/handlers/chatCore/headers.ts`, mirroring +`isNoMemoryRequested`: + +```ts +export function resolveCompressionHeader( + headers: Record | Headers | null | undefined +): string | null { + const value = (getHeaderValueCaseInsensitive(headers, "x-omniroute-compression") || "").trim(); + return value || null; +} +``` + +It returns the raw trimmed value (or `null`); the resolver owns interpretation and casing +rules (so the single source of truth for "what a value means" stays in the resolver, with +the parser only reading the wire). + +### 3.4 Threading + +chatCore reads the header from `clientRawRequest?.headers`, then passes it as a new +`header?: string | null` argument (default `undefined`) through +`selectCompressionStrategy` / `selectCompressionPlan` / `getEffectiveMode` -> +`resolveBasePlan`, exactly the pattern Phase 2 used for `combos`. Phase 1/2 call sites that +omit the argument are byte-for-byte unchanged. + +For the `` form, chatCore builds the named-combo map keyed by **both** combo `id` and +lowercased `name` (`{ [c.id]: c.pipeline, [c.name.toLowerCase()]: c.pipeline }`). The +active-profile lookup keys on `config.activeComboId` (always a UUID/slug `id`), so the added +name keys are inert for it — one map serves both paths. The resolver matches `` +**name-first** (per Decision A): it looks up the value lowercased (hitting a `name` key, or an +already-lowercase `id`), then falls back to the value as-is (an exact `id`) — +`combos[value.toLowerCase()] ?? combos[value]`. All combo `id`s are lowercase +(`uuidv4()` hex or the `default-caveman` slug), so an exact id still resolves on the first +lookup. + +### 3.5 Where the header logic lives + +The existing `headerToPlan` interpretation (`off` / `default` / `engine:` / ``) +is reused. `resolveBasePlan` evaluates the header **before** the routing-override branch. +`default` routes to `deriveDefaultPlanFromConfig` (which already handles both +`enginesExplicit` and legacy `defaultMode`), so `default` means "the Default profile" for +every install type. The resolver remains **pure** — no `src/lib/db` import (enforced by the +existing cycle/source guard). + +--- + +## 4. Observability + +A new `compression` key in `OMNIROUTE_RESPONSE_HEADERS` +(`src/shared/constants/headers.ts`) -> response header: + +``` +X-OmniRoute-Compression: ; source= +``` + +Examples: `aggressive; source=request-header`, `off; source=request-header`, +`stacked; source=active-profile`, `lite; source=auto-trigger`, `off; source=off`. + +chatCore captures `{ mode, source }` from the compression resolution (computed early, +~line 1530) into an outer-scope variable and injects the header when building +`responseHeaders` (~line 4697), so it appears on both streaming and non-streaming +responses. The header is informational only and never affects routing. + +--- + +## 5. Error handling & safety + +- **Header absent / blank** -> `null`; behaviour is byte-identical to Phase 2. +- **Unknown value** -> silent fall-through to normal resolution + a `COMPRESSION` debug log; + never a 4xx. The response header reflects the layer that actually won (e.g. + `source=auto-trigger`), not `request-header`. +- **`engine:` naming a disabled / unknown engine** -> fall-through (same rule as the + current `headerToPlan`: returns `null`). +- **Gating:** the header is honored **unconditionally**, like `x-omniroute-no-memory`. + Rationale: it only affects the compression of the **client's own request**. The worst case + is a client opting *itself out* of compression (`off`), which increases only that client's + own upstream token count — there is no cross-tenant, security, or cost-shifting concern. + +--- + +## 6. Components (units & responsibilities) + +| Unit | Responsibility | Depends on | +|---|---|---| +| `resolveCompressionHeader` (`chatCore/headers.ts`) | Read the raw header value off the wire. | `getHeaderValueCaseInsensitive` | +| `resolveBasePlan` + `headerToPlan` (`strategySelector.ts` / `resolveCompressionPlan.ts`) | Interpret the value, evaluate header-first precedence, return `{ mode, stackedPipeline, source }`. | config, combos map (pure) | +| `DerivedPlan.source` (`deriveDefaultPlan.ts` type) | Carry which layer decided. | — | +| `OMNIROUTE_RESPONSE_HEADERS.compression` (`shared/constants/headers.ts`) | Name the response header. | — | +| chatCore wiring (`chatCore.ts`) | Parse, thread `header`, build id+name combo map, capture `{mode,source}`, emit response header. | all of the above | + +--- + +## 7. Testing + +- **Parser unit** (`headers.ts`): each value form, absent, blank, mixed casing, value with + surrounding whitespace -> correct raw/`null`. +- **Resolver unit** (`strategySelector` / `resolveCompressionPlan`): each form resolves the + expected plan and `source`; header beats an active profile and a routing override; + unknown value falls through to normal resolution; a valid value bypasses auto-trigger on a + large prompt; `default` returns the derived Default for both `enginesExplicit` and legacy + installs; `` matches by name and by id. +- **Integration / fetch-capture**: per-request precedence end-to-end (same config, header + present vs absent yields different applied plans) and the `X-OmniRoute-Compression` + response header value. +- **Source guard**: the resolver still has no `src/lib/db` import. + +--- + +## 8. Scope + +**In scope:** header parsing, top-of-precedence wiring, `source` on `DerivedPlan`, the +response header, docs (`API_REFERENCE` / `COMPRESSION_GUIDE`), tests. + +**Out of scope (YAGNI):** bare mode names in the header (`lite`/`aggressive`/…); panel UI for +the header; honoring the header on non-chat paths (`combo.ts` proactive-fallback, the +preview route). The header is a chat-request feature. diff --git a/docs/compression/2026-06-21-compression-phase3-request-header-plan.md b/docs/compression/2026-06-21-compression-phase3-request-header-plan.md new file mode 100644 index 0000000000..3e9fa8b20b --- /dev/null +++ b/docs/compression/2026-06-21-compression-phase3-request-header-plan.md @@ -0,0 +1,730 @@ +--- +title: "Compression Phase 3: Per-Request Header — Implementation Plan" +version: 3.8.33 +lastUpdated: 2026-06-21 +--- + +# Compression Phase 3: Per-Request Header — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the `x-omniroute-compression` per-request header so a client can override the resolved compression plan for a single request, taking precedence over every operator-configured layer. + +**Architecture:** A pure header interpreter (`planFromHeader`) is evaluated at the top of the compression resolver (`resolveBasePlan` in `strategySelector.ts`); chatCore parses the header off the wire, threads it through the existing selector signature (the same way Phase 2 threaded `combos`), captures the resolved `{ mode, source }`, and emits an `X-OmniRoute-Compression` response header. The resolver stays pure (no DB import). + +**Tech Stack:** TypeScript, Node test runner (`node --import tsx/esm --test`), better-sqlite3 (combos store, read via chatCore only), Next.js handler (`open-sse/handlers/chatCore.ts`). + +**Spec:** `docs/compression/2026-06-21-compression-phase3-request-header-design.md` + +--- + +## Key prior-art / facts (read before starting) + +- The resolver entry point is `resolveBasePlan` in `open-sse/services/compression/strategySelector.ts`. It already threads a `combos` map (Phase 2). Public selectors: `selectCompressionPlan` / `selectCompressionStrategy` / `getEffectiveMode`, signature ending in `..., combos = {}`. +- `DerivedPlan` is defined in `open-sse/services/compression/deriveDefaultPlan.ts` as `{ mode: string; stackedPipeline: Array<{ engine: string; intensity?: string }> }`. +- `open-sse/services/compression/resolveCompressionPlan.ts` carries a **dormant** header branch (`ctx.header` + private `headerToPlan`). No caller ever passes `header` (confirmed: the only `.header` refs in compression code are those lines; no test passes it). This plan **removes** that dormant branch so header interpretation has a single home. +- Parsing precedent: `isNoMemoryRequested` in `open-sse/handlers/chatCore/headers.ts` (uses `getHeaderValueCaseInsensitive`). Tests for that module: `tests/unit/chatcore-headers.test.ts`. +- A combo's `id` is a `uuidv4()` (or the seeded slug `default-caveman`); `name` has **no** UNIQUE constraint (migration 042). Header `` matches **name-first** (Decision A). +- chatCore reads request headers from `clientRawRequest?.headers`. The compression block builds `namedCombos` (~line 1373) and calls `selectCompressionStrategy` (~line 1532) then `selectCompressionPlan` (~line 1601). Response headers are built at two sites: the non-streaming JSON path (`responseHeaders` at ~line 4570, after `attachOmniRouteMetaHeaders`) and the streaming path (`responseHeaders` at ~line 4697). +- **Decision A:** `` matched name-first (lowercased), then exact id. **Decision B:** any valid header value bypasses auto-trigger. +- **Boundary (implement as written):** the master switch is the hard gate — when `config.enabled` is false, the request is uncompressed regardless of the header (the header redirects among configured plans only when compression is enabled). A test asserts this. + +--- + +## File Structure + +| File | Responsibility | Change | +|---|---|---| +| `open-sse/services/compression/deriveDefaultPlan.ts` | `DerivedPlan` type + new `CompressionSource` union + optional `source`. | Modify | +| `open-sse/services/compression/strategySelector.ts` | `planFromHeader` interpreter, header-first precedence in `resolveBasePlan`, `source` tagging, `formatCompressionMeta`, thread `header` through public selectors. | Modify | +| `open-sse/services/compression/resolveCompressionPlan.ts` | Remove the dormant `header` branch + `headerToPlan` + `ResolveCtx.header`. | Modify | +| `open-sse/handlers/chatCore/headers.ts` | `resolveCompressionHeader` parser. | Modify | +| `src/shared/constants/headers.ts` | `compression` response-header name. | Modify | +| `open-sse/handlers/chatCore.ts` | Parse header, add name keys to `namedCombos`, thread `header`, capture `{mode,source}`, emit response header at both sites. | Modify | +| `tests/unit/compression/compression-header-dispatch.test.ts` | Resolver + `planFromHeader` + `source` + precedence + `formatCompressionMeta`. | Create | +| `tests/unit/chatcore-headers.test.ts` | `resolveCompressionHeader` parsing. | Modify | +| `docs/reference/API_REFERENCE.md`, `docs/compression/COMPRESSION_GUIDE.md` | Document the header. | Modify | +| `config/quality/file-size-baseline.json` | Rebaseline chatCore if it crosses its pin. | Modify (if needed) | + +--- + +## Task 1: Header interpreter, precedence, and `source` in the resolver + +**Files:** +- Modify: `open-sse/services/compression/deriveDefaultPlan.ts` +- Modify: `open-sse/services/compression/strategySelector.ts` +- Modify: `open-sse/services/compression/resolveCompressionPlan.ts` +- Test: `tests/unit/compression/compression-header-dispatch.test.ts` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/unit/compression/compression-header-dispatch.test.ts`: + +```ts +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + selectCompressionStrategy, + selectCompressionPlan, + planFromHeader, + formatCompressionMeta, +} from "../../../open-sse/services/compression/strategySelector.ts"; +import { + DEFAULT_COMPRESSION_CONFIG, + type CompressionConfig, +} from "../../../open-sse/services/compression/types.ts"; + +const combos = { + c1: [{ engine: "rtk", intensity: "standard" }, { engine: "caveman", intensity: "full" }], + "fast combo": [{ engine: "lite" }], +}; + +function cfg(overrides: Partial = {}): CompressionConfig { + return { ...DEFAULT_COMPRESSION_CONFIG, enabled: true, ...overrides }; +} + +// header is the 7th positional arg of selectCompressionPlan/selectCompressionStrategy. +function planWithHeader(config: CompressionConfig, header: string | null) { + return selectCompressionPlan(config, null, 0, undefined, undefined, combos, header); +} + +describe("planFromHeader (Phase 3)", () => { + it("off => mode off, source request-header", () => { + const p = planFromHeader(cfg(), "off", combos); + assert.deepEqual(p, { mode: "off", stackedPipeline: [], source: "request-header" }); + }); + + it("default => the panel-derived default, ignoring the active profile", () => { + const config = cfg({ + activeComboId: "c1", + enginesExplicit: true, + engines: { rtk: { enabled: true } }, + }); + const p = planFromHeader(config, "default", combos); + assert.equal(p?.mode, "rtk"); // engines map default, NOT the c1 active profile + assert.equal(p?.source, "request-header"); + }); + + it("engine: => that single engine when enabled (case-insensitive)", () => { + const config = cfg({ enginesExplicit: true, engines: { rtk: { enabled: true } } }); + assert.equal(planFromHeader(config, "engine:RTK", combos)?.mode, "rtk"); + }); + + it("engine: => null (fall-through) when the engine is disabled", () => { + const config = cfg({ engines: { rtk: { enabled: false } } }); + assert.equal(planFromHeader(config, "engine:rtk", combos), null); + }); + + it(" matches by name (case-insensitive) and by id", () => { + assert.deepEqual(planFromHeader(cfg(), "FAST COMBO", combos)?.stackedPipeline, combos["fast combo"]); + assert.deepEqual(planFromHeader(cfg(), "c1", combos)?.stackedPipeline, combos.c1); + }); + + it("unknown value => null (fall-through)", () => { + assert.equal(planFromHeader(cfg(), "nonsense", combos), null); + }); +}); + +describe("header precedence in resolveBasePlan (Phase 3)", () => { + it("a valid header beats the active profile", () => { + const config = cfg({ activeComboId: "c1" }); + assert.equal(planWithHeader(config, "off").mode, "off"); + assert.equal(planWithHeader(config, "off").source, "request-header"); + }); + + it("a valid header beats a routing-combo override", () => { + const config = cfg({ comboOverrides: { "route-x": "stacked" } }); + const plan = selectCompressionPlan(config, "route-x", 0, undefined, undefined, combos, "off"); + assert.equal(plan.mode, "off"); + assert.equal(plan.source, "request-header"); + }); + + it("a valid header bypasses auto-trigger (Decision B)", () => { + const config = cfg({ + autoTriggerTokens: 1000, + autoTriggerMode: "aggressive", + enginesExplicit: true, + engines: { rtk: { enabled: true } }, + }); + // Large prompt would auto-escalate to aggressive; the header pins the panel default. + assert.equal(planWithHeader(config, "default").mode, "rtk"); + }); + + it("an unknown header falls through to the normal resolution", () => { + const config = cfg({ activeComboId: "c1" }); + const plan = planWithHeader(config, "bogus"); + assert.equal(plan.mode, "stacked"); + assert.equal(plan.source, "active-profile"); + }); + + it("master-off beats the header (hard kill switch)", () => { + const config = cfg({ enabled: false, engines: { rtk: { enabled: true } } }); + const plan = planWithHeader(config, "engine:rtk"); + assert.equal(plan.mode, "off"); + assert.equal(plan.source, "off"); + }); +}); + +describe("source on non-header paths", () => { + it("routing-override / active-profile / auto-trigger / default / off", () => { + assert.equal( + selectCompressionPlan(cfg({ comboOverrides: { r: "lite" } }), "r", 0, undefined, undefined, combos, null).source, + "routing-override" + ); + assert.equal(planWithHeader(cfg({ activeComboId: "c1" }), null).source, "active-profile"); + assert.equal( + planWithHeader(cfg({ autoTriggerTokens: 10, autoTriggerMode: "lite" }), null).source, + "auto-trigger" + ); + assert.equal( + planWithHeader(cfg({ enginesExplicit: true, engines: { rtk: { enabled: true } } }), null).source, + "default" + ); + assert.equal(planWithHeader(cfg({ enabled: false }), null).source, "off"); + }); +}); + +describe("formatCompressionMeta", () => { + it("renders '; source='", () => { + assert.equal( + formatCompressionMeta({ mode: "aggressive", stackedPipeline: [], source: "request-header" }), + "aggressive; source=request-header" + ); + assert.equal(formatCompressionMeta({ mode: "off", stackedPipeline: [] }), "off; source=off"); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit tests/unit/compression/compression-header-dispatch.test.ts` +Expected: FAIL — `planFromHeader`/`formatCompressionMeta` are not exported yet (import error / not a function). + +- [ ] **Step 3: Add `CompressionSource` + `source` to `DerivedPlan`** + +In `open-sse/services/compression/deriveDefaultPlan.ts`, replace the `DerivedPlan` interface block: + +```ts +export type CompressionSource = + | "request-header" + | "routing-override" + | "active-profile" + | "auto-trigger" + | "default" + | "off"; + +export interface DerivedPlan { + mode: string; + stackedPipeline: Array<{ engine: string; intensity?: string }>; + /** Which precedence layer decided this plan (Phase 3 observability). Optional so + * Phase 1/2 callers and snapshots are unaffected. */ + source?: CompressionSource; +} +``` + +(Leave the `deriveDefaultPlan` function body unchanged — it does not set `source`; the resolver tags it.) + +- [ ] **Step 4: Remove the dormant header branch from `resolveCompressionPlan.ts`** + +In `open-sse/services/compression/resolveCompressionPlan.ts`: delete the `header` field from `ResolveCtx`, delete the `// 1. header` block, and delete the private `headerToPlan` function. The file becomes: + +```ts +import { deriveDefaultPlan, type DerivedPlan } from "./deriveDefaultPlan.ts"; + +export interface ResolveCtx { + comboId?: string | null; + combos?: Record>; // named combo pipelines by id +} + +export function resolveCompressionPlan(config: any, ctx: ResolveCtx): DerivedPlan { + if (config?.enabled === false) return { mode: "off", stackedPipeline: [] }; + + // routing-combo override + const ov = ctx.comboId ? config?.comboOverrides?.[ctx.comboId] : undefined; + if (ov) return modeToPlan(ov, config); + + // active named combo + if (config?.activeComboId && ctx.combos?.[config.activeComboId]) { + return { mode: "stacked", stackedPipeline: ctx.combos[config.activeComboId] }; + } + + // derived default + return deriveDefaultPlan(config?.engines ?? {}, config?.enabled !== false); +} + +function modeToPlan(mode: string, config: any): DerivedPlan { + return mode === "stacked" + ? { mode: "stacked", stackedPipeline: config?.stackedPipeline ?? [] } + : { mode, stackedPipeline: [] }; +} +``` + +- [ ] **Step 5: Implement `planFromHeader`, `withSource`, header-first precedence, and `formatCompressionMeta` in `strategySelector.ts`** + +In `open-sse/services/compression/strategySelector.ts`: + +(a) Update the `DerivedPlan` import to also bring `CompressionSource`: + +```ts +import { deriveDefaultPlan, type DerivedPlan, type CompressionSource } from "./deriveDefaultPlan.ts"; +``` + +(b) Add these helpers (place them just above `resolveBasePlan`): + +```ts +/** Tags a plan with the precedence layer that produced it (Phase 3 observability). */ +function withSource(plan: DerivedPlan, source: CompressionSource): DerivedPlan { + return { ...plan, source }; +} + +/** + * Interprets the `x-omniroute-compression` request header into a plan, or null when the + * value is unrecognized (caller falls through to normal resolution). Pure. + * off -> no compression + * default -> the panel-derived Default (ignores active profile / routing / auto-trigger) + * engine: -> that single engine, when enabled in config.engines + * -> a named combo, matched name-first (lowercased) then exact id (Decision A) + */ +export function planFromHeader( + config: CompressionConfig, + header: string, + combos: NamedCombos +): DerivedPlan | null { + const h = header.trim(); + if (!h) return null; + const lower = h.toLowerCase(); + + if (lower === "off") return withSource({ mode: "off", stackedPipeline: [] }, "request-header"); + + if (lower === "default") { + // Empty combos + null comboId yields the pure panel default (no active-combo leak). + return withSource(deriveDefaultPlanFromConfig(config, null, {}), "request-header"); + } + + if (lower.startsWith("engine:")) { + const id = lower.slice("engine:".length); + const engine = config.engines?.[id]; + return engine?.enabled + ? withSource(deriveDefaultPlan({ [id]: engine }, true), "request-header") + : null; + } + + const combo = combos[lower] ?? combos[h]; + return combo ? withSource({ mode: "stacked", stackedPipeline: combo }, "request-header") : null; +} + +/** Renders the X-OmniRoute-Compression response header value. */ +export function formatCompressionMeta(plan: DerivedPlan): string { + return `${plan.mode}; source=${plan.source ?? "off"}`; +} +``` + +(c) Add a `header` parameter to `resolveBasePlan` and tag every return with `withSource`: + +```ts +function resolveBasePlan( + config: CompressionConfig, + comboId: string | null, + estimatedTokens: number, + combos: NamedCombos = {}, + header: string | null = null +): DerivedPlan { + if (!config.enabled) return withSource({ mode: "off", stackedPipeline: [] }, "off"); + + // Phase 3: an explicit, recognized header wins over every operator layer (Decision B). + // The master switch above is the hard kill: a header cannot turn compression on. + if (header) { + const fromHeader = planFromHeader(config, header, combos); + if (fromHeader) return fromHeader; // already tagged "request-header" + } + + const comboMode = checkComboOverride(config, comboId); + if (comboMode) { + return withSource(resolveCompressionPlan(config, { comboId, combos }), "routing-override"); + } + + if (config.activeComboId && combos[config.activeComboId]) { + return withSource( + { mode: "stacked", stackedPipeline: combos[config.activeComboId] }, + "active-profile" + ); + } + + if (shouldAutoTrigger(config, estimatedTokens)) { + const mode = config.autoTriggerMode ?? "lite"; + return withSource( + mode === "stacked" + ? { mode, stackedPipeline: config.stackedPipeline ?? [] } + : { mode, stackedPipeline: [] }, + "auto-trigger" + ); + } + + const plan = deriveDefaultPlanFromConfig(config, comboId, combos); + return withSource(plan, plan.mode === "off" ? "off" : "default"); +} +``` + +(d) Thread `header` through the public selectors (append as the last positional arg, default `null`), preserving `source` through the caching-aware adjustment: + +```ts +export function getEffectiveMode( + config: CompressionConfig, + comboId: string | null, + estimatedTokens: number, + combos: NamedCombos = {}, + header: string | null = null +): CompressionMode { + return resolveBasePlan(config, comboId, estimatedTokens, combos, header).mode as CompressionMode; +} + +export function selectCompressionPlan( + config: CompressionConfig, + comboId: string | null, + estimatedTokens: number, + body?: Record, + context?: CachingDetectionContext, + combos: NamedCombos = {}, + header: string | null = null +): DerivedPlan { + const plan = resolveBasePlan(config, comboId, estimatedTokens, combos, header); + if (body) { + const ctx = detectCachingContext(body, context); + const cacheAware = getCacheAwareStrategy(plan.mode as CompressionMode, ctx); + return { ...plan, mode: cacheAware.strategy as CompressionMode }; // ...plan preserves source + } + return plan; +} + +export function selectCompressionStrategy( + config: CompressionConfig, + comboId: string | null, + estimatedTokens: number, + body?: Record, + context?: CachingDetectionContext, + combos: NamedCombos = {}, + header: string | null = null +): CompressionMode { + return selectCompressionPlan(config, comboId, estimatedTokens, body, context, combos, header) + .mode as CompressionMode; +} +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit tests/unit/compression/compression-header-dispatch.test.ts` +Expected: PASS (all cases). + +- [ ] **Step 7: Run the full compression suite to confirm no regression** + +Run: `node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit "tests/unit/compression/**/*.test.ts"` +Expected: PASS — the existing 774+ compression tests stay green (resolveCompressionPlan / strategySelector / active-combo dispatch unchanged in behavior for header-less callers). + +- [ ] **Step 8: Commit** + +```bash +git add open-sse/services/compression/deriveDefaultPlan.ts \ + open-sse/services/compression/strategySelector.ts \ + open-sse/services/compression/resolveCompressionPlan.ts \ + tests/unit/compression/compression-header-dispatch.test.ts +git commit -m "feat(compression): header-first resolver + plan source (Phase 3 core)" +``` + +--- + +## Task 2: `resolveCompressionHeader` parser + +**Files:** +- Modify: `open-sse/handlers/chatCore/headers.ts` +- Test: `tests/unit/chatcore-headers.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/unit/chatcore-headers.test.ts` (keep existing imports; add `resolveCompressionHeader` to the import from `../../open-sse/handlers/chatCore/headers.ts`): + +```ts +import { resolveCompressionHeader } from "../../open-sse/handlers/chatCore/headers.ts"; +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +describe("resolveCompressionHeader", () => { + it("reads the raw value case-insensitively and trims it", () => { + assert.equal(resolveCompressionHeader({ "x-omniroute-compression": " engine:rtk " }), "engine:rtk"); + assert.equal(resolveCompressionHeader(new Headers({ "X-OmniRoute-Compression": "off" })), "off"); + }); + + it("returns null when absent or blank", () => { + assert.equal(resolveCompressionHeader({}), null); + assert.equal(resolveCompressionHeader({ "x-omniroute-compression": " " }), null); + assert.equal(resolveCompressionHeader(null), null); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit tests/unit/chatcore-headers.test.ts` +Expected: FAIL — `resolveCompressionHeader` is not exported. + +- [ ] **Step 3: Implement the parser** + +Append to `open-sse/handlers/chatCore/headers.ts`: + +```ts +/** + * Per-request compression override via the `x-omniroute-compression` header. Mirrors the + * `x-omniroute-no-memory` convention (#4290). Returns the raw trimmed value, or null when + * absent/blank. The resolver (planFromHeader) owns interpretation and casing rules; this + * helper only reads the wire. + */ +export function resolveCompressionHeader( + headers: Record | Headers | null | undefined +): string | null { + const value = (getHeaderValueCaseInsensitive(headers, "x-omniroute-compression") || "").trim(); + return value || null; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit tests/unit/chatcore-headers.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add open-sse/handlers/chatCore/headers.ts tests/unit/chatcore-headers.test.ts +git commit -m "feat(compression): resolveCompressionHeader parser (Phase 3)" +``` + +--- + +## Task 3: chatCore wiring + response header + +**Files:** +- Modify: `src/shared/constants/headers.ts` +- Modify: `open-sse/handlers/chatCore.ts` + +- [ ] **Step 1: Add the response-header name constant** + +In `src/shared/constants/headers.ts`, add the `compression` entry to `OMNIROUTE_RESPONSE_HEADERS` (alphabetical, after `cacheHit`): + +```ts + cacheHit: "X-OmniRoute-Cache-Hit", + compression: "X-OmniRoute-Compression", + costSaved: "X-OmniRoute-Cost-Saved", +``` + +- [ ] **Step 2: Import the parser and declare the capture variable** + +In `open-sse/handlers/chatCore.ts`, line 7, add `resolveCompressionHeader` to the existing import from `./chatCore/headers.ts`: + +```ts +import { getHeaderValueCaseInsensitive, isNoMemoryRequested, resolveCompressionHeader } from "./chatCore/headers.ts"; +``` + +Near the other compression-scoped `let`s (~line 1321, beside `let cavemanOutputModeApplied = false;`), add: + +```ts + let compressionResponseMeta: string | null = null; +``` + +- [ ] **Step 3: Parse the header and add name keys to the combos map** + +In the compression block, replace the `namedCombos` build (~line 1373) so it keys by **both** id and lowercased name, and parse the header right after: + +```ts + let namedCombos: Record = {}; + try { + const { listCompressionCombos } = await import("../../src/lib/db/compressionCombos.ts"); + namedCombos = Object.fromEntries( + listCompressionCombos().flatMap((c) => [ + [c.id, c.pipeline], + [c.name.toLowerCase(), c.pipeline], + ]) + ); + } catch (err) { + log?.debug?.( + "COMPRESSION", + "Named combos load skipped: " + (err instanceof Error ? err.message : String(err)) + ); + } + // Phase 3: per-request override. Unknown values fall through in the resolver (never error). + const compressionHeader = resolveCompressionHeader(clientRawRequest?.headers ?? null); + if (compressionHeader) { + log?.debug?.("COMPRESSION", `x-omniroute-compression header: ${compressionHeader}`); + } +``` + +(The active-profile lookup keys on `config.activeComboId`, always a UUID/slug id, so the added name keys are inert for it — one map serves both paths. A combo named `off`/`default` cannot be selected by name because the keyword branches run first; note this in the docs.) + +- [ ] **Step 4: Thread the header into both selector calls** + +Append `compressionHeader` as the last argument to `selectCompressionStrategy` (~line 1532) and `selectCompressionPlan` (~line 1601): + +```ts + const modeBeforeOutputTransform = selectCompressionStrategy( + config, + compressionComboKey, + estimatedTokens, + body as Record, + { provider, targetFormat, model: effectiveModel }, + namedCombos, + compressionHeader + ); +``` + +```ts + const compressionPlan = selectCompressionPlan( + config, + compressionComboKey, + estimatedTokens, + compressionInputBody, + { provider, targetFormat, model: effectiveModel }, + namedCombos, + compressionHeader + ); +``` + +- [ ] **Step 5: Capture the resolved meta** + +Immediately after `const mode = compressionPlan.mode as CompressionConfig["defaultMode"];` (~line 1609), add (importing `formatCompressionMeta` alongside the other strategySelector imports destructured at ~line 1349): + +```ts + compressionResponseMeta = formatCompressionMeta(compressionPlan); +``` + +And add `formatCompressionMeta` to the destructured import from `../services/compression/strategySelector.ts` (~line 1349): + +```ts + formatCompressionMeta, +``` + +- [ ] **Step 6: Emit the response header at both build sites** + +In the non-streaming JSON path, right after the `attachOmniRouteMetaHeaders(responseHeaders, { ... });` call (~line 4582), add: + +```ts + if (compressionResponseMeta) { + responseHeaders[OMNIROUTE_RESPONSE_HEADERS.compression] = compressionResponseMeta; + } +``` + +In the streaming path, right after the `responseHeaders` object literal closes (the `};` after `"x-omniroute-request-id": pendingRequestId,`, ~line 4708), add: + +```ts + if (compressionResponseMeta) { + responseHeaders[OMNIROUTE_RESPONSE_HEADERS.compression] = compressionResponseMeta; + } +``` + +- [ ] **Step 7: Typecheck + cycle/source guard** + +Run: `npm run typecheck:core` +Expected: exit 0. + +Run: `npm run check:cycles` +Expected: exit 0 — the resolver still has no `src/lib/db` import (header logic lives in `strategySelector.ts`, name-key map built in chatCore). + +- [ ] **Step 8: Run the compression suite again (no regression from wiring)** + +Run: `node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit "tests/unit/compression/**/*.test.ts"` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add src/shared/constants/headers.ts open-sse/handlers/chatCore.ts +git commit -m "feat(compression): wire x-omniroute-compression header + response header (Phase 3)" +``` + +--- + +## Task 4: Docs + file-size + full validation + +**Files:** +- Modify: `docs/reference/API_REFERENCE.md` +- Modify: `docs/compression/COMPRESSION_GUIDE.md` +- Modify (if needed): `config/quality/file-size-baseline.json` + +- [ ] **Step 1: Document the header (API reference)** + +In `docs/reference/API_REFERENCE.md`, near the `x-omniroute-no-memory` documentation, add a section: + +````markdown +### `x-omniroute-compression` + +Per-request override of the compression plan. Highest precedence — beats the routing-combo +override, the active profile, auto-trigger, and the panel Default. Values: + +| Value | Effect | +|-------|--------| +| `off` | No compression for this request. | +| `default` | The panel-derived Default profile (ignores the active profile). | +| `engine:` | A single engine when enabled, e.g. `engine:rtk`. | +| `` | A named combo, matched by name (case-insensitive) first, then by id. | + +Unknown values are ignored (the request is never rejected). The applied plan is echoed in the +`X-OmniRoute-Compression: ; source=` response header. The master compression +switch is a hard gate: when compression is disabled globally, this header cannot enable it. +```` + +- [ ] **Step 2: Document the header (compression guide)** + +In `docs/compression/COMPRESSION_GUIDE.md`, add a short "Per-request override" subsection mirroring the table above (one paragraph + the value table). Keep all angle-bracket tokens (`engine:`, ``, ``, ``) inside backticks or fenced blocks (the docs are MDX-compiled). + +- [ ] **Step 3: Validate MDX** + +Run: `npx fumadocs-mdx` +Expected: `[MDX] generated files in ...ms` with no error. + +- [ ] **Step 4: Reconcile the file-size baseline if chatCore crossed its pin** + +Run: `npm run check:file-size` +Expected: PASS. If it reports `chatCore.ts: > `, update `config/quality/file-size-baseline.json`: set the `open-sse/handlers/chatCore.ts` frozen value to the reported `` and add a justification key: + +```json + "_rebaseline_2026_06_21_phase3_request_header": "Compression Phase 3 (x-omniroute-compression per-request header) own growth: chatCore.ts -> at the existing compression-dispatch chokepoint — parse the header (resolveCompressionHeader), add lowercased-name keys to the namedCombos map, thread it as the new last arg to selectCompressionStrategy + selectCompressionPlan, capture formatCompressionMeta(compressionPlan) into compressionResponseMeta, and emit X-OmniRoute-Compression at the two response-header build sites. The resolver stays pure (planFromHeader + source live in open-sse/services/compression/strategySelector.ts, ` | A single engine when enabled, e.g. `engine:rtk`. | +| `` | A named combo, matched by name (case-insensitive) first, then by id. | + +The applied plan is echoed back in the `X-OmniRoute-Compression: ; source=` response +header, where `` is one of `request-header`, `routing-override`, `active-profile`, +`auto-trigger`, `default`, or `off`. + ### API ```bash diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index fec1f3844c..103912cc7c 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index b132a12ab7..6eb006d22f 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index b132a12ab7..6eb006d22f 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index 582fe33717..739c7da81b 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index a1e8f950ff..f88d500299 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index 7e03c65603..578925e232 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index 3dd63801fc..34e6ddf822 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index 5920898530..38f35f9fb9 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index f21cd1b402..8c6186898d 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index fd73de7a3e..f820a06f1e 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index a16bb7c8ac..3d040ada91 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index f4dfa0e45b..fd2d6ebac7 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index b3fd0e5ae1..5c12c9ff6d 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index 9aded28f5e..424762243b 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index 76ff74dde0..f5d01b81e6 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index 8e3a0f5afc..3e241d703a 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index 8f4ce8550d..5551f4c104 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index a7f8be7d78..9f6eec413b 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index 67e5bf129e..d7e7dff8e4 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index 83c7ca91d3..8db5a1c736 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index 10e806f7bf..02decd4d48 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index 4d11ce8351..9127c8b702 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index 3e7df09c20..59b26ce4c7 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index e11338aaad..569dd5f6fa 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index ad8759cb1f..66de70d787 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index 07c2b439ce..32d87f6973 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index 989d4955b8..753496e05d 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index 9ff3bc7ff2..226f172cc9 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index 2b889a92df..61c0fd11fc 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index 7c1b761ded..47f0666baf 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index 37bad5c52d..25e7e2a022 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index 0757125fab..91e351bcbc 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index c3945ac28d..4276520d65 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index cc59634344..0ebe6abe6f 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index f49a2fb8e2..a79ddea747 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index a1afedd6d8..84af575dd2 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index 4aa8aa0b15..21b097a57c 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index ff932ea110..1b245aab5b 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index 9f1d76b88b..03f94dbb09 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 29d3c486a0..4f9a8084b8 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 01b47254c1..231023c17d 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -6,6 +6,62 @@ ## [3.8.31] — 2026-06-20 +## [3.8.34] — 2026-06-23 + +### ✨ New Features + +- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw) +- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:` / ``. The response echoes `X-OmniRoute-Compression: ; source=`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw) +- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw) +- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion) +- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn) +- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself) +- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw) + +### 🐛 Fixed + +- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw) +- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77) +- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77) +- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore) +- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw) +- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw) +- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw) +- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag) +- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810) +- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas) +- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa) +- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810) +- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself) +- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself) +- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself) +- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself) +- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari) +- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari) +- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari) +- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari) +- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari) +- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari) +- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari) +- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari) +- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari) +- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari) +- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari) +- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw) +- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw) +- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw) +- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw) +- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw) + +### 📝 Maintenance + +- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari) +- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw) +- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw) +- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw) + +--- + ## [3.8.33] — TBD _See English CHANGELOG for v3.8.33 details._ diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 3eedb525ce..4472b50f8a 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -83,6 +83,32 @@ Content-Type: application/json > **Cache-hit cost semantics:** on a semantic-cache HIT (`X-OmniRoute-Cache-Hit: true`) no upstream call is made, so `X-OmniRoute-Response-Cost` is `0.0000000000` (the **incremental** cost of serving the hit). The original/would-have-been cost is reported separately in `X-OmniRoute-Cost-Saved`. Billing consumers should sum `X-OmniRoute-Response-Cost` (hits cost nothing); cache analytics can aggregate `X-OmniRoute-Cost-Saved`. +### `x-omniroute-compression` + +Per-request override of the compression plan. Highest precedence — beats the routing-combo +override, the active profile, auto-trigger, and the panel Default. Values: + +| Value | Effect | +|-------|--------| +| `off` | No compression for this request. | +| `default` | The panel-derived Default profile (ignores the active profile). | +| `engine:` | A single engine when enabled, e.g. `engine:rtk`. | +| `` | A named combo, matched by name (case-insensitive) first, then by id. | + +Notes: +- Unknown values are ignored (the request is never rejected); resolution falls through to the normal operator precedence. +- If multiple combos share a name, pass the combo **id** for a deterministic match. +- A combo whose name is `off` or `default` cannot be selected by name (those keywords are interpreted first); reference such a combo by its id. +- The master compression switch is a hard gate: when compression is disabled globally, this header cannot enable it. + +The applied plan is echoed back in the response header: + +``` +X-OmniRoute-Compression: ; source= +``` + +where `` is one of `request-header`, `routing-override`, `active-profile`, `auto-trigger`, `default`, or `off`. + --- ## Embeddings diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index ad5590de62..ce1f812143 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -265,6 +265,15 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp | `OMNIROUTE_GEMINI_CLI_USAGE_URL` | `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | `open-sse/services/usage.ts` | Gemini CLI quota lookup endpoint. Override for relays / test fixtures. | | `OMNIROUTE_OPENCODE_QUOTA_URL` | `https://opencode.ai/zen/go/v1/quota` | `open-sse/services/opencodeQuotaFetcher.ts` | OpenCode (zen/go) quota lookup endpoint used by the Usage page. Override for relays / test fixtures. | | `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | `https://api.z.ai/api/monitor/usage/quota/limit` | `open-sse/services/usage.ts` | OpenCode Go quota lookup endpoint used by the Usage page. Override for relays / test fixtures. | +| `OMNIROUTE_OPENCODE_GO_DASHBOARD_URL` | `https://opencode.ai/workspace` | `open-sse/services/usage.ts` | OpenCode Go dashboard base URL used for quota scraping when a workspace ID and auth cookie are configured. Override for relays / test fixtures. | +| `OPENCODE_GO_WORKSPACE_ID` | _(unset)_ | `open-sse/services/usage.ts` | OpenCode Go workspace ID used for dashboard quota scraping. Prefer the per-connection Dashboard field when multiple accounts are configured. | +| `OMNIROUTE_OPENCODE_GO_WORKSPACE_ID` | _(unset)_ | `open-sse/services/usage.ts` | Alternate OpenCode Go workspace ID env var used before the shorter alias. Prefer the per-connection Dashboard field when multiple accounts are configured. | +| `OPENCODE_GO_AUTH_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | OpenCode Go `auth` cookie used for dashboard quota scraping. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | +| `OMNIROUTE_OPENCODE_GO_AUTH_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Alternate OpenCode Go `auth` cookie env var used before the shorter alias. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | +| `OMNIROUTE_OLLAMA_CLOUD_USAGE_URL` | `https://ollama.com/settings` | `open-sse/services/usage.ts` | Ollama Cloud settings URL used for quota scraping. Override for relays / test fixtures. | +| `OLLAMA_USAGE_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Ollama Cloud `__Secure-session` cookie used for settings-page quota scraping. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | +| `OLLAMA_CLOUD_USAGE_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Alternate Ollama Cloud `__Secure-session` cookie env var. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | +| `OMNIROUTE_OLLAMA_USAGE_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Alternate Ollama Cloud `__Secure-session` cookie env var used before the shorter aliases. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | | `OMNIROUTE_CODEWHISPERER_BASE_URL` | `https://codewhisperer.us-east-1.amazonaws.com` | `open-sse/services/usage.ts` | CodeWhisperer (AWS Kiro) usage limits endpoint. Override for relays / test fixtures. | > [!IMPORTANT] @@ -634,6 +643,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | `CALL_LOG_RETENTION_DAYS` | `7` | Days to keep request/call log entries in the database. | | `CALL_LOG_MAX_ENTRIES` | `10000` | Max call log entries in the in-memory buffer. | | `CALL_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `call_logs` SQLite table before pruning. | +| `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_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. | diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index 77265ccf10..d1114dcfa8 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.8.33 + version: 3.8.34 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/electron/package-lock.json b/electron/package-lock.json index 17c2484284..89d9bf9a65 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute-desktop", - "version": "3.8.33", + "version": "3.8.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute-desktop", - "version": "3.8.33", + "version": "3.8.34", "license": "MIT", "dependencies": { "electron-updater": "^6.8.9" diff --git a/electron/package.json b/electron/package.json index d4f60c74f5..a063e9a03e 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.8.33", + "version": "3.8.34", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/open-sse/config/providers/registry/opencode/go/index.ts b/open-sse/config/providers/registry/opencode/go/index.ts index 8f319eda4b..224d3deab1 100644 --- a/open-sse/config/providers/registry/opencode/go/index.ts +++ b/open-sse/config/providers/registry/opencode/go/index.ts @@ -43,6 +43,15 @@ export const opencode_goProvider: RegistryEntry = { { id: "qwen3.5-plus", name: "Qwen3.5 Plus", targetFormat: "claude", supportsVision: false }, { id: "hy3-preview", name: "Hunyuan3 Preview" }, { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true }, + // OpencodeExecutor rewrites these aliases to the canonical upstream id and injects reasoning_effort. + { id: "deepseek-v4-pro-low", name: "DeepSeek V4 Pro (low effort)", supportsReasoning: true }, + { + id: "deepseek-v4-pro-medium", + name: "DeepSeek V4 Pro (medium effort)", + supportsReasoning: true, + }, + { id: "deepseek-v4-pro-high", name: "DeepSeek V4 Pro (high effort)", supportsReasoning: true }, + { id: "deepseek-v4-pro-max", name: "DeepSeek V4 Pro (max effort)", supportsReasoning: true }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true }, ], }; diff --git a/open-sse/config/providers/registry/reka/index.ts b/open-sse/config/providers/registry/reka/index.ts index f91ac8d780..b394800a09 100644 --- a/open-sse/config/providers/registry/reka/index.ts +++ b/open-sse/config/providers/registry/reka/index.ts @@ -9,7 +9,10 @@ export const rekaProvider: RegistryEntry = { authType: "apikey", authHeader: "bearer", models: [ + // reka-flash-3 stays first so it remains the provider default (the free-tier + // model in freeModelCatalog); reka-flash was added in #4621 as an extra option. { id: "reka-flash-3", name: "Reka Flash 3" }, + { id: "reka-flash", name: "Reka Flash" }, { id: "reka-edge-2603", name: "Reka Edge 2603" }, ], }; diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index fea719bd92..de3c41135a 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -252,15 +252,21 @@ const MISTRAL_NO_REASONING_EFFORT_PATTERN = /devstral/i; // models, and the `oswe-*` family (Raptor) which still rejects // reasoning_effort. // Order matters: the opt-in check must run BEFORE the broad Claude/haiku/oswe strip. -const GITHUB_REASONING_EFFORT_OPT_IN_PATTERN = - /claude[-_.]?(?:opus|sonnet)[-_.]?4[-_.]6/i; +const GITHUB_REASONING_EFFORT_OPT_IN_PATTERN = /claude[-_.]?(?:opus|sonnet)[-_.]?4[-_.]6/i; const GITHUB_NO_REASONING_EFFORT_PATTERN = /(claude|haiku|oswe)/i; function supportsMaxEffortForProvider(provider: string, model: string): boolean { - return ( + const isClaude = (provider === PROVIDER_CLAUDE || isClaudeCodeCompatible(provider)) && - supportsClaudeMaxEffort(model) - ); + supportsClaudeMaxEffort(model); + // 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 + // upstream. Scoped to opencode-go deliberately: OpenRouter's DeepSeek path + // (pi#4055) is the documented inverse and expects xhigh, not max. + const isOpencodeGoDeepSeek = + provider === "opencode-go" && model.toLowerCase().includes("deepseek"); + return isClaude || isOpencodeGoDeepSeek; } export function sanitizeReasoningEffortForProvider( diff --git a/open-sse/executors/copilot-m365-connection.ts b/open-sse/executors/copilot-m365-connection.ts new file mode 100644 index 0000000000..b3c2a1507d --- /dev/null +++ b/open-sse/executors/copilot-m365-connection.ts @@ -0,0 +1,116 @@ +/** + * Microsoft 365 Copilot (individual / Substrate BizChat) connection helpers. + * + * Pure URL / credential / prompt builders for the #4042 individual M365 path. + * Kept transport-free (no BaseExecutor import — only a type import) so they can + * be unit-tested without the executor's heavy runtime dependency chain. The + * access_token rides in the WS query string per the protocol, so any logging of + * the URL MUST go through redactWsUrl(). + */ + +import { randomUUID, randomBytes } from "node:crypto"; +import type { ProviderCredentials } from "./base.ts"; + +type JsonRecord = Record; + +/** Individual-tier defaults observed in @skyzea1's #4042 capture. */ +export const M365_INDIVIDUAL_DEFAULTS = { + host: "substrate.office.com", + source: "officeweb", + product: "Office", + agentHost: "Bizchat.FullScreen", + licenseType: "Starter", + agent: "web", + scenario: "OfficeWebPaidConsumerCopilot", +} as const; + +export interface M365ConnectionParams { + host: string; + chathubPath: string; // "@" + accessToken: string; +} + +/** A new 32-hex chat session id (== XRoutingParameterSessionKey == clientrequestid). */ +export function newChatSessionId(): string { + return randomBytes(16).toString("hex"); +} + +/** + * Read the pasted credential bits. The individual access_token is opaque (JWE), + * so it is consumed verbatim. The Chathub path (`user@tenant`) is pasted + * alongside it because it is not derivable from the opaque token. + */ +export function resolveConnectionParams( + credentials: ProviderCredentials | undefined +): M365ConnectionParams | { error: string } { + const psd = (credentials?.providerSpecificData ?? {}) as JsonRecord; + const accessToken = + (typeof credentials?.apiKey === "string" && credentials.apiKey) || + (typeof psd.accessToken === "string" && psd.accessToken) || + (typeof psd.access_token === "string" && psd.access_token) || + ""; + if (!accessToken) { + return { error: "Missing M365 Copilot access_token. Paste it as the provider credential." }; + } + const chathubPath = + (typeof psd.chathubPath === "string" && psd.chathubPath) || + (typeof psd.userTenant === "string" && psd.userTenant) || + ""; + if (!chathubPath || !chathubPath.includes("@")) { + return { + error: + "Missing M365 Chathub path. Paste the '@' segment from the WebSocket URL.", + }; + } + const host = (typeof psd.host === "string" && psd.host) || M365_INDIVIDUAL_DEFAULTS.host; + return { host, chathubPath, accessToken }; +} + +/** + * Build the BizChat WebSocket URL. The access_token rides in the query string + * (per the protocol), so callers must never log the returned URL verbatim — use + * redactWsUrl() for any logging. + */ +export function buildWsUrl(params: M365ConnectionParams): string { + const sessionKey = newChatSessionId(); + const query = new URLSearchParams({ + chatsessionid: sessionKey, + XRoutingParameterSessionKey: sessionKey, + clientrequestid: sessionKey, + "X-SessionId": randomUUID(), + ConversationId: randomUUID(), + access_token: params.accessToken, + source: M365_INDIVIDUAL_DEFAULTS.source, + product: M365_INDIVIDUAL_DEFAULTS.product, + agentHost: M365_INDIVIDUAL_DEFAULTS.agentHost, + licenseType: M365_INDIVIDUAL_DEFAULTS.licenseType, + isEdu: "false", + agent: M365_INDIVIDUAL_DEFAULTS.agent, + scenario: M365_INDIVIDUAL_DEFAULTS.scenario, + }); + return `wss://${params.host}/m365Copilot/Chathub/${params.chathubPath}?${query.toString()}`; +} + +/** Strip the access_token from a WS URL so it is safe to log. */ +export function redactWsUrl(wsUrl: string): string { + return wsUrl.replace(/access_token=[^&]*/i, "access_token=REDACTED"); +} + +/** Flatten OpenAI messages into a single prompt (system instructions prepended). */ +export function buildPrompt(body: JsonRecord | undefined): string { + const messages = (body?.messages as Array) || []; + const systemMsgs = messages.filter((m) => m.role === "system"); + const userMsg = messages.filter((m) => m.role === "user").pop(); + const userText = + typeof userMsg?.content === "string" ? userMsg.content : JSON.stringify(userMsg?.content ?? ""); + let prompt = ""; + if (systemMsgs.length > 0) { + const sysText = systemMsgs + .map((m) => (typeof m.content === "string" ? m.content : "")) + .filter(Boolean) + .join("\n"); + if (sysText) prompt += `[System Instructions]\n${sysText}\n\n`; + } + prompt += userText; + return prompt; +} diff --git a/open-sse/executors/copilot-m365-frames.ts b/open-sse/executors/copilot-m365-frames.ts new file mode 100644 index 0000000000..e236792dc2 --- /dev/null +++ b/open-sse/executors/copilot-m365-frames.ts @@ -0,0 +1,201 @@ +/** + * Microsoft 365 Copilot (BizChat / Substrate) SignalR-over-WebSocket framing. + * + * Pure, transport-free helpers that translate between the OpenAI chat shape and + * the Substrate BizChat SignalR JSON protocol observed on the individual M365 + * path (`m365.cloud.microsoft/chat` → `wss://substrate.office.com/m365Copilot/ + * Chathub/...`). Keeping these pure lets us unit-test the wire format against the + * real frame captures contributed in #4042 without opening a live socket — the + * live round-trip is the separate Rule #18 validation gate for the executor. + * + * Protocol (from @skyzea1's #4042 capture): + * - JSON messages terminated with the SignalR record separator `\x1e`. + * - Handshake: → {"protocol":"json","version":1} ← {} → {"type":6} + * - Send: type:4 invocation to target "chat" with arguments[0] = { message, ... } + * - Stream: type:1 target:"update" deltas (bot text at arguments[0].messages[].text, + * accumulated — NOT incremental) → isLastUpdate:true → type:2 final → type:3 completion. + */ + +/** SignalR record separator (0x1e) terminating every JSON frame. */ +export const RECORD_SEPARATOR = String.fromCharCode(0x1e); + +/** SignalR handshake request — the first frame the client must send. */ +export const HANDSHAKE_REQUEST = { protocol: "json", version: 1 } as const; + +/** SignalR keepalive ping frame. */ +export const KEEPALIVE_PING = { type: 6 } as const; + +/** Allowed message types observed in the individual M365 send frame. */ +export const ALLOWED_MESSAGE_TYPES = [ + "Chat", + "Suggestion", + "InternalSearchQuery", + "Disengaged", + "InternalLoaderMessage", + "Progress", + "GeneratedCode", + "RenderCardRequest", + "AdsQuery", + "SemanticSerp", + "GenerateContentQuery", +] as const; + +/** Append the record separator to a JSON-serializable frame. */ +export function encodeFrame(obj: unknown): string { + return JSON.stringify(obj) + RECORD_SEPARATOR; +} + +/** Serialized handshake request frame. */ +export function handshakeFrame(): string { + return encodeFrame(HANDSHAKE_REQUEST); +} + +/** Serialized keepalive ping frame. */ +export function keepaliveFrame(): string { + return encodeFrame(KEEPALIVE_PING); +} + +/** + * Split a raw socket buffer into complete `\x1e`-terminated frames, returning any + * trailing partial frame as `rest` so it can be prepended to the next chunk. + */ +export function splitFrames(buffer: string): { frames: string[]; rest: string } { + const parts = buffer.split(RECORD_SEPARATOR); + // The last element is either "" (buffer ended on a separator) or a partial frame. + const rest = parts.pop() ?? ""; + const frames = parts.filter((p) => p.length > 0); + return { frames, rest }; +} + +/** Safely JSON.parse a single frame body; returns null on malformed input. */ +export function parseFrame(frame: string): Record | null { + const trimmed = frame.trim(); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +/** + * A SignalR handshake response is `{}` on success, or `{ error: "..." }` on + * failure. Returns the error string, or null when the handshake succeeded. + */ +export function handshakeError(frame: Record | null): string | null { + if (!frame) return null; + const err = frame.error; + return typeof err === "string" && err.length > 0 ? err : null; +} + +export interface ChatInvocationOptions { + text: string; + /** Per-connection trace id (hex), reused as clientCorrelationId/traceId. */ + traceId: string; + /** Per-session id (GUID). */ + sessionId: string; + /** Whether this is the first turn of the conversation. */ + isStartOfSession?: boolean; + /** Tier-specific option flags; left empty by default (tuned during live validation). */ + optionsSets?: string[]; + tone?: string; +} + +/** + * Build the `type:4` chat invocation frame body (not yet `\x1e`-terminated). + * Mirrors the argument shape captured on the individual M365 path in #4042. + */ +export function buildChatInvocation(opts: ChatInvocationOptions): Record { + return { + type: 4, + target: "chat", + invocationId: "0", + arguments: [ + { + source: "officeweb", + clientCorrelationId: opts.traceId, + sessionId: opts.sessionId, + optionsSets: opts.optionsSets ?? [], + streamingMode: "ConciseWithPadding", + spokenTextMode: "None", + options: {}, + extraExtensionParameters: {}, + allowedMessageTypes: [...ALLOWED_MESSAGE_TYPES], + sliceIds: [], + threadLevelGptId: {}, + traceId: opts.traceId, + isStartOfSession: opts.isStartOfSession ?? true, + clientInfo: {}, + message: { + author: "user", + inputMethod: "Keyboard", + text: opts.text, + messageType: "Chat", + }, + plugins: [], + isSbsSupported: false, + tone: opts.tone ?? "", + renderReferencesBehindEOS: true, + disconnectBehavior: "", + }, + ], + }; +} + +/** True when the frame is a SignalR invocation/streamItem (`type:1`) update. */ +export function isUpdateFrame(frame: Record | null): boolean { + return !!frame && frame.type === 1 && frame.target === "update"; +} + +/** True when the frame is the SignalR completion (`type:3`) for the chat invocation. */ +export function isCompletionFrame(frame: Record | null): boolean { + return !!frame && frame.type === 3; +} + +/** True when an update frame is flagged as the last update of the turn. */ +export function isLastUpdate(frame: Record | null): boolean { + if (!isUpdateFrame(frame)) return false; + const args = (frame as Record).arguments; + const first = Array.isArray(args) ? (args[0] as Record | undefined) : undefined; + return first?.isLastUpdate === true; +} + +/** + * Extract the accumulated bot text from a `type:1` update frame, reading the last + * bot-authored message's `.text`. Returns null when the frame carries no bot text + * (Progress/Suggestion/ReferencesListComplete updates, throttling-only frames, etc.). + */ +export function extractBotText(frame: Record | null): string | null { + if (!isUpdateFrame(frame)) return null; + const args = (frame as Record).arguments; + const first = Array.isArray(args) ? (args[0] as Record | undefined) : undefined; + const messages = first?.messages; + if (!Array.isArray(messages)) return null; + // Prefer the last bot-authored message with non-empty text. + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] as Record | undefined; + if (!m) continue; + const author = m.author; + const text = m.text; + if ((author === "bot" || author === undefined) && typeof text === "string" && text.length > 0) { + return text; + } + } + return null; +} + +/** + * BizChat update frames carry the FULL accumulated answer each time, not an + * incremental delta. Given the previously-emitted text and the new accumulated + * text, return the new suffix to stream. When the new text does not extend the + * previous (a replace/rewrite), the whole new text is returned so nothing is lost. + */ +export function incrementalDelta(previous: string, next: string): string { + if (!next) return ""; + if (next === previous) return ""; + if (next.startsWith(previous)) return next.slice(previous.length); + return next; +} diff --git a/open-sse/executors/deepseek-web.ts b/open-sse/executors/deepseek-web.ts index 242973924f..4e2546dce1 100644 --- a/open-sse/executors/deepseek-web.ts +++ b/open-sse/executors/deepseek-web.ts @@ -1,10 +1,11 @@ import { BaseExecutor, type ExecuteInput } from "./base.ts"; import { solveDeepSeekPowAsync } from "../lib/deepseek-pow.ts"; +import { type OpenAIToolCall } from "../translator/webTools.ts"; import { - serializeToolsToPrompt, - parseToolCallsFromText, - type OpenAIToolCall, -} from "../translator/webTools.ts"; + serializeDeepSeekToolPrompt, + parseDeepSeekToolCalls, + buildToolConversationPrompt, +} from "../translator/deepseekWebTools.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; export const DEEPSEEK_WEB_BASE = "https://chat.deepseek.com"; @@ -196,170 +197,170 @@ function transformSSE(deepseekStream: ReadableStream, model: string): ReadableSt return new ReadableStream( { - async start(controller) { - const reader = deepseekStream.getReader(); - let buffer = ""; + async start(controller) { + const reader = deepseekStream.getReader(); + let buffer = ""; - const emit = (obj: object) => { - controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`)); - }; + const emit = (obj: object) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`)); + }; - const chunk = (delta: object, finish?: string) => { - emit({ - id, - object: "chat.completion.chunk", - created, - model: streamModel, - choices: [{ index: 0, delta, finish_reason: finish ?? null }], - }); - }; + const chunk = (delta: object, finish?: string) => { + emit({ + id, + object: "chat.completion.chunk", + created, + model: streamModel, + choices: [{ index: 0, delta, finish_reason: finish ?? null }], + }); + }; - const ensureRole = () => { - if (!emittedRole) { - emittedRole = true; - chunk({ role: "assistant", content: "" }); - } - }; + const ensureRole = () => { + if (!emittedRole) { + emittedRole = true; + chunk({ role: "assistant", content: "" }); + } + }; - const finishStream = () => { - const citations = appendSearchCitations(searchResults, streamModel); - if (citations) { + const finishStream = () => { + const citations = appendSearchCitations(searchResults, streamModel); + if (citations) { + ensureRole(); + chunk({ content: `\n\n${citations}` }); + } ensureRole(); - chunk({ content: `\n\n${citations}` }); - } - ensureRole(); - chunk({}, "stop"); - controller.enqueue(encoder.encode("data: [DONE]\n\n")); - controller.close(); - }; + chunk({}, "stop"); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }; - const sendByPath = (raw: string) => { - const text = formatStreamContent(raw, streamModel); - if (!text) return; - ensureRole(); - let path = currentPath; - if (!path && thinkingModel) path = "thinking"; - else if (!path && isSearchModel(streamModel)) path = "content"; - if (path === "thinking") { - chunk({ reasoning_content: text }); - } else { - chunk({ content: text }); - } - }; + const sendByPath = (raw: string) => { + const text = formatStreamContent(raw, streamModel); + if (!text) return; + ensureRole(); + let path = currentPath; + if (!path && thinkingModel) path = "thinking"; + else if (!path && isSearchModel(streamModel)) path = "content"; + if (path === "thinking") { + chunk({ reasoning_content: text }); + } else { + chunk({ content: text }); + } + }; - const applyFragmentType = (frag: any) => { - const type = String(frag?.type || "").toUpperCase(); - if (type === "THINK") currentPath = "thinking"; - else if (type === "ANSWER" || type === "RESPONSE") currentPath = "content"; - }; - - const handleFragment = (frag: any, setPathFromType = false) => { - if (setPathFromType) applyFragmentType(frag); - if (typeof frag?.content !== "string" || frag.content.length === 0) return; - if (!setPathFromType) { + const applyFragmentType = (frag: any) => { const type = String(frag?.type || "").toUpperCase(); if (type === "THINK") currentPath = "thinking"; else if (type === "ANSWER" || type === "RESPONSE") currentPath = "content"; - } - sendByPath(frag.content); - }; + }; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; + const handleFragment = (frag: any, setPathFromType = false) => { + if (setPathFromType) applyFragmentType(frag); + if (typeof frag?.content !== "string" || frag.content.length === 0) return; + if (!setPathFromType) { + const type = String(frag?.type || "").toUpperCase(); + if (type === "THINK") currentPath = "thinking"; + else if (type === "ANSWER" || type === "RESPONSE") currentPath = "content"; + } + sendByPath(frag.content); + }; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; - for (const line of lines) { - if (!line.startsWith("data: ") && !line.startsWith("data:")) continue; - const payload = line.replace(/^data:\s*/, "").trim(); + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; - if (payload === "[DONE]") { - finishStream(); - return; - } + for (const line of lines) { + if (!line.startsWith("data: ") && !line.startsWith("data:")) continue; + const payload = line.replace(/^data:\s*/, "").trim(); - let data: Record; - try { - data = JSON.parse(payload); - } catch { - continue; - } - - const p = (data as any)?.p; - const o = (data as any)?.o; - const v = (data as any)?.v; - - if (v && typeof v === "object" && v.response) { - if (v.response.thinking_enabled === true) currentPath = "thinking"; - else if (v.response.thinking_enabled === false) currentPath = "content"; - const fragments = v.response.fragments; - if (Array.isArray(fragments)) { - for (const frag of fragments) handleFragment(frag, false); + if (payload === "[DONE]") { + finishStream(); + return; } - } - if (p === "response/fragments") { - if (Array.isArray(v)) { - for (const frag of v) handleFragment(frag, true); - } else if (v && typeof v === "object") { - handleFragment(v, true); + let data: Record; + try { + data = JSON.parse(payload); + } catch { + continue; } - } - if (p === "response" && Array.isArray(v)) { - for (const entry of v) { - if (entry?.p === "response" && entry?.v?.thinking_enabled === true) { - currentPath = "thinking"; + const p = (data as any)?.p; + const o = (data as any)?.o; + const v = (data as any)?.v; + + if (v && typeof v === "object" && v.response) { + if (v.response.thinking_enabled === true) currentPath = "thinking"; + else if (v.response.thinking_enabled === false) currentPath = "content"; + const fragments = v.response.fragments; + if (Array.isArray(fragments)) { + for (const frag of fragments) handleFragment(frag, false); } } - } - if (p === "response/search_status") continue; + if (p === "response/fragments") { + if (Array.isArray(v)) { + for (const frag of v) handleFragment(frag, true); + } else if (v && typeof v === "object") { + handleFragment(v, true); + } + } - if (p === "response/search_results" && Array.isArray(v)) { - if (o !== "BATCH") { - searchResults.length = 0; - searchResults.push(...v); - } else { - for (const op of v) { - const match = String(op?.p || "").match(/^(\d+)\/cite_index$/); - if (match) { - const index = parseInt(match[1], 10); - if (searchResults[index]) searchResults[index].cite_index = op.v; + if (p === "response" && Array.isArray(v)) { + for (const entry of v) { + if (entry?.p === "response" && entry?.v?.thinking_enabled === true) { + currentPath = "thinking"; } } } - continue; - } - if (typeof v === "string") { - sendByPath(v); - } else if (Array.isArray(v) && p === "response") { - for (const entry of v) { - if (Array.isArray(entry?.v)) { - const joined = entry.v.map((item: any) => item?.content || "").join(""); - if (joined) sendByPath(joined); + if (p === "response/search_status") continue; + + if (p === "response/search_results" && Array.isArray(v)) { + if (o !== "BATCH") { + searchResults.length = 0; + searchResults.push(...v); + } else { + for (const op of v) { + const match = String(op?.p || "").match(/^(\d+)\/cite_index$/); + if (match) { + const index = parseInt(match[1], 10); + if (searchResults[index]) searchResults[index].cite_index = op.v; + } + } + } + continue; + } + + if (typeof v === "string") { + sendByPath(v); + } else if (Array.isArray(v) && p === "response") { + for (const entry of v) { + if (Array.isArray(entry?.v)) { + const joined = entry.v.map((item: any) => item?.content || "").join(""); + if (joined) sendByPath(joined); + } } } - } - // Do not close on FINISHED — DeepSeek may still send search_results afterward. - if (p === "response/status" && v === "FINISHED") { - continue; + // Do not close on FINISHED — DeepSeek may still send search_results afterward. + if (p === "response/status" && v === "FINISHED") { + continue; + } } } + } catch (err) { + controller.error(err); + return; } - } catch (err) { - controller.error(err); - return; - } - finishStream(); - }, + finishStream(); + }, }, { highWaterMark: 16384 } ); @@ -738,6 +739,10 @@ function buildToolAwareResult(opts: { const sse = new ReadableStream({ start(controller) { emit(controller, { role: "assistant", content: "" }, null); + // Surrounding natural-language text (and reasoning) is emitted before the tool_calls + // so a model reply that interleaves a plan with a call still reaches the client (#7). + if (reasoningContent) emit(controller, { reasoning_content: reasoningContent }, null); + if (content) emit(controller, { content }, null); if (hasCalls) { emit( controller, @@ -751,8 +756,6 @@ function buildToolAwareResult(opts: { }, null ); - } else if (content) { - emit(controller, { content }, null); } emit(controller, {}, finishReason); controller.enqueue(encoder.encode("data: [DONE]\n\n")); @@ -826,7 +829,7 @@ export class DeepSeekWebExecutor extends BaseExecutor { // back into OpenAI tool_calls on the way out. const requestedTools = bodyObj.tools; const hasTools = Array.isArray(requestedTools) && requestedTools.length > 0; - const toolSystemPrompt = hasTools ? serializeToolsToPrompt(requestedTools) : ""; + const toolSystemPrompt = hasTools ? serializeDeepSeekToolPrompt(requestedTools) : ""; const messages = (Array.isArray(bodyObj.messages) ? bodyObj.messages : []) as Array<{ role: string; @@ -868,7 +871,12 @@ export class DeepSeekWebExecutor extends BaseExecutor { const accessToken = await acquireAccessToken(userToken, signal, log); log?.info?.("DEEPSEEK-WEB", `Token acquired in ${Date.now() - t0}ms`); - const prompt = messagesToPrompt(promptMessages, historyWindow); + // Tool (agentic) requests replay the whole trajectory — prior tool calls and their + // results — so the model keeps context across turns instead of restarting each time. + // Plain chat keeps the legacy last-user-message / rolling-window behavior. + const prompt = hasTools + ? buildToolConversationPrompt(messages, toolSystemPrompt) + : messagesToPrompt(promptMessages, historyWindow); const refFileIds = Array.isArray(bodyObj.ref_file_ids) ? bodyObj.ref_file_ids : []; log?.info?.( "DEEPSEEK-WEB", @@ -1030,7 +1038,7 @@ export class DeepSeekWebExecutor extends BaseExecutor { if (hasTools) { const { content, reasoningContent } = await collectSSEContent(resp.body!, clientModel); await cleanupFn(); - const { content: cleanedContent, toolCalls } = parseToolCallsFromText( + const { content: cleanedContent, toolCalls } = parseDeepSeekToolCalls( content, `call-${Date.now()}`, requestedTools diff --git a/open-sse/executors/firecrawl-fetch.ts b/open-sse/executors/firecrawl-fetch.ts index a1938cd71c..c8ae752466 100644 --- a/open-sse/executors/firecrawl-fetch.ts +++ b/open-sse/executors/firecrawl-fetch.ts @@ -55,9 +55,12 @@ export async function firecrawlFetch(opts: FirecrawlScrapeOptions): Promise 0) { requestBody.maxDepth = depth; diff --git a/open-sse/executors/github.ts b/open-sse/executors/github.ts index 6fcdd2e18c..eadc46d90e 100644 --- a/open-sse/executors/github.ts +++ b/open-sse/executors/github.ts @@ -99,6 +99,20 @@ export class GithubExecutor extends BaseExecutor { modifiedBody.tools = modifiedBody.tools.slice(0, 128); } + // GitHub Copilot's gpt-5.4 family rejects requests carrying `temperature` with HTTP 400: + // "Unsupported parameter: 'temperature' is not supported with this model." + // OmniRoute's existing `stripGpt5SamplingWhenReasoning` guard only fires for + // provider==="openai" (raw api.openai.com Chat Completions), so GitHub Copilot routes + // never hit it. Strip temperature here unconditionally for gpt-5.4 so the 400 cannot + // reach the user. Port from 9router#612 (closes upstream #536). + if ( + typeof model === "string" && + /gpt-5\.4/i.test(model) && + modifiedBody.temperature !== undefined + ) { + delete modifiedBody.temperature; + } + // GitHub Copilot /chat/completions only accepts {type:'text'} or {type:'image_url'} // content parts. Clients like Cursor IDE pass through Anthropic-shape parts // (tool_use, tool_result, thinking) untouched when using Claude models, which makes diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 62f833f6ab..cc13453ec9 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -134,7 +134,6 @@ const executors = { "v0-vercel-web": new V0VercelWebExecutor(), v0: new V0VercelWebExecutor(), // Alias "kimi-web": new KimiWebExecutor(), - kimi: new KimiWebExecutor(), // Alias "kimi-coding-apikey": new KimiExecutor(), // Alias "kimi-coding": new KimiExecutor(), // Alias "doubao-web": new DoubaoWebExecutor(), diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index 0088d2a4e9..af0cf431b3 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -79,7 +79,9 @@ export class OpencodeExecutor extends BaseExecutor { // Forward OpenCode request metadata headers from client const findClientHeader = (name: string) => - Object.entries(clientHeaders).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1]; + Object.entries(clientHeaders).find( + ([key]) => key.toLowerCase() === name.toLowerCase() + )?.[1]; const opencodeHeaderKeys = [ "x-opencode-session", @@ -145,6 +147,21 @@ export class OpencodeExecutor extends BaseExecutor { ) { modifiedBody.tools = modifiedBody.tools.slice(0, 128); } + if (modifiedBody && typeof modifiedBody === "object" && !Array.isArray(modifiedBody)) { + const mb = modifiedBody as Record; + const m = String(model || ""); + const effortLevels = ["low", "medium", "high", "max"] as const; + const matchedLevel = effortLevels.find((level) => m.endsWith(`-${level}`)); + if (matchedLevel) { + const base = m.slice(0, -matchedLevel.length - 1); + if (base.toLowerCase() === "deepseek-v4-pro") { + mb.model = "deepseek-v4-pro"; + if (mb.reasoning_effort === undefined) { + mb.reasoning_effort = matchedLevel; + } + } + } + } return modifiedBody; } } diff --git a/open-sse/handlers/audioSpeech.ts b/open-sse/handlers/audioSpeech.ts index 1034349016..9bb280248c 100644 --- a/open-sse/handlers/audioSpeech.ts +++ b/open-sse/handlers/audioSpeech.ts @@ -771,6 +771,98 @@ async function handleXiaomiMimoSpeech(providerConfig, body, modelId, token, cred }); } +/** + * MiniMax T2A v2 — POST returns hex-encoded audio in a JSON envelope guarded by + * `base_resp.status_code` (0 = success). + * Port of decolua/9router#1043 by toanalien . + */ +function hexToBytes(audioHex) { + const clean = typeof audioHex === "string" ? audioHex.trim() : ""; + if (!clean) throw new Error("MiniMax TTS returned no audio"); + if (clean.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(clean)) { + throw new Error("MiniMax TTS returned invalid audio"); + } + const len = clean.length / 2; + const out = new Uint8Array(len); + for (let i = 0; i < len; i++) { + out[i] = parseInt(clean.substr(i * 2, 2), 16); + } + return out; +} + +async function handleMinimaxSpeech(providerConfig, body, modelId, token) { + const voiceId = + (typeof body.voice === "string" && body.voice) || "English_expressive_narrator"; + const res = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...buildAuthHeaders(providerConfig, token), + }, + body: JSON.stringify({ + model: modelId || "speech-2.8-hd", + text: body.input, + stream: false, + language_boost: "auto", + output_format: "hex", + voice_setting: { + voice_id: voiceId, + speed: typeof body.speed === "number" ? body.speed : 1, + vol: 1, + pitch: 0, + }, + audio_setting: { + sample_rate: 32000, + bitrate: 128000, + format: "mp3", + channel: 1, + }, + }), + }); + + const rawText = await res.text(); + let data: Record = {}; + if (rawText) { + try { + const parsed = JSON.parse(rawText); + if (parsed && typeof parsed === "object") data = parsed as Record; + } catch { + data = {}; + } + } + + if (!res.ok) { + return upstreamErrorResponse(res, rawText); + } + + const baseResp = + ((data.base_resp || data.baseResp) as Record | undefined) || {}; + const statusCode = Number(baseResp.status_code ?? baseResp.statusCode ?? 0); + const statusMessage = String( + baseResp.status_msg || baseResp.statusMsg || data.message || "" + ); + if (statusCode !== 0) { + return errorResponse(502, `MiniMax TTS: ${statusMessage || "upstream error"}`); + } + + const audioField = (data.data as Record | undefined)?.audio; + let bytes: Uint8Array; + try { + bytes = hexToBytes(audioField); + } catch (err) { + const msg = err instanceof Error ? err.message : "invalid audio"; + return errorResponse(502, `MiniMax TTS: ${msg}`); + } + + return new Response(bytes, { + status: 200, + headers: { + ...CORS_HEADERS, + "Content-Type": "audio/mpeg", + }, + }); +} + /** * Handle Coqui TTS (local, no auth) * POST {baseUrl} with { text, speaker_id } → WAV audio @@ -929,6 +1021,10 @@ export async function handleAudioSpeech({ return handleXiaomiMimoSpeech(providerConfig, body, modelId, token, credentials); } + if (providerConfig.format === "minimax-tts") { + return handleMinimaxSpeech(providerConfig, body, modelId, token); + } + if (providerConfig.format === "coqui") { return handleCoquiSpeech(providerConfig, body); } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index fafa02fc8b..6268c77d6f 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -7,7 +7,11 @@ import { checkIdempotencyCache } from "./chatCore/idempotency.ts"; import { checkSemanticCache } from "./chatCore/semanticCache.ts"; import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts"; import { cloneBoundedChatLogPayload, truncateForLog } from "./chatCore/logTruncation.ts"; -import { getHeaderValueCaseInsensitive, isNoMemoryRequested } from "./chatCore/headers.ts"; +import { + getHeaderValueCaseInsensitive, + isNoMemoryRequested, + resolveCompressionHeader, +} from "./chatCore/headers.ts"; import { markCodexScopeRateLimited } from "./chatCore/codexFailover.ts"; import { getCombosCached, getUpstreamProxyConfigCached } from "./chatCore/comboContextCache.ts"; export { clearCombosCache, clearUpstreamProxyConfigCache } from "./chatCore/comboContextCache.ts"; @@ -163,10 +167,7 @@ import { normalizeExecutorResult, executeWithUpstreamStartTimeout, } from "./chatCore/upstreamTimeouts.ts"; -import { - getModelNormalizeToolCallId, - getModelPreserveOpenAIDeveloperRole, -} from "@/lib/localDb"; +import { getModelNormalizeToolCallId, getModelPreserveOpenAIDeveloperRole } from "@/lib/localDb"; import { getProviderCredentials, extractSessionAffinityKey } from "@/sse/services/auth"; import { deleteSessionAccountAffinity } from "@/lib/db/sessionAccountAffinity"; import { getExecutor } from "../executors/index.ts"; @@ -903,9 +904,7 @@ export async function handleChatCore({ // field instead of the upstream model, so strict clients (Claude Desktop) that validate // response.model === request.model stop rejecting alias/combo requests with a 401. const echoModel = - settings.echoRequestedModelName === true && - typeof requestedModel === "string" && - requestedModel + settings.echoRequestedModelName === true && typeof requestedModel === "string" && requestedModel ? requestedModel : null; const detailedLoggingEnabled = @@ -1203,6 +1202,7 @@ export async function handleChatCore({ let cavemanOutputModeApplied = false; let cavemanOutputModeIntensity: string | null = null; let preCompressionBody: typeof body | null = null; + let compressionResponseMeta: string | null = null; // Delegated Context Editing (Claude only): captured at the canonical compression // settings read below, then threaded to executor.execute() further down. Lives at // function scope because the read happens inside the per-message compression block. @@ -1234,6 +1234,8 @@ export async function handleChatCore({ activeComboResolves, applyCompressionAsync, resolveCacheAwareConfig, + formatCompressionMeta, + buildNamedComboLookup, } = await import("../services/compression/strategySelector.ts"); const { trackCompressionStats } = await import("../services/compression/stats.ts"); let config: CompressionConfig = compressionSettings ?? { @@ -1404,20 +1406,26 @@ export async function handleChatCore({ let namedCombos: Record = {}; try { const { listCompressionCombos } = await import("../../src/lib/db/compressionCombos.ts"); - namedCombos = Object.fromEntries(listCompressionCombos().map((c) => [c.id, c.pipeline])); + namedCombos = buildNamedComboLookup(listCompressionCombos()); } catch (err) { log?.debug?.( "COMPRESSION", "Named combos load skipped: " + (err instanceof Error ? err.message : String(err)) ); } + // Phase 3: per-request override. Unknown values fall through in the resolver (never error). + const compressionHeader = resolveCompressionHeader(clientRawRequest?.headers ?? null); + if (compressionHeader) { + log?.debug?.("COMPRESSION", `x-omniroute-compression header: ${compressionHeader}`); + } const modeBeforeOutputTransform = selectCompressionStrategy( config, compressionComboKey, estimatedTokens, body as Record, { provider, targetFormat, model: effectiveModel }, - namedCombos + namedCombos, + compressionHeader ); if ( modeBeforeOutputTransform === "stacked" && @@ -1486,9 +1494,11 @@ export async function handleChatCore({ estimatedTokens, compressionInputBody, { provider, targetFormat, model: effectiveModel }, - namedCombos + namedCombos, + compressionHeader ); const mode = compressionPlan.mode as CompressionConfig["defaultMode"]; + compressionResponseMeta = formatCompressionMeta(compressionPlan); // When the per-engine toggle map derives a stacked pipeline (and no named/routing // combo already set config.stackedPipeline), feed that derived pipeline through so // applyCompressionAsync (which reads config.stackedPipeline for stacked mode) runs the @@ -3320,17 +3330,20 @@ export async function handleChatCore({ ? 499 : error.name === "TimeoutError" || error.name === "BodyTimeoutError" ? HTTP_STATUS.GATEWAY_TIMEOUT - : (error.status && typeof error.status === "number") ? error.status - : HTTP_STATUS.BAD_GATEWAY; + : error.status && typeof error.status === "number" + ? error.status + : HTTP_STATUS.BAD_GATEWAY; const failureMessage = error.name === "AbortError" ? "Request aborted" : formatProviderError(error, provider, model, failureStatus); const upstreamErrorCode = getUpstreamErrorIdentifier(error); const upstreamErrorType = - upstreamErrorCode === ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE ? "upstream_timeout" - : failureStatus === 401 ? "authentication_error" - : undefined; + upstreamErrorCode === ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE + ? "upstream_timeout" + : failureStatus === 401 + ? "authentication_error" + : undefined; appendRequestLog({ model, provider, @@ -4462,6 +4475,9 @@ export async function handleChatCore({ costUsd: estimatedCost, requestId: skillRequestId, }); + if (compressionResponseMeta) { + responseHeaders[OMNIROUTE_RESPONSE_HEADERS.compression] = compressionResponseMeta; + } // #1311: echo the requested alias/combo name in the non-streaming response model. if (echoModel) echoModelInObject(translatedResponse, echoModel); return { @@ -4587,6 +4603,9 @@ export async function handleChatCore({ }), "x-omniroute-request-id": pendingRequestId, }; + if (compressionResponseMeta) { + responseHeaders[OMNIROUTE_RESPONSE_HEADERS.compression] = compressionResponseMeta; + } // Create transform stream with logger for streaming response let transformStream; diff --git a/open-sse/handlers/chatCore/headers.ts b/open-sse/handlers/chatCore/headers.ts index cecaea5b0f..913898ad3b 100644 --- a/open-sse/handlers/chatCore/headers.ts +++ b/open-sse/handlers/chatCore/headers.ts @@ -31,3 +31,16 @@ export function isNoMemoryRequested( .toLowerCase(); return value === "true" || value === "1" || value === "yes"; } + +/** + * Per-request compression override via the `x-omniroute-compression` header. Mirrors the + * `x-omniroute-no-memory` convention (#4290). Returns the raw trimmed value, or null when + * absent/blank. The resolver (planFromHeader) owns interpretation and casing rules; this + * helper only reads the wire. + */ +export function resolveCompressionHeader( + headers: Record | Headers | null | undefined +): string | null { + const value = (getHeaderValueCaseInsensitive(headers, "x-omniroute-compression") || "").trim(); + return value || null; +} diff --git a/open-sse/handlers/chatCore/telemetryHelpers.ts b/open-sse/handlers/chatCore/telemetryHelpers.ts index 263029cf10..c6b92e683c 100644 --- a/open-sse/handlers/chatCore/telemetryHelpers.ts +++ b/open-sse/handlers/chatCore/telemetryHelpers.ts @@ -1,22 +1,54 @@ import { fetchLiveProviderLimits } from "@/lib/usage/providerLimits"; import { isClaudeExtraUsageBlockEnabled } from "@/lib/providers/claudeExtraUsage"; +// #4604 — Lazy backoff for the best-effort live-WS sidecar bridge. In single-port +// deployments the sidecar (port 20129) is not running, so every compression event +// POST failed with ECONNREFUSED; because the global fetch is proxyFetch, each +// failure logged a "[ProxyFetch] Undici dispatcher failed" warning (272× in 42min). +// After a few consecutive failures we stop attempting for a cooldown window (then +// probe once), mirroring the project's lazy circuit-breaker recovery. A success +// clears the backoff so a sidecar that comes up later is picked back up. +const LIVE_WS_MAX_CONSECUTIVE_FAILURES = 3; +const LIVE_WS_DISABLE_MS = 60_000; +let liveWsConsecutiveFailures = 0; +let liveWsDisabledUntil = 0; + +/** Test-only: reset the live-WS forwarding backoff state. */ +export function __resetLiveWsForwardingState(): void { + liveWsConsecutiveFailures = 0; + liveWsDisabledUntil = 0; +} + export async function forwardDashboardEventToLiveWs( event: string, - payload: unknown + payload: unknown, + fetchImpl: typeof fetch = fetch, + now: () => number = Date.now ): Promise { + // Skip while the bridge is in a cooldown window after repeated failures. + if (liveWsDisabledUntil > now()) return; + const port = process.env.LIVE_WS_PORT || "20129"; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 1_500); try { - await fetch(`http://127.0.0.1:${port}/__omniroute_event`, { + await fetchImpl(`http://127.0.0.1:${port}/__omniroute_event`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ event, payload, timestamp: Date.now() }), + body: JSON.stringify({ event, payload, timestamp: now() }), signal: controller.signal, }); + // Success → the sidecar is reachable; clear any accumulated backoff. + liveWsConsecutiveFailures = 0; + liveWsDisabledUntil = 0; } catch { - // Best-effort sidecar bridge; do not affect the chat hot path. + // Best-effort sidecar bridge; do not affect the chat hot path. Trip the + // cooldown once failures pile up so a missing sidecar stops spamming logs. + liveWsConsecutiveFailures += 1; + if (liveWsConsecutiveFailures >= LIVE_WS_MAX_CONSECUTIVE_FAILURES) { + liveWsDisabledUntil = now() + LIVE_WS_DISABLE_MS; + liveWsConsecutiveFailures = 0; + } } finally { clearTimeout(timeout); } diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 3b2da49eaa..1e63e105bd 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -44,6 +44,24 @@ import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; import { FetchTimeoutError, fetchWithTimeout, getConfiguredTimeout } from "@/shared/utils/fetchTimeout"; import { sanitizeErrorMessage, sanitizeUpstreamDetails } from "../utils/error.ts"; +// --- Per-provider handlers (extracted to co-located files in PR-#4582-batch) --- +// Imported locally so internal callers (handleImageGeneration / handleImageEdit) +// resolve to a real binding. extractMarkdownImageUrls + CHATGPT_WEB_IMAGE_ID_RE +// are still used by handleImageEdit below, so they are imported (not re-defined). +import { handleSDWebUIImageGeneration } from "./imageGeneration/providers/sdWebUI.ts"; +import { handleHyperbolicImageGeneration } from "./imageGeneration/providers/hyperbolic.ts"; +import { handleComfyUIImageGeneration } from "./imageGeneration/providers/comfyUI.ts"; +import { handleImagen3ImageGeneration } from "./imageGeneration/providers/imagen3.ts"; +import { handleIdeogramImageGeneration } from "./imageGeneration/providers/ideogram.ts"; +import { handleHaiperImageGeneration } from "./imageGeneration/providers/haiper.ts"; +import { handleLeonardoImageGeneration } from "./imageGeneration/providers/leonardo.ts"; +import { + handleChatGptWebImageGeneration, + extractMarkdownImageUrls, + CHATGPT_WEB_IMAGE_ID_RE, +} from "./imageGeneration/providers/chatgptWeb.ts"; + + interface KieImageOptions { model: string; provider: string; @@ -1096,190 +1114,6 @@ export async function handleOpenAIImageEdit({ return result; } -const CHATGPT_WEB_IMAGE_MARKDOWN_RE = /!\[[^\]]*\]\(([^)\s]+)\)/g; -const CHATGPT_WEB_IMAGE_ID_RE = /\/v1\/chatgpt-web\/image\/([a-f0-9]{16,64})(?=[?\s"'<>)]|$)/i; - -function extractMarkdownImageUrls(text: string): string[] { - const urls: string[] = []; - // String.prototype.matchAll consumes a fresh iterator and ignores the - // regex's lastIndex, so no manual reset is required. - for (const match of text.matchAll(CHATGPT_WEB_IMAGE_MARKDOWN_RE)) { - if (match[1]) urls.push(match[1]); - } - return urls; -} - -function buildChatGptWebImagePrompt(body): string { - const prompt = String(body.prompt || "").trim(); - const details: string[] = [`Create an image for this prompt: ${prompt}`]; - if (typeof body.size === "string" && body.size.trim()) { - details.push(`Requested size: ${body.size.trim()}.`); - } - if (typeof body.quality === "string" && body.quality.trim()) { - details.push(`Requested quality: ${body.quality.trim()}.`); - } - if (typeof body.style === "string" && body.style.trim()) { - details.push(`Requested style: ${body.style.trim()}.`); - } - return details.join("\n"); -} - -async function handleChatGptWebImageGeneration({ - model, - provider, - body, - credentials, - log, - signal, - clientHeaders, -}) { - const startTime = Date.now(); - const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; - if (!prompt) { - return saveImageErrorResult({ - provider, - model, - status: 400, - startTime, - error: "Prompt is required for ChatGPT Web image generation", - }); - } - - if (!credentials?.apiKey) { - return saveImageErrorResult({ - provider, - model, - status: 401, - startTime, - error: "ChatGPT Web credentials missing session cookie", - }); - } - - // Each image is one chatgpt.com chat turn (~30s). Cap at 4 (matches OpenAI's - // own limit for GPT Image models) so a stray n=1000 doesn't pin the - // executor for hours before the upstream HTTP timeout fires. - const CHATGPT_WEB_IMAGE_N_MAX = 4; - const rawCount = Number.isInteger(body.n) && (body.n as number) > 0 ? (body.n as number) : 1; - if (rawCount > CHATGPT_WEB_IMAGE_N_MAX) { - return saveImageErrorResult({ - provider, - model, - status: 400, - startTime, - error: `ChatGPT Web image generation supports n=1..${CHATGPT_WEB_IMAGE_N_MAX} (got ${rawCount}); each n is a separate ~30s chat turn.`, - }); - } - const requestedCount = rawCount; - if (log && requestedCount > 1) { - log.warn( - "IMAGE", - `ChatGPT Web returns one image per chat turn; requested n=${requestedCount} will run sequentially` - ); - } - - const wantsBase64 = body.response_format === "b64_json"; - const images: Array<{ url?: string; b64_json?: string }> = []; - const requestBody = { - model, - prompt: prompt.slice(0, 500), - size: body.size || undefined, - quality: body.quality || undefined, - }; - - for (let i = 0; i < requestedCount; i++) { - const executor = new ChatGptWebExecutor(); - const result = await executor.execute({ - model, - body: { - messages: [{ role: "user", content: buildChatGptWebImagePrompt(body) }], - }, - stream: false, - credentials, - signal, - log, - clientHeaders, - }); - - const responseText = await result.response.text(); - if (result.response.status >= 400) { - return saveImageErrorResult({ - provider, - model, - status: result.response.status, - startTime, - error: responseText, - requestBody, - }); - } - - let content = ""; - try { - const json = JSON.parse(responseText); - content = String(json?.choices?.[0]?.message?.content || ""); - } catch { - content = responseText; - } - - const urls = extractMarkdownImageUrls(content); - if (urls.length === 0) { - return saveImageErrorResult({ - provider, - model, - status: 502, - startTime, - error: `ChatGPT Web completed without returning image markdown: ${content.slice(0, 300)}`, - requestBody, - }); - } - - for (const url of urls) { - if (!wantsBase64) { - images.push({ url }); - continue; - } - const id = url.match(CHATGPT_WEB_IMAGE_ID_RE)?.[1]; - const cached = id ? getChatGptImage(id) : null; - if (!cached) { - return saveImageErrorResult({ - provider, - model, - status: 502, - startTime, - error: "ChatGPT Web image bytes expired before b64_json conversion", - requestBody, - }); - } - images.push({ b64_json: cached.bytes.toString("base64") }); - } - } - - return saveImageSuccessResult({ - provider, - model, - startTime, - requestBody, - responseBody: { images_count: images.length }, - images, - }); -} - -/** - * Handle a multipart /v1/images/edits request for chatgpt-web. Open WebUI - * uploads the prior image's bytes; we hash them and look up our cache. - * - * The hash match is reliable because Open WebUI's image-gen pipeline - * downloads our /v1/chatgpt-web/image/ URL byte-for-byte and re-serves - * those exact bytes through its own file store. When the user asks to edit - * the image, OWUI uploads the same bytes back to us via multipart — same - * hash, we find the conversation context, and drive the executor with a - * synthetic chat thread that triggers continuation mode. - * - * No-match cases (cache evicted by TTL, or the user uploaded a foreign - * image) get a clear 400. We can't actually edit an image we don't have a - * conversation context for — chatgpt.com's image_gen tool needs the - * original conversation node, and we don't have a path to upload bytes - * directly. - */ export async function handleImageEdit({ provider, model, @@ -2538,7 +2372,7 @@ async function handleCodexImageGeneration({ }); } -function saveImageSuccessResult({ +export function saveImageSuccessResult({ provider, model, startTime, @@ -2567,7 +2401,7 @@ function saveImageSuccessResult({ }; } -function saveImageErrorResult({ provider, model, status, startTime, error, requestBody = null }) { +export function saveImageErrorResult({ provider, model, status, startTime, error, requestBody = null }) { saveCallLog({ method: "POST", path: "/v1/images/generations", @@ -2658,104 +2492,6 @@ async function fetchImageEndpoint(url, headers, body, provider, log) { * Handle Hyperbolic image generation * Uses { model_name, prompt, height, width } and returns { images: [{ image: base64 }] } */ -async function handleHyperbolicImageGeneration({ - model, - provider, - providerConfig, - body, - credentials, - log, -}) { - const startTime = Date.now(); - const token = credentials.apiKey || credentials.accessToken; - - const [width, height] = (body.size || "1024x1024").split("x").map(Number); - - const upstreamBody = { - model_name: model, - prompt: body.prompt, - height: height || 1024, - width: width || 1024, - backend: "auto", - }; - - if (log) { - const promptPreview = String(body.prompt ?? "").slice(0, 60); - log.info("IMAGE", `${provider}/${model} (hyperbolic) | prompt: "${promptPreview}..."`); - } - - try { - const response = await fetch(providerConfig.baseUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(upstreamBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - if (log) - log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); - - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: response.status, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText.slice(0, 500), - }).catch(() => {}); - - return { success: false, status: response.status, error: errorText }; - } - - const data = await response.json(); - // Transform { images: [{ image: base64 }] } → OpenAI format - const images = (data.images || []).map((img) => ({ - b64_json: img.image, - revised_prompt: body.prompt, - })); - - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - responseBody: { images_count: images.length }, - }).catch(() => {}); - - return { - success: true, - data: { created: Math.floor(Date.now() / 1000), data: images }, - }; - } catch (err) { - if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: err.message, - }).catch(() => {}); - return { - success: false, - status: 502, - error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, - }; - } -} - -/** - * Handle NanoBanana image generation - * NanoBanana is async (submit task -> poll status -> return final image URL/base64) - */ async function handleNanoBananaImageGeneration({ model, provider, @@ -3117,660 +2853,4 @@ function normalizePositiveNumber(value, fallback) { * Handle SD WebUI image generation (local, no auth) * POST {baseUrl} with { prompt, negative_prompt, width, height, steps } * Response: { images: ["base64..."] } - */ -async function handleSDWebUIImageGeneration({ model, provider, providerConfig, body, log }) { - const startTime = Date.now(); - const [width, height] = (body.size || "512x512").split("x").map(Number); - - const upstreamBody = { - prompt: body.prompt, - negative_prompt: body.negative_prompt || "", - width: width || 512, - height: height || 512, - steps: body.steps || 20, - cfg_scale: body.cfg_scale || 7, - sampler_name: body.sampler || "Euler a", - batch_size: body.n || 1, - override_settings: { - sd_model_checkpoint: model, - }, - }; - - if (log) { - const promptPreview = String(body.prompt ?? "").slice(0, 60); - log.info("IMAGE", `${provider}/${model} (sdwebui) | prompt: "${promptPreview}..."`); - } - - try { - const response = await fetch(providerConfig.baseUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(upstreamBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - if (log) - log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); - - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: response.status, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText.slice(0, 500), - }).catch(() => {}); - - return { success: false, status: response.status, error: errorText }; - } - - const data = await response.json(); - // SD WebUI returns { images: ["base64...", ...] } - const images = (data.images || []).map((b64) => ({ - b64_json: b64, - revised_prompt: body.prompt, - })); - - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - responseBody: { images_count: images.length }, - }).catch(() => {}); - - return { - success: true, - data: { created: Math.floor(Date.now() / 1000), data: images }, - }; - } catch (err) { - if (log) log.error("IMAGE", `${provider} sdwebui error: ${err.message}`); - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: err.message, - }).catch(() => {}); - return { - success: false, - status: 502, - error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, - }; - } -} - -/** - * Handle ComfyUI image generation (local, no auth) - * Submits a txt2img workflow, polls for completion, fetches output - */ -async function handleComfyUIImageGeneration({ model, provider, providerConfig, body, log }) { - const startTime = Date.now(); - const [width, height] = (body.size || "1024x1024").split("x").map(Number); - - // Default txt2img workflow template for ComfyUI - const workflow = { - "3": { - class_type: "KSampler", - inputs: { - seed: parseInt(randomUUID().replace(/-/g, "").substring(0, 8), 16) % 2 ** 32, - steps: body.steps || 20, - cfg: body.cfg_scale || 7, - sampler_name: "euler", - scheduler: "normal", - denoise: 1, - model: ["4", 0], - positive: ["6", 0], - negative: ["7", 0], - latent_image: ["5", 0], - }, - }, - "4": { - class_type: "CheckpointLoaderSimple", - inputs: { ckpt_name: model }, - }, - "5": { - class_type: "EmptyLatentImage", - inputs: { width: width || 1024, height: height || 1024, batch_size: body.n || 1 }, - }, - "6": { - class_type: "CLIPTextEncode", - inputs: { text: body.prompt, clip: ["4", 1] }, - }, - "7": { - class_type: "CLIPTextEncode", - inputs: { text: body.negative_prompt || "", clip: ["4", 1] }, - }, - "8": { - class_type: "VAEDecode", - inputs: { samples: ["3", 0], vae: ["4", 2] }, - }, - "9": { - class_type: "SaveImage", - inputs: { filename_prefix: "omniroute", images: ["8", 0] }, - }, - }; - - if (log) { - const promptPreview = String(body.prompt ?? "").slice(0, 60); - log.info("IMAGE", `${provider}/${model} (comfyui) | prompt: "${promptPreview}..."`); - } - - try { - const promptId = await submitComfyWorkflow(providerConfig.baseUrl, workflow); - const historyEntry = await pollComfyResult(providerConfig.baseUrl, promptId); - const outputFiles = extractComfyOutputFiles(historyEntry); - - const images = []; - for (const file of outputFiles) { - const buffer = await fetchComfyOutput( - providerConfig.baseUrl, - file.filename, - file.subfolder, - file.type - ); - const base64 = Buffer.from(buffer).toString("base64"); - images.push({ b64_json: base64, revised_prompt: body.prompt }); - } - - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - responseBody: { images_count: images.length }, - }).catch(() => {}); - - return { - success: true, - data: { created: Math.floor(Date.now() / 1000), data: images }, - }; - } catch (err) { - if (log) log.error("IMAGE", `${provider} comfyui error: ${err.message}`); - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: err.message, - }).catch(() => {}); - return { - success: false, - status: 502, - error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, - }; - } -} - -async function handleHaiperImageGeneration({ - model, - provider, - providerConfig, - body, - credentials, - log, -}) { - const startTime = Date.now(); - const token = credentials?.apiKey || ""; - const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); - if (log) { - log.info("IMAGE", `${provider}/${model} (haiper) | prompt: "${prompt.slice(0, 60)}..."`); - } - try { - const res = await fetch(providerConfig.baseUrl, { - method: "POST", - headers: { "Content-Type": "application/json", HAIPER_KEY: token }, - body: JSON.stringify({ prompt, aspect_ratio: body.aspect_ratio || "16:9" }), - }); - if (!res.ok) { - const errorText = await res.text(); - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: res.status, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText.slice(0, 500), - }).catch(() => {}); - return { success: false, status: res.status, error: errorText }; - } - const { job_id } = await res.json(); - const deadline = Date.now() + 300000; - while (Date.now() < deadline) { - await sleep(5000); - const statusRes = await fetch(`${providerConfig.statusUrl}/${job_id}`, { - headers: { HAIPER_KEY: token }, - }); - const status = await statusRes.json(); - if (status.status === "completed" || status.status === "succeeded") { - const imgUrl = status.creation_url || status.output?.image_url; - if (imgUrl) { - const imgRes = await fetch(imgUrl); - if (!imgRes.ok) { - return { - success: false, - status: imgRes.status, - error: `Failed to download image: ${imgRes.status}`, - }; - } - const buf = await imgRes.arrayBuffer(); - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - }).catch(() => {}); - return { - success: true, - data: { - created: Math.floor(Date.now() / 1000), - data: [{ b64_json: Buffer.from(buf).toString("base64") }], - }, - }; - } - } - if (status.status === "failed") { - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: "Haiper image generation failed", - }).catch(() => {}); - return { success: false, status: 502, error: "Haiper image generation failed" }; - } - } - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 504, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: "Haiper image generation timed out", - }).catch(() => {}); - return { success: false, status: 504, error: "Haiper image generation timed out" }; - } catch (err) { - if (log) log.error("IMAGE", `${provider} haiper error: ${err.message}`); - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: err.message, - }).catch(() => {}); - return { - success: false, - status: 502, - error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, - }; - } -} - -async function handleLeonardoImageGeneration({ - model, - provider, - providerConfig, - body, - credentials, - log, -}) { - const startTime = Date.now(); - const token = credentials?.apiKey || ""; - const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); - if (log) { - log.info("IMAGE", `${provider}/${model} (leonardo) | prompt: "${prompt.slice(0, 60)}..."`); - } - try { - const res = await fetch(providerConfig.baseUrl, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, - body: JSON.stringify({ - modelId: model || "phoenix", - prompt, - width: body.width || 1024, - height: body.height || 1024, - num_images: 1, - }), - }); - if (!res.ok) { - const errorText = await res.text(); - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: res.status, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText.slice(0, 500), - }).catch(() => {}); - return { success: false, status: res.status, error: errorText }; - } - const { sdGenerationJob } = await res.json(); - const genId = sdGenerationJob?.generationId; - if (!genId) { - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: "No generation ID returned", - }).catch(() => {}); - return { success: false, status: 502, error: "No generation ID returned" }; - } - const deadline = Date.now() + 300000; - while (Date.now() < deadline) { - await sleep(5000); - const statusRes = await fetch(`${providerConfig.baseUrl}/${genId}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - const status = await statusRes.json(); - const gen = status.generations_by_pk || status; - if (gen.status === "COMPLETE") { - const imgUrl = gen.generated_images?.[0]?.url; - if (imgUrl) { - const imgRes = await fetch(imgUrl); - if (!imgRes.ok) { - return { - success: false, - status: imgRes.status, - error: `Failed to download image: ${imgRes.status}`, - }; - } - const buf = await imgRes.arrayBuffer(); - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - }).catch(() => {}); - return { - success: true, - data: { - created: Math.floor(Date.now() / 1000), - data: [{ b64_json: Buffer.from(buf).toString("base64") }], - }, - }; - } - } - if (gen.status === "FAILED") { - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: "Leonardo image generation failed", - }).catch(() => {}); - return { success: false, status: 502, error: "Leonardo image generation failed" }; - } - } - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 504, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: "Leonardo image generation timed out", - }).catch(() => {}); - return { success: false, status: 504, error: "Leonardo image generation timed out" }; - } catch (err) { - if (log) log.error("IMAGE", `${provider} leonardo error: ${err.message}`); - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: err.message, - }).catch(() => {}); - return { - success: false, - status: 502, - error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, - }; - } -} - -async function handleIdeogramImageGeneration({ - model, - provider, - providerConfig, - body, - credentials, - log, -}) { - const startTime = Date.now(); - const token = credentials?.apiKey || ""; - const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); - if (log) { - log.info("IMAGE", `${provider}/${model} (ideogram) | prompt: "${prompt.slice(0, 60)}..."`); - } - try { - const res = await fetch(providerConfig.baseUrl, { - method: "POST", - headers: { "Content-Type": "application/json", "Api-Key": token }, - body: JSON.stringify({ prompt, aspect_ratio: "ASPECT_16_9", model: model || "V_3" }), - }); - if (!res.ok) { - const errorText = await res.text(); - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: res.status, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText.slice(0, 500), - }).catch(() => {}); - return { success: false, status: res.status, error: errorText }; - } - const data = await res.json(); - if (data.data && data.data.length > 0) { - const imgUrl = data.data[0].url; - const imgRes = await fetch(imgUrl); - if (!imgRes.ok) { - return { - success: false, - status: imgRes.status, - error: `Failed to download image: ${imgRes.status}`, - }; - } - const buf = await imgRes.arrayBuffer(); - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - }).catch(() => {}); - return { - success: true, - data: { - created: Math.floor(Date.now() / 1000), - data: [{ b64_json: Buffer.from(buf).toString("base64") }], - }, - }; - } - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: "No images returned from Ideogram", - }).catch(() => {}); - return { success: false, status: 502, error: "No images returned from Ideogram" }; - } catch (err) { - if (log) log.error("IMAGE", `${provider} ideogram error: ${err.message}`); - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: err.message, - }).catch(() => {}); - return { - success: false, - status: 502, - error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, - }; - } -} - -type Imagen3ImageGenArgs = { - model: string; - provider: string; - providerConfig: { baseUrl: string }; - body: { prompt?: string; size?: string; n?: number }; - credentials: { apiKey?: string; accessToken?: string }; - log?: { - info?: (tag: string, msg: string) => void; - error?: (tag: string, msg: string) => void; - } | null; -}; - -type Imagen3NormalizedImage = { - b64_json?: unknown; - url?: unknown; - revised_prompt?: string; -}; - -/** - * Handle Imagen 3 image generation - */ -async function handleImagen3ImageGeneration({ - model, - provider, - providerConfig, - body, - credentials, - log, -}: Imagen3ImageGenArgs) { - const startTime = Date.now(); - const token = credentials.apiKey || credentials.accessToken; - const aspectRatio = mapImageSize(body.size); - - const upstreamBody = { - prompt: body.prompt, - aspect_ratio: aspectRatio, - number_of_images: body.n ?? 1, - }; - - if (log) { - const promptPreview = String(body.prompt ?? "").slice(0, 60); - log.info( - "IMAGE", - `${provider}/${model} (imagen3) | prompt: "${promptPreview}..." | aspect_ratio: ${aspectRatio}` - ); - } - - try { - const response = await fetch(providerConfig.baseUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(upstreamBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - if (log) - log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); - - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: response.status, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText.slice(0, 500), - requestBody: upstreamBody, - }).catch(() => {}); - - return { success: false, status: response.status, error: errorText }; - } - - const data = await response.json(); - - // Normalize response to OpenAI format - const images: Imagen3NormalizedImage[] = []; - if (Array.isArray(data.images)) { - images.push( - ...data.images.map((img: Record) => ({ - b64_json: img.image ?? img.b64_json ?? img.url ?? img, - revised_prompt: body.prompt, - })) - ); - } else if (Array.isArray(data.data)) { - images.push(...data.data); - } else if (data.url || data.b64_json || data.image) { - images.push({ - b64_json: data.image || data.b64_json || data.url, - url: data.url, - revised_prompt: body.prompt, - }); - } - - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - responseBody: { images_count: images.length }, - }).catch(() => {}); - - return { - success: true, - data: { created: data.created || Math.floor(Date.now() / 1000), data: images }, - }; - } catch (err: unknown) { - const errMsg = err instanceof Error ? err.message : String(err); - if (log) log.error("IMAGE", `${provider} fetch error: ${errMsg}`); - - saveCallLog({ - method: "POST", - path: "/v1/images/generations", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errMsg, - }).catch(() => {}); - - return { success: false, status: 502, error: `Image provider error: ${errMsg}` }; - } -} + */ \ No newline at end of file diff --git a/open-sse/handlers/imageGeneration/providers/chatgptWeb.ts b/open-sse/handlers/imageGeneration/providers/chatgptWeb.ts new file mode 100644 index 0000000000..7faa531f5c --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/chatgptWeb.ts @@ -0,0 +1,175 @@ +// Auto-extracted from open-sse/handlers/imageGeneration.ts in PR-#4582-batch +// Family: chatgpt-web | Module: chatgptWeb | Lines: 1102-1282 (181 LOC) +// Ref: see open-sse/handlers/imageGeneration.ts top-of-file comment for split rationale + +import { ChatGptWebExecutor } from "../../../executors/chatgpt-web.ts"; +import { getChatGptImage } from "../../../services/chatgptImageCache.ts"; +import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts"; + +export const CHATGPT_WEB_IMAGE_MARKDOWN_RE = /!\[[^\]]*\]\(([^)\s]+)\)/g; +export const CHATGPT_WEB_IMAGE_ID_RE = + /\/v1\/chatgpt-web\/image\/([a-f0-9]{16,64})(?=[?\s"'<>)]|$)/i; + +export function extractMarkdownImageUrls(text: string): string[] { + const urls: string[] = []; + // String.prototype.matchAll consumes a fresh iterator and ignores the + // regex's lastIndex, so no manual reset is required. + for (const match of text.matchAll(CHATGPT_WEB_IMAGE_MARKDOWN_RE)) { + if (match[1]) urls.push(match[1]); + } + return urls; +} + +export function buildChatGptWebImagePrompt(body): string { + const prompt = String(body.prompt || "").trim(); + const details: string[] = [`Create an image for this prompt: ${prompt}`]; + if (typeof body.size === "string" && body.size.trim()) { + details.push(`Requested size: ${body.size.trim()}.`); + } + if (typeof body.quality === "string" && body.quality.trim()) { + details.push(`Requested quality: ${body.quality.trim()}.`); + } + if (typeof body.style === "string" && body.style.trim()) { + details.push(`Requested style: ${body.style.trim()}.`); + } + return details.join("\n"); +} + +export async function handleChatGptWebImageGeneration({ + model, + provider, + body, + credentials, + log, + signal, + clientHeaders, +}) { + const startTime = Date.now(); + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; + if (!prompt) { + return saveImageErrorResult({ + provider, + model, + status: 400, + startTime, + error: "Prompt is required for ChatGPT Web image generation", + }); + } + + if (!credentials?.apiKey) { + return saveImageErrorResult({ + provider, + model, + status: 401, + startTime, + error: "ChatGPT Web credentials missing session cookie", + }); + } + + // Each image is one chatgpt.com chat turn (~30s). Cap at 4 (matches OpenAI's + // own limit for GPT Image models) so a stray n=1000 doesn't pin the + // executor for hours before the upstream HTTP timeout fires. + const CHATGPT_WEB_IMAGE_N_MAX = 4; + const rawCount = Number.isInteger(body.n) && (body.n as number) > 0 ? (body.n as number) : 1; + if (rawCount > CHATGPT_WEB_IMAGE_N_MAX) { + return saveImageErrorResult({ + provider, + model, + status: 400, + startTime, + error: `ChatGPT Web image generation supports n=1..${CHATGPT_WEB_IMAGE_N_MAX} (got ${rawCount}); each n is a separate ~30s chat turn.`, + }); + } + const requestedCount = rawCount; + if (log && requestedCount > 1) { + log.warn( + "IMAGE", + `ChatGPT Web returns one image per chat turn; requested n=${requestedCount} will run sequentially` + ); + } + + const wantsBase64 = body.response_format === "b64_json"; + const images: Array<{ url?: string; b64_json?: string }> = []; + const requestBody = { + model, + prompt: prompt.slice(0, 500), + size: body.size || undefined, + quality: body.quality || undefined, + }; + + for (let i = 0; i < requestedCount; i++) { + const executor = new ChatGptWebExecutor(); + const result = await executor.execute({ + model, + body: { + messages: [{ role: "user", content: buildChatGptWebImagePrompt(body) }], + }, + stream: false, + credentials, + signal, + log, + clientHeaders, + }); + + const responseText = await result.response.text(); + if (result.response.status >= 400) { + return saveImageErrorResult({ + provider, + model, + status: result.response.status, + startTime, + error: responseText, + requestBody, + }); + } + + let content = ""; + try { + const json = JSON.parse(responseText); + content = String(json?.choices?.[0]?.message?.content || ""); + } catch { + content = responseText; + } + + const urls = extractMarkdownImageUrls(content); + if (urls.length === 0) { + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: `ChatGPT Web completed without returning image markdown: ${content.slice(0, 300)}`, + requestBody, + }); + } + + for (const url of urls) { + if (!wantsBase64) { + images.push({ url }); + continue; + } + const id = url.match(CHATGPT_WEB_IMAGE_ID_RE)?.[1]; + const cached = id ? getChatGptImage(id) : null; + if (!cached) { + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: "ChatGPT Web image bytes expired before b64_json conversion", + requestBody, + }); + } + images.push({ b64_json: cached.bytes.toString("base64") }); + } + } + + return saveImageSuccessResult({ + provider, + model, + startTime, + requestBody, + responseBody: { images_count: images.length }, + images, + }); +} diff --git a/open-sse/handlers/imageGeneration/providers/comfyUI.ts b/open-sse/handlers/imageGeneration/providers/comfyUI.ts new file mode 100644 index 0000000000..4540c52a0c --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/comfyUI.ts @@ -0,0 +1,116 @@ +// Auto-extracted from open-sse/handlers/imageGeneration.ts in PR-#4582-batch +// Family: comfyui | Module: comfyUI | Lines: 3213-3314 (102 LOC) +// Ref: see open-sse/handlers/imageGeneration.ts top-of-file comment for split rationale + +import { randomUUID } from "crypto"; +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; +import { + submitComfyWorkflow, + pollComfyResult, + fetchComfyOutput, + extractComfyOutputFiles, +} from "../../../utils/comfyuiClient.ts"; + +export async function handleComfyUIImageGeneration({ model, provider, providerConfig, body, log }) { + const startTime = Date.now(); + const [width, height] = (body.size || "1024x1024").split("x").map(Number); + + // Default txt2img workflow template for ComfyUI + const workflow = { + "3": { + class_type: "KSampler", + inputs: { + seed: parseInt(randomUUID().replace(/-/g, "").substring(0, 8), 16) % 2 ** 32, + steps: body.steps || 20, + cfg: body.cfg_scale || 7, + sampler_name: "euler", + scheduler: "normal", + denoise: 1, + model: ["4", 0], + positive: ["6", 0], + negative: ["7", 0], + latent_image: ["5", 0], + }, + }, + "4": { + class_type: "CheckpointLoaderSimple", + inputs: { ckpt_name: model }, + }, + "5": { + class_type: "EmptyLatentImage", + inputs: { width: width || 1024, height: height || 1024, batch_size: body.n || 1 }, + }, + "6": { + class_type: "CLIPTextEncode", + inputs: { text: body.prompt, clip: ["4", 1] }, + }, + "7": { + class_type: "CLIPTextEncode", + inputs: { text: body.negative_prompt || "", clip: ["4", 1] }, + }, + "8": { + class_type: "VAEDecode", + inputs: { samples: ["3", 0], vae: ["4", 2] }, + }, + "9": { + class_type: "SaveImage", + inputs: { filename_prefix: "omniroute", images: ["8", 0] }, + }, + }; + + if (log) { + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log.info("IMAGE", `${provider}/${model} (comfyui) | prompt: "${promptPreview}..."`); + } + + try { + const promptId = await submitComfyWorkflow(providerConfig.baseUrl, workflow); + const historyEntry = await pollComfyResult(providerConfig.baseUrl, promptId); + const outputFiles = extractComfyOutputFiles(historyEntry); + + const images = []; + for (const file of outputFiles) { + const buffer = await fetchComfyOutput( + providerConfig.baseUrl, + file.filename, + file.subfolder, + file.type + ); + const base64 = Buffer.from(buffer).toString("base64"); + images.push({ b64_json: base64, revised_prompt: body.prompt }); + } + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { images_count: images.length }, + }).catch(() => {}); + + return { + success: true, + data: { created: Math.floor(Date.now() / 1000), data: images }, + }; + } catch (err) { + if (log) log.error("IMAGE", `${provider} comfyui error: ${err.message}`); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: err.message, + }).catch(() => {}); + return { + success: false, + status: 502, + error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, + }; + } +} + diff --git a/open-sse/handlers/imageGeneration/providers/haiper.ts b/open-sse/handlers/imageGeneration/providers/haiper.ts new file mode 100644 index 0000000000..f22ad35cd8 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/haiper.ts @@ -0,0 +1,120 @@ +// Auto-extracted from open-sse/handlers/imageGeneration.ts in PR-#4582-batch +// Family: haiper | Module: haiper | Lines: 3315-3426 (112 LOC) +// Ref: see open-sse/handlers/imageGeneration.ts top-of-file comment for split rationale + +import { saveCallLog } from "@/lib/usageDb"; +import { sleep } from "../../../utils/sleep.ts"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; + +export async function handleHaiperImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const startTime = Date.now(); + const token = credentials?.apiKey || ""; + const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); + if (log) { + log.info("IMAGE", `${provider}/${model} (haiper) | prompt: "${prompt.slice(0, 60)}..."`); + } + try { + const res = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json", HAIPER_KEY: token }, + body: JSON.stringify({ prompt, aspect_ratio: body.aspect_ratio || "16:9" }), + }); + if (!res.ok) { + const errorText = await res.text(); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: res.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + }).catch(() => {}); + return { success: false, status: res.status, error: errorText }; + } + const { job_id } = await res.json(); + const deadline = Date.now() + 300000; + while (Date.now() < deadline) { + await sleep(5000); + const statusRes = await fetch(`${providerConfig.statusUrl}/${job_id}`, { + headers: { HAIPER_KEY: token }, + }); + const status = await statusRes.json(); + if (status.status === "completed" || status.status === "succeeded") { + const imgUrl = status.creation_url || status.output?.image_url; + if (imgUrl) { + const imgRes = await fetch(imgUrl); + if (!imgRes.ok) { + return { + success: false, + status: imgRes.status, + error: `Failed to download image: ${imgRes.status}`, + }; + } + const buf = await imgRes.arrayBuffer(); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + }).catch(() => {}); + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [{ b64_json: Buffer.from(buf).toString("base64") }], + }, + }; + } + } + if (status.status === "failed") { + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: "Haiper image generation failed", + }).catch(() => {}); + return { success: false, status: 502, error: "Haiper image generation failed" }; + } + } + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 504, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: "Haiper image generation timed out", + }).catch(() => {}); + return { success: false, status: 504, error: "Haiper image generation timed out" }; + } catch (err) { + if (log) log.error("IMAGE", `${provider} haiper error: ${err.message}`); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: err.message, + }).catch(() => {}); + return { + success: false, + status: 502, + error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, + }; + } +} + diff --git a/open-sse/handlers/imageGeneration/providers/hyperbolic.ts b/open-sse/handlers/imageGeneration/providers/hyperbolic.ts new file mode 100644 index 0000000000..528695c30d --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/hyperbolic.ts @@ -0,0 +1,100 @@ +// Auto-extracted from open-sse/handlers/imageGeneration.ts in PR-#4582-batch +// Family: hyperbolic | Module: hyperbolic | Lines: 2661-2758 (98 LOC) +// Ref: see open-sse/handlers/imageGeneration.ts top-of-file comment for split rationale + +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; + +export async function handleHyperbolicImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const startTime = Date.now(); + const token = credentials.apiKey || credentials.accessToken; + + const [width, height] = (body.size || "1024x1024").split("x").map(Number); + + const upstreamBody = { + model_name: model, + prompt: body.prompt, + height: height || 1024, + width: width || 1024, + backend: "auto", + }; + + if (log) { + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log.info("IMAGE", `${provider}/${model} (hyperbolic) | prompt: "${promptPreview}..."`); + } + + try { + const response = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(upstreamBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + if (log) + log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: response.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + }).catch(() => {}); + + return { success: false, status: response.status, error: errorText }; + } + + const data = await response.json(); + // Transform { images: [{ image: base64 }] } → OpenAI format + const images = (data.images || []).map((img) => ({ + b64_json: img.image, + revised_prompt: body.prompt, + })); + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { images_count: images.length }, + }).catch(() => {}); + + return { + success: true, + data: { created: Math.floor(Date.now() / 1000), data: images }, + }; + } catch (err) { + if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: err.message, + }).catch(() => {}); + return { + success: false, + status: 502, + error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, + }; + } +} diff --git a/open-sse/handlers/imageGeneration/providers/ideogram.ts b/open-sse/handlers/imageGeneration/providers/ideogram.ts new file mode 100644 index 0000000000..824ca45385 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/ideogram.ts @@ -0,0 +1,96 @@ +// Auto-extracted from open-sse/handlers/imageGeneration.ts in PR-#4582-batch +// Family: ideogram | Module: ideogram | Lines: 3559-3669 (111 LOC) +// Ref: see open-sse/handlers/imageGeneration.ts top-of-file comment for split rationale + +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; + +export async function handleIdeogramImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const startTime = Date.now(); + const token = credentials?.apiKey || ""; + const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); + if (log) { + log.info("IMAGE", `${provider}/${model} (ideogram) | prompt: "${prompt.slice(0, 60)}..."`); + } + try { + const res = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json", "Api-Key": token }, + body: JSON.stringify({ prompt, aspect_ratio: "ASPECT_16_9", model: model || "V_3" }), + }); + if (!res.ok) { + const errorText = await res.text(); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: res.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + }).catch(() => {}); + return { success: false, status: res.status, error: errorText }; + } + const data = await res.json(); + if (data.data && data.data.length > 0) { + const imgUrl = data.data[0].url; + const imgRes = await fetch(imgUrl); + if (!imgRes.ok) { + return { + success: false, + status: imgRes.status, + error: `Failed to download image: ${imgRes.status}`, + }; + } + const buf = await imgRes.arrayBuffer(); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + }).catch(() => {}); + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [{ b64_json: Buffer.from(buf).toString("base64") }], + }, + }; + } + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: "No images returned from Ideogram", + }).catch(() => {}); + return { success: false, status: 502, error: "No images returned from Ideogram" }; + } catch (err) { + if (log) log.error("IMAGE", `${provider} ideogram error: ${err.message}`); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: err.message, + }).catch(() => {}); + return { + success: false, + status: 502, + error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, + }; + } +} diff --git a/open-sse/handlers/imageGeneration/providers/imagen3.ts b/open-sse/handlers/imageGeneration/providers/imagen3.ts new file mode 100644 index 0000000000..c53a0016c2 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/imagen3.ts @@ -0,0 +1,136 @@ +// Auto-extracted from open-sse/handlers/imageGeneration.ts in PR-#4582-batch +// Family: imagen3 | Module: imagen3 | Lines: 3670-3777 (108 LOC) +// Ref: see open-sse/handlers/imageGeneration.ts top-of-file comment for split rationale + +import { saveCallLog } from "@/lib/usageDb"; +import { mapImageSize } from "../../../translator/image/sizeMapper.ts"; + +type Imagen3ImageGenArgs = { + model: string; + provider: string; + providerConfig: { baseUrl: string }; + body: { prompt?: string; size?: string; n?: number }; + credentials: { apiKey?: string; accessToken?: string }; + log?: { + info?: (tag: string, msg: string) => void; + error?: (tag: string, msg: string) => void; + } | null; +}; + +type Imagen3NormalizedImage = { + b64_json?: unknown; + url?: unknown; + revised_prompt?: string; +}; + +/** + * Handle Imagen 3 image generation + */ +export async function handleImagen3ImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: Imagen3ImageGenArgs) { + const startTime = Date.now(); + const token = credentials.apiKey || credentials.accessToken; + const aspectRatio = mapImageSize(body.size); + + const upstreamBody = { + prompt: body.prompt, + aspect_ratio: aspectRatio, + number_of_images: body.n ?? 1, + }; + + if (log) { + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log.info( + "IMAGE", + `${provider}/${model} (imagen3) | prompt: "${promptPreview}..." | aspect_ratio: ${aspectRatio}` + ); + } + + try { + const response = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(upstreamBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + if (log) + log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: response.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + requestBody: upstreamBody, + }).catch(() => {}); + + return { success: false, status: response.status, error: errorText }; + } + + const data = await response.json(); + + // Normalize response to OpenAI format + const images: Imagen3NormalizedImage[] = []; + if (Array.isArray(data.images)) { + images.push( + ...data.images.map((img: Record) => ({ + b64_json: img.image ?? img.b64_json ?? img.url ?? img, + revised_prompt: body.prompt, + })) + ); + } else if (Array.isArray(data.data)) { + images.push(...data.data); + } else if (data.url || data.b64_json || data.image) { + images.push({ + b64_json: data.image || data.b64_json || data.url, + url: data.url, + revised_prompt: body.prompt, + }); + } + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { images_count: images.length }, + }).catch(() => {}); + + return { + success: true, + data: { created: data.created || Math.floor(Date.now() / 1000), data: images }, + }; + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : String(err); + if (log) log.error("IMAGE", `${provider} fetch error: ${errMsg}`); + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errMsg, + }).catch(() => {}); + + return { success: false, status: 502, error: `Image provider error: ${errMsg}` }; + } +} + diff --git a/open-sse/handlers/imageGeneration/providers/leonardo.ts b/open-sse/handlers/imageGeneration/providers/leonardo.ts new file mode 100644 index 0000000000..c407693f46 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/leonardo.ts @@ -0,0 +1,140 @@ +// Auto-extracted from open-sse/handlers/imageGeneration.ts in PR-#4582-batch +// Family: leonardo | Module: leonardo | Lines: 3427-3558 (132 LOC) +// Ref: see open-sse/handlers/imageGeneration.ts top-of-file comment for split rationale + +import { saveCallLog } from "@/lib/usageDb"; +import { sleep } from "../../../utils/sleep.ts"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; + +export async function handleLeonardoImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}) { + const startTime = Date.now(); + const token = credentials?.apiKey || ""; + const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); + if (log) { + log.info("IMAGE", `${provider}/${model} (leonardo) | prompt: "${prompt.slice(0, 60)}..."`); + } + try { + const res = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + modelId: model || "phoenix", + prompt, + width: body.width || 1024, + height: body.height || 1024, + num_images: 1, + }), + }); + if (!res.ok) { + const errorText = await res.text(); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: res.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + }).catch(() => {}); + return { success: false, status: res.status, error: errorText }; + } + const { sdGenerationJob } = await res.json(); + const genId = sdGenerationJob?.generationId; + if (!genId) { + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: "No generation ID returned", + }).catch(() => {}); + return { success: false, status: 502, error: "No generation ID returned" }; + } + const deadline = Date.now() + 300000; + while (Date.now() < deadline) { + await sleep(5000); + const statusRes = await fetch(`${providerConfig.baseUrl}/${genId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const status = await statusRes.json(); + const gen = status.generations_by_pk || status; + if (gen.status === "COMPLETE") { + const imgUrl = gen.generated_images?.[0]?.url; + if (imgUrl) { + const imgRes = await fetch(imgUrl); + if (!imgRes.ok) { + return { + success: false, + status: imgRes.status, + error: `Failed to download image: ${imgRes.status}`, + }; + } + const buf = await imgRes.arrayBuffer(); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + }).catch(() => {}); + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [{ b64_json: Buffer.from(buf).toString("base64") }], + }, + }; + } + } + if (gen.status === "FAILED") { + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: "Leonardo image generation failed", + }).catch(() => {}); + return { success: false, status: 502, error: "Leonardo image generation failed" }; + } + } + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 504, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: "Leonardo image generation timed out", + }).catch(() => {}); + return { success: false, status: 504, error: "Leonardo image generation timed out" }; + } catch (err) { + if (log) log.error("IMAGE", `${provider} leonardo error: ${err.message}`); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: err.message, + }).catch(() => {}); + return { + success: false, + status: 502, + error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, + }; + } +} + diff --git a/open-sse/handlers/imageGeneration/providers/sdWebUI.ts b/open-sse/handlers/imageGeneration/providers/sdWebUI.ts new file mode 100644 index 0000000000..aa4576ef22 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/sdWebUI.ts @@ -0,0 +1,94 @@ +// Auto-extracted from open-sse/handlers/imageGeneration.ts in PR-#4582-batch +// Family: sd-webui | Module: sdWebUI | Lines: 3121-3212 (92 LOC) +// Ref: see open-sse/handlers/imageGeneration.ts top-of-file comment for split rationale + +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; + +export async function handleSDWebUIImageGeneration({ model, provider, providerConfig, body, log }) { + const startTime = Date.now(); + const [width, height] = (body.size || "512x512").split("x").map(Number); + + const upstreamBody = { + prompt: body.prompt, + negative_prompt: body.negative_prompt || "", + width: width || 512, + height: height || 512, + steps: body.steps || 20, + cfg_scale: body.cfg_scale || 7, + sampler_name: body.sampler || "Euler a", + batch_size: body.n || 1, + override_settings: { + sd_model_checkpoint: model, + }, + }; + + if (log) { + const promptPreview = String(body.prompt ?? "").slice(0, 60); + log.info("IMAGE", `${provider}/${model} (sdwebui) | prompt: "${promptPreview}..."`); + } + + try { + const response = await fetch(providerConfig.baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(upstreamBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + if (log) + log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: response.status, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: errorText.slice(0, 500), + }).catch(() => {}); + + return { success: false, status: response.status, error: errorText }; + } + + const data = await response.json(); + // SD WebUI returns { images: ["base64...", ...] } + const images = (data.images || []).map((b64) => ({ + b64_json: b64, + revised_prompt: body.prompt, + })); + + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 200, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + responseBody: { images_count: images.length }, + }).catch(() => {}); + + return { + success: true, + data: { created: Math.floor(Date.now() / 1000), data: images }, + }; + } catch (err) { + if (log) log.error("IMAGE", `${provider} sdwebui error: ${err.message}`); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 502, + model: `${provider}/${model}`, + provider, + duration: Date.now() - startTime, + error: err.message, + }).catch(() => {}); + return { + success: false, + status: 502, + error: `Image provider error: ${sanitizeErrorMessage((err as Error).message || err)}`, + }; + } +} diff --git a/open-sse/package.json b/open-sse/package.json index d286d39b3f..171738a4b3 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.8.33", + "version": "3.8.34", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 824ea19054..e62698f904 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -1436,13 +1436,11 @@ export function checkFallbackError( // "Usage Limit Reached" for the 5-hour subscription quota. The // pattern-based classifier now flags these as QUOTA_EXHAUSTED, but // without a dedicated branch the request would still fall through to - // the generic 429 retry path (~5s base cooldown). Honor any - // upstream retry hint (Retry-After header or ISO timestamp in the - // body) when present, otherwise apply a 1h cooldown so all Pro - // accounts on the same subscription tier stop cycling through tight - // retries until the window genuinely resets. Generic quota-reset text - // still follows the provider profile's upstream-hint policy; this - // branch is only for known Claude subscription quota messages. (We + // the generic 429 retry path (~5s base cooldown). Honor upstream + // Retry-After / reset hints only when the profile enables them; + // otherwise apply a local 1h cooldown so all Pro accounts on the same + // subscription tier stop cycling through tight retries without letting + // upstream-provided windows bypass the operator setting. (We // deliberately do not use COOLDOWN_MS.paymentRequired here — that // constant is 2 minutes, which is shorter than the recovery time of a // subscription quota.) @@ -1452,13 +1450,9 @@ export function checkFallbackError( !isDailyQuotaExhausted(errorStr) && isSubscriptionQuotaText(errorStr.toLowerCase()) ) { - // For a subscription quota error an upstream reset hint is the most - // accurate wait. Header hints follow the profile policy via - // getUpstreamRetryHintMs(); precise body timestamps remain safe for - // this dedicated branch because it only handles known subscription - // quota messages. When no hint is available, keep the dedicated 1h - // cooldown instead of falling through to the generic short 429 backoff. - const hintMs = getUpstreamRetryHintMs() ?? parseRetryFromErrorText(errorStr) ?? null; + // getUpstreamRetryHintMs() gates both headers and body reset text on + // profile.useUpstreamRetryHints. + const hintMs = getUpstreamRetryHintMs(); const SUBSCRIPTION_QUOTA_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour return { shouldFallback: true, @@ -1474,14 +1468,7 @@ export function checkFallbackError( quotaResetHintMs && classifyErrorText(errorStr) === RateLimitReason.QUOTA_EXHAUSTED ) { - return { - shouldFallback: true, - cooldownMs: quotaResetHintMs, - baseCooldownMs: quotaResetHintMs, - newBackoffLevel: 0, - reason: RateLimitReason.QUOTA_EXHAUSTED, - usedUpstreamRetryHint: true, - }; + return buildRetryableFallback(RateLimitReason.QUOTA_EXHAUSTED); } // #2929: A route-restriction 403 (e.g. Fireworks Fire Pass keys returning diff --git a/open-sse/services/autoCombo/builtinCatalog.ts b/open-sse/services/autoCombo/builtinCatalog.ts index 107b257930..134b48205c 100644 --- a/open-sse/services/autoCombo/builtinCatalog.ts +++ b/open-sse/services/autoCombo/builtinCatalog.ts @@ -37,6 +37,7 @@ export const AUTO_TEMPLATE_VARIANTS: Record = { "auto/smart": "smart", "auto/claude-opus": "smart", "auto/claude-sonnet": "coding", + "auto/best-free": "cheap", }; /** @@ -85,7 +86,8 @@ export async function createBuiltinAutoCombo(modelStr: string, suffix: string) { const resolved = resolveAutoVariant(modelStr, suffix); if (resolved.recognized) { - const virtualCombo = await createVirtualAutoCombo(resolved.variant); + const spec = modelStr === "auto/best-free" ? { tier: "free" as const } : undefined; + const virtualCombo = await createVirtualAutoCombo(resolved.variant, spec); virtualCombo.name = modelStr; virtualCombo.id = modelStr; return virtualCombo; diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index fa535982c5..8912148c4d 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -130,13 +130,6 @@ function isChatAutoComboNoAuthProvider(providerDef: NoAuthProviderDefinition): b return providerDef.serviceKinds.includes("llm"); } -function getFirstRegistryModelId(providerInfo: { models?: Array<{ id?: string }> } | undefined) { - const firstModel = Array.isArray(providerInfo?.models) ? providerInfo.models[0] : undefined; - return typeof firstModel?.id === "string" && firstModel.id.trim().length > 0 - ? firstModel.id - : undefined; -} - function getNoAuthCandidates( excludedProviders: Set, blockedProviders: Set @@ -156,8 +149,8 @@ function getNoAuthCandidates( continue; const providerInfo = registry[providerId]; - const modelId = getFirstRegistryModelId(providerInfo); - if (!modelId) continue; + const registryModels = Array.isArray(providerInfo?.models) ? providerInfo.models : []; + if (registryModels.length === 0) continue; // No-auth providers do not have provider_connections rows. Use the same // synthetic connection id returned by getProviderCredentials() so the @@ -169,13 +162,18 @@ function getNoAuthCandidates( ? providerInfo.alias : null; const routingPrefix = providerDef.alias || registryAlias || providerId; - candidates.push({ - provider: providerId, - connectionId: SYNTHETIC_NOAUTH_CONNECTION_ID, - model: modelId, - modelStr: `${routingPrefix}/${modelId}`, - costPer1MTokens: 0, - }); + + for (const model of registryModels) { + const modelId = typeof model?.id === "string" && model.id.trim().length > 0 ? model.id : null; + if (!modelId) continue; + candidates.push({ + provider: providerId, + connectionId: SYNTHETIC_NOAUTH_CONNECTION_ID, + model: modelId, + modelStr: `${routingPrefix}/${modelId}`, + costPer1MTokens: 0, + }); + } } return candidates; diff --git a/open-sse/services/bailianQuotaFetcher.ts b/open-sse/services/bailianQuotaFetcher.ts index 3eb0c11847..33a85664fc 100644 --- a/open-sse/services/bailianQuotaFetcher.ts +++ b/open-sse/services/bailianQuotaFetcher.ts @@ -19,7 +19,7 @@ * Registration: call registerBailianCodingPlanQuotaFetcher() once at server startup. */ -import { registerQuotaFetcher, type QuotaInfo } from "./quotaPreflight.ts"; +import { registerQuotaFetcher, registerQuotaWindows, type QuotaInfo } from "./quotaPreflight.ts"; import { registerMonitorFetcher } from "./quotaMonitor.ts"; // Bailian quota hosts (international / china fallback) @@ -34,9 +34,15 @@ const BAILIAN_QUOTA_PATH = // Cache TTL — short enough to be reactive, long enough to avoid rate limits const CACHE_TTL_MS = 60_000; // 60 seconds +// Window keys as surfaced to the dashboard and quota-window registry +export const BAILIAN_WINDOW_5H = "window_5h"; +export const BAILIAN_WINDOW_WEEKLY = "window_weekly"; +export const BAILIAN_WINDOW_MONTHLY = "window_monthly"; + // Triple-window quota info (richer than QuotaInfo — includes all 3 windows) // [Oracle CONDITIONAL] bailian-coding-plan only — do not reuse for other providers export interface BailianTripleWindowQuota extends QuotaInfo { + windows: Record; window5h: { percentUsed: number; resetAt: string | null }; windowWeekly: { percentUsed: number; resetAt: string | null }; windowMonthly: { percentUsed: number; resetAt: string | null }; @@ -181,6 +187,11 @@ function parseBailianQuotaResponse(data: unknown): BailianTripleWindowQuota | nu percentUsed: pctMonthly, resetAt: resetAtMonthly > 0 ? new Date(resetAtMonthly * 1000).toISOString() : null, }; + const windows = { + [BAILIAN_WINDOW_5H]: window5h, + [BAILIAN_WINDOW_WEEKLY]: windowWeekly, + [BAILIAN_WINDOW_MONTHLY]: windowMonthly, + }; // Dominant reset = reset time of the most restrictive window const dominantResetAt = @@ -195,6 +206,7 @@ function parseBailianQuotaResponse(data: unknown): BailianTripleWindowQuota | nu total: 100, percentUsed: worstPercentUsed, resetAt: dominantResetAt, + windows, window5h, windowWeekly, windowMonthly, @@ -313,4 +325,9 @@ export function invalidateBailianQuotaCache(connectionId: string): void { export function registerBailianCodingPlanQuotaFetcher(): void { registerQuotaFetcher("bailian-coding-plan", fetchBailianQuota); registerMonitorFetcher("bailian-coding-plan", fetchBailianQuota); + registerQuotaWindows("bailian-coding-plan", [ + BAILIAN_WINDOW_5H, + BAILIAN_WINDOW_WEEKLY, + BAILIAN_WINDOW_MONTHLY, + ]); } diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 3645510dc8..69d2932804 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -326,6 +326,36 @@ function quotaRemainingPercentFromQuota(quota: unknown): number { return 100; } +const QUOTA_BLOCKING_CONNECTION_STATUSES = new Set([ + "banned", + "credits_exhausted", + "deactivated", + "expired", + "rate_limited", +]); + +function normalizeConnectionStatus(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function hasFutureRateLimitUntil(value: unknown): boolean { + if (value == null || value === "") return false; + const time = new Date(String(value)).getTime(); + return Number.isFinite(time) && time > Date.now(); +} + +export function getConnectionStatusQuotaCutoffReason( + connection: Record | undefined +): string | undefined { + if (!connection) return undefined; + const status = normalizeConnectionStatus(connection.testStatus); + if (QUOTA_BLOCKING_CONNECTION_STATUSES.has(status)) return status; + if (status === "unavailable" && hasFutureRateLimitUntil(connection.rateLimitedUntil)) { + return "rate_limited"; + } + return undefined; +} + export async function buildAutoCandidates( targets: ResolvedComboTarget[], comboName: string, @@ -473,6 +503,19 @@ export async function buildAutoCandidates( let quotaCutoffReason: string | undefined; const fetcher = getQuotaFetcher(provider); const connection = target.connectionId ? connectionById.get(target.connectionId) : undefined; + // Gate the terminal-status cutoff behind the same opt-in as the quota-percent + // cutoff (#4483): when quota cutoff is disabled, a connection in a terminal + // testStatus must still fall through to normal connection-cooldown / model-lockout + // handling instead of being hard-blocked here (which would surface a misleading + // "below quota cutoff" 429 when every candidate is transiently unavailable). + const statusCutoffReason = quotaCutoffEnabled + ? getConnectionStatusQuotaCutoffReason(connection) + : undefined; + if (statusCutoffReason) { + quotaCutoffBlocked = true; + quotaCutoffReason = statusCutoffReason; + quotaRemaining = 0; + } if (fetcher && target.connectionId) { const quotaKey = `${provider}:${target.connectionId}`; if (!quotaPromises.has(quotaKey)) { @@ -491,8 +534,10 @@ export async function buildAutoCandidates( } const quota = await quotaPromises.get(quotaKey)!; resetWindowAffinity = calculateResetWindowAffinity(quota, resetWindowConfig); - quotaRemaining = quotaRemainingPercentFromQuota(quota); - if (quotaCutoffEnabled) { + if (!quotaCutoffBlocked) { + quotaRemaining = quotaRemainingPercentFromQuota(quota); + } + if (!quotaCutoffBlocked && quotaCutoffEnabled) { const cutoffDecision = evaluateQuotaCutoff( quota as QuotaInfo | null, buildAutoQuotaThresholds(provider, connection, resilienceSettings) diff --git a/open-sse/services/combo/comboStructure.ts b/open-sse/services/combo/comboStructure.ts index 0930068693..df6ecfb826 100644 --- a/open-sse/services/combo/comboStructure.ts +++ b/open-sse/services/combo/comboStructure.ts @@ -478,6 +478,14 @@ function deriveRequestCompatibilityRequirements( }; } +function exceedsKnownOutputLimit( + requestedOutputTokens: number, + maxOutputTokens: number | null +): boolean { + if (requestedOutputTokens <= 0 || maxOutputTokens === null) return false; + return maxOutputTokens < requestedOutputTokens; +} + function getTargetCompatibilityFailures( target: ResolvedComboTarget, requirements: RequestCompatibilityRequirements @@ -506,11 +514,7 @@ function getTargetCompatibilityFailures( failures.push("structured_output"); } - if ( - requirements.requestedOutputTokens > 0 && - Number.isFinite(capabilities.maxOutputTokens) && - capabilities.maxOutputTokens < requirements.requestedOutputTokens - ) { + if (exceedsKnownOutputLimit(requirements.requestedOutputTokens, capabilities.maxOutputTokens)) { failures.push("output_tokens"); } diff --git a/open-sse/services/comboMetrics.ts b/open-sse/services/comboMetrics.ts index 698d567975..4946327774 100644 --- a/open-sse/services/comboMetrics.ts +++ b/open-sse/services/comboMetrics.ts @@ -193,7 +193,10 @@ const shadowMetrics = new Map(); const MAX_METRICS_ENTRIES = 500; const METRICS_TTL_MS = 60 * 60 * 1000; // 1 hour -function evictOldestMetric(targetMap: Map): void { +function evictOldestMetric( + targetMap: Map, + options: { deletePairedShadow?: boolean } = {} +): void { let oldest: string | null = null; let oldestTime = Infinity; for (const [name, entry] of targetMap) { @@ -201,8 +204,10 @@ function evictOldestMetric(targetMap: Map if (t < oldestTime) { oldestTime = t; oldest = name; } } if (oldest) { - metrics.delete(oldest); - shadowMetrics.delete(oldest); + targetMap.delete(oldest); + if (options.deletePairedShadow) { + shadowMetrics.delete(oldest); + } } } @@ -254,7 +259,7 @@ export function recordComboRequest( } ): void { if (!metrics.has(comboName) && metrics.size >= MAX_METRICS_ENTRIES) { - evictOldestMetric(metrics); + evictOldestMetric(metrics, { deletePairedShadow: true }); } if (!metrics.has(comboName)) { metrics.set(comboName, createComboEntry(strategy)); @@ -445,7 +450,7 @@ export function getAllComboMetrics(): Record { */ export function recordComboIntent(comboName: string, intent: string): void { if (!metrics.has(comboName) && metrics.size >= MAX_METRICS_ENTRIES) { - evictOldestMetric(metrics); + evictOldestMetric(metrics, { deletePairedShadow: true }); } if (!metrics.has(comboName)) { metrics.set(comboName, createComboEntry("priority")); diff --git a/open-sse/services/compression/deriveDefaultPlan.ts b/open-sse/services/compression/deriveDefaultPlan.ts index ee7960fe69..15ccdbeebc 100644 --- a/open-sse/services/compression/deriveDefaultPlan.ts +++ b/open-sse/services/compression/deriveDefaultPlan.ts @@ -10,9 +10,20 @@ const SINGLE_MODE_OF: Record = { rtk: "rtk", }; +export type CompressionSource = + | "request-header" + | "routing-override" + | "active-profile" + | "auto-trigger" + | "default" + | "off"; + export interface DerivedPlan { mode: string; stackedPipeline: Array<{ engine: string; intensity?: string }>; + /** Which precedence layer decided this plan (Phase 3 observability). Optional so + * Phase 1/2 callers and snapshots are unaffected. */ + source?: CompressionSource; } /** diff --git a/open-sse/services/compression/planResolution.ts b/open-sse/services/compression/planResolution.ts new file mode 100644 index 0000000000..2722b9dbc6 --- /dev/null +++ b/open-sse/services/compression/planResolution.ts @@ -0,0 +1,106 @@ +import type { CompressionConfig, CompressionPipelineStep } from "./types.ts"; +import { resolveCompressionPlan } from "./resolveCompressionPlan.ts"; +import { + deriveDefaultPlan, + type DerivedPlan, + type CompressionSource, +} from "./deriveDefaultPlan.ts"; + +/** Named-combo map: combo id → its stacked pipeline (operator-defined profiles). */ +type NamedCombos = Record; + +/** Tags a plan with the precedence layer that produced it (Phase 3 observability). */ +export function withSource(plan: DerivedPlan, source: CompressionSource): DerivedPlan { + return { ...plan, source }; +} + +/** + * Interprets the `x-omniroute-compression` request header into a plan, or null when the + * value is unrecognized (caller falls through to normal resolution). Pure. + * off -> no compression + * default -> the panel-derived Default (ignores active profile / routing / auto-trigger) + * engine: -> that single engine, when enabled in config.engines + * -> a named combo, matched name-first (lowercased) then exact id (Decision A) + */ +export function planFromHeader( + config: CompressionConfig, + header: string, + combos: NamedCombos +): DerivedPlan | null { + const h = header.trim(); + if (!h) return null; + const lower = h.toLowerCase(); + + if (lower === "off") return withSource({ mode: "off", stackedPipeline: [] }, "request-header"); + + if (lower === "default") { + // Empty combos + null comboId yields the pure panel default (no active-combo leak). + return withSource(deriveDefaultPlanFromConfig(config, null, {}), "request-header"); + } + + if (lower.startsWith("engine:")) { + const id = lower.slice("engine:".length).trim(); + const engine = config.engines?.[id]; + return engine?.enabled + ? withSource(deriveDefaultPlan({ [id]: engine }, true), "request-header") + : null; + } + + const combo = combos[lower] ?? combos[h]; + return combo ? withSource({ mode: "stacked", stackedPipeline: combo }, "request-header") : null; +} + +/** Renders the X-OmniRoute-Compression response header value. */ +export function formatCompressionMeta(plan: DerivedPlan): string { + return `${plan.mode}; source=${plan.source ?? "off"}`; +} + +/** + * Builds the named-combo lookup keyed by BOTH combo id and lowercased (trimmed) name, so the + * `` header form can match by either. A combo with a blank/whitespace/missing name + * contributes only its id key — a blank name would otherwise register a useless "" key, and a + * single malformed row must not throw and disable the whole map (`name` is NOT NULL in the DB + * but may be an empty string). Pure. + */ +export function buildNamedComboLookup( + combos: Array<{ id: string; name?: string | null; pipeline: CompressionPipelineStep[] }> +): NamedCombos { + const map: NamedCombos = {}; + for (const c of combos) { + map[c.id] = c.pipeline; + const name = c.name?.trim(); + if (name) map[name.toLowerCase()] = c.pipeline; + } + return map; +} + +/** + * Derived-default step. The per-engine toggle map drives the default ONLY when it was + * EXPLICITLY configured via the panel (a stored `engines` row — `config.enginesExplicit`). + * For legacy installs the map is backfilled for DISPLAY only (so the panel shows current + * state); dispatch falls back to the historical `config.defaultMode` so behaviour is + * byte-for-byte preserved until the operator opts into the panel by saving. This avoids a + * silent behaviour change for installs whose backfilled engine flags don't exactly match + * their old defaultMode. + */ +export function deriveDefaultPlanFromConfig( + config: CompressionConfig, + comboId: string | null, + combos: NamedCombos = {} +): DerivedPlan { + if (config.enginesExplicit) { + // Panel-configured: the engines map (via the resolver, which stays header/active-combo + // aware for Phases 2-3) is authoritative — including an explicit "everything off". + return resolveCompressionPlan(config, { comboId, combos }); + } + + // Legacy path: defaultMode carries the effective mode (the engines map is display-only here). + const legacyMode = config.defaultMode; + if (legacyMode && legacyMode !== "off") { + return legacyMode === "stacked" + ? { mode: legacyMode, stackedPipeline: config.stackedPipeline ?? [] } + : { mode: legacyMode, stackedPipeline: [] }; + } + + return { mode: "off", stackedPipeline: [] }; +} diff --git a/open-sse/services/compression/resolveCompressionPlan.ts b/open-sse/services/compression/resolveCompressionPlan.ts index a9b0543a9d..6c45b0621b 100644 --- a/open-sse/services/compression/resolveCompressionPlan.ts +++ b/open-sse/services/compression/resolveCompressionPlan.ts @@ -2,32 +2,22 @@ import { deriveDefaultPlan, type DerivedPlan } from "./deriveDefaultPlan.ts"; export interface ResolveCtx { comboId?: string | null; - header?: string | null; // x-omniroute-compression (Phase 3 parses+passes; Phase 1 callers pass undefined) combos?: Record>; // named combo pipelines by id } export function resolveCompressionPlan(config: any, ctx: ResolveCtx): DerivedPlan { if (config?.enabled === false) return { mode: "off", stackedPipeline: [] }; - // 1. header (Phase 3 supplies parsed value; here it composes if present) - if (ctx.header) { - if (ctx.header === "off") return { mode: "off", stackedPipeline: [] }; - if (ctx.header !== "default") { - const fromHeader = headerToPlan(ctx.header, config, ctx); - if (fromHeader) return fromHeader; // unknown => fall through - } - } - - // 2. routing-combo override + // routing-combo override const ov = ctx.comboId ? config?.comboOverrides?.[ctx.comboId] : undefined; if (ov) return modeToPlan(ov, config); - // 3. active named combo + // active named combo if (config?.activeComboId && ctx.combos?.[config.activeComboId]) { return { mode: "stacked", stackedPipeline: ctx.combos[config.activeComboId] }; } - // 4. derived default + // derived default return deriveDefaultPlan(config?.engines ?? {}, config?.enabled !== false); } @@ -36,12 +26,3 @@ function modeToPlan(mode: string, config: any): DerivedPlan { ? { mode: "stacked", stackedPipeline: config?.stackedPipeline ?? [] } : { mode, stackedPipeline: [] }; } - -function headerToPlan(h: string, config: any, ctx: ResolveCtx): DerivedPlan | null { - if (h.startsWith("engine:")) { - const id = h.slice(7); - return config?.engines?.[id]?.enabled ? deriveDefaultPlan({ [id]: config.engines[id] }, true) : null; - } - if (ctx.combos?.[h]) return { mode: "stacked", stackedPipeline: ctx.combos[h] }; - return null; -} diff --git a/open-sse/services/compression/strategySelector.ts b/open-sse/services/compression/strategySelector.ts index 6361f8d999..51e5865122 100644 --- a/open-sse/services/compression/strategySelector.ts +++ b/open-sse/services/compression/strategySelector.ts @@ -22,6 +22,16 @@ import { } from "./cachingAware.ts"; import { resolveCompressionPlan } from "./resolveCompressionPlan.ts"; import { deriveDefaultPlan, type DerivedPlan } from "./deriveDefaultPlan.ts"; +import { + withSource, + planFromHeader, + formatCompressionMeta, + deriveDefaultPlanFromConfig, + buildNamedComboLookup, +} from "./planResolution.ts"; + +// Re-export so existing importers (resolver test + chatCore dynamic import) keep resolving. +export { planFromHeader, formatCompressionMeta, buildNamedComboLookup }; /** Named-combo map: combo id → its stacked pipeline (operator-defined profiles). */ type NamedCombos = Record; @@ -62,63 +72,47 @@ function resolveBasePlan( config: CompressionConfig, comboId: string | null, estimatedTokens: number, - combos: NamedCombos = {} + combos: NamedCombos = {}, + header: string | null = null ): DerivedPlan { - if (!config.enabled) return { mode: "off", stackedPipeline: [] }; + if (!config.enabled) return withSource({ mode: "off", stackedPipeline: [] }, "off"); + + // Phase 3: an explicit, recognized header wins over every operator layer (Decision B). + // The master switch above is the hard kill: a header cannot turn compression on. + if (header) { + const fromHeader = planFromHeader(config, header, combos); + if (fromHeader) return fromHeader; // already tagged "request-header" + } const comboMode = checkComboOverride(config, comboId); if (comboMode) { // A routing-combo "stacked" override still wants the configured stacked pipeline, // so route it through the resolver (which reads config.stackedPipeline for stacked). - return resolveCompressionPlan(config, { comboId, combos }); + return withSource(resolveCompressionPlan(config, { comboId, combos }), "routing-override"); } // Active profile: an EXPLICIT operator choice. Resolves regardless of enginesExplicit and // above auto-trigger (manual choice beats automatic escalation), but below a routing-combo // override (route-scoped is more specific). if (config.activeComboId && combos[config.activeComboId]) { - return { mode: "stacked", stackedPipeline: combos[config.activeComboId] }; + return withSource( + { mode: "stacked", stackedPipeline: combos[config.activeComboId] }, + "active-profile" + ); } if (shouldAutoTrigger(config, estimatedTokens)) { const mode = config.autoTriggerMode ?? "lite"; - return mode === "stacked" - ? { mode, stackedPipeline: config.stackedPipeline ?? [] } - : { mode, stackedPipeline: [] }; + return withSource( + mode === "stacked" + ? { mode, stackedPipeline: config.stackedPipeline ?? [] } + : { mode, stackedPipeline: [] }, + "auto-trigger" + ); } - return deriveDefaultPlanFromConfig(config, comboId, combos); -} - -/** - * Derived-default step. The per-engine toggle map drives the default ONLY when it was - * EXPLICITLY configured via the panel (a stored `engines` row — `config.enginesExplicit`). - * For legacy installs the map is backfilled for DISPLAY only (so the panel shows current - * state); dispatch falls back to the historical `config.defaultMode` so behaviour is - * byte-for-byte preserved until the operator opts into the panel by saving. This avoids a - * silent behaviour change for installs whose backfilled engine flags don't exactly match - * their old defaultMode. - */ -function deriveDefaultPlanFromConfig( - config: CompressionConfig, - comboId: string | null, - combos: NamedCombos = {} -): DerivedPlan { - if (config.enginesExplicit) { - // Panel-configured: the engines map (via the resolver, which stays header/active-combo - // aware for Phases 2-3) is authoritative — including an explicit "everything off". - return resolveCompressionPlan(config, { comboId, combos }); - } - - // Legacy path: defaultMode carries the effective mode (the engines map is display-only here). - const legacyMode = config.defaultMode; - if (legacyMode && legacyMode !== "off") { - return legacyMode === "stacked" - ? { mode: legacyMode, stackedPipeline: config.stackedPipeline ?? [] } - : { mode: legacyMode, stackedPipeline: [] }; - } - - return { mode: "off", stackedPipeline: [] }; + const plan = deriveDefaultPlanFromConfig(config, comboId, combos); + return withSource(plan, plan.mode === "off" ? "off" : "default"); } /** @@ -146,9 +140,10 @@ export function getEffectiveMode( config: CompressionConfig, comboId: string | null, estimatedTokens: number, - combos: NamedCombos = {} + combos: NamedCombos = {}, + header: string | null = null ): CompressionMode { - return resolveBasePlan(config, comboId, estimatedTokens, combos).mode as CompressionMode; + return resolveBasePlan(config, comboId, estimatedTokens, combos, header).mode as CompressionMode; } /** @@ -165,15 +160,16 @@ export function selectCompressionPlan( estimatedTokens: number, body?: Record, context?: CachingDetectionContext, - combos: NamedCombos = {} + combos: NamedCombos = {}, + header: string | null = null ): DerivedPlan { - const plan = resolveBasePlan(config, comboId, estimatedTokens, combos); + const plan = resolveBasePlan(config, comboId, estimatedTokens, combos, header); // Apply caching-aware adjustments to the mode if body is provided if (body) { const ctx = detectCachingContext(body, context); const cacheAware = getCacheAwareStrategy(plan.mode as CompressionMode, ctx); - return { ...plan, mode: cacheAware.strategy as CompressionMode }; + return { ...plan, mode: cacheAware.strategy as CompressionMode }; // ...plan preserves source } return plan; @@ -185,9 +181,11 @@ export function selectCompressionStrategy( estimatedTokens: number, body?: Record, context?: CachingDetectionContext, - combos: NamedCombos = {} + combos: NamedCombos = {}, + header: string | null = null ): CompressionMode { - return selectCompressionPlan(config, comboId, estimatedTokens, body, context, combos).mode as CompressionMode; + return selectCompressionPlan(config, comboId, estimatedTokens, body, context, combos, header) + .mode as CompressionMode; } /** @@ -433,9 +431,7 @@ async function applyUltraAsync( stats: { ...slm.stats, mode: "ultra", - techniquesUsed: Array.from( - new Set([...(slm.stats.techniquesUsed ?? []), "ultra-slm"]) - ), + techniquesUsed: Array.from(new Set([...(slm.stats.techniquesUsed ?? []), "ultra-slm"])), }, }; } @@ -445,7 +441,11 @@ async function applyUltraAsync( } // SLM tier unavailable or produced no gain → fall back per slmFallbackToAggressive. - return applyCompression(body, ultraConfig?.slmFallbackToAggressive ? "aggressive" : "ultra", options); + return applyCompression( + body, + ultraConfig?.slmFallbackToAggressive ? "aggressive" : "ultra", + options + ); } function normalizePipelineStep(step: CompressionPipelineStep | string): CompressionPipelineStep { diff --git a/open-sse/services/opencodeOllamaUsage.ts b/open-sse/services/opencodeOllamaUsage.ts new file mode 100644 index 0000000000..7a8befa01c --- /dev/null +++ b/open-sse/services/opencodeOllamaUsage.ts @@ -0,0 +1,562 @@ +import { sanitizeErrorMessage } from "../utils/error.ts"; + +type JsonRecord = Record; +type UsageQuota = { + used: number; + total: number; + remaining?: number; + remainingPercentage?: number; + resetAt: string | null; + unlimited: boolean; + displayName?: string; + details?: Array<{ name: string; used: number }>; + currency?: string; +}; + +const OPENCODE_GO_QUOTA_URL = + process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL ?? "https://api.z.ai/api/monitor/usage/quota/limit"; +const OPENCODE_GO_DASHBOARD_BASE_URL = + process.env.OMNIROUTE_OPENCODE_GO_DASHBOARD_URL ?? "https://opencode.ai/workspace"; +const OPENCODE_GO_QUOTA_TOTALS = { session: 12, weekly: 30, mcp_monthly: 60 } as const; +const OPENCODE_GO_QUOTA_ORDER = ["session", "weekly", "mcp_monthly"] as const; +const OPENCODE_GO_SCRAPED_NUMBER = String.raw`(-?\d+(?:\.\d+)?)`; +const OLLAMA_CLOUD_USAGE_URL = + process.env.OMNIROUTE_OLLAMA_CLOUD_USAGE_URL ?? "https://ollama.com/settings"; +const OLLAMA_CLOUD_SESSION_COOKIE = "__Secure-session"; + +type OpenCodeGoQuotaName = (typeof OPENCODE_GO_QUOTA_ORDER)[number]; +type DashboardWindow = { usagePercent: number; resetAt: string | null }; +type OpenCodeGoDashboardUsage = Partial>; +type OpenCodeGoDashboardConfig = + | { state: "configured"; workspaceId: string; authCookie: string } + | { state: "incomplete"; missing: string } + | { state: "none" }; +type OllamaCloudUsage = { + session?: DashboardWindow; + weekly?: DashboardWindow; + planTier?: string | null; +}; +type OllamaCloudConfig = + | { state: "configured"; cookie: string } + | { state: "invalid"; error: string } + | { state: "none" }; + +function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function toNumber(value: unknown, fallback = 0): number { + const parsed = + typeof value === "number" + ? value + : typeof value === "string" && value.trim().length > 0 + ? Number(value) + : Number.NaN; + return Number.isFinite(parsed) ? parsed : fallback; +} + +function toPercentage(value: unknown): number { + return Math.max(0, Math.min(100, toNumber(value, 0))); +} + +function toTitleCase(value: string): string { + return value + .trim() + .split(/[\s_-]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join(" "); +} + +function roundCurrency(value: number): number { + return Math.round(value * 100) / 100; +} + +function safeToIsoString(time: number): string | null { + if (!Number.isFinite(time) || time < 0 || time > 8.64e15) return null; + try { + return new Date(time).toISOString(); + } catch { + return null; + } +} + +function parseResetTime(resetValue: unknown): string | null { + const numeric = toNumber(resetValue, Number.NaN); + if (Number.isFinite(numeric) && numeric > 0) return safeToIsoString(numeric); + if (typeof resetValue !== "string" || !resetValue.trim()) return null; + const parsed = Date.parse(resetValue); + return Number.isFinite(parsed) ? safeToIsoString(parsed) : null; +} + +function getProviderSpecificString(data: JsonRecord | undefined, keys: string[]): string { + const obj = toRecord(data); + for (const key of keys) { + const value = obj[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return ""; +} + +function resolveOpenCodeGoDashboardConfig( + providerSpecificData?: JsonRecord +): OpenCodeGoDashboardConfig { + const workspaceId = + process.env.OMNIROUTE_OPENCODE_GO_WORKSPACE_ID?.trim() || + process.env.OPENCODE_GO_WORKSPACE_ID?.trim() || + getProviderSpecificString(providerSpecificData, [ + "openCodeGoWorkspaceId", + "opencodeGoWorkspaceId", + "workspaceId", + ]); + const authCookie = + process.env.OMNIROUTE_OPENCODE_GO_AUTH_COOKIE?.trim() || + process.env.OPENCODE_GO_AUTH_COOKIE?.trim() || + getProviderSpecificString(providerSpecificData, [ + "openCodeGoAuthCookie", + "opencodeGoAuthCookie", + "authCookie", + ]); + + if (!workspaceId && !authCookie) return { state: "none" }; + if (workspaceId && authCookie) return { state: "configured", workspaceId, authCookie }; + return { + state: "incomplete", + missing: workspaceId ? "OPENCODE_GO_AUTH_COOKIE" : "OPENCODE_GO_WORKSPACE_ID", + }; +} + +function normalizeOpenCodeGoAuthCookie(value: string): string { + return value + .trim() + .replace(/^auth=/i, "") + .trim(); +} + +function buildBearerAuthorization(value: string): string { + const token = value + .trim() + .replace(/^Bearer\s+/i, "") + .trim(); + return token ? `Bearer ${token}` : ""; +} + +function getOpenCodeGoTokenQuotaName( + limit: JsonRecord, + existingQuotas: Record +): "session" | "weekly" { + const unit = toNumber(limit.unit, 0); + const number = toNumber(limit.number, 0); + if (unit === 3 && number === 5) return "session"; + if (unit === 6 && number === 1) return "weekly"; + if ((unit === 4 && number === 7) || (unit === 3 && number >= 24 * 7)) return "weekly"; + return existingQuotas.session ? "weekly" : "session"; +} + +function buildOpenCodeGoDollarQuota( + quotaName: OpenCodeGoQuotaName, + percentage: unknown, + resetAt: string | null, + usedOverride?: unknown, + details?: UsageQuota["details"] +): UsageQuota { + const total = OPENCODE_GO_QUOTA_TOTALS[quotaName]; + const percentUsed = toPercentage(percentage); + const rawUsed = toNumber(usedOverride, Number.NaN); + const used = roundCurrency( + Number.isFinite(rawUsed) ? Math.max(0, Math.min(total, rawUsed)) : (total * percentUsed) / 100 + ); + const remaining = roundCurrency(Math.max(0, total - used)); + return { + used, + total, + remaining, + remainingPercentage: + total > 0 ? Math.max(0, Math.min(100, Math.round((remaining / total) * 100))) : 100, + resetAt, + unlimited: false, + displayName: + quotaName === "session" ? "5-hour rolling" : quotaName === "weekly" ? "Weekly" : "Monthly", + currency: "USD", + details, + }; +} + +function orderOpenCodeGoQuotas(quotas: Record): Record { + const ordered: Record = {}; + for (const key of OPENCODE_GO_QUOTA_ORDER) if (quotas[key]) ordered[key] = quotas[key]; + for (const [key, quota] of Object.entries(quotas)) if (!ordered[key]) ordered[key] = quota; + return ordered; +} + +function parseOpenCodeGoSsrWindow(html: string, field: string): DashboardWindow | null { + for (const candidate of [ + { + usageIndex: 1, + resetIndex: 2, + pattern: String.raw`${field}:\$R\[\d+\]=\{[^}]*usagePercent:${OPENCODE_GO_SCRAPED_NUMBER}[^}]*resetInSec:${OPENCODE_GO_SCRAPED_NUMBER}[^}]*\}`, + }, + { + usageIndex: 2, + resetIndex: 1, + pattern: String.raw`${field}:\$R\[\d+\]=\{[^}]*resetInSec:${OPENCODE_GO_SCRAPED_NUMBER}[^}]*usagePercent:${OPENCODE_GO_SCRAPED_NUMBER}[^}]*\}`, + }, + ]) { + const match = new RegExp(candidate.pattern).exec(html); + if (!match) continue; + const usagePercent = toNumber(match[candidate.usageIndex], Number.NaN); + const resetInSec = toNumber(match[candidate.resetIndex], Number.NaN); + if (Number.isFinite(usagePercent) && Number.isFinite(resetInSec)) { + return { + usagePercent, + resetAt: safeToIsoString(Date.now() + Math.max(0, resetInSec) * 1000), + }; + } + } + return null; +} + +function parseOpenCodeGoHumanReset(value: string): number | null { + const text = value.toLowerCase().replace(/\s+/g, " ").trim(); + if (["reset-now", "reset now", "now", "resets now"].includes(text)) return 0; + const days = text.match(/(\d+(?:\.\d+)?)\s*days?/); + const hours = text.match(/(\d+(?:\.\d+)?)\s*hours?/); + const minutes = text.match(/(\d+(?:\.\d+)?)\s*minutes?/); + const seconds = text.match(/(\d+(?:\.\d+)?)\s*seconds?/); + if (!days && !hours && !minutes && !seconds) return null; + return ( + toNumber(days?.[1], 0) * 86_400 + + toNumber(hours?.[1], 0) * 3_600 + + toNumber(minutes?.[1], 0) * 60 + + toNumber(seconds?.[1], 0) + ); +} + +function parseOpenCodeGoDashboardHtml(html: string): OpenCodeGoDashboardUsage | null { + const usage: OpenCodeGoDashboardUsage = { + session: parseOpenCodeGoSsrWindow(html, "rollingUsage") ?? undefined, + weekly: parseOpenCodeGoSsrWindow(html, "weeklyUsage") ?? undefined, + mcp_monthly: parseOpenCodeGoSsrWindow(html, "monthlyUsage") ?? undefined, + }; + if (usage.session || usage.weekly || usage.mcp_monthly) return usage; + + for (const content of html.split(/data-slot="usage-item"/).slice(1)) { + const label = content + .match(/data-slot="usage-label">([^<]+)[^0-9]*(\d+(?:\.\d+)?)/)?.[1], + Number.NaN + ); + const resetMatch = content.match(/data-slot="(reset-time|reset-now)">([\s\S]*?)<\/span>/); + if (!label || !Number.isFinite(usagePercent) || !resetMatch) continue; + const resetInSec = + resetMatch[1] === "reset-now" + ? 0 + : parseOpenCodeGoHumanReset( + // Strip any HTML comment, INCLUDING an unterminated one (React SSR emits + // / hydration markers). The `(?:-->|$)` arm consumes a + // trailing "" too, so no partial "|$)/g, "").replace(/Resets?\s*in\s*/i, "") + ); + if (resetInSec === null || !Number.isFinite(resetInSec)) continue; + const window = { + usagePercent, + resetAt: safeToIsoString(Date.now() + Math.max(0, resetInSec) * 1000), + }; + if (label.includes("rolling")) usage.session = window; + else if (label.includes("weekly")) usage.weekly = window; + else if (label.includes("monthly")) usage.mcp_monthly = window; + } + return usage.session || usage.weekly || usage.mcp_monthly ? usage : null; +} + +async function fetchOpenCodeGoDashboardUsage( + config: Extract +) { + const url = `${OPENCODE_GO_DASHBOARD_BASE_URL.replace(/\/+$/, "")}/${encodeURIComponent( + config.workspaceId + )}/go`; + const response = await fetch(url, { + headers: { + Accept: "text/html", + Cookie: `auth=${normalizeOpenCodeGoAuthCookie(config.authCookie)}`, + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/148.0", + }, + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) + return { usage: null, message: `OpenCode Go dashboard error (${response.status}).` }; + const usage = parseOpenCodeGoDashboardHtml(await response.text()); + return { + usage, + message: usage ? undefined : "OpenCode Go dashboard response did not contain quota windows.", + }; +} + +export async function getOpenCodeGoUsage(apiKey: string, providerSpecificData?: JsonRecord) { + const dashboardConfig = resolveOpenCodeGoDashboardConfig(providerSpecificData); + if (dashboardConfig.state === "incomplete") { + return { + message: `OpenCode Go dashboard quota config is incomplete. Missing ${dashboardConfig.missing}.`, + }; + } + if (dashboardConfig.state === "configured") { + try { + const dashboard = await fetchOpenCodeGoDashboardUsage(dashboardConfig); + if (!dashboard.usage) { + return { message: dashboard.message || "OpenCode Go dashboard quota data unavailable." }; + } + const quotas: Record = {}; + for (const quotaName of OPENCODE_GO_QUOTA_ORDER) { + const usage = dashboard.usage[quotaName]; + if (usage) { + quotas[quotaName] = buildOpenCodeGoDollarQuota( + quotaName, + usage.usagePercent, + usage.resetAt + ); + } + } + return { plan: "OpenCode Go", quotas: orderOpenCodeGoQuotas(quotas) }; + } catch (error) { + return { message: `OpenCode Go dashboard quota error: ${sanitizeErrorMessage(error)}` }; + } + } + + const token = apiKey.trim().replace(/^Bearer\s+/i, ""); + if (!token) { + return { + message: + "OpenCode Go quota requires OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE. " + + "The API key can be used for chat/models, but OpenCode Go does not expose quota via API key.", + }; + } + + try { + const res = await fetch(OPENCODE_GO_QUOTA_URL, { + headers: { + Authorization: buildBearerAuthorization(token), + "Accept-Language": "en-US,en", + "Content-Type": "application/json", + Accept: "application/json", + }, + }); + if (!res.ok) { + if (res.status === 401 || res.status === 403) { + return { + message: + "OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " + + "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping.", + }; + } + return { + message: + `OpenCode Go quota API error (${res.status}). ` + + "Set OMNIROUTE_OPENCODE_GO_QUOTA_URL to a working endpoint, or follow " + + "https://github.com/anomalyco/opencode/issues/16017 for upstream status.", + }; + } + + let json: unknown; + try { + json = await res.json(); + } catch { + return { message: "OpenCode Go quota response parsing failed." }; + } + const root = toRecord(json); + if ( + toNumber(root.code, 200) === 401 || + toNumber(root.code, 200) === 403 || + root.success === false + ) { + return { + message: + "OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " + + "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping.", + }; + } + + const data = toRecord(root.data); + const quotas: Record = {}; + for (const limit of Array.isArray(data.limits) ? data.limits : []) { + const src = toRecord(limit); + const type = String(src.type || "").toUpperCase(); + const resetAt = parseResetTime(src.nextResetTime); + if (type === "TOKENS_LIMIT" || type === "TOKEN_LIMIT") { + const quotaName = getOpenCodeGoTokenQuotaName(src, quotas); + quotas[quotaName] = buildOpenCodeGoDollarQuota( + quotaName, + src.percentage, + resetAt, + undefined, + Array.isArray(src.models) + ? src.models.map((model) => { + const modelInfo = toRecord(model); + return { + name: String(modelInfo.model || modelInfo.modelCode || "usage"), + used: toNumber(modelInfo.percentage, 0), + }; + }) + : undefined + ); + } else if (type === "TIME_LIMIT" || type === "TIME_USAGE_LIMIT") { + quotas.mcp_monthly = buildOpenCodeGoDollarQuota( + "mcp_monthly", + src.percentage, + resetAt, + src.currentValue, + Array.isArray(src.usageDetails) + ? src.usageDetails.map((item) => { + const detail = toRecord(item); + return { + name: String(detail.modelCode || detail.name || "usage"), + used: toNumber(detail.usage, 0), + }; + }) + : undefined + ); + } + } + + const levelRaw = + typeof data.planName === "string" + ? data.planName + : typeof data.level === "string" + ? data.level + : ""; + const planLabel = toTitleCase(levelRaw.replace(/\s*plan$/i, "")); + return { + plan: planLabel + ? /^opencode\s+go\b/i.test(planLabel) + ? planLabel + : `OpenCode Go ${planLabel}` + : null, + quotas: orderOpenCodeGoQuotas(quotas), + }; + } catch (error) { + return { message: `OpenCode Go quota API error: ${sanitizeErrorMessage(error)}` }; + } +} + +function resolveOllamaCloudConfig(providerSpecificData?: JsonRecord): OllamaCloudConfig { + const cookie = + process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE?.trim() || + process.env.OLLAMA_USAGE_COOKIE?.trim() || + process.env.OLLAMA_CLOUD_USAGE_COOKIE?.trim() || + getProviderSpecificString(providerSpecificData, [ + "ollamaUsageCookie", + "ollamaCloudUsageCookie", + "ollamaCloudCookie", + "usageCookie", + "cookie", + ]); + if (!cookie) return { state: "none" }; + if (cookie.includes("\r") || cookie.includes("\n")) { + return { state: "invalid", error: "Ollama Cloud cookie contains invalid CRLF characters." }; + } + return { state: "configured", cookie }; +} + +function normalizeOllamaCloudCookie(value: string): string { + const trimmed = value.trim(); + return trimmed.toLowerCase().startsWith(`${OLLAMA_CLOUD_SESSION_COOKIE.toLowerCase()}=`) + ? trimmed.slice(OLLAMA_CLOUD_SESSION_COOKIE.length + 1).trim() + : trimmed; +} + +function extractOllamaUsagePercent(trackHtml: string): number | null { + const tagHeader = trackHtml.match(/^[^>]*/)?.[0] ?? ""; + const ariaMatch = tagHeader.match(/(\d+(?:\.\d+)?)%\s*used/); + if (ariaMatch) { + const pct = toNumber(ariaMatch[1], Number.NaN); + if (Number.isFinite(pct) && pct >= 0 && pct <= 100) return pct; + } + const style = tagHeader.match(/style="([^"]*)"/)?.[1] ?? ""; + const pct = toNumber(style.match(/(?:^|;)\s*width\s*:\s*([0-9.]+)%/)?.[1], Number.NaN); + return Number.isFinite(pct) && pct >= 0 && pct <= 100 ? pct : null; +} + +function parseOllamaCloudSettingsHtml(html: string): OllamaCloudUsage | null { + const parts = html.split(/\bdata-usage-track\b/); + if (parts.length < 2) return null; + const extractTime = (text: string): string | null => { + const match = text.match(/class="[^"]*local-time[^"]*"[^>]*data-time="([^"]*)"/); + return match?.[1] || null; + }; + const sessionPercent = extractOllamaUsagePercent(parts[1]); + const weeklyPercent = parts[2] ? extractOllamaUsagePercent(parts[2]) : null; + if (sessionPercent === null && weeklyPercent === null) return null; + return { + ...(sessionPercent !== null + ? { session: { usagePercent: sessionPercent, resetAt: extractTime(parts[1]) } } + : {}), + ...(weeklyPercent !== null + ? { weekly: { usagePercent: weeklyPercent, resetAt: extractTime(parts[2]) } } + : {}), + planTier: html.match(/class="[^"]*capitalize[^"]*"[^>]*>([^<]*) +) { + const response = await fetch(OLLAMA_CLOUD_USAGE_URL, { + redirect: "manual", + headers: { + Accept: "text/html", + Cookie: `${OLLAMA_CLOUD_SESSION_COOKIE}=${normalizeOllamaCloudCookie(config.cookie)}`, + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/148.0", + }, + signal: AbortSignal.timeout(10_000), + }); + if (response.status >= 300 && response.status < 400) { + return { usage: null, message: "Ollama Cloud authentication expired. Refresh the cookie." }; + } + if (!response.ok) + return { usage: null, message: `Ollama Cloud settings error (${response.status}).` }; + const usage = parseOllamaCloudSettingsHtml(await response.text()); + return { + usage, + message: usage ? undefined : "Ollama Cloud settings page did not contain usage quota tracks.", + }; +} + +export async function getOllamaCloudUsage(providerSpecificData?: JsonRecord) { + const config = resolveOllamaCloudConfig(providerSpecificData); + if (config.state === "none") { + return { + message: + "Ollama Cloud quota requires OLLAMA_USAGE_COOKIE. Copy the __Secure-session cookie from ollama.com/settings.", + }; + } + if (config.state === "invalid") return { message: config.error }; + + try { + const result = await fetchOllamaCloudUsageFromSettings(config); + if (!result.usage) return { message: result.message || "Ollama Cloud quota data unavailable." }; + const quotas: Record = {}; + for (const key of ["session", "weekly"] as const) { + const quota = result.usage[key]; + if (!quota) continue; + const pct = toPercentage(quota.usagePercent); + quotas[key] = { + used: pct, + total: 100, + remaining: Math.max(0, 100 - pct), + remainingPercentage: Math.max(0, 100 - pct), + resetAt: quota.resetAt, + unlimited: false, + displayName: key === "session" ? "Session" : "Weekly", + }; + } + return { + plan: result.usage.planTier ? `Ollama Cloud ${result.usage.planTier}` : "Ollama Cloud", + quotas, + }; + } catch (error) { + return { message: `Ollama Cloud quota error: ${sanitizeErrorMessage(error)}` }; + } +} diff --git a/open-sse/services/providerCooldownTracker.ts b/open-sse/services/providerCooldownTracker.ts index afb5a9303f..f5a14d2006 100644 --- a/open-sse/services/providerCooldownTracker.ts +++ b/open-sse/services/providerCooldownTracker.ts @@ -17,13 +17,16 @@ interface CooldownEntry { lastFailureAt: number; /** Number of consecutive failures (resets on success) */ failureCount: number; + /** How long this entry must be retained for cleanup purposes */ + retentionMs: number; } // Global cooldown state: keyed by "provider:connectionId" or "provider" const cooldownMap = new Map(); -// Evict entries older than this to prevent unbounded memory growth -const MAX_ENTRY_AGE_MS = 30 * 60 * 1000; // 30 minutes +// Evict entries older than their configured retention horizon to prevent +// unbounded memory growth without shortening operator-configured cooldowns. +const DEFAULT_ENTRY_RETENTION_MS = 30 * 60 * 1000; // 30 minutes const CLEANUP_INTERVAL_MS = 60 * 1000; // Cleanup every 60s let cleanupTimer: ReturnType | null = null; @@ -31,17 +34,32 @@ let cleanupTimer: ReturnType | null = null; function startCleanupIfNeeded(): void { if (cleanupTimer) return; cleanupTimer = setInterval(() => { - const now = Date.now(); - for (const [key, entry] of cooldownMap) { - if (now - entry.lastFailureAt > MAX_ENTRY_AGE_MS) { - cooldownMap.delete(key); - } - } + cleanupExpiredCooldownEntries(); }, CLEANUP_INTERVAL_MS); // Allow Node.js to exit even if the timer is running if (cleanupTimer.unref) cleanupTimer.unref(); } +function getEntryRetentionMs(settings?: ResilienceSettings): number { + const maxRetryCooldownMs = + settings?.providerCooldown?.maxRetryCooldownMs ?? + DEFAULT_RESILIENCE_SETTINGS.providerCooldown.maxRetryCooldownMs; + return Math.max(DEFAULT_ENTRY_RETENTION_MS, maxRetryCooldownMs); +} + +/** + * Remove expired cooldown entries using each entry's configured retention + * horizon. Exported for diagnostics and focused tests; normal runtime cleanup is + * still performed by the unref'd interval started on first cooldown record. + */ +export function cleanupExpiredCooldownEntries(now = Date.now()): void { + for (const [key, entry] of cooldownMap) { + if (now - entry.lastFailureAt > entry.retentionMs) { + cooldownMap.delete(key); + } + } +} + /** * Build a cooldown key from provider and optional connectionId. */ @@ -66,12 +84,14 @@ export function recordProviderCooldown( const key = cooldownKey(provider, connectionId); const existing = cooldownMap.get(key); const now = Date.now(); + const retentionMs = getEntryRetentionMs(settings); if (existing) { existing.lastFailureAt = now; existing.failureCount++; + existing.retentionMs = Math.max(existing.retentionMs, retentionMs); } else { - cooldownMap.set(key, { lastFailureAt: now, failureCount: 1 }); + cooldownMap.set(key, { lastFailureAt: now, failureCount: 1, retentionMs }); } startCleanupIfNeeded(); diff --git a/open-sse/services/reasoningTokenBuffer.ts b/open-sse/services/reasoningTokenBuffer.ts index 23290de713..65d021e7f5 100644 --- a/open-sse/services/reasoningTokenBuffer.ts +++ b/open-sse/services/reasoningTokenBuffer.ts @@ -1,7 +1,4 @@ import { getResolvedModelCapabilities } from "../../src/lib/modelCapabilities.ts"; -import { MODEL_SPECS } from "../../src/shared/constants/modelSpecs.ts"; - -const DEFAULT_MAX_OUTPUT_TOKENS = MODEL_SPECS.__default__.maxOutputTokens; export function toPositiveInteger(value: unknown): number | null { const numericValue = @@ -29,7 +26,7 @@ export function resolveReasoningBufferedMaxTokens( if (capabilities.supportsThinking !== true) return null; const maxOutputTokens = toPositiveInteger(capabilities.maxOutputTokens); - if (maxOutputTokens === null || maxOutputTokens === DEFAULT_MAX_OUTPUT_TOKENS) return null; + if (maxOutputTokens === null) return null; if (current > maxOutputTokens) return maxOutputTokens; if (current === maxOutputTokens) return current; diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 6db2f04ea3..edd14b8a09 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -20,6 +20,7 @@ import { getDbInstance } from "@/lib/db/core"; import { fetchBailianQuota, type BailianTripleWindowQuota } from "./bailianQuotaFetcher.ts"; import { fetchDeepseekQuota, type DeepseekQuota } from "./deepseekQuotaFetcher.ts"; import { fetchOpencodeQuota, type OpencodeTripleWindowQuota } from "./opencodeQuotaFetcher.ts"; +import { getOllamaCloudUsage, getOpenCodeGoUsage } from "./opencodeOllamaUsage.ts"; import { applyAntigravityClientProfileHeaders, getAntigravityBootstrapHeaders, @@ -91,19 +92,6 @@ const NANOGPT_CONFIG = { usageUrl: "https://nano-gpt.com/api/subscription/v1/usage", }; -const OPENCODE_GO_QUOTA_URL = - // Note: api.z.ai rejects opencode-go keys with {"code":401}. This default is a - // known broken placeholder (see issues #10448, #16017). The env-var override lets - // operators point at a working endpoint once OpenCode ships one. - process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL ?? "https://api.z.ai/api/monitor/usage/quota/limit"; -const OPENCODE_GO_QUOTA_TOTALS = { - session: 12, - weekly: 30, - mcp_monthly: 60, -} as const; -const OPENCODE_GO_QUOTA_ORDER = ["session", "weekly", "mcp_monthly"] as const; -type OpenCodeGoQuotaName = (typeof OPENCODE_GO_QUOTA_ORDER)[number]; - // Cursor dashboard usage API config // The endpoint that powers https://cursor.com/dashboard/spending. Validates the WorkOS // session via the WorkosCursorSessionToken cookie (format: `${userId}::${jwt}`) and @@ -214,77 +202,6 @@ function getGlmQuotaDisplayName(quotaName: string): string { if (quotaName === "weekly") return "Weekly Quota"; return quotaName; } - -function getOpenCodeGoTokenQuotaName( - limit: JsonRecord, - existingQuotas: Record -): "session" | "weekly" { - const unit = toNumber(limit.unit, 0); - const number = toNumber(limit.number, 0); - - if (unit === 3 && number === 5) return "session"; - if (unit === 6 && number === 1) return "weekly"; - if ((unit === 4 && number === 7) || (unit === 3 && number >= 24 * 7)) return "weekly"; - - return existingQuotas.session ? "weekly" : "session"; -} - -function getOpenCodeGoQuotaDisplayName(quotaName: OpenCodeGoQuotaName): string { - if (quotaName === "session") return "5-hour rolling"; - if (quotaName === "weekly") return "Weekly"; - return "Monthly"; -} - -function normalizeOpenCodeGoQuotaToken(apiKey: string): string { - return apiKey.trim().replace(/^Bearer\s+/i, ""); -} - -function buildOpenCodeGoDollarQuota( - quotaName: OpenCodeGoQuotaName, - percentage: unknown, - resetAt: string | null, - usedOverride?: unknown, - details?: UsageQuota["details"] -): UsageQuota { - const total = OPENCODE_GO_QUOTA_TOTALS[quotaName]; - const percentUsed = toPercentage(percentage); - const rawUsed = toNumber(usedOverride, Number.NaN); - const used = roundCurrency( - Number.isFinite(rawUsed) ? Math.max(0, Math.min(total, rawUsed)) : (total * percentUsed) / 100 - ); - const remaining = roundCurrency(Math.max(0, total - used)); - const remainingPercentage = - total > 0 - ? clampPercentage(Math.round((remaining / total) * 100)) - : clampPercentage(100 - percentUsed); - - return { - used, - total, - remaining, - remainingPercentage, - resetAt, - unlimited: false, - displayName: getOpenCodeGoQuotaDisplayName(quotaName), - currency: "USD", - details, - }; -} - -function orderOpenCodeGoQuotas(quotas: Record): Record { - const ordered: Record = {}; - - for (const key of OPENCODE_GO_QUOTA_ORDER) { - if (quotas[key]) ordered[key] = quotas[key]; - } - - for (const [key, quota] of Object.entries(quotas)) { - if (!ordered[key]) ordered[key] = quota; - } - - return ordered; -} - function getFieldValue(source: unknown, snakeKey: string, camelKey: string): unknown { const obj = toRecord(source); return obj[snakeKey] ?? obj[camelKey] ?? null; @@ -966,121 +883,6 @@ async function getGlmUsage(apiKey: string, providerSpecificData?: Record).code, 200); - if (code === 401 || code === 403 || (json as Record).success === false) { - return { - message: - "OpenCode Go does not expose a public quota API. Chat requests still work. " + - "Set OMNIROUTE_OPENCODE_GO_QUOTA_URL to a working endpoint, or follow " + - "https://github.com/anomalyco/opencode/issues/16017 for upstream status.", - }; - } - - const data = toRecord((json as Record).data); - const limits: unknown[] = Array.isArray(data.limits) ? data.limits : []; - const quotas: Record = {}; - - for (const limit of limits) { - const src = toRecord(limit); - const type = String(src.type || "").toUpperCase(); - const resetAt = parseResetTime(src.nextResetTime); - - if (type === "TOKENS_LIMIT" || type === "TOKEN_LIMIT") { - const quotaName = getOpenCodeGoTokenQuotaName(src, quotas); - - quotas[quotaName] = buildOpenCodeGoDollarQuota( - quotaName, - src.percentage, - resetAt, - undefined, - Array.isArray(src.models) - ? (src.models as unknown[]).map((model) => { - const modelInfo = toRecord(model); - return { - name: String(modelInfo.model || modelInfo.modelCode || "usage"), - used: toNumber(modelInfo.percentage, 0), - }; - }) - : undefined - ); - continue; - } - - if (type === "TIME_LIMIT" || type === "TIME_USAGE_LIMIT") { - quotas.mcp_monthly = buildOpenCodeGoDollarQuota( - "mcp_monthly", - src.percentage, - resetAt, - src.currentValue, - Array.isArray(src.usageDetails) - ? src.usageDetails.map((item) => { - const detail = toRecord(item); - return { - name: String(detail.modelCode || detail.name || "usage"), - used: toNumber(detail.usage, 0), - }; - }) - : undefined - ); - } - } - - const levelRaw = - typeof data.planName === "string" - ? data.planName - : typeof data.level === "string" - ? data.level - : ""; - const planLabel = toTitleCase(levelRaw.replace(/\s*plan$/i, "")); - const plan = planLabel - ? /^opencode\s+go\b/i.test(planLabel) - ? planLabel - : `OpenCode Go ${planLabel}` - : null; - - return { plan, quotas: orderOpenCodeGoQuotas(quotas) }; -} - /** * Bailian (Alibaba Coding Plan) Usage * Fetches triple-window quota (5h, weekly, monthly) and returns worst-case. @@ -1589,7 +1391,9 @@ export async function getUsageForProvider( ...(provider === "glm-cn" ? { apiRegion: "china" } : {}), }); case "opencode-go": - return await getOpenCodeGoUsage(apiKey || ""); + return await getOpenCodeGoUsage(apiKey || "", providerSpecificData); + case "ollama-cloud": + return await getOllamaCloudUsage(providerSpecificData); case "minimax": case "minimax-cn": return await getMiniMaxUsage(apiKey || "", provider); @@ -3118,7 +2922,10 @@ async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRec // "auth expired, chat may still work" message instead of a generic upstream-error blob // so the quota card matches what users with legacy social-auth accounts already see. // Inspired by https://github.com/decolua/9router/pull/620. - if ((response.status === 401 || response.status === 403) && isSocialAuthKiroAccount(providerSpecificData)) { + if ( + (response.status === 401 || response.status === 403) && + isSocialAuthKiroAccount(providerSpecificData) + ) { return { message: "Kiro quota API authentication expired. Chat may still work.", quotas: {}, diff --git a/open-sse/translator/deepseekWebTools.ts b/open-sse/translator/deepseekWebTools.ts new file mode 100644 index 0000000000..34723839f2 --- /dev/null +++ b/open-sse/translator/deepseekWebTools.ts @@ -0,0 +1,441 @@ +// DeepSeek-web-specific tool-call translation. +// +// chat.deepseek.com has no native function calling, so OmniRoute serializes the OpenAI +// `tools[]` into a prompt contract and parses the model's text reply back into OpenAI +// `tool_calls`. The canonical `webTools.ts` parser handles the well-behaved +// `{json}` / bare-JSON shapes used by most web-cookie providers, and it MUST +// stay untouched (it works for the others). +// +// DeepSeek, however, emits a much wider zoo of ad-hoc shapes: +// {json} name in the tag suffix, body is the arguments +// {id,type,params} alternate key names (type → name, params → arguments) +// {json} name in an attribute +// {json} tool name in the id attribute +// {json} doubled / nested wrappers +// x{json} XML children +// parameter style +// +// A single regex cannot robustly cover all of these (nesting + attributes + XML children), +// so this parser tokenizes the tool tags and walks them with a stack instead. It reuses the +// proven JSON-normalization / fuzzy-name-matching / range-stripping helpers from webTools.ts +// rather than duplicating them. + +import { + parseToolCallsFromText, + parseLooseJsonObject, + getRequestedToolNames, + resolveRequestedToolName, + toArgumentsString, + stripRanges, + type OpenAIToolCall, + type RequestedToolName, +} from "./webTools.ts"; + +interface OpenAIToolDef { + type?: string; + function?: { name?: string; description?: string; parameters?: unknown }; +} + +// ── Stricter, compact tool-use prompt ─────────────────────────────────────── + +/** + * Serialize an OpenAI `tools` array into a DeepSeek-specific system-prompt block. + * + * It is deliberately stricter than the generic `serializeToolsToPrompt`: DeepSeek tends to + * (a) invent its own wrappers and (b) merely *describe* a plan instead of emitting a call. + * The wording forces the single canonical `{json}` shape and forbids the + * alternatives, while staying short to avoid wasting tokens. + */ +export function serializeDeepSeekToolPrompt(tools: unknown): string { + if (!Array.isArray(tools) || tools.length === 0) return ""; + + const lines: string[] = []; + for (const t of tools as OpenAIToolDef[]) { + const fn = t?.function; + if (!fn?.name) continue; + const desc = typeof fn.description === "string" && fn.description ? fn.description : ""; + let params = ""; + try { + params = fn.parameters ? JSON.stringify(fn.parameters) : ""; + } catch { + params = ""; + } + lines.push( + `- ${fn.name}${desc ? `: ${desc}` : ""}${params ? `\n parameters: ${params}` : ""}` + ); + } + if (lines.length === 0) return ""; + + return [ + "You can call tools. To call a tool, output ONLY this exact block (no markdown fence):", + '{"name": "", "arguments": { ... }}', + "Rules:", + "- Use exactly .... Do NOT use , , , , id=/name= attributes, or code fences.", + '- "name" must be one of the tools below; "arguments" must be a JSON object.', + "- When a tool is needed, emit the block instead of only describing the plan.", + "- Emit one block per call; you may put several blocks back to back.", + "- If no tool is needed, just answer normally without any block.", + "", + "Available tools:", + ...lines, + ].join("\n"); +} + +// ── Tool-aware conversation prompt ─────────────────────────────────────────── + +interface ChatMessage { + role: string; + content?: unknown; + tool_calls?: Array<{ id?: string; function?: { name?: string; arguments?: unknown } }>; + tool_call_id?: string; + name?: string; +} + +function extractText(content: unknown): string { + if (Array.isArray(content)) { + return (content as Array<{ type?: string; text?: string }>) + .filter((item) => item?.type === "text") + .map((item) => item?.text ?? "") + .join("\n"); + } + return content == null ? "" : String(content); +} + +/** + * Build the single `prompt` string for an agentic (tool-using) DeepSeek-web turn. + * + * The web endpoint takes a flat prompt with no `messages[]`, so the legacy `messagesToPrompt` + * only forwarded the last user message — which makes an agent loop amnesiac: on every turn the + * follow-up messages carry no new *user* text, so DeepSeek only ever saw the original task and + * kept restarting (re-creating todos, re-listing files…). This builder instead replays the WHOLE + * trajectory — including the assistant's prior `` calls and each `role:"tool"` result — so + * the model continues from where it left off instead of starting over. + */ +export function buildToolConversationPrompt( + messages: ChatMessage[], + toolSystemPrompt: string +): string { + const systemParts: string[] = []; + if (toolSystemPrompt) systemParts.push(toolSystemPrompt); + + const lines: string[] = []; + const callNameById = new Map(); + let sawToolActivity = false; + + for (const m of messages) { + if (m.role === "system") { + const t = extractText(m.content).trim(); + if (t) systemParts.push(t); + } else if (m.role === "user") { + const t = extractText(m.content).trim(); + if (t) lines.push(`User: ${t}`); + } else if (m.role === "assistant") { + const t = extractText(m.content).trim(); + const calls = Array.isArray(m.tool_calls) ? m.tool_calls : []; + const parts: string[] = []; + if (t) parts.push(t); + for (const c of calls) { + const name = typeof c?.function?.name === "string" ? c.function.name : ""; + const rawArgs = c?.function?.arguments; + const args = + typeof rawArgs === "string" && rawArgs ? rawArgs : JSON.stringify(rawArgs ?? {}); + if (c?.id) callNameById.set(c.id, name); + parts.push(`{"name": ${JSON.stringify(name)}, "arguments": ${args}}`); + sawToolActivity = true; + } + if (parts.length) lines.push(`Assistant: ${parts.join("\n")}`); + } else if (m.role === "tool") { + const t = extractText(m.content).trim(); + const name = (m.tool_call_id && callNameById.get(m.tool_call_id)) || m.name || "tool"; + lines.push(`Tool result (${name}): ${t || "(no output)"}`); + sawToolActivity = true; + } + } + + const parts: string[] = []; + if (systemParts.length) parts.push(systemParts.join("\n\n")); + if (lines.length) parts.push(lines.join("\n\n")); + if (sawToolActivity) { + // Anchor the model to the work already done so it advances instead of repeating it. + parts.push( + "Continue the task using the tool results above. Do NOT repeat tool calls that already " + + "succeeded; perform the next step or give the final answer." + ); + } + + return parts.join("\n\n").replace(/!\[.*?\]\(.*?\)/g, ""); +} + +// ── Tag tokenizer ──────────────────────────────────────────────────────────── + +interface TagToken { + start: number; + end: number; + closing: boolean; + suffix: string; // tool name after ':' in the tag (e.g. `` → "bash") + attrs: string; // raw attribute text inside the tag +} + +// Matches an opening/closing or tag, optionally with a `:name` +// suffix and an attribute list. `tool_call` is listed first so it wins the alternation. +const TAG_TOKEN_RE = /<(\/?)(?:tool_call|tool)(:[A-Za-z0-9_.+-]+)?((?:\s[^>]*)?)\/?>/g; + +function tokenizeToolTags(text: string): TagToken[] { + const tokens: TagToken[] = []; + let m: RegExpExecArray | null; + TAG_TOKEN_RE.lastIndex = 0; + while ((m = TAG_TOKEN_RE.exec(text)) !== null) { + tokens.push({ + start: m.index, + end: TAG_TOKEN_RE.lastIndex, + closing: m[1] === "/", + suffix: m[2] ? m[2].slice(1) : "", + attrs: m[3] || "", + }); + } + return tokens; +} + +interface ToolBlock { + open: TagToken; + close: TagToken; + innerStart: number; + innerEnd: number; +} + +// Pair tags with a stack: every closing tag pairs with the nearest unmatched open. An open +// left unmatched at the end (e.g. the stray outer `` of a doubled wrapper, or a +// never-closed `` followed by ``) gets a synthetic close at the end +// of the text so its body is still parsed; the doubled-wrapper outer is then dropped by the +// leaf filter. +function pairToolBlocks(tokens: TagToken[], textLen: number): ToolBlock[] { + const blocks: ToolBlock[] = []; + const stack: TagToken[] = []; + for (const tok of tokens) { + if (!tok.closing) { + stack.push(tok); + continue; + } + const open = stack.pop(); + if (!open) continue; + blocks.push({ open, close: tok, innerStart: open.end, innerEnd: tok.start }); + } + for (const open of stack) { + const synthetic: TagToken = { + start: textLen, + end: textLen, + closing: true, + suffix: "", + attrs: "", + }; + blocks.push({ open, close: synthetic, innerStart: open.end, innerEnd: textLen }); + } + return blocks; +} + +// ── Attribute / XML-child helpers ──────────────────────────────────────────── + +/** Read an attribute value, tolerating backslash-escaped quotes inside the value. */ +function getAttr(attrs: string, name: string): string | null { + const re = new RegExp(`\\b${name}\\s*=\\s*("|')`); + const m = re.exec(attrs); + if (!m) return null; + const quote = m[1]; + let j = m.index + m[0].length; + let out = ""; + while (j < attrs.length) { + const ch = attrs[j]; + if (ch === "\\") { + out += attrs[j + 1] ?? ""; + j += 2; + continue; + } + if (ch === quote) break; + out += ch; + j += 1; + } + return out; +} + +function getXmlChild(inner: string, tag: string): string | null { + const m = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i").exec(inner); + return m ? m[1].trim() : null; +} + +// The body group is a tempered greedy token: `(?:(?!` (no closing tag) cannot let the body matcher swallow a +// following `...` and drop that parameter. +const PARAM_TAG_RE = /]*?)\/?>(?:((?:(?!)?/gi; + +/** Collect `` / `y` into an object. */ +function buildArgsFromParameters(inner: string): Record | null { + const out: Record = {}; + let found = false; + let m: RegExpExecArray | null; + PARAM_TAG_RE.lastIndex = 0; + while ((m = PARAM_TAG_RE.exec(inner)) !== null) { + const attrs = m[1] || ""; + const body = m[2]; + const name = getAttr(attrs, "name"); + if (!name) continue; + const value = getAttr(attrs, "content") ?? (typeof body === "string" ? body.trim() : ""); + out[name] = value; + found = true; + } + return found ? out : null; +} + +// ── Single-block extraction ────────────────────────────────────────────────── + +interface ExtractedCall { + name: string; + arguments: string; +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +/** + * Turn one tool block (tag name + inner text) into a name + JSON-string arguments. + * Returns null when no plausible tool name can be recovered. + */ +function extractCall( + tagName: string, + innerRaw: string, + requested: RequestedToolName[] +): ExtractedCall | null { + const inner = innerRaw.trim(); + + const nameChild = getXmlChild(inner, "name"); + const argsChild = getXmlChild(inner, "arguments") ?? getXmlChild(inner, "parameters"); + const paramObj = argsChild ? null : buildArgsFromParameters(inner); + const hasXmlChildren = !!nameChild || !!argsChild || !!paramObj; + + const json = hasXmlChildren ? null : parseLooseJsonObject(inner); + const jsonName = json ? (asString(json.name) ?? asString(json.type)) : null; + + const childResolved = nameChild ? resolveRequestedToolName(nameChild, requested) : null; + const jsonResolved = jsonName ? resolveRequestedToolName(jsonName, requested) : null; + const tagResolved = tagName ? resolveRequestedToolName(tagName, requested) : null; + + // Prefer a name that maps to a requested tool. The JSON body wins over the tag attribute + // because DeepSeek sometimes emits a bogus tag name (e.g. name="skill", #3260). + let name: string | null = null; + let nameFromTag = false; + const pick = (val: string | null, fromTag: boolean) => { + if (!name && val) { + name = val; + nameFromTag = fromTag; + } + }; + pick(childResolved, false); + pick(jsonResolved, false); + pick(tagResolved, true); + pick(nameChild, false); + pick(jsonName, false); + pick(tagName, true); + + // Shell-style `{ "command": "..." }` with no tag name: treat command as the tool name only + // if it actually resolves to a requested tool (the value is otherwise the command itself). + if (!name && !tagName && json) { + const command = asString(json.command); + const resolved = command ? resolveRequestedToolName(command, requested) : null; + if (resolved) { + name = resolved; + nameFromTag = false; + } + } + if (!name) return null; + + let argsValue: unknown; + if (argsChild) { + argsValue = parseLooseJsonObject(argsChild) ?? argsChild; + } else if (paramObj) { + argsValue = paramObj; + } else if (json) { + if (json.arguments !== undefined) argsValue = json.arguments; + else if (json.params !== undefined) argsValue = json.params; + else if (nameFromTag) { + // `{"command": ...}` — the whole JSON object is the arguments payload. + argsValue = json; + } else { + // Name came from the JSON body — the remaining keys are the arguments. + const { name: _n, type: _t, id: _i, command: _c, arguments: _a, params: _p, ...rest } = json; + argsValue = rest; + } + } else { + argsValue = {}; + } + + return { name, arguments: toArgumentsString(argsValue) }; +} + +// ── Public parser ───────────────────────────────────────────────────────────── + +/** + * Parse a DeepSeek-web text reply into OpenAI `tool_calls`. Returns the surrounding text with the recognized blocks stripped (so it can + * still be streamed to the client) plus the parsed calls, or `null` when none are present. + * + * Falls back to the canonical `webTools.parseToolCallsFromText` for tag-free replies so that + * bare-JSON and plain `` behavior stays identical to the shared implementation. + */ +export function parseDeepSeekToolCalls( + text: string, + idSeed = "call", + requestedTools?: unknown +): { content: string; toolCalls: OpenAIToolCall[] | null } { + if (typeof text !== "string" || text.length === 0) { + return { content: text ?? "", toolCalls: null }; + } + + const tokens = tokenizeToolTags(text); + if (tokens.length === 0) { + // No DeepSeek-specific tags — defer to the proven canonical parser (bare JSON, etc.). + return parseToolCallsFromText(text, idSeed, requestedTools); + } + + const requested = getRequestedToolNames(requestedTools); + const blocks = pairToolBlocks(tokens, text.length); + + // Only extract from leaf blocks (no other block nested inside), so a doubled + // `...` wrapper yields a single call from the inner block. + const isLeaf = (b: ToolBlock) => + !blocks.some((o) => o !== b && o.open.start >= b.innerStart && o.close.end <= b.innerEnd); + + const toolCalls: OpenAIToolCall[] = []; + const acceptedRanges: Array<{ start: number; end: number }> = []; + + for (const block of blocks.filter(isLeaf).sort((a, b) => a.open.start - b.open.start)) { + const tagName = + block.open.suffix || + getAttr(block.open.attrs, "name") || + getAttr(block.open.attrs, "id") || + ""; + const inner = text.slice(block.innerStart, block.innerEnd); + const call = extractCall(tagName, inner, requested); + if (!call) continue; + toolCalls.push({ + id: `${idSeed}_${toolCalls.length}`, + type: "function", + function: { name: call.name, arguments: call.arguments }, + }); + acceptedRanges.push({ start: block.open.start, end: block.close.end }); + } + + if (toolCalls.length === 0) { + // Tags were present but none parsed (e.g. malformed) — try the canonical bare-JSON path. + return parseToolCallsFromText(text, idSeed, requestedTools); + } + + // Strip the accepted blocks plus any stray tool tags left outside them (the unmatched outer + // `` of a doubled wrapper, leftover `` of a non-leaf wrapper, etc.). + const within = (tok: TagToken) => + acceptedRanges.some((r) => tok.start >= r.start && tok.end <= r.end); + const ranges = [ + ...acceptedRanges, + ...tokens.filter((t) => !within(t)).map((t) => ({ start: t.start, end: t.end })), + ]; + + return { content: stripRanges(text, ranges), toolCalls }; +} diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index 92b825f46e..fadc428731 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -55,7 +55,10 @@ export function claudeToGeminiRequest(model, body, stream, credentials = null) { result.generationConfig.topK = body.top_k; } if (body.max_tokens !== undefined) { - result.generationConfig.maxOutputTokens = capMaxOutputTokens(model, body.max_tokens); + const maxOutputTokens = capMaxOutputTokens(model, body.max_tokens); + if (maxOutputTokens !== null) { + result.generationConfig.maxOutputTokens = maxOutputTokens; + } } // ── System instruction ───────────────────────────────────────── diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 4ec6236f75..55a1f28433 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -41,14 +41,13 @@ function applyCopilotSummarizedThinkingDisplay( // - max_tokens must be <= model output cap (e.g. 128000 for Opus 4.7) const MIN_CLAUDE_THINKING_BUDGET = 1024; const MIN_RESPONSE_ROOM = 1024; -const FALLBACK_OUTPUT_CAP = 128000; -function safeCapMaxOutputTokens(model: string): number { +function safeCapMaxOutputTokens(model: string): number | null { try { const cap = capMaxOutputTokens(model); - return typeof cap === "number" && cap > 0 ? cap : FALLBACK_OUTPUT_CAP; + return typeof cap === "number" && cap > 0 ? cap : null; } catch { - return FALLBACK_OUTPUT_CAP; + return null; } } @@ -86,26 +85,35 @@ export function fitThinkingToMaxTokens( // No budgeted thinking — just cap max_tokens to the model output ceiling. if (!thinking || requestedBudget <= 0) { return { - maxTokens: Math.min(Math.max(callerMaxTokens, 1), modelCap), + maxTokens: + modelCap === null + ? Math.max(callerMaxTokens, 1) + : Math.min(Math.max(callerMaxTokens, 1), modelCap), thinking, }; } let responseRoom = Math.max(callerMaxTokens, MIN_RESPONSE_ROOM); - let target = Math.min(responseRoom + requestedBudget, modelCap); + let target = + modelCap === null + ? responseRoom + requestedBudget + : Math.min(responseRoom + requestedBudget, modelCap); let fittedBudget = target - responseRoom; // If the cap squeezed thinking below Anthropic's floor, try shrinking // response room to MIN_RESPONSE_ROOM to recover budget. if (fittedBudget < MIN_CLAUDE_THINKING_BUDGET && responseRoom > MIN_RESPONSE_ROOM) { responseRoom = MIN_RESPONSE_ROOM; - target = Math.min(responseRoom + requestedBudget, modelCap); + target = + modelCap === null + ? responseRoom + requestedBudget + : Math.min(responseRoom + requestedBudget, modelCap); fittedBudget = target - responseRoom; } // Cap too tight for any thinking — disable rather than send an invalid request. if (fittedBudget < MIN_CLAUDE_THINKING_BUDGET) { - return { maxTokens: modelCap, thinking: undefined }; + return { maxTokens: modelCap ?? Math.max(callerMaxTokens, 1), thinking: undefined }; } const adjustedThinking: Record = { ...thinking }; diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 39e2016da6..bc9e6651e6 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -32,9 +32,8 @@ import { import { buildGeminiTools, sanitizeGeminiToolName } from "../helpers/geminiToolsSanitizer.ts"; // Observed Antigravity wrapper output cap, not an underlying model capability. -// Keep this bridge-local: capMaxOutputTokens() falls back to OmniRoute's generic -// 8192 default for unknown Claude-family IDs, while Antigravity currently caps -// visible output around 16K. See: https://github.com/keisksw/antigravity-output-analysis +// Keep this bridge-local: Antigravity currently caps visible output around 16K. +// See: https://github.com/keisksw/antigravity-output-analysis const ANTIGRAVITY_CLAUDE_MAX_OUTPUT_TOKENS = 16_384; type GeminiPart = Record; @@ -274,13 +273,12 @@ function openaiToGeminiBase( if (body.stop !== undefined) { result.generationConfig.stopSequences = Array.isArray(body.stop) ? body.stop : [body.stop]; } - const requestedMaxOutputTokens = (body.max_tokens ?? body.max_completion_tokens) as - | number - | undefined; - if (requestedMaxOutputTokens !== undefined) { - result.generationConfig.maxOutputTokens = capMaxOutputTokens(model, requestedMaxOutputTokens); - } else { - result.generationConfig.maxOutputTokens = capMaxOutputTokens(model); + const maxOutputTokens = capMaxOutputTokens( + model, + (body.max_tokens ?? body.max_completion_tokens) as number | undefined + ); + if (maxOutputTokens !== null) { + result.generationConfig.maxOutputTokens = maxOutputTokens; } // Thinking / Reasoning support (Google Gemini 2.0+ Thinking models) diff --git a/open-sse/translator/webTools.ts b/open-sse/translator/webTools.ts index 28a94219d9..a3fc8f1766 100644 --- a/open-sse/translator/webTools.ts +++ b/open-sse/translator/webTools.ts @@ -34,7 +34,7 @@ interface ToolParseCandidate { requireRequestedTool: boolean; } -interface RequestedToolName { +export interface RequestedToolName { original: string; normalized: string; } @@ -45,7 +45,7 @@ function toRecord(value: unknown): Record | null { : null; } -function getRequestedToolNames(tools: unknown): RequestedToolName[] { +export function getRequestedToolNames(tools: unknown): RequestedToolName[] { if (!Array.isArray(tools)) return []; const names: RequestedToolName[] = []; const seen = new Set(); @@ -102,7 +102,10 @@ function scoreToolName(emitted: string, requested: RequestedToolName): number { return similarity >= 0.72 ? similarity : 0; } -function resolveRequestedToolName(emitted: string, requestedTools: RequestedToolName[]): string | null { +export function resolveRequestedToolName( + emitted: string, + requestedTools: RequestedToolName[] +): string | null { if (requestedTools.length === 0) return emitted; let best: { name: string; score: number } | null = null; @@ -227,7 +230,7 @@ function normalizeLooseJson(value: string): string { .replace(/,\s*([}\]])/g, "$1"); } -function parseLooseJsonObject(raw: string): Record | null { +export function parseLooseJsonObject(raw: string): Record | null { const trimmed = stripCodeFence(raw); for (const candidate of [trimmed, normalizeLooseJson(trimmed)]) { try { @@ -282,7 +285,10 @@ function findBareJsonCandidates(text: string): ToolParseCandidate[] { depth -= 1; if (depth === 0 && start >= 0) { const raw = text.slice(start, i + 1); - if (/[{,]\s*["']?(name|command)["']?\s*:/i.test(raw) && /[{,]\s*["']?arguments["']?\s*:/i.test(raw)) { + if ( + /[{,]\s*["']?(name|command)["']?\s*:/i.test(raw) && + /[{,]\s*["']?arguments["']?\s*:/i.test(raw) + ) { candidates.push({ raw, start, end: i + 1, requireRequestedTool: true }); } start = -1; @@ -293,11 +299,14 @@ function findBareJsonCandidates(text: string): ToolParseCandidate[] { return candidates; } -function rangesOverlap(a: { start: number; end: number }, b: { start: number; end: number }): boolean { +function rangesOverlap( + a: { start: number; end: number }, + b: { start: number; end: number } +): boolean { return a.start < b.end && b.start < a.end; } -function stripRanges(text: string, ranges: Array<{ start: number; end: number }>): string { +export function stripRanges(text: string, ranges: Array<{ start: number; end: number }>): string { let content = text; const sorted = [...ranges].sort((a, b) => b.start - a.start); for (const range of sorted) { @@ -308,13 +317,18 @@ function stripRanges(text: string, ranges: Array<{ start: number; end: number }> const afterOnLine = content.slice(range.end, lineEnd); const removeWholeLine = beforeOnLine.trim() === "" && afterOnLine.trim() === ""; const start = removeWholeLine ? lineStart : range.start; - const end = removeWholeLine && nextLineBreak !== -1 ? nextLineBreak + 1 : removeWholeLine ? lineEnd : range.end; + const end = + removeWholeLine && nextLineBreak !== -1 + ? nextLineBreak + 1 + : removeWholeLine + ? lineEnd + : range.end; content = `${content.slice(0, start)}${content.slice(end)}`; } return content.replace(/\n{3,}/g, "\n\n").trim(); } -function toArgumentsString(value: unknown): string { +export function toArgumentsString(value: unknown): string { if (value === undefined) return "{}"; if (typeof value === "string") { const parsed = parseLooseJsonObject(value); @@ -346,7 +360,9 @@ export function serializeToolsToPrompt(tools: unknown): string { } catch { params = ""; } - lines.push(`- ${fn.name}${desc ? `: ${desc}` : ""}${params ? `\n parameters: ${params}` : ""}`); + lines.push( + `- ${fn.name}${desc ? `: ${desc}` : ""}${params ? `\n parameters: ${params}` : ""}` + ); } if (lines.length === 0) return ""; @@ -471,7 +487,7 @@ interface ToolPrepResult { */ export function prepareToolMessages( bodyObj: Record, - messages: Array<{ role: string; content: unknown }>, + messages: Array<{ role: string; content: unknown }> ): ToolPrepResult { const requestedTools = bodyObj.tools; const hasTools = Array.isArray(requestedTools) && requestedTools.length > 0; @@ -500,9 +516,13 @@ interface ToolCompletionResult { export function buildToolAwareResult( rawContent: string, requestedTools: unknown, - idSeed = "call", + idSeed = "call" ): ToolCompletionResult { - const { content, toolCalls } = parseToolCallsFromText(rawContent, `${idSeed}-${Date.now()}`, requestedTools); + const { content, toolCalls } = parseToolCallsFromText( + rawContent, + `${idSeed}-${Date.now()}`, + requestedTools + ); return { content, toolCalls, diff --git a/open-sse/utils/noThinkingAlias.ts b/open-sse/utils/noThinkingAlias.ts index 9acbb2b6c8..c0dfcfbb6b 100644 --- a/open-sse/utils/noThinkingAlias.ts +++ b/open-sse/utils/noThinkingAlias.ts @@ -6,7 +6,7 @@ * thinking-capable model into a no-thinking mode purely by *model selection*, the * gateway exposes a synthetic catalog id: * - * claude-3-omniroute-no-thinking// + * no-think// * * When such an id arrives on a request we strip the prefix back to the real * `/` and suppress reasoning (`thinking:{type:"disabled"}` for the @@ -21,7 +21,7 @@ */ import { getModelSpec } from "@/shared/constants/modelSpecs"; -export const NO_THINKING_PREFIX = "claude-3-omniroute-no-thinking/"; +export const NO_THINKING_PREFIX = "no-think/"; /** True when `modelId` carries the no-thinking gateway prefix. */ export function isNoThinkingAlias(modelId: unknown): modelId is string { diff --git a/open-sse/utils/proxyDispatcher.ts b/open-sse/utils/proxyDispatcher.ts index aab0050a66..df120d11a6 100644 --- a/open-sse/utils/proxyDispatcher.ts +++ b/open-sse/utils/proxyDispatcher.ts @@ -108,10 +108,42 @@ function getProxyDispatcherOptions(env: Record = pro }; } +export function getDefaultDispatcherConnectionLimit( + env: Record = process.env +): number { + const raw = env.OMNIROUTE_DIRECT_DISPATCHER_CONNECTIONS; + if (raw == null || raw.trim() === "") return DEFAULT_PROXY_DISPATCHER_CONNECTIONS; + + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 1) { + console.warn( + `[ProxyDispatcher] Invalid OMNIROUTE_DIRECT_DISPATCHER_CONNECTIONS="${raw}". Using default ${DEFAULT_PROXY_DISPATCHER_CONNECTIONS}.` + ); + return DEFAULT_PROXY_DISPATCHER_CONNECTIONS; + } + + return Math.min(Math.floor(parsed), MAX_PROXY_DISPATCHER_CONNECTIONS); +} + +function getDefaultDispatcherOptions(env: Record = process.env) { + const options = getDispatcherOptions(); + // #4580 — On the direct egress path, undici's default pipelining (1) lets a long + // SSE stream monopolize the single pooled socket per origin, serializing every + // other concurrent request to that same provider. Mirror the proxy fix (#4288): + // disable pipelining and keep several connections available. Unlike the proxy + // path we KEEP keep-alive — the 1ms TTL there is a cheap-proxy-socket workaround, + // not needed (and harmful to perf) for direct connections. + return { + ...options, + connections: getDefaultDispatcherConnectionLimit(env), + pipelining: 0, + }; +} + export function getDefaultDispatcher(): Dispatcher { const globalWithCache = globalThis as GlobalWithDispatcherCache; if (!globalWithCache[DEFAULT_DISPATCHER_KEY]) { - globalWithCache[DEFAULT_DISPATCHER_KEY] = new Agent(getDispatcherOptions()); + globalWithCache[DEFAULT_DISPATCHER_KEY] = new Agent(getDefaultDispatcherOptions()); } return globalWithCache[DEFAULT_DISPATCHER_KEY]; } @@ -362,6 +394,12 @@ export function __getProxyDispatcherOptionsForTest( return getProxyDispatcherOptions(env); } +export function __getDefaultDispatcherOptionsForTest( + env: Record = process.env +) { + return getDefaultDispatcherOptions(env); +} + export function createProxyDispatcher(proxyUrl: string): Dispatcher { const normalizedUrl = normalizeProxyUrl(proxyUrl, "proxy dispatcher"); const dispatcherCache = getDispatcherCache(); diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 3e51ea4e91..6d54886840 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -1545,6 +1545,26 @@ export function createSSEStream(options: StreamOptions = {}) { if (Array.isArray(parsed.choices) && parsed.choices.length === 0) { const emptyChoicesUsage = extractUsage(parsed) ?? parsed.usage; if (hasValidUsage(emptyChoicesUsage)) { + // Some upstreams (e.g. Ollama Cloud) emit prompt_tokens: 0 + // even when input was sent — they simply don't count input + // tokens. When we have a non-zero output but zero input, + // estimate the real input token count from the request body. + if ( + emptyChoicesUsage && + typeof emptyChoicesUsage === "object" && + !Array.isArray(emptyChoicesUsage) && + emptyChoicesUsage.completion_tokens > 0 + ) { + const pt = emptyChoicesUsage.prompt_tokens ?? 0; + if (pt === 0) { + const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI); + if (estimated?.prompt_tokens > 0) { + emptyChoicesUsage.prompt_tokens = estimated.prompt_tokens; + emptyChoicesUsage.total_tokens = + (emptyChoicesUsage.total_tokens ?? 0) + estimated.prompt_tokens; + } + } + } usage = emptyChoicesUsage; output = `data: ${JSON.stringify(parsed)}\n\n`; injectedUsage = true; diff --git a/package-lock.json b/package-lock.json index c390e17096..8a1767faf8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "omniroute", - "version": "3.8.33", + "version": "3.8.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.33", + "version": "3.8.34", "hasInstallScript": true, "license": "MIT", "workspaces": [ "open-sse" ], "dependencies": { - "@aws-sdk/client-bedrock-runtime": "^3.1045.0", + "@aws-sdk/client-bedrock-runtime": "^3.1073.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -24,7 +24,7 @@ "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.23", "@types/mdx": "^2.0.13", - "@xyflow/react": "^12.10.2", + "@xyflow/react": "^12.11.1", "axios": "^1.16.1", "bcryptjs": "^3.0.3", "bottleneck": "^2.19.5", @@ -34,9 +34,9 @@ "express": "^5.2.1", "fetch-socks": "^1.3.3", "fflate": "^0.8.3", - "fumadocs-core": "^16.9.0", + "fumadocs-core": "^16.10.5", "fumadocs-mdx": "^15.0.7", - "fumadocs-ui": "^16.9.0", + "fumadocs-ui": "^16.10.5", "http-proxy-middleware": "^4.0.0", "https-proxy-agent": "^9.0.0", "ink": "^7.0.3", @@ -44,13 +44,13 @@ "ink-text-input": "^6.0.0", "ioredis": "^5.10.1", "jose": "^6.2.3", - "js-yaml": "^4.1.1", + "js-yaml": "^5.0.0", "jsonc-parser": "^3.3.1", "lowdb": "^7.0.1", - "lucide-react": "^1.16.0", + "lucide-react": "^1.21.0", "marked": "^18.0.4", "marked-terminal": "^7.3.0", - "material-symbols": "^0.45.1", + "material-symbols": "^0.45.2", "mermaid": "^11.15.0", "monaco-editor": "^0.55.1", "next": "^16.2.6", @@ -58,7 +58,7 @@ "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", "open": "^11.0.0", - "ora": "^9.4.0", + "ora": "^9.4.1", "parse5": "^8.0.1", "pino": "^10.3.1", "pino-abstract-transport": "^3.0.0", @@ -103,7 +103,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/bun": "latest", "@types/keytar": "^4.4.2", - "@types/node": "^25.9.1", + "@types/node": "^26.0.0", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.0", @@ -114,15 +114,15 @@ "dpdm": "^4.2.0", "eslint": "^9.39.4", "eslint-config-next": "16.2.9", - "eslint-plugin-sonarjs": "^4.0.3", + "eslint-plugin-sonarjs": "^4.1.0", "fast-check": "^4.8.0", "glob": "^13.0.6", "husky": "^9.1.7", "jscpd": "^4.2.5", "jsdom": "^29.1.1", - "knip": "^6.16.1", + "knip": "^6.18.0", "license-checker-rseidelsohn": "^5.0.1", - "lint-staged": "^17.0.5", + "lint-staged": "^17.0.8", "lockfile-lint": "^5.0.0", "node-loader": "^2.1.0", "prettier": "^3.8.3", @@ -355,9 +355,9 @@ } }, "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1071.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1071.0.tgz", - "integrity": "sha512-1Sa7UTC98Wy+M+zYbbXEqhcL5WUE3VnAmO1Y2RikmYjWx/8nCHudjSj1bVovk6Obfrrsir9Q1AbrmKbgxwI90g==", + "version": "3.1073.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1073.0.tgz", + "integrity": "sha512-Vecj8r9/KIh/Nu9T7CRoCw5EBqnmAa9Q+Iwi5J5Mr0IEBMH6KUoOgAjayfyEZjvvZTllLJ2dOAx5cYeIz8QD6A==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -367,7 +367,7 @@ "@aws-sdk/eventstream-handler-node": "^3.972.22", "@aws-sdk/middleware-eventstream": "^3.972.18", "@aws-sdk/middleware-websocket": "^3.972.30", - "@aws-sdk/token-providers": "3.1071.0", + "@aws-sdk/token-providers": "3.1073.0", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", @@ -379,6 +379,23 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/token-providers": { + "version": "3.1073.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1073.0.tgz", + "integrity": "sha512-Tolawuc3I9Q6pElcqoBQMLCiCOfKn3eqG4oNIRci4BurhsrJmzXkhF3N+6LRXJrWYFtJKfTkBuLbYCLr8+pwig==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/core": { "version": "3.974.22", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.22.tgz", @@ -2291,6 +2308,29 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -4901,9 +4941,9 @@ } }, "node_modules/@oxc-parser/binding-android-arm-eabi": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.135.0.tgz", - "integrity": "sha512-sHeZItACNcA5WRAWqF6ixriR4GkZDyY10gVgnZU7pXku1DjHFATSqnwZM809jl0gXPHxb6fKzYQCK7bNK5cACQ==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.137.0.tgz", + "integrity": "sha512-KDs+0VPdEmasOkpuJHW9V5WCF+cvYdMQv2Jd+aJXt+cxIx12NToRQRbXaRwUEDsZw+/jMk81Ve8ZFbjUkJTOwA==", "cpu": [ "arm" ], @@ -4918,9 +4958,9 @@ } }, "node_modules/@oxc-parser/binding-android-arm64": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.135.0.tgz", - "integrity": "sha512-wPte+SzgzWWFgMSF8YZDNM+tBXtJg0AXBi7+tU3yS2z1f2Af9kRLZLKuJojADmuD/cZexmnMHHC3SDItTW77Iw==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.137.0.tgz", + "integrity": "sha512-WhALNzfy3x/RfC6bsqX+csavuUY0yHHE7XfgPE5M542uhoBZUUoGTPG+nkMbGoG4+gcfss5s7urMyn5QBHu0sw==", "cpu": [ "arm64" ], @@ -4935,9 +4975,9 @@ } }, "node_modules/@oxc-parser/binding-darwin-arm64": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.135.0.tgz", - "integrity": "sha512-BmKz3lHIsqVos+9aPcdYCT9MG3APoUyM43KlEFhJMWNVDOGG8FKyiFz81Bc+mGz2o0hpuQ3PfXLfVWJrKXjo2g==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.137.0.tgz", + "integrity": "sha512-bFPr5hgmNMOMoyPTGtdsK4Ug21RovIPojRMgDDhSp1LtCnc/DkLwGONKjgRjszg677RlGnkYSviQ8hHaUPOVYA==", "cpu": [ "arm64" ], @@ -4952,9 +4992,9 @@ } }, "node_modules/@oxc-parser/binding-darwin-x64": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.135.0.tgz", - "integrity": "sha512-dM8BS+8+Br1fNvmh2QZbGiHaYttwLebRa6J4Uz9vuFzMNmvsdRYwf7993ptOaV0JTrR63AaoVLjX7nhWbijxjQ==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.137.0.tgz", + "integrity": "sha512-CL5dMm1asqXIDZHg14FLxj3Mc36w8PI7xCWh1uA4is6z8g2XrIILoTcQYOxDbwzuk34RDPX5IAGUxZr6LA9KAg==", "cpu": [ "x64" ], @@ -4969,9 +5009,9 @@ } }, "node_modules/@oxc-parser/binding-freebsd-x64": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.135.0.tgz", - "integrity": "sha512-xlZnvvJdR9bGu2pOhvR5hMuKPHCE6Sa9owK5A484mzjHdm75VRV5nCs5w/jkmGODMMTFc+KN7EnZqEieM813kw==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.137.0.tgz", + "integrity": "sha512-79h8rYGnSlKPGWo7mHr2ixO6ea7aW8B0CT965SZ8SLbNnCOH5aOYBTeVXUY6eMvEaiLyWr8Skuiugr5pDYgLGw==", "cpu": [ "x64" ], @@ -4986,9 +5026,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.135.0.tgz", - "integrity": "sha512-PSR8LmBK/H/PQRiN8g7RebQgZX/ntVCrdT/JBfNxE5ezdHG1s2i4rbazsRJYD83TTI1MmgTpC0MGL42PLtskQQ==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.137.0.tgz", + "integrity": "sha512-ASgmlSimhGyr0lksgVIo6hibz1obnDq4qJbiMX/AzltfgPnanRrzG1Q+23g8ljOHOjv6dsznkUuCYL3gg0sY1Q==", "cpu": [ "arm" ], @@ -5003,9 +5043,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.135.0.tgz", - "integrity": "sha512-I85GJXzfUsigkkk7Ngdz95C217M4FdUi1Z2HrX5UyPmURobwQZ7m2bbUvwFkz4VGZd+lymFGKHvDZ3RQC9qOzA==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.137.0.tgz", + "integrity": "sha512-AU2J9aa22Sx32wRGnDjybOU9TQXXQUud5sdUi+ZB0XxwM8aToWLweV+yA0wlQm0yIUVqljquqoHCYEq9II8gJQ==", "cpu": [ "arm" ], @@ -5020,9 +5060,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm64-gnu": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.135.0.tgz", - "integrity": "sha512-zqEY0npz0g0aGZj/8a5BclunjVDytsBQHYtIC10Gd26HcrLwbVF6YDbqRQjunMGYdSo97u6xOBl05aTDI2diDQ==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.137.0.tgz", + "integrity": "sha512-GdEtiG89yMr7XkUGxifgodXEEm2f+xW2f9CpDjlgAnBOwhTmrpQMvhOGobLVKUyzf/qHBXW16smk5zbF3nZU6w==", "cpu": [ "arm64" ], @@ -5040,9 +5080,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm64-musl": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.135.0.tgz", - "integrity": "sha512-mWAfprP819gQ2qYst1RxgTI8b/z0b29OpoKfRflIXLHde2dZLihQD4g47Onuvtpo5GPIkMYPRlX9QoeZfs/GnQ==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.137.0.tgz", + "integrity": "sha512-EGJ+Bs8iXx8KBH8DQ5BLoEm5lnHaYjlh4/8j8vFhrr/6z4tqONy5BZDzLpKmmNWlN6Hlc5r8YOuBVHqZ9vRFEQ==", "cpu": [ "arm64" ], @@ -5060,9 +5100,9 @@ } }, "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.135.0.tgz", - "integrity": "sha512-gri8c2AOmJKJwOux2KTHFBfUaXoJURuVMKhmKEi/2hTF55cQteTDV2XNfTiE5oCC+Tnem1Y4/MWzcyDadtsSag==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.137.0.tgz", + "integrity": "sha512-vzFUQENy/fnbSe5DZWovq6tIBc1uhuMztanSW6rz1e9WdQE4gHwYuD7ZII6JnrJifd1R3RSoqiZbgRFlVL2tYQ==", "cpu": [ "ppc64" ], @@ -5080,9 +5120,9 @@ } }, "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.135.0.tgz", - "integrity": "sha512-Y2tkupCG5wo0SxH2rMLG4d4Kmv6DaM3sBp+GuM5lox0S8Za6VxKgQrY2Mut088QQxKkEE89n/4CCCgmw2o0e3Q==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.137.0.tgz", + "integrity": "sha512-SfVI14HBQs9gtLcUD5hTt5hsNbdrqSUNg9S8muN+LhVQ5nf1WwH3hAoK6B9NKgdYgWAQSXFXGiiBedQ4r/BKuw==", "cpu": [ "riscv64" ], @@ -5100,9 +5140,9 @@ } }, "node_modules/@oxc-parser/binding-linux-riscv64-musl": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.135.0.tgz", - "integrity": "sha512-xDRJq6i6WTynjeP+ISbDpyH4p9BaJ0wuQcL0lCSDkt9qOXC9dmwpOu1VG/TlwmPI3KpYntmO9nJCuc3TMTsNBA==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.137.0.tgz", + "integrity": "sha512-e7Ppy4FCIFNQxT/ikSeIWFoQ0l+N9vgtRBtLcyZXeolTzApyVoPqEXsYPrcdM/9i0Bwk8knvYd37vaEMxHyi6g==", "cpu": [ "riscv64" ], @@ -5120,9 +5160,9 @@ } }, "node_modules/@oxc-parser/binding-linux-s390x-gnu": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.135.0.tgz", - "integrity": "sha512-V4MoUuiCRNvihxhIufRxvK+ka013V4joTSK0FAGA1KEjLuNprfH6N/Qw2uxQEVIFuNYMhD/hV6xJ/ptbzlKdHg==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.137.0.tgz", + "integrity": "sha512-Bho5qFwdhqsIFR7gipYEUlqvi3SRrY8sugxXig380MIaakBB1PyU9+7dBiBVScfImTNWhijUxdBwqrprGdq5WA==", "cpu": [ "s390x" ], @@ -5140,9 +5180,9 @@ } }, "node_modules/@oxc-parser/binding-linux-x64-gnu": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.135.0.tgz", - "integrity": "sha512-JCFZ7zM7KXOKoPAbK/ZB4wY0M1jxRECiem2UQuiXLjzGqS9+hno7mtX+qyK2F7HWK2xPhyJb+frpcOtk5DKOtg==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.137.0.tgz", + "integrity": "sha512-36mGWtg7PyFzjJwGDkH6/F4o2nIDEoKXLPr/X/lwqklkomQwJJt1I5GJVmGhovUEmgPK5WAeAZMqlFCehwiy9Q==", "cpu": [ "x64" ], @@ -5160,9 +5200,9 @@ } }, "node_modules/@oxc-parser/binding-linux-x64-musl": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.135.0.tgz", - "integrity": "sha512-9jSVS1b3hOV7sdKH4aA2DFfnTz0RgQd0v2BefR+LYbH8yIlmSM22JJZbAAjVeVXmFgUAk3zJQ1tpE/Nd+Vi2YQ==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.137.0.tgz", + "integrity": "sha512-/Jqx6+N7A44n2BdvUr7pXhVr2vFjs6WGH3unZRczwrfiH0H1zY0QwKQMG/dtRiTlKGDKGukznPT8lx84/oEsZg==", "cpu": [ "x64" ], @@ -5180,9 +5220,9 @@ } }, "node_modules/@oxc-parser/binding-openharmony-arm64": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.135.0.tgz", - "integrity": "sha512-M857ZLBSdn1Uy/SJJz5zh0qGu67B4P9omCgXGBU2LLqTzraX6ZjVNaKq5yW1PDw/LgJXDXR/dbZfgmB310f11Q==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.137.0.tgz", + "integrity": "sha512-9Uj0qHNNl+OgT1UTGwF7ixIXU6T1u2SbMidmgPy/h1h/fl2gRS6YpAxxY1gwHofcWjoTwkoMFd8xs5Vuj6GOFA==", "cpu": [ "arm64" ], @@ -5197,9 +5237,9 @@ } }, "node_modules/@oxc-parser/binding-wasm32-wasi": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.135.0.tgz", - "integrity": "sha512-2w6DVcntQZX9U5RhXtgiWb3FLWFB5EcwI1U8yr3htOCJUJjagN4BFUHz/Y/d9ZsumndZ6ByxxWEtbUZNE1bfFw==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.137.0.tgz", + "integrity": "sha512-gW2vfkytNGgMVADiuzdvOfw0mWG9za20F/1fCJsif5aBMAvWJTSbpIXbIe0XkOe0VENk+PadpQ7cZgUy2sUJcA==", "cpu": [ "wasm32" ], @@ -5207,18 +5247,71 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.5" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@oxc-parser/binding-win32-arm64-msvc": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.135.0.tgz", - "integrity": "sha512-rX1U8+IH2Z37EJjDXKa1iifvUQAdba+vZ4Ewj1iaG5eA/QaSybzclCOwtWa0/5BuUQnnK/T2JHUEFrwhL6Ck2Q==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.137.0.tgz", + "integrity": "sha512-x+pFANF0yL5uK/6T7lu6SlR5qid6sp//eZXKLq5iNsIE+EQg6EaS8/wsW7E91nXXjpnPhSoMOHXShSVhGRdn8w==", "cpu": [ "arm64" ], @@ -5233,9 +5326,9 @@ } }, "node_modules/@oxc-parser/binding-win32-ia32-msvc": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.135.0.tgz", - "integrity": "sha512-9FAisBbH1QICGAjlJobiuKGd/jOuVmyqniWdQMwTa5SkCl6hhuotBCJf1n46B0flYbSOR5TzfV9HZCWSyb3c/Q==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.137.0.tgz", + "integrity": "sha512-sQUqym80PFi6McRsIqfJrSu2JrSClEZIXXD+/FjAFoULEKzOPsldIdFBG96xdX8aVMzCNQ9792FPx3MfkEIrFA==", "cpu": [ "ia32" ], @@ -5250,9 +5343,9 @@ } }, "node_modules/@oxc-parser/binding-win32-x64-msvc": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.135.0.tgz", - "integrity": "sha512-wYF+A2AzJ2n7ul6q+Z2G/ia0S2+8cUp0AgWZzoFvF4WmUcl1P7p+o6se1Gdr5wGnWuF0iAMIkGddrjCarNr2yA==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.137.0.tgz", + "integrity": "sha512-2AsevxlvNN4WKxpEn3RtqD5zbqMaXF+T7JXblsP4gVuY+vC9dXS4ED/PwfRCliFqoeisYS3Iro4DHzxr0TEvVA==", "cpu": [ "x64" ], @@ -5277,9 +5370,9 @@ } }, "node_modules/@oxc-resolver/binding-android-arm-eabi": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.20.0.tgz", - "integrity": "sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.21.3.tgz", + "integrity": "sha512-eNU11A2WNizh04v3uyaJCootrHIaS0B9aHYXvAvVnPNk4xYSjMUjHnhQ6dewPN2MRYDskV85d1N0Aw0WNWhcyg==", "cpu": [ "arm" ], @@ -5291,9 +5384,9 @@ ] }, "node_modules/@oxc-resolver/binding-android-arm64": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.20.0.tgz", - "integrity": "sha512-QqslZAuFQG8Q9xm7JuIn8JUbvywhSBMVhuQHtYW+auirZJloS41oxUUaBXk7uUhZJgp44c5zQLeVvmFaDQB+2Q==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.21.3.tgz", + "integrity": "sha512-8Q+ZjTLvn2dIcWsrmhdrEihm7q+ag/k+mkry7Z+t0QbbHaVxXQfvH9AewyVMh/WrpEKhQ3DDgx9fYbqeCpeOEw==", "cpu": [ "arm64" ], @@ -5305,9 +5398,9 @@ ] }, "node_modules/@oxc-resolver/binding-darwin-arm64": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.20.0.tgz", - "integrity": "sha512-MUcavykj2ewlR+kc5arpg4tC2RvzJkUxWtNv74pf7lcNk00GpIpN43vXMj+j6r4eMmfZhlb8hueKoIb8e9kAGQ==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.21.3.tgz", + "integrity": "sha512-wkh0qKZGHXVUDxFw3oA1TXnU2BDYY/r775oJflGeIr8uDPPoN2pk8gijQIzYRT6hoql/lg3+Tx/SaTn9e2/aGg==", "cpu": [ "arm64" ], @@ -5319,9 +5412,9 @@ ] }, "node_modules/@oxc-resolver/binding-darwin-x64": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.20.0.tgz", - "integrity": "sha512-BGB16nRUK5Etiv//ihPyzj8Lj1px0mhh4YIfe0FDf045ywknfSm0GEbiRESpr6Q4K82AvnyaRIhhluHByvS4bg==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.21.3.tgz", + "integrity": "sha512-HbNc23FAQYbuyDV2vBWMez4u4mrsm5RAkniGZAWqr6lYZ3N4beeqIb776jzwRl8qL2zRhHVXpUj97X0QgogVzg==", "cpu": [ "x64" ], @@ -5333,9 +5426,9 @@ ] }, "node_modules/@oxc-resolver/binding-freebsd-x64": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.20.0.tgz", - "integrity": "sha512-JZgtePaqj3qmD5XFHJaSLWzHRxQu0LaPkdoM1KJXYADvAaa83ijXHclV3ej3CueeW0wxfIAbGCZVP45J0CA7uQ==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.21.3.tgz", + "integrity": "sha512-K6xNsTUPEUdfrn0+kbMq5nOUB5w1C5pavPQngt4TM2FpN91lP0PBe2srSpamb4d69O7h86oAi/qWX/kZNRSjkw==", "cpu": [ "x64" ], @@ -5347,9 +5440,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.20.0.tgz", - "integrity": "sha512-hOQ/p3ry3v3SchUBXicrrnszaI/UmYzM4wtS4RGfwgVUX7a+HbyQSzJ5aOzu+o6XZkFkS3ZXN4PZAzhOb77OSg==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.21.3.tgz", + "integrity": "sha512-VcFmOpcpWX1zoEy8M58tR2M9YxM+Z9RuQhqAx5q0CTmrruaP7Gveejg75hzd/5sg5nk9G3aLALEa3hE2FsmmTQ==", "cpu": [ "arm" ], @@ -5361,9 +5454,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.20.0.tgz", - "integrity": "sha512-2ArPksaw0AqeuGBfoS715VF+JvJQAhD2niWgjE5hVO+L+nAfikVQopvngCMX9x4BD8itWoQ3dnikrQyl5Ho5Jg==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.21.3.tgz", + "integrity": "sha512-quVoxFLBy43hWaQbbDtQNRwAX5vX76mv7n64icAtQcJ3eNgVeblqmkupF/hAneNthdqSlnd1sTjb3aQSaDPaCQ==", "cpu": [ "arm" ], @@ -5375,13 +5468,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.20.0.tgz", - "integrity": "sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.21.3.tgz", + "integrity": "sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5389,13 +5485,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm64-musl": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.20.0.tgz", - "integrity": "sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.21.3.tgz", + "integrity": "sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5403,13 +5502,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.20.0.tgz", - "integrity": "sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.21.3.tgz", + "integrity": "sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5417,13 +5519,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.20.0.tgz", - "integrity": "sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.21.3.tgz", + "integrity": "sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5431,13 +5536,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.20.0.tgz", - "integrity": "sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.21.3.tgz", + "integrity": "sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5445,13 +5553,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.20.0.tgz", - "integrity": "sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.21.3.tgz", + "integrity": "sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5459,13 +5570,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-x64-gnu": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.20.0.tgz", - "integrity": "sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.21.3.tgz", + "integrity": "sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5473,13 +5587,16 @@ ] }, "node_modules/@oxc-resolver/binding-linux-x64-musl": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.20.0.tgz", - "integrity": "sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.21.3.tgz", + "integrity": "sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5487,9 +5604,9 @@ ] }, "node_modules/@oxc-resolver/binding-openharmony-arm64": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.20.0.tgz", - "integrity": "sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.21.3.tgz", + "integrity": "sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg==", "cpu": [ "arm64" ], @@ -5501,9 +5618,9 @@ ] }, "node_modules/@oxc-resolver/binding-wasm32-wasi": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.20.0.tgz", - "integrity": "sha512-Tn0y1XOFYHNfK1wp1Z5QK8Rcld/bsOwRISQXfqAZ5IBpv8Gz1IvV39fUWNprqNdRizgcvFhOzWwFun2zkJsyBg==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.21.3.tgz", + "integrity": "sha512-i8oluoel5kru/j1WNrjmQSiA3GQ7wvIYVR1IwIoZtKogAhya2iub+ZKIeSIkcJOrnzQ18Tzl/F+kL3fYOxZLvA==", "cpu": [ "wasm32" ], @@ -5511,18 +5628,71 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.0", + "@emnapi/runtime": "1.11.0", + "@napi-rs/wasm-runtime": "^1.1.5" }, "engines": { "node": ">=14.0.0" } }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz", + "integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", + "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.20.0.tgz", - "integrity": "sha512-qPi25YNPe4YenS8MgsQU2+bIFHxxpLx1LVna2444cEHqNPhNjvWf9zqj4aWE43H9LpAsTmkkAlA3eL5ElBU3mA==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.21.3.tgz", + "integrity": "sha512-M/8dw8dD6aOs+NlPJax401CZB9I7Aut84isQLgALGGwke4Afvw+/7yYhZb94yXf6t2sPLhQLmSmtSV+2FhsOWg==", "cpu": [ "arm64" ], @@ -5534,9 +5704,9 @@ ] }, "node_modules/@oxc-resolver/binding-win32-x64-msvc": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.20.0.tgz", - "integrity": "sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.21.3.tgz", + "integrity": "sha512-H7BCt/VnS9hnmMp42eGhZ99izSCRvlnWwy/N71K1/J8QoExwY4262Z8QiEkMDtduRJrztayDxETTckmUuAVL9Q==", "cpu": [ "x64" ], @@ -6140,19 +6310,19 @@ "license": "MIT" }, "node_modules/@radix-ui/react-accordion": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.13.tgz", - "integrity": "sha512-xITxBB2p5m5tAe7M0F95kb4uAh7jSIKGlExMEm93HlW+XxZHV2eXFbPWLktd4JhRiwcnXNbO7iekcrbZy6ZCvA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.14.tgz", + "integrity": "sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collapsible": "1.1.13", - "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-collapsible": "1.1.14", + "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { @@ -6171,12 +6341,12 @@ } }, "node_modules/@radix-ui/react-arrow": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.9.tgz", - "integrity": "sha512-yqHW5WQ/cTpU/un7dqqIKNy2iRU8BC0JB78PEzTfCCYvZu1U6W9KwObAniMk9nhSfyotKPQTYaUD/HB0f5muig==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.10.tgz", + "integrity": "sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.5" + "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", @@ -6194,9 +6364,9 @@ } }, "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.13.tgz", - "integrity": "sha512-F0s8+p2XNpfc3k02zBfB0jPWbkHVG162+p7BdUMyJ2308QMqZ+oaclX+FAzKFovgL5OqRU+Rvy6f/vbdlJVaqA==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.14.tgz", + "integrity": "sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", @@ -6204,7 +6374,7 @@ "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, @@ -6224,15 +6394,15 @@ } }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.9.tgz", - "integrity": "sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.10.tgz", + "integrity": "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-slot": "1.2.5" + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -6280,22 +6450,22 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.16.tgz", - "integrity": "sha512-l9ok83YBclEZhbjgzt76Hw733e6cvRKPNgO6GJ/IETlufXG9p+fRu2wlvpImQvR6xdJ8h7J8J2DBvsPEiEsKMw==", + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz", + "integrity": "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.9", + "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" @@ -6331,14 +6501,14 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.12.tgz", - "integrity": "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz", + "integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-escape-keydown": "1.1.2" }, @@ -6373,13 +6543,13 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.9.tgz", - "integrity": "sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz", + "integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { @@ -6416,25 +6586,25 @@ } }, "node_modules/@radix-ui/react-navigation-menu": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.15.tgz", - "integrity": "sha512-/fS8hKCcRt4DwCGa5QIB3juRXmfYSOk4a2AEe/BDIyy7Hm+eje2Y13oUx5zejl+wFt1owrM7E8NWlbaEl5EGpg==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.16.tgz", + "integrity": "sha512-nJ0SkrSQgudyYhMiYeHA1ayLVuduEJCFLan1RZZN7c9kqzzCFLaU9kuy81uNtqzweM9YaQPgWzxi9MwQ9jZ04g==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.5" + "@radix-ui/react-visually-hidden": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -6452,23 +6622,23 @@ } }, "node_modules/@radix-ui/react-popover": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.16.tgz", - "integrity": "sha512-8brVpAU5Uq7Bh0c8EFc4ZTf2JJTYn0o+1L+CUJB3UYIOkTjKGMgoHvduylrahdmNlr3DfH0rFq2DrbNZXgaspw==", + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.17.tgz", + "integrity": "sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-dismissable-layer": "1.1.13", "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.9", + "@radix-ui/react-focus-scope": "1.1.10", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.0", - "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" @@ -6489,16 +6659,16 @@ } }, "node_modules/@radix-ui/react-popper": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.0.tgz", - "integrity": "sha512-9PB589e1aWZbrlFUHdz6WiPCL+xLZHQFX7oibqG/6Q0SwOkxDyQX9W/cyPa+sAPPKuC8cpLCpRczE5a/1DiwVQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz", + "integrity": "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.9", + "@radix-ui/react-arrow": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", @@ -6521,12 +6691,12 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.11.tgz", - "integrity": "sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz", + "integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { @@ -6568,12 +6738,12 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", - "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.5" + "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", @@ -6591,18 +6761,18 @@ } }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.12.tgz", - "integrity": "sha512-FvgPt1bRmg8Xt2QpF7NUZW3dE0ZQHGm41dAdgT2J2GJPoIXz+9Em3NobAxf4fupcxhgHu03E5CRiU2MWvObXyg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.13.tgz", + "integrity": "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", - "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-collection": "1.1.10", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3" }, @@ -6622,9 +6792,9 @@ } }, "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.11.tgz", - "integrity": "sha512-DS39ziOgea75U/TrXKU2/oKp0be2jrDHnzFLvahg/0iNAT1Zq16e4Uw0WXwyXvsK+mG3BRyMb7A3NRZMDuEXtQ==", + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.12.tgz", + "integrity": "sha512-xuafVzQiTCLsyEjakowTdG3OgTXsmO7IdCiO77otIa+z44xoLNs9Do5eg7POFumIOCjtG6djfm6RKUKpUa/csA==", "license": "MIT", "dependencies": { "@radix-ui/number": "1.1.2", @@ -6633,7 +6803,7 @@ "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-primitive": "2.1.6", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2" }, @@ -6653,9 +6823,9 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.5.tgz", - "integrity": "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", + "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" @@ -6671,9 +6841,9 @@ } }, "node_modules/@radix-ui/react-tabs": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.14.tgz", - "integrity": "sha512-D5jwp9JNuwDeCw3CYD2Fz+sSHo0droQjC8u75dJHe4aWr5q6yBiXZU+hurXnKudRgEpUkD5TsI6bjHPo5ThUxA==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.15.tgz", + "integrity": "sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.4", @@ -6681,8 +6851,8 @@ "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", - "@radix-ui/react-primitive": "2.1.5", - "@radix-ui/react-roving-focus": "1.1.12", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { @@ -6837,12 +7007,12 @@ } }, "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.5.tgz", - "integrity": "sha512-tPcHNI3FajdDBFpl/Ez1m2WL0ufJqBKyHxMDBvKitopamK36WwBGOMicuMEZKkM5Wce41QxUyv6BsiqfrWBiGg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.6.tgz", + "integrity": "sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.5" + "@radix-ui/react-primitive": "2.1.6" }, "peerDependencies": { "@types/react": "*", @@ -9240,12 +9410,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.9.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", - "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", + "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~8.3.0" } }, "node_modules/@types/node-fetch": { @@ -10069,12 +10239,12 @@ "optional": true }, "node_modules/@xyflow/react": { - "version": "12.11.0", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.0.tgz", - "integrity": "sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==", + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.1.tgz", + "integrity": "sha512-L+zBoLGSXham0MnlY8QqjfR7/C5JNw0zxkaey5aZ5XmCgJBAdH4+WRIu8CR40d3l/BdU635V6YbhBK1jMo8/6Q==", "license": "MIT", "dependencies": { - "@xyflow/system": "0.0.77", + "@xyflow/system": "0.0.78", "classcat": "^5.0.3", "zustand": "^4.4.0" }, @@ -10122,9 +10292,9 @@ } }, "node_modules/@xyflow/system": { - "version": "0.0.77", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.77.tgz", - "integrity": "sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==", + "version": "0.0.78", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.78.tgz", + "integrity": "sha512-lY0z2qP33fUhTva9Vaxrk0lqZta2pkbxB1trHAx1omnJqRtPvDlAQYV2r5fhS6AdpkulYmbNW0svy+A4/t4B/g==", "license": "MIT", "dependencies": { "@types/d3-drag": "^3.0.7", @@ -14271,9 +14441,9 @@ } }, "node_modules/eslint-plugin-sonarjs": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.0.3.tgz", - "integrity": "sha512-5drkJKLC9qQddIiaATV0e8+ygbUc7b0Ti6VB7M2d3jmKNh3X0RaiIJYTs3dr9xnlhlrxo+/s1FoO3Jgv6O/c7g==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.1.0.tgz", + "integrity": "sha512-rh+FlVz0yfd2RNIb6WqSkuGh0addX/Qi5scwQ5FphXDFrM6fZKcxP1+attJ78yUKcyYfiu6MTaISPpAFPzqRJw==", "dev": true, "license": "LGPL-3.0-only", "dependencies": { @@ -14281,14 +14451,15 @@ "builtin-modules": "^3.3.0", "bytes": "^3.1.2", "functional-red-black-tree": "^1.0.1", - "globals": "^17.4.0", + "globals": "^17.6.0", "jsx-ast-utils-x": "^0.1.0", "lodash.merge": "^4.6.2", "minimatch": "^10.2.5", "scslre": "^0.3.0", - "semver": "^7.7.4", + "semver": "^7.8.4", "ts-api-utils": "^2.5.0", - "typescript": ">=5" + "typescript": ">=5", + "yaml": "^2.9.0" }, "peerDependencies": { "eslint": "^8.0.0 || ^9.0.0 || ^10.0.0" @@ -15345,9 +15516,9 @@ } }, "node_modules/fumadocs-core": { - "version": "16.10.3", - "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.10.3.tgz", - "integrity": "sha512-xXhqz/fqbN7pLlshJb/B5L+vzMJOmWxoPj7+KMRTa/4A669hKeeCBPpRAiooMqjblWqIRSxLiO02/ds8ltvUPQ==", + "version": "16.10.5", + "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.10.5.tgz", + "integrity": "sha512-e/xrZnKvQo8bF/WYMwPuym8PR3OtjZzHy0S/EIOvGwjKRgVq9z6J58zaBpi4LvYtPVZxNGsxdZVlmZXCVWq4FQ==", "license": "MIT", "dependencies": { "@orama/orama": "^3.1.18", @@ -15384,7 +15555,7 @@ "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-router": "7.x.x", + "react-router": "7.x.x || 8.x.x", "waku": "*", "zod": "4.x.x" }, @@ -15445,6 +15616,28 @@ } } }, + "node_modules/fumadocs-core/node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/fumadocs-mdx": { "version": "15.0.12", "resolved": "https://registry.npmjs.org/fumadocs-mdx/-/fumadocs-mdx-15.0.12.tgz", @@ -15509,26 +15702,48 @@ } } }, + "node_modules/fumadocs-mdx/node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/fumadocs-ui": { - "version": "16.10.3", - "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.10.3.tgz", - "integrity": "sha512-0aSLdQ73EWoCmYcQYr2uNHlSB/s2fD+NMugtdZF3vC4lqs0MfyOtwnZPyYAskUnXNs6HECly/Hu6oY5JqmlkHg==", + "version": "16.10.5", + "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.10.5.tgz", + "integrity": "sha512-vd69ckYx/4a1aoJTCUJ5LBkqNeOFxm3r+8SK9bVYaeHJrY/n8+4W6b0soqxVqgj1UwNmgovoAg0vlsYmSxZBgQ==", "license": "MIT", "dependencies": { "@fuma-translate/react": "^1.0.2", "@fumadocs/tailwind": "0.0.5", - "@radix-ui/react-accordion": "^1.2.13", - "@radix-ui/react-collapsible": "^1.1.13", - "@radix-ui/react-dialog": "^1.1.16", + "@radix-ui/react-accordion": "^1.2.14", + "@radix-ui/react-collapsible": "^1.1.14", + "@radix-ui/react-dialog": "^1.1.17", "@radix-ui/react-direction": "^1.1.2", - "@radix-ui/react-navigation-menu": "^1.2.15", - "@radix-ui/react-popover": "^1.1.16", + "@radix-ui/react-navigation-menu": "^1.2.16", + "@radix-ui/react-popover": "^1.1.17", "@radix-ui/react-presence": "^1.1.6", - "@radix-ui/react-scroll-area": "^1.2.11", - "@radix-ui/react-slot": "^1.2.5", - "@radix-ui/react-tabs": "^1.1.14", + "@radix-ui/react-scroll-area": "^1.2.12", + "@radix-ui/react-slot": "^1.3.0", + "@radix-ui/react-tabs": "^1.1.15", "class-variance-authority": "^0.7.1", - "lucide-react": "^1.17.0", + "lucide-react": "^1.20.0", "motion": "^12.40.0", "next-themes": "^0.4.6", "react-remove-scroll": "^2.7.2", @@ -15542,7 +15757,7 @@ "@takumi-rs/image-response": "*", "@types/mdx": "*", "@types/react": "*", - "fumadocs-core": "16.10.3", + "fumadocs-core": "16.10.5", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0" @@ -17870,9 +18085,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.0.0.tgz", + "integrity": "sha512-GSvaPUbk1U+FMZ7rJzF+F8e5YVtu7KnD40et/5rBXXRBv2jCO9L3qCewvIDDdudC0QycTFlf6EAA+h3kxBsuUw==", "funding": [ { "type": "github", @@ -17888,7 +18103,7 @@ "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.js" + "js-yaml": "bin/js-yaml.mjs" } }, "node_modules/jscpd": { @@ -18205,9 +18420,9 @@ "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" }, "node_modules/knip": { - "version": "6.17.1", - "resolved": "https://registry.npmjs.org/knip/-/knip-6.17.1.tgz", - "integrity": "sha512-HcQsZSQ4Ymhuay4BVzJtM5pFZNDSomYYqcNCZOSITPQh9g18a09DqziWAxSt2G+BH9wGlG+0ZjWpEnaFlnKseQ==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.18.0.tgz", + "integrity": "sha512-RlT6fK3epETsEUCVdlz96CiJN3DQJwuxOuQhEeAVWjNRuAUJHWwe+XolTL8vIiwLPZ38tTdPWJUgZyTdskOEog==", "dev": true, "funding": [ { @@ -18225,8 +18440,8 @@ "formatly": "^0.3.0", "get-tsconfig": "4.14.0", "jiti": "^2.7.0", - "oxc-parser": "^0.135.0", - "oxc-resolver": "^11.20.0", + "oxc-parser": "^0.137.0", + "oxc-resolver": "11.21.3", "picomatch": "^4.0.4", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", @@ -19034,9 +19249,9 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "17.0.7", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.7.tgz", - "integrity": "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA==", + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.8.tgz", + "integrity": "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==", "dev": true, "license": "MIT", "dependencies": { @@ -19198,6 +19413,29 @@ "node": ">= 6" } }, + "node_modules/lockfile-lint/node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -19390,9 +19628,9 @@ } }, "node_modules/lucide-react": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.20.0.tgz", - "integrity": "sha512-jhXLeC/7m0/tjL1nzMdKk6x256zWA6AtbhTVreHOiKPoeX2d6MK4FbyIQPpVq0E6iPWBisyy1TW+pEge/uMEuQ==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", + "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -19553,9 +19791,9 @@ } }, "node_modules/material-symbols": { - "version": "0.45.1", - "resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.45.1.tgz", - "integrity": "sha512-iQibeoKymxHthNqTjYg/jCN2pRySFQ+DAD0sYK9FS7rUpf38i1qR3N4I1jfi/bLS5qdEKvkkJAvf2mE89amSZw==", + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.45.2.tgz", + "integrity": "sha512-TxcmySpXFc+03tgyl0Z6J1Yvn5/eY9b9vnizJu5Xg3RrrL5jFLp3KlZKq5k2ttKj3SqH/omofH7BwZMO/zk1mw==", "license": "Apache-2.0" }, "node_modules/math-intrinsics": { @@ -22068,9 +22306,9 @@ } }, "node_modules/ora": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.0.tgz", - "integrity": "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==", + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.1.tgz", + "integrity": "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==", "license": "MIT", "dependencies": { "chalk": "^5.6.2", @@ -22120,13 +22358,13 @@ } }, "node_modules/oxc-parser": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.135.0.tgz", - "integrity": "sha512-/DaPStu0s2zzNSRRniKyTPM6Z/o+DapOp2JYNKDL8AsgaBGPK2IdZyB87SQjVH+xeQPz+Qr9mrjglfkYgtbVRA==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.137.0.tgz", + "integrity": "sha512-yFImD+WLElJpLKy8llG1qe4DCmMsL18peRp8XP1JKfig/gISbJkglnpDtX2aTmAn10kZF7164HbN2H8QPsXxGg==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "^0.135.0" + "@oxc-project/types": "^0.137.0" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -22135,32 +22373,32 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxc-parser/binding-android-arm-eabi": "0.135.0", - "@oxc-parser/binding-android-arm64": "0.135.0", - "@oxc-parser/binding-darwin-arm64": "0.135.0", - "@oxc-parser/binding-darwin-x64": "0.135.0", - "@oxc-parser/binding-freebsd-x64": "0.135.0", - "@oxc-parser/binding-linux-arm-gnueabihf": "0.135.0", - "@oxc-parser/binding-linux-arm-musleabihf": "0.135.0", - "@oxc-parser/binding-linux-arm64-gnu": "0.135.0", - "@oxc-parser/binding-linux-arm64-musl": "0.135.0", - "@oxc-parser/binding-linux-ppc64-gnu": "0.135.0", - "@oxc-parser/binding-linux-riscv64-gnu": "0.135.0", - "@oxc-parser/binding-linux-riscv64-musl": "0.135.0", - "@oxc-parser/binding-linux-s390x-gnu": "0.135.0", - "@oxc-parser/binding-linux-x64-gnu": "0.135.0", - "@oxc-parser/binding-linux-x64-musl": "0.135.0", - "@oxc-parser/binding-openharmony-arm64": "0.135.0", - "@oxc-parser/binding-wasm32-wasi": "0.135.0", - "@oxc-parser/binding-win32-arm64-msvc": "0.135.0", - "@oxc-parser/binding-win32-ia32-msvc": "0.135.0", - "@oxc-parser/binding-win32-x64-msvc": "0.135.0" + "@oxc-parser/binding-android-arm-eabi": "0.137.0", + "@oxc-parser/binding-android-arm64": "0.137.0", + "@oxc-parser/binding-darwin-arm64": "0.137.0", + "@oxc-parser/binding-darwin-x64": "0.137.0", + "@oxc-parser/binding-freebsd-x64": "0.137.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.137.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.137.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.137.0", + "@oxc-parser/binding-linux-arm64-musl": "0.137.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.137.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.137.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.137.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.137.0", + "@oxc-parser/binding-linux-x64-gnu": "0.137.0", + "@oxc-parser/binding-linux-x64-musl": "0.137.0", + "@oxc-parser/binding-openharmony-arm64": "0.137.0", + "@oxc-parser/binding-wasm32-wasi": "0.137.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.137.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.137.0", + "@oxc-parser/binding-win32-x64-msvc": "0.137.0" } }, "node_modules/oxc-parser/node_modules/@oxc-project/types": { - "version": "0.135.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.135.0.tgz", - "integrity": "sha512-wR+xRdFkUBMvcAjBJ2q2kcZM6d+DKu2NgoOyxZgYwZdLhmiv6+rnO8PZ/P68kMiZtIKm+pW7zyEJ4kSOs0vo+Q==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", "dev": true, "license": "MIT", "funding": { @@ -22168,34 +22406,34 @@ } }, "node_modules/oxc-resolver": { - "version": "11.20.0", - "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.20.0.tgz", - "integrity": "sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==", + "version": "11.21.3", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.21.3.tgz", + "integrity": "sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxc-resolver/binding-android-arm-eabi": "11.20.0", - "@oxc-resolver/binding-android-arm64": "11.20.0", - "@oxc-resolver/binding-darwin-arm64": "11.20.0", - "@oxc-resolver/binding-darwin-x64": "11.20.0", - "@oxc-resolver/binding-freebsd-x64": "11.20.0", - "@oxc-resolver/binding-linux-arm-gnueabihf": "11.20.0", - "@oxc-resolver/binding-linux-arm-musleabihf": "11.20.0", - "@oxc-resolver/binding-linux-arm64-gnu": "11.20.0", - "@oxc-resolver/binding-linux-arm64-musl": "11.20.0", - "@oxc-resolver/binding-linux-ppc64-gnu": "11.20.0", - "@oxc-resolver/binding-linux-riscv64-gnu": "11.20.0", - "@oxc-resolver/binding-linux-riscv64-musl": "11.20.0", - "@oxc-resolver/binding-linux-s390x-gnu": "11.20.0", - "@oxc-resolver/binding-linux-x64-gnu": "11.20.0", - "@oxc-resolver/binding-linux-x64-musl": "11.20.0", - "@oxc-resolver/binding-openharmony-arm64": "11.20.0", - "@oxc-resolver/binding-wasm32-wasi": "11.20.0", - "@oxc-resolver/binding-win32-arm64-msvc": "11.20.0", - "@oxc-resolver/binding-win32-x64-msvc": "11.20.0" + "@oxc-resolver/binding-android-arm-eabi": "11.21.3", + "@oxc-resolver/binding-android-arm64": "11.21.3", + "@oxc-resolver/binding-darwin-arm64": "11.21.3", + "@oxc-resolver/binding-darwin-x64": "11.21.3", + "@oxc-resolver/binding-freebsd-x64": "11.21.3", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.3", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.3", + "@oxc-resolver/binding-linux-arm64-gnu": "11.21.3", + "@oxc-resolver/binding-linux-arm64-musl": "11.21.3", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.3", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.3", + "@oxc-resolver/binding-linux-riscv64-musl": "11.21.3", + "@oxc-resolver/binding-linux-s390x-gnu": "11.21.3", + "@oxc-resolver/binding-linux-x64-gnu": "11.21.3", + "@oxc-resolver/binding-linux-x64-musl": "11.21.3", + "@oxc-resolver/binding-openharmony-arm64": "11.21.3", + "@oxc-resolver/binding-wasm32-wasi": "11.21.3", + "@oxc-resolver/binding-win32-arm64-msvc": "11.21.3", + "@oxc-resolver/binding-win32-x64-msvc": "11.21.3" } }, "node_modules/p-limit": { @@ -26746,9 +26984,9 @@ } }, "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { @@ -27996,6 +28234,29 @@ "node": ">=20.0" } }, + "node_modules/xmlbuilder2/node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", @@ -28241,7 +28502,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.33" + "version": "3.8.34" } } } diff --git a/package.json b/package.json index cebadbb6d5..a27020b7bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.33", + "version": "3.8.34", "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { @@ -145,6 +145,7 @@ "check:complexity": "node scripts/check/check-complexity.mjs", "check:dead-code": "node scripts/check/check-dead-code.mjs", "check:cognitive-complexity": "node scripts/check/check-cognitive-complexity.mjs", + "check:release-green": "node scripts/quality/validate-release-green.mjs", "check:type-coverage": "node scripts/check/check-type-coverage.mjs", "check:lockfile": "node scripts/check/check-lockfile.mjs", "check:bundle-size": "node scripts/check/check-bundle-size.mjs", @@ -194,7 +195,7 @@ "build:cli-api": "node --import tsx/esm scripts/cli/generate-api-commands.mjs" }, "dependencies": { - "@aws-sdk/client-bedrock-runtime": "^3.1045.0", + "@aws-sdk/client-bedrock-runtime": "^3.1073.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -205,7 +206,7 @@ "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.23", "@types/mdx": "^2.0.13", - "@xyflow/react": "^12.10.2", + "@xyflow/react": "^12.11.1", "axios": "^1.16.1", "bcryptjs": "^3.0.3", "bottleneck": "^2.19.5", @@ -215,9 +216,9 @@ "express": "^5.2.1", "fetch-socks": "^1.3.3", "fflate": "^0.8.3", - "fumadocs-core": "^16.9.0", + "fumadocs-core": "^16.10.5", "fumadocs-mdx": "^15.0.7", - "fumadocs-ui": "^16.9.0", + "fumadocs-ui": "^16.10.5", "http-proxy-middleware": "^4.0.0", "https-proxy-agent": "^9.0.0", "ink": "^7.0.3", @@ -225,13 +226,13 @@ "ink-text-input": "^6.0.0", "ioredis": "^5.10.1", "jose": "^6.2.3", - "js-yaml": "^4.1.1", + "js-yaml": "^5.0.0", "jsonc-parser": "^3.3.1", "lowdb": "^7.0.1", - "lucide-react": "^1.16.0", + "lucide-react": "^1.21.0", "marked": "^18.0.4", "marked-terminal": "^7.3.0", - "material-symbols": "^0.45.1", + "material-symbols": "^0.45.2", "mermaid": "^11.15.0", "monaco-editor": "^0.55.1", "next": "^16.2.6", @@ -239,7 +240,7 @@ "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", "open": "^11.0.0", - "ora": "^9.4.0", + "ora": "^9.4.1", "parse5": "^8.0.1", "pino": "^10.3.1", "pino-abstract-transport": "^3.0.0", @@ -289,7 +290,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/bun": "latest", "@types/keytar": "^4.4.2", - "@types/node": "^25.9.1", + "@types/node": "^26.0.0", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.0", @@ -300,15 +301,15 @@ "dpdm": "^4.2.0", "eslint": "^9.39.4", "eslint-config-next": "16.2.9", - "eslint-plugin-sonarjs": "^4.0.3", + "eslint-plugin-sonarjs": "^4.1.0", "fast-check": "^4.8.0", "glob": "^13.0.6", "husky": "^9.1.7", "jscpd": "^4.2.5", "jsdom": "^29.1.1", - "knip": "^6.16.1", + "knip": "^6.18.0", "license-checker-rseidelsohn": "^5.0.1", - "lint-staged": "^17.0.5", + "lint-staged": "^17.0.8", "lockfile-lint": "^5.0.0", "node-loader": "^2.1.0", "prettier": "^3.8.3", diff --git a/scripts/check/check-fabricated-docs.mjs b/scripts/check/check-fabricated-docs.mjs index 7a4a867a07..02ba62ac33 100644 --- a/scripts/check/check-fabricated-docs.mjs +++ b/scripts/check/check-fabricated-docs.mjs @@ -131,6 +131,7 @@ const ENV_VAR_DENYLIST = new Set([ "REPOSITORY_MAP", "AUTHZ_GUIDE", "RESILIENCE_GUIDE", + "COMPRESSION_GUIDE", "MCP_SERVER", "MCP_AUDIT", "MCP_TOOLS", diff --git a/scripts/check/check-openapi-coverage.mjs b/scripts/check/check-openapi-coverage.mjs index 57cf6651d6..0476107ea8 100644 --- a/scripts/check/check-openapi-coverage.mjs +++ b/scripts/check/check-openapi-coverage.mjs @@ -9,7 +9,7 @@ import fs from "node:fs"; import path from "node:path"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; const ROOT = process.cwd(); const API_ROOT = path.join(ROOT, "src", "app", "api"); diff --git a/scripts/check/check-openapi-routes.mjs b/scripts/check/check-openapi-routes.mjs index bb19af19b4..7f8b8b4923 100644 --- a/scripts/check/check-openapi-routes.mjs +++ b/scripts/check/check-openapi-routes.mjs @@ -9,7 +9,7 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; import { assertNoStale } from "./lib/allowlist.mjs"; const ROOT = process.cwd(); diff --git a/scripts/check/check-openapi-security-tiers.mjs b/scripts/check/check-openapi-security-tiers.mjs index 874dfed78e..c2acfd6d23 100644 --- a/scripts/check/check-openapi-security-tiers.mjs +++ b/scripts/check/check-openapi-security-tiers.mjs @@ -8,7 +8,7 @@ import fs from "node:fs"; import path from "node:path"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; const ROOT = process.cwd(); const OPENAPI_PATH = path.join(ROOT, "docs", "reference", "openapi.yaml"); diff --git a/scripts/check/check-public-creds.mjs b/scripts/check/check-public-creds.mjs index b6cc521db6..f73c4a296c 100644 --- a/scripts/check/check-public-creds.mjs +++ b/scripts/check/check-public-creds.mjs @@ -81,15 +81,15 @@ const ENV_KEY_RE = /(clientId|clientSecret|apiKey)Env\s*:/; // // 6A.8: Expanded scope to open-sse/** + src/lib/oauth/**. Newly discovered FPs: // -// open-sse/services/usage.ts L582: `getMiniMaxUsage(apiKey: string, provider: "minimax" | "minimax-cn")` +// open-sse/services/usage.ts L499: `getMiniMaxUsage(apiKey: string, provider: "minimax" | "minimax-cn")` // The CRED_KEY_RE matches `apiKey:` in the TypeScript function-parameter type annotation. // "minimax" and "minimax-cn" are provider-name strings in the type annotation, NOT credentials. // This is a false positive (the gate was designed for object-literal assignments, not fn params). // TODO(6A.8): Consider tightening CRED_KEY_RE to exclude function-signature contexts — but // that adds complexity; the FP rate is low (1 file). Frozen by file:line:value key. export const KNOWN_LITERAL_CREDS = new Set([ - "open-sse/services/usage.ts:582:minimax", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved 543→547 by #3838/#4293, then 547→582 by the v3.8.33 usage.ts growth) - "open-sse/services/usage.ts:582:minimax-cn", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved 543→547 by #3838/#4293, then 547→582 by the v3.8.33 usage.ts growth) + "open-sse/services/usage.ts:499:minimax", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved 582→499 by the OpenCode/Ollama usage extraction) + "open-sse/services/usage.ts:499:minimax-cn", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved 582→499 by the OpenCode/Ollama usage extraction) ]); /** diff --git a/scripts/cli/generate-api-commands.mjs b/scripts/cli/generate-api-commands.mjs index 43bd10efad..ef4ce7d3a3 100644 --- a/scripts/cli/generate-api-commands.mjs +++ b/scripts/cli/generate-api-commands.mjs @@ -6,7 +6,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, "..", ".."); diff --git a/scripts/docs/gen-openapi-module.mjs b/scripts/docs/gen-openapi-module.mjs index 9bccdfd635..785ee055c2 100644 --- a/scripts/docs/gen-openapi-module.mjs +++ b/scripts/docs/gen-openapi-module.mjs @@ -18,7 +18,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); diff --git a/scripts/quality/collect-metrics.mjs b/scripts/quality/collect-metrics.mjs index 2807409831..7baa98fb65 100644 --- a/scripts/quality/collect-metrics.mjs +++ b/scripts/quality/collect-metrics.mjs @@ -10,7 +10,7 @@ import { promises as fsAsync } from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { execFileSync } from "node:child_process"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; const cwd = process.cwd(); const out = {}; diff --git a/scripts/quality/validate-release-green.mjs b/scripts/quality/validate-release-green.mjs new file mode 100644 index 0000000000..477cacea4c --- /dev/null +++ b/scripts/quality/validate-release-green.mjs @@ -0,0 +1,250 @@ +#!/usr/bin/env node +// scripts/quality/validate-release-green.mjs +// +// "Release-green" pre-flight validator (Solution C). +// +// WHY: the full gate (ci.yml — unit shards, vitest, ratchets, package-artifact) +// runs ONLY on the release PR (PR → main). PRs into release/** only get the +// fast-gates (quality.yml: TIA-impacted tests + typecheck + lint checks). So +// reds accumulate silently on the release branch and explode — in layers — at +// release time. This script reproduces the release-equivalent validation against +// the CURRENT working tree so the maintainer (or the nightly, Solution D) can see +// the real state of the release branch at any time. +// +// DESIGN — never blocking to contributors: +// • HARD checks (typecheck, lint errors, unit, vitest, db-rules, public-creds, +// optionally package-artifact) → a failure here is a real defect; exit 1. +// • DRIFT checks (eslint WARNINGS, cognitive-complexity, file-size) → ratchet +// drift accrued across the cycle is NOT a contributor's fault; it is reported +// and rebaselined by the maintainer at release. Drift NEVER changes the exit +// code, so wiring this as a check can never block anyone on drift. +// +// This script DIAGNOSES + REPORTS only (no auto-fix). The fix-to-green +// orchestration lives in the (future) /green-prs + review-prs flows that call it. +// +// Usage: +// node scripts/quality/validate-release-green.mjs [--json] [--with-build] [--quick] +// --json emit machine-readable JSON to stdout (report goes to stderr) +// --with-build also run check:pack-artifact (needs a dist/ build — slow) +// --quick skip the slow unit + vitest suites (drift + typecheck + lint only) + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, "..", ".."); +const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"; + +// ─── Pure helpers (exported for tests) ────────────────────────────────────── + +/** Read the committed ratchet baseline value for a metric (null if unknown). */ +export function baselineValue(metric, root = ROOT) { + try { + const raw = JSON.parse(readFileSync(join(root, "config/quality/quality-baseline.json"), "utf8")); + const metrics = raw.metrics || raw; + const v = metrics?.[metric]?.value; + return typeof v === "number" ? v : null; + } catch { + return null; + } +} + +/** Best-effort "first meaningful failure line" from captured command output. */ +export function firstFailureLine(out) { + const lines = String(out || "") + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + const hit = lines.find((l) => /✖|not ok|AssertionError|error TS|FAIL|Error:|REGRESS/i.test(l)); + return (hit || lines[lines.length - 1] || "failed").slice(0, 200); +} + +/** Sum {errorCount,warningCount} across an eslint --format json result array. */ +export function eslintCounts(parsed) { + let errors = 0; + let warnings = 0; + for (const f of parsed || []) { + errors += f.errorCount || 0; + warnings += f.warningCount || 0; + } + return { errors, warnings }; +} + +/** Parse the eslint JSON array out of mixed stdout (tolerates a leading banner). */ +export function parseEslintJson(out) { + const start = String(out || "").indexOf("["); + if (start < 0) return null; + try { + return JSON.parse(String(out).slice(start)); + } catch { + return null; + } +} + +/** Pull the cognitive-complexity violation count from the gate's output. */ +export function parseCognitiveCount(out) { + const m = String(out || "").match(/(\d+)\s+(?:function\(s\) exceed|violações|violations)/i); + return m ? Number(m[1]) : null; +} + +/** + * Drift verdict for a ratchet: a metric that grew past its committed baseline is + * "drift" (reported, never blocking). `direction:"down"` metrics (warnings, + * complexity, file-size counts) regress when current > baseline. + */ +export function isDrift(current, baseline) { + if (typeof current !== "number" || typeof baseline !== "number") return false; + return current > baseline; +} + +/** releaseGreen iff there are zero failing HARD checks (drift never blocks). */ +export function computeVerdict(results) { + const hardFailures = results.filter((r) => r.kind === "hard" && !r.ok); + const drift = results.filter((r) => r.kind === "drift" && !r.ok); + return { releaseGreen: hardFailures.length === 0, hardFailures, drift }; +} + +// ─── Orchestration (only when run directly) ───────────────────────────────── + +function run(cmd, cmdArgs) { + try { + const out = execFileSync(cmd, cmdArgs, { + cwd: ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 256 * 1024 * 1024, + env: { ...process.env, FORCE_COLOR: "0" }, + }); + return { code: 0, out }; + } catch (err) { + return { + code: typeof err.status === "number" ? err.status : 1, + out: `${err.stdout || ""}${err.stderr || ""}`, + }; + } +} + +function main() { + const args = new Set(process.argv.slice(2)); + const JSON_OUT = args.has("--json"); + const WITH_BUILD = args.has("--with-build"); + const QUICK = args.has("--quick"); + + const results = []; + const record = (r) => { + results.push(r); + const icon = r.ok ? "✅" : r.kind === "drift" ? "🟡" : "❌"; + process.stderr.write(`${icon} [${r.kind}] ${r.label}${r.detail ? ` — ${r.detail}` : ""}\n`); + }; + + const hardCmd = (id, label, cmd, cmdArgs) => { + const { code, out } = run(cmd, cmdArgs); + record({ id, label, kind: "hard", ok: code === 0, detail: code === 0 ? "pass" : firstFailureLine(out) }); + }; + + process.stderr.write("🔎 Release-green validation (current working tree)\n\n"); + + hardCmd("typecheck", "Typecheck (core)", npmCmd, ["run", "typecheck:core"]); + + // ESLint: ONE pass → errors (hard) + warnings (drift) + { + const { out } = run("npx", ["eslint", ".", "--format", "json"]); + const parsed = parseEslintJson(out); + if (!parsed) { + record({ id: "lint", label: "ESLint", kind: "hard", ok: false, detail: "could not parse eslint json" }); + } else { + const { errors, warnings } = eslintCounts(parsed); + record({ id: "lint-errors", label: "ESLint errors", kind: "hard", ok: errors === 0, detail: `${errors} error(s)` }); + const base = baselineValue("eslintWarnings"); + const over = isDrift(warnings, base); + record({ + id: "eslint-warnings", + label: "ESLint warnings (ratchet)", + kind: "drift", + ok: !over, + detail: + base == null + ? `${warnings} (no baseline)` + : `${warnings} vs baseline ${base}${over ? ` (+${warnings - base} drift → rebaseline at release)` : ""}`, + }); + } + } + + hardCmd("db-rules", "DB rules", npmCmd, ["run", "check:db-rules"]); + hardCmd("public-creds", "Public creds", npmCmd, ["run", "check:public-creds"]); + + // Cognitive-complexity (drift) + { + const { out } = run(npmCmd, ["run", "check:cognitive-complexity"]); + const current = parseCognitiveCount(out); + const base = baselineValue("cognitiveComplexity"); + const over = isDrift(current, base); + record({ + id: "cognitive-complexity", + label: "Cognitive complexity (ratchet)", + kind: "drift", + ok: !over, + detail: + current == null + ? "could not parse count" + : `${current} vs baseline ${base}${over ? ` (+${current - base} drift → rebaseline at release)` : ""}`, + }); + } + + // file-size (drift) + { + const { code, out } = run(npmCmd, ["run", "check:file-size"]); + record({ + id: "file-size", + label: "File-size ratchet", + kind: "drift", + ok: code === 0, + detail: code === 0 ? "within frozen caps" : firstFailureLine(out), + }); + } + + if (!QUICK) { + hardCmd("unit", "Unit tests (full, CI concurrency)", npmCmd, ["run", "test:unit:ci"]); + hardCmd("vitest", "Vitest (MCP / autoCombo / cache)", npmCmd, ["run", "test:vitest"]); + } + if (WITH_BUILD) { + hardCmd("pack-artifact", "Package artifact (npm pack policy)", npmCmd, ["run", "check:pack-artifact"]); + } + + const { releaseGreen, hardFailures, drift } = computeVerdict(results); + + process.stderr.write("\n──────── verdict ────────\n"); + process.stderr.write(`HARD failures (block — real defects): ${hardFailures.length}\n`); + hardFailures.forEach((r) => process.stderr.write(` ❌ ${r.label}: ${r.detail}\n`)); + process.stderr.write(`Ratchet drift (non-blocking — rebaseline at release): ${drift.length}\n`); + drift.forEach((r) => process.stderr.write(` 🟡 ${r.label}: ${r.detail}\n`)); + process.stderr.write( + releaseGreen + ? "\n✅ RELEASE-GREEN (no hard failures). Any drift above is rebaselined at release, not a contributor concern.\n" + : "\n❌ NOT release-green — hard failures must be fixed (in the originating PR branch, via co-authorship).\n" + ); + + if (JSON_OUT) { + process.stdout.write( + JSON.stringify( + { + releaseGreen, + hardFailures: hardFailures.map((r) => ({ id: r.id, label: r.label, detail: r.detail })), + drift: drift.map((r) => ({ id: r.id, label: r.label, detail: r.detail })), + checks: results.map((r) => ({ id: r.id, kind: r.kind, ok: r.ok, detail: r.detail })), + }, + null, + 2 + ) + "\n" + ); + } + + process.exit(releaseGreen ? 0 : 1); +} + +// Run only when invoked directly (so tests can import the pure helpers). +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main(); +} diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index f6d3fa56bb..1461f2215b 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -13,10 +13,8 @@ import { useNotificationStore } from "@/store/notificationStore"; import { copyToClipboard } from "@/shared/utils/clipboard"; import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel"; import { useIsElectron, useOpenExternal } from "@/shared/hooks/useElectron"; -import { useLiveRequests } from "@/hooks/useLiveDashboard"; -import { selectActiveRequests } from "../home/topologyUtils"; +import { HomeProviderTopologySection } from "./HomeProviderTopologySection"; -const ProviderTopology = dynamic(() => import("../home/ProviderTopology"), { ssr: false }); const ProviderQuotaWidget = dynamic(() => import("../home/ProviderQuotaWidget"), { ssr: false }); import type { NewsAnnouncement } from "@/shared/utils/releaseNotes"; @@ -123,7 +121,8 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { const [updating, setUpdating] = useState(false); // Platform detection and download links for Electron - const platform = typeof globalThis.window === "undefined" ? undefined : globalThis.window.electronAPI?.platform; + const platform = + typeof globalThis.window === "undefined" ? undefined : globalThis.window.electronAPI?.platform; const electronDownload = useMemo(() => { const latest = versionInfo?.latest || ""; const cleanLatest = latest.replace(/^v/, ""); @@ -171,7 +170,8 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { }>({ status: "idle" }); useEffect(() => { - if (!isElectron || typeof globalThis.window === "undefined" || !globalThis.window.electronAPI) return; + if (!isElectron || typeof globalThis.window === "undefined" || !globalThis.window.electronAPI) + return; // Trigger initial check silently on mount globalThis.window.electronAPI.checkForUpdates().catch((err: any) => { @@ -196,9 +196,12 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { // Appearance settings for home page pinning const [pinProviderQuotaToHome, setPinProviderQuotaToHome] = useState(false); const [showQuickStartOnHome, setShowQuickStartOnHome] = useState(true); // default on - const [showProviderTopologyOnHome, setShowProviderTopologyOnHome] = useState(true); // default on + // #4596: default hidden until appearance settings load, so the live-WS + // topology connection is never opened before we know the user wants it. + const [showProviderTopologyOnHome, setShowProviderTopologyOnHome] = useState(false); const [autoRefreshProviderQuota, setAutoRefreshProviderQuota] = useState(false); const [autoRefreshProviderQuotaInterval, setAutoRefreshProviderQuotaInterval] = useState(180); + const [appearanceSettingsLoaded, setAppearanceSettingsLoaded] = useState(false); useEffect(() => { // Fetch the pin settings (lightweight) @@ -225,6 +228,9 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { }) .catch(() => { /* ignore — defaults stay */ + }) + .finally(() => { + setAppearanceSettingsLoaded(true); }); }, []); @@ -236,10 +242,9 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { const fetchData = useCallback(async () => { try { - const [provRes, modelsRes, metricsRes, versionRes] = await Promise.all([ + const [provRes, modelsRes, versionRes] = await Promise.all([ fetch("/api/providers"), fetch("/api/models"), - fetch("/api/provider-metrics"), fetch("/api/system/version"), ]); if (provRes.ok) { @@ -250,10 +255,6 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { const modelsData = await modelsRes.json(); setModels(modelsData.models || []); } - if (metricsRes.ok) { - const metricsData = await metricsRes.json(); - setProviderMetrics(metricsData.metrics || {}); - } if (versionRes.ok) { const versionData = await versionRes.json(); setVersionInfo(versionData); @@ -278,6 +279,10 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { }, []); useEffect(() => { + if (!appearanceSettingsLoaded || !showProviderTopologyOnHome) { + return; + } + let cancelled = false; let timeoutId: ReturnType | null = null; let controller: AbortController | null = null; @@ -317,7 +322,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { if (timeoutId) clearTimeout(timeoutId); controller?.abort(); }; - }, []); + }, [appearanceSettingsLoaded, showProviderTopologyOnHome]); // T07: Check for unhealthy API keys and show notification (once per session) const notifiedUnhealthyKeys = useRef>(new Set()); @@ -1061,9 +1066,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { {pinProviderQuotaToHome && ( }> )} @@ -1159,35 +1162,13 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { )} - {/* Provider Topology (controlled by Appearance setting, default on) */} {showProviderTopologyOnHome && ( - -
-
-

{t("providerTopology")}

-

- Connected providers routing through OmniRoute in real time -

-
-
- - Active - - - Recent - - - Error - -
-
- -
+ )} {/* Provider Models Modal */} diff --git a/src/app/(dashboard)/dashboard/HomeProviderTopologySection.tsx b/src/app/(dashboard)/dashboard/HomeProviderTopologySection.tsx new file mode 100644 index 0000000000..53abbbeed2 --- /dev/null +++ b/src/app/(dashboard)/dashboard/HomeProviderTopologySection.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import dynamic from "next/dynamic"; + +import { Card } from "@/shared/components"; +import { useLiveRequests } from "@/hooks/useLiveDashboard"; +import { selectActiveRequests } from "../home/topologyUtils"; + +const ProviderTopology = dynamic(() => import("../home/ProviderTopology"), { ssr: false }); + +type TopologyProvider = { + id: string; + provider: string; + name?: string; +}; + +export function HomeProviderTopologySection({ + providers, + lastProvider, + errorProvider, + enabled = true, +}: { + providers: TopologyProvider[]; + lastProvider: string; + errorProvider: string; + enabled?: boolean; +}) { + const t = useTranslations("home"); + // #4596: gate the live-WS connection so it only opens while the topology + // section is actually shown on the home page. + const { activeRequests: liveActiveRequests } = useLiveRequests({ enabled }); + + return ( + +
+
+

{t("providerTopology")}

+

+ Connected providers routing through OmniRoute in real time +

+
+
+ + Active + + + Recent + + + Error + +
+
+ +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts b/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts index 7530b48d11..39474592f0 100644 --- a/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts @@ -111,6 +111,7 @@ export function formatUsdCost(value: number, locale: string): string { */ export function maskKey(fullKey: string | null | undefined): string { if (!fullKey) return ""; + if (fullKey.includes("****")) return fullKey; return fullKey.length > 8 ? `${fullKey.slice(0, 8)}...` : fullKey; } diff --git a/src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx index ff023a2f31..c817d72c4c 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard.tsx @@ -339,6 +339,20 @@ export default function OpenClawToolCard({ : t("installCliPrompt", { tool: "Open Claw" })}

+ {/* + Always surface Manual Config even when the CLI is not + detected locally — typical of remote OmniRoute + deployments where the CLI lives on the user's machine, + not on the server. Upstream report: #579. + */} + )} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx index 1e44881b18..7f0447d4db 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx @@ -12,8 +12,6 @@ import { getProviderBaseUrlHint, getProviderBaseUrlPlaceholder, isGlmProvider, - parseRoutingTagsInput, - parseExcludedModelsInput, getWebSessionCredentialLabel, getWebSessionCredentialHint, getWebSessionCredentialCheckLabel, @@ -28,7 +26,8 @@ import { getWebSessionCredentialRequirement } from "../../webSessionCredentials" import { useOpenRouterPresetControl } from "../OpenRouterPresetInput"; import WebSessionCredentialGuide from "../WebSessionCredentialGuide"; import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields"; -import { assignCcCompatibleRequestDefaults } from "./ccCompatibleRequestDefaults"; +import { buildAddProviderSpecificData } from "./connectionProviderSpecificData"; +import QuotaScrapingFields, { EMPTY_QUOTA_SCRAPING_FIELDS } from "./QuotaScrapingFields"; export interface AddApiKeyModalProps { isOpen: boolean; @@ -112,6 +111,7 @@ export default function AddApiKeyModal({ customUserAgent: "", accountId: "", consoleApiKey: "", + ...EMPTY_QUOTA_SCRAPING_FIELDS, ccCompatibleContext1m: false, ccCompatibleRedactThinking: false, passthroughModels: false, @@ -283,47 +283,27 @@ export default function AddApiKeyModal({ } } - const providerSpecificData: Record = {}; - if (formData.customUserAgent.trim()) { - providerSpecificData.customUserAgent = formData.customUserAgent.trim(); - } - openRouterPreset.applyTo(providerSpecificData); - if (formData.routingTags.trim()) { - providerSpecificData.tags = parseRoutingTagsInput(formData.routingTags); - } - if (formData.excludedModels.trim()) { - providerSpecificData.excludedModels = parseExcludedModelsInput(formData.excludedModels); - } - if (formData.passthroughModels) { - providerSpecificData.passthroughModels = true; - } - if (showFreeModelsToggle && formData.importFreeModelsOnly) { - providerSpecificData.importFreeModelsOnly = true; - } - if (provider === "bailian-coding-plan" && formData.consoleApiKey.trim()) { - providerSpecificData.consoleApiKey = formData.consoleApiKey.trim(); - } - if (isGooglePse && formData.cx.trim()) { - providerSpecificData.cx = formData.cx.trim(); - } - if (usesBaseUrl) { - providerSpecificData.baseUrl = validatedBaseUrl; - } else if (showsRegion) { - providerSpecificData.region = formData.region.trim() || defaultRegion; - } else if (isGlm) { - providerSpecificData.apiRegion = formData.apiRegion; - } else if (isCloudflare && formData.accountId.trim()) { - providerSpecificData.accountId = formData.accountId.trim(); - } - if (isCcCompatible) assignCcCompatibleRequestDefaults(providerSpecificData, formData); + const providerSpecificData = buildAddProviderSpecificData({ + provider, + formData, + openRouterPreset, + showFreeModelsToggle, + isGooglePse, + usesBaseUrl, + validatedBaseUrl, + showsRegion, + defaultRegion, + isGlm, + isCloudflare, + isCcCompatible, + }); const payload = { name: formData.name, apiKey: credentialInput.trim() || undefined, priority: formData.priority, testStatus: "active", - providerSpecificData: - Object.keys(providerSpecificData).length > 0 ? providerSpecificData : undefined, + providerSpecificData, }; const error = await onSave(payload); @@ -710,6 +690,12 @@ export default function AddApiKeyModal({ )} {freeModelsToggle} + setFormData({ ...formData, ...patch })} + t={t} + /> {isCompatible && !isCcCompatible && (

{isAnthropic diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx index 306a0caccd..049797e4df 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx @@ -41,8 +41,11 @@ export default function EditCompatibleNodeModal({ }); const [saving, setSaving] = useState(false); const [checkKey, setCheckKey] = useState(""); + const [checkModelId, setCheckModelId] = useState(""); const [validating, setValidating] = useState(false); - const [validationResult, setValidationResult] = useState(null); + const [validationResult, setValidationResult] = useState< + null | { valid: boolean; error?: string | null; method?: string | null } + >(null); const [showAdvanced, setShowAdvanced] = useState(false); useEffect(() => { @@ -113,12 +116,17 @@ export default function EditCompatibleNodeModal({ compatMode: isCcCompatible ? "cc" : undefined, chatPath: formData.chatPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_CHAT_PATH : ""), modelsPath: isCcCompatible ? "" : formData.modelsPath, + modelId: checkModelId.trim() || undefined, }), }); const data = await res.json(); - setValidationResult(data.valid ? "success" : "failed"); + setValidationResult({ + valid: !!data.valid, + error: data.error ?? null, + method: data.method ?? null, + }); } catch { - setValidationResult("failed"); + setValidationResult({ valid: false, error: "Network error" }); } finally { setValidating(false); } @@ -259,10 +267,26 @@ export default function EditCompatibleNodeModal({ + setCheckModelId(e.target.value)} + placeholder={t("testModelIdPlaceholder")} + hint={t("testModelIdHint")} + /> {validationResult && ( - - {validationResult === "success" ? t("valid") : t("invalid")} - +

+ + {validationResult.valid ? t("valid") : t("invalid")} + + {validationResult.error && ( + + {validationResult.error} + + )} +
)}
+ setFormData({ ...formData, ...patch })} + t={t} + editMode + /> {supportsGoogleProjectId && (
{isAntigravity && ( @@ -1020,10 +1013,9 @@ export default function EditConnectionModal({ return (
- {statusIcon} {t("primaryKey")}: {connection.apiKey.slice(0, 6)}... - {connection.apiKey.slice(-4)} + {statusIcon} {t("primaryKey")}: {connection.apiKey} {health && ( +) { + if (provider === "opencode-go") { + target.opencodeGoWorkspaceId = values.opencodeGoWorkspaceId.trim() || undefined; + if (values.opencodeGoAuthCookie.trim()) { + target.opencodeGoAuthCookie = values.opencodeGoAuthCookie.trim(); + } + } else if (provider === "ollama-cloud" && values.ollamaCloudUsageCookie.trim()) { + target.ollamaCloudUsageCookie = values.ollamaCloudUsageCookie.trim(); + } +} + +type QuotaScrapingFieldsProps = { + provider?: string; + values: QuotaScrapingFieldValues; + onChange: (patch: Partial) => void; + t: ProviderMessageTranslator; + editMode?: boolean; +}; + +export default function QuotaScrapingFields({ + provider, + values, + onChange, + t, + editMode = false, +}: QuotaScrapingFieldsProps) { + if (provider === "opencode-go") { + return ( +
+ onChange({ opencodeGoWorkspaceId: e.target.value })} + placeholder="workspace_..." + hint={providerText( + t, + "opencodeGoWorkspaceIdHint", + "Required for quota scraping. Copy it from the OpenCode Go workspace URL." + )} + autoComplete="off" + spellCheck={false} + /> + onChange({ opencodeGoAuthCookie: e.target.value })} + placeholder="auth=..." + hint={providerText( + t, + "opencodeGoAuthCookieHint", + editMode + ? "Leave blank to keep the stored cookie. Paste auth=... or only the cookie value to replace it." + : "Paste the auth cookie value from opencode.ai. The auth= prefix is accepted." + )} + autoComplete="off" + spellCheck={false} + autoCapitalize="off" + /> +
+ ); + } + + if (provider === "ollama-cloud") { + return ( +
+ onChange({ ollamaCloudUsageCookie: e.target.value })} + placeholder="__Secure-session=..." + hint={providerText( + t, + "ollamaCloudUsageCookieHint", + editMode + ? "Leave blank to keep the stored cookie. Paste the __Secure-session cookie value from ollama.com/settings to replace it." + : "Required for quota scraping. Paste the __Secure-session cookie value from ollama.com/settings." + )} + autoComplete="off" + spellCheck={false} + autoCapitalize="off" + /> +
+ ); + } + + return null; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/__tests__/connModals.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/__tests__/connModals.test.tsx index d74f01579f..24c3ce6efe 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/__tests__/connModals.test.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/__tests__/connModals.test.tsx @@ -149,6 +149,31 @@ describe("conn-modals (Phase 1c extraction)", () => { expect(c.querySelector("*")).not.toBeNull(); }); + it("EditConnectionModal renders the API key value returned by the provider connection API", () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const fullApiKey = "test-provider-key-full-1234567890abcdef78b9"; + const connection = { + id: "conn-full-key", + name: "Test Connection", + provider: "openai", + authType: "apikey", + priority: 1, + apiKey: fullApiKey, + }; + const c = renderModal( + + ); + + expect(c.textContent).toContain(fullApiKey); + expect(c.textContent).not.toContain("test-p...78b9"); + }); + it("EditConnectionModal renders OpenRouter preset when provider comes from the page", () => { const onSave = vi.fn().mockResolvedValue(undefined); const connection = { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/connectionProviderSpecificData.ts b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/connectionProviderSpecificData.ts new file mode 100644 index 0000000000..69abfc2f4b --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/connectionProviderSpecificData.ts @@ -0,0 +1,125 @@ +import { parseExcludedModelsInput, parseRoutingTagsInput } from "../../providerPageHelpers"; +import { + assignCcCompatibleRequestDefaults, + mergeCcCompatibleRequestDefaults, +} from "./ccCompatibleRequestDefaults"; +import { + assignQuotaScrapingProviderData, + type QuotaScrapingFieldValues, +} from "./QuotaScrapingFields"; + +type FormData = QuotaScrapingFieldValues & { + accountId: string; + apiRegion: string; + ccCompatibleContext1m: boolean; + ccCompatibleRedactThinking: boolean; + consoleApiKey: string; + customUserAgent: string; + cx: string; + excludedModels: string; + importFreeModelsOnly: boolean; + passthroughModels: boolean; + region: string; + routingTags: string; + tag?: string; + validationModelId?: string; +}; +type ProviderSpecificData = Record; + +export function buildAddProviderSpecificData(options: { + provider?: string; + formData: FormData; + openRouterPreset: { applyTo: (target: ProviderSpecificData) => void }; + showFreeModelsToggle: boolean; + isGooglePse: boolean; + usesBaseUrl: boolean; + validatedBaseUrl: string | null; + showsRegion: boolean; + defaultRegion: string; + isGlm: boolean; + isCloudflare: boolean; + isCcCompatible?: boolean; +}) { + const { + provider, + formData, + openRouterPreset, + showFreeModelsToggle, + isGooglePse, + usesBaseUrl, + validatedBaseUrl, + showsRegion, + defaultRegion, + isGlm, + isCloudflare, + isCcCompatible, + } = options; + const data: ProviderSpecificData = {}; + if (formData.customUserAgent.trim()) data.customUserAgent = formData.customUserAgent.trim(); + openRouterPreset.applyTo(data); + if (formData.routingTags.trim()) data.tags = parseRoutingTagsInput(formData.routingTags); + if (formData.excludedModels.trim()) { + data.excludedModels = parseExcludedModelsInput(formData.excludedModels); + } + if (formData.passthroughModels) data.passthroughModels = true; + if (showFreeModelsToggle && formData.importFreeModelsOnly) data.importFreeModelsOnly = true; + if (provider === "bailian-coding-plan" && formData.consoleApiKey.trim()) { + data.consoleApiKey = formData.consoleApiKey.trim(); + } + assignQuotaScrapingProviderData(provider, formData, data); + if (isGooglePse && formData.cx.trim()) data.cx = formData.cx.trim(); + if (usesBaseUrl) data.baseUrl = validatedBaseUrl; + else if (showsRegion) data.region = formData.region.trim() || defaultRegion; + else if (isGlm) data.apiRegion = formData.apiRegion; + else if (isCloudflare && formData.accountId.trim()) data.accountId = formData.accountId.trim(); + if (isCcCompatible) assignCcCompatibleRequestDefaults(data, formData); + return Object.keys(data).length > 0 ? data : undefined; +} + +export function assignEditApiKeyProviderSpecificData(options: { + provider: string; + formData: FormData; + target: ProviderSpecificData; + extraApiKeys: string[]; + openRouterPreset: { getPatch: () => ProviderSpecificData }; + usesBaseUrl: boolean; + validatedBaseUrl: string | null; + showsRegion: boolean; + defaultRegion: string; + isGlm: boolean; + isCloudflare: boolean; + supportsGoogleProjectId: boolean; + trimmedCloudCodeProjectId: string; + isGooglePse: boolean; + isCcCompatible: boolean; +}) { + const o = options; + Object.assign(o.target, { + extraApiKeys: o.extraApiKeys.filter((key) => key.trim().length > 0), + tag: o.formData.tag.trim() || undefined, + tags: parseRoutingTagsInput(o.formData.routingTags), + excludedModels: parseExcludedModelsInput(o.formData.excludedModels), + customUserAgent: o.formData.customUserAgent.trim(), + ...o.openRouterPreset.getPatch(), + ...(o.formData.passthroughModels ? { passthroughModels: true } : {}), + }); + if (o.provider === "bailian-coding-plan") { + o.target.consoleApiKey = o.formData.consoleApiKey.trim() || undefined; + } + assignQuotaScrapingProviderData(o.provider, o.formData, o.target); + if (o.formData.validationModelId) o.target.validationModelId = o.formData.validationModelId; + if (o.isGooglePse) o.target.cx = o.formData.cx.trim() || undefined; + if (o.usesBaseUrl) o.target.baseUrl = o.validatedBaseUrl; + else if (o.showsRegion) o.target.region = o.formData.region.trim() || o.defaultRegion; + else if (o.isGlm) o.target.apiRegion = o.formData.apiRegion; + else if (o.isCloudflare && o.formData.accountId.trim()) { + o.target.accountId = o.formData.accountId.trim(); + } + if (o.supportsGoogleProjectId) o.target.projectId = o.trimmedCloudCodeProjectId || null; + if (o.isCcCompatible) { + o.target.requestDefaults = mergeCcCompatibleRequestDefaults( + o.target.requestDefaults, + o.formData + ); + } +} diff --git a/src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx b/src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx index 8b0b10a8cd..5eecba769e 100644 --- a/src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx @@ -90,8 +90,11 @@ export default function AddCompatibleProviderModal({ const [formData, setFormData] = useState(() => createInitialForm(mode)); const [submitting, setSubmitting] = useState(false); const [checkKey, setCheckKey] = useState(""); + const [checkModelId, setCheckModelId] = useState(""); const [validating, setValidating] = useState(false); - const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null); + const [validationResult, setValidationResult] = useState< + null | { valid: boolean; error?: string | null; method?: string | null } + >(null); const [showAdvanced, setShowAdvanced] = useState(false); const apiTypeOptions = useMemo( @@ -209,6 +212,8 @@ export default function AddCompatibleProviderModal({ body.compatMode = defaults.compatMode; body.chatPath = formData.chatPath || CC_DEFAULT_CHAT_PATH; } + const trimmedModelId = checkModelId.trim(); + if (trimmedModelId) body.modelId = trimmedModelId; const res = await fetch("/api/provider-nodes/validate", { method: "POST", @@ -216,9 +221,13 @@ export default function AddCompatibleProviderModal({ body: JSON.stringify(body), }); const data = await res.json(); - setValidationResult(data.valid ? "success" : "failed"); + setValidationResult({ + valid: !!data.valid, + error: data.error ?? null, + method: data.method ?? null, + }); } catch { - setValidationResult("failed"); + setValidationResult({ valid: false, error: "Network error" }); } finally { setValidating(false); } @@ -322,10 +331,26 @@ export default function AddCompatibleProviderModal({
+ setCheckModelId(e.target.value)} + placeholder={t("testModelIdPlaceholder")} + hint={t("testModelIdHint")} + /> {validationResult && ( - - {validationResult === "success" ? t("valid") : t("invalid")} - +
+ + {validationResult.valid ? t("valid") : t("invalid")} + + {validationResult.error && ( + + {validationResult.error} + + )} +
)}
diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 731457bfb5..8a2567af1b 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { Card, CardSkeleton, Badge, Button, CollapsibleSection } from "@/shared/components"; import { AGGREGATOR_PROVIDER_IDS, @@ -9,7 +9,6 @@ import { IDE_PROVIDER_IDS, IMAGE_ONLY_PROVIDER_IDS, VIDEO_PROVIDER_IDS, - isClaudeCodeCompatibleProvider, } from "@/shared/constants/providers"; import { useRouter, useSearchParams } from "next/navigation"; import { getErrorCode, getRelativeTime } from "@/shared/utils"; @@ -19,6 +18,7 @@ import { useNotificationStore } from "@/store/notificationStore"; import { useTranslations } from "next-intl"; import { buildStaticProviderEntries, + buildCompatibleProviderGroups, filterConfiguredProviderEntries, shouldFilterProviderEntriesForDisplayMode, shouldShowFirstProviderHint, @@ -480,37 +480,18 @@ export default function ProvidersPage() { } }; - const compatibleProviders = providerNodes - .filter((node) => node.type === "openai-compatible") - .map((node) => ({ - id: node.id, - name: node.name || t("openaiCompatibleName"), - color: "#10A37F", - textIcon: "OC", - apiType: node.apiType, - })); - - const anthropicCompatibleProviders = providerNodes - .filter( - (node) => node.type === "anthropic-compatible" && !isClaudeCodeCompatibleProvider(node.id) - ) - .map((node) => ({ - id: node.id, - name: node.name || t("anthropicCompatibleName"), - color: "#D97757", - textIcon: "AC", - })); - - const ccCompatibleProviders = providerNodes - .filter( - (node) => node.type === "anthropic-compatible" && isClaudeCodeCompatibleProvider(node.id) - ) - .map((node) => ({ - id: node.id, - name: node.name || ccCompatibleLabel, - color: "#B45309", - textIcon: "CC", - })); + const compatibleProviderGroups = useMemo( + () => + buildCompatibleProviderGroups(providerNodes, { + openaiCompatibleName: t("openaiCompatibleName"), + anthropicCompatibleName: t("anthropicCompatibleName"), + claudeCodeCompatibleName: ccCompatibleLabel, + }), + [ccCompatibleLabel, providerNodes, t] + ); + const compatibleProviders = compatibleProviderGroups.openai; + const anthropicCompatibleProviders = compatibleProviderGroups.anthropic; + const ccCompatibleProviders = compatibleProviderGroups.claudeCode; const effectiveProviderDisplayMode = providerDisplayMode === "configured" && connections.length === 0 ? "all" : providerDisplayMode; @@ -530,7 +511,7 @@ export default function ProvidersPage() { activeServiceKind ); - const blockedProviderSet = new Set(blockedProviders); + const blockedProviderSet = useMemo(() => new Set(blockedProviders), [blockedProviders]); const rawNoAuthEntriesAll = buildStaticProviderEntries("no-auth", getProviderStats); const noAuthEntriesAll = rawNoAuthEntriesAll.filter(({ providerId, provider }) => { const alias = typeof provider.alias === "string" ? provider.alias : null; @@ -970,8 +951,8 @@ export default function ProvidersPage() { }`} title={t("testAllCompatible")} > - - {testingMode === "compatible" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "compatible" ? t("testing") : t("testAll")} @@ -1065,8 +1046,8 @@ export default function ProvidersPage() { title={t("testAllOAuth")} aria-label={t("testAllOAuth")} > - - {testingMode === "oauth" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "oauth" ? t("testing") : t("testAll")} @@ -1115,8 +1096,8 @@ export default function ProvidersPage() { title={t("testAll")} aria-label={t("testAll")} > - - {testingMode === "ide" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "ide" ? t("testing") : t("testAll")} @@ -1172,8 +1153,8 @@ export default function ProvidersPage() { }`} title={t("testAll")} > - - {testingMode === "web-cookie" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "web-cookie" ? t("testing") : t("testAll")} @@ -1216,8 +1197,8 @@ export default function ProvidersPage() { }`} title={t("testAll")} > - - {testingMode === "free" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "free" ? t("testing") : t("testAll")} @@ -1261,8 +1242,8 @@ export default function ProvidersPage() { title={t("testAllApiKey")} aria-label={t("testAllApiKey")} > - - {testingMode === "apikey" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "apikey" ? t("testing") : t("testAll")} @@ -1313,8 +1294,8 @@ export default function ProvidersPage() { }`} title={t("testAll")} > - - {testingMode === "no-auth" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "no-auth" ? t("testing") : t("testAll")} @@ -1357,8 +1338,8 @@ export default function ProvidersPage() { }`} title={t("testAll")} > - - {testingMode === "upstream-proxy" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "upstream-proxy" ? t("testing") : t("testAll")} @@ -1500,8 +1481,8 @@ export default function ProvidersPage() { }`} title={t("testAll")} > - - {testingMode === "cloud-agent" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "cloud-agent" ? t("testing") : t("testAll")} @@ -1548,8 +1529,8 @@ export default function ProvidersPage() { }`} title={t("testAll")} > - - {testingMode === "local" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "local" ? t("testing") : t("testAll")} @@ -1592,8 +1573,8 @@ export default function ProvidersPage() { }`} title={t("testAll")} > - - {testingMode === "search" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "search" ? t("testing") : t("testAll")} @@ -1702,8 +1683,8 @@ export default function ProvidersPage() { }`} title={t("testAll")} > - - {testingMode === "audio" ? "sync" : "play_arrow"} + + play_arrow {testingMode === "audio" ? t("testing") : t("testAll")} diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index f7a6b4d739..32da435927 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -7,6 +7,7 @@ import { type ResolvedProviderCatalogEntry, type StaticProviderCatalogCategory, } from "@/lib/providers/catalog"; +import { isClaudeCodeCompatibleProvider } from "@/shared/constants/providers"; import { getModelsByProviderId } from "@/shared/constants/models"; import { providerHasServiceKind } from "@/lib/providers/serviceKindIndex"; import { compareTr, matchesSearch } from "@/shared/utils/turkishText"; @@ -25,6 +26,20 @@ export interface ProviderEntry> { toggleAuthType: "oauth" | "free" | "apikey" | "no-auth"; } +export type CompatibleProviderInfo = { + id: string; + name: string; + color: string; + textIcon: string; + apiType?: string; +}; + +export type CompatibleProviderGroups = { + openai: CompatibleProviderInfo[]; + anthropic: CompatibleProviderInfo[]; + claudeCode: CompatibleProviderInfo[]; +}; + export function shouldApplyConfiguredOnlyFilter( showConfiguredOnly: boolean, connectionCount: number @@ -110,6 +125,53 @@ export function buildStaticProviderEntries( ); } +export function buildCompatibleProviderGroups( + providerNodes: Array<{ id: string; name?: string; type?: string; apiType?: string }>, + labels: { + openaiCompatibleName: string; + anthropicCompatibleName: string; + claudeCodeCompatibleName: string; + } +): CompatibleProviderGroups { + const openai: CompatibleProviderInfo[] = []; + const anthropic: CompatibleProviderInfo[] = []; + const claudeCode: CompatibleProviderInfo[] = []; + + for (const node of providerNodes) { + if (node.type === "openai-compatible") { + openai.push({ + id: node.id, + name: node.name || labels.openaiCompatibleName, + color: "#10A37F", + textIcon: "OC", + apiType: node.apiType, + }); + continue; + } + + if (node.type !== "anthropic-compatible") continue; + + if (isClaudeCodeCompatibleProvider(node.id)) { + claudeCode.push({ + id: node.id, + name: node.name || labels.claudeCodeCompatibleName, + color: "#B45309", + textIcon: "CC", + }); + continue; + } + + anthropic.push({ + id: node.id, + name: node.name || labels.anthropicCompatibleName, + color: "#D97757", + textIcon: "AC", + }); + } + + return { openai, anthropic, claudeCode }; +} + export function filterConfiguredProviderEntries( entries: ProviderEntry[], showConfiguredOnly: boolean, diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx index c09bb79c18..9c42bb0647 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx @@ -91,6 +91,7 @@ export default function QuotaCard({ quotas={quotas} loading={loading} error={error} + message={quota?.message ?? null} refreshedAt={displayRefreshedAt} hasStaleData={hasStaleData} onRefresh={onRefresh} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/formatters.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/formatters.ts new file mode 100644 index 0000000000..d5eaf86caa --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/formatters.ts @@ -0,0 +1,6 @@ +export function formatAutoRefreshCountdown(ms: number): string { + const totalSeconds = Math.max(0, Math.ceil(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index fcd9f47316..7176942e8a 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -21,6 +21,8 @@ import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import { useNotificationStore } from "@/store/notificationStore"; import QuotaCutoffModal from "./QuotaCutoffModal"; import QuotaCardGrid from "./QuotaCardGrid"; +import { useVisibleQuotaData } from "./useVisibleQuotaData"; +import { formatAutoRefreshCountdown } from "./formatters"; import { translateUsageOrFallback, type UsageTranslationValues } from "./i18nFallback"; import { compareTr } from "@/shared/utils/turkishText"; @@ -33,7 +35,6 @@ const MIN_FETCH_INTERVAL_MS = 30000; const QUOTA_BAR_GREEN_THRESHOLD = 50; const QUOTA_BAR_YELLOW_THRESHOLD = 20; -// Display label per known provider; the icon is resolved by ProviderIcon. const PROVIDER_LABEL: Record = { antigravity: "Antigravity", "gemini-cli": "Gemini CLI", @@ -46,6 +47,7 @@ const PROVIDER_LABEL: Record = { zai: "Z.AI", glmt: "GLM Thinking", "opencode-go": "OpenCode Go", + "ollama-cloud": "Ollama Cloud", "kimi-coding": "Kimi Coding", minimax: "MiniMax", "minimax-cn": "MiniMax CN", @@ -53,8 +55,7 @@ const PROVIDER_LABEL: Record = { deepseek: "DeepSeek", }; -// Group ordering — single source of truth for "where does Codex sit -// relative to Antigravity on the page". +// Group ordering — single source of truth for provider placement. const PROVIDER_ORDER: Record = { antigravity: 1, "gemini-cli": 2, @@ -66,10 +67,11 @@ const PROVIDER_ORDER: Record = { zai: 8, glmt: 9, "opencode-go": 10, - "kimi-coding": 11, - minimax: 12, - "minimax-cn": 13, - nanogpt: 14, + "ollama-cloud": 11, + "kimi-coding": 12, + minimax: 13, + "minimax-cn": 14, + nanogpt: 15, }; const TIER_FILTERS = [ @@ -213,13 +215,6 @@ function aggregateWorst(statuses: StatusKey[]): "critical" | "alert" | "ok" | "e return worst; } -function formatAutoRefreshCountdown(ms: number): string { - const totalSeconds = Math.max(0, Math.ceil(ms / 1000)); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; -} - interface ProviderLimitsProps { showFilters?: boolean; autoRefreshInterval?: number; @@ -564,6 +559,7 @@ export default function ProviderLimits({ (a, b) => (PROVIDER_ORDER[a.provider] || 99) - (PROVIDER_ORDER[b.provider] || 99) ); }, [filteredConnections]); + const visibleQuotaData = useVisibleQuotaData(sortedConnections, quotaData); const resolvedPlanByConnection = useMemo(() => { const out: Record = {}; @@ -613,10 +609,10 @@ export default function ProviderLimits({ const statusByConnection = useMemo(() => { const out: Record = {}; for (const conn of sortedConnections) { - out[conn.id] = getWorstStatus(quotaData[conn.id]?.quotas); + out[conn.id] = getWorstStatus(visibleQuotaData[conn.id]?.quotas); } return out; - }, [sortedConnections, quotaData]); + }, [sortedConnections, visibleQuotaData]); const purchaseTypeCounts = useMemo(() => { const counts: Record = { @@ -697,8 +693,8 @@ export default function ProviderLimits({ const sa = statusRank[statusByConnection[a.id] || "empty"]; const sb = statusRank[statusByConnection[b.id] || "empty"]; if (sa !== sb) return sa - sb; - const ra = getSoonestResetMs(quotaData[a.id]?.quotas); - const rb = getSoonestResetMs(quotaData[b.id]?.quotas); + const ra = getSoonestResetMs(visibleQuotaData[a.id]?.quotas); + const rb = getSoonestResetMs(visibleQuotaData[b.id]?.quotas); return ra - rb; }); }, [ @@ -711,7 +707,7 @@ export default function ProviderLimits({ statusByConnection, envFilter, providerFilter, - quotaData, + visibleQuotaData, ]); // Distinct provider keys present in the current connection set (after the @@ -1055,7 +1051,7 @@ export default function ProviderLimits({ renderInlineQuotaSummary(quota.quotas)} onRefresh={refreshProvider} onOpenCutoff={(conn) => { - const windows = (quotaData[conn.id]?.quotas || []).filter( + const windows = (visibleQuotaData[conn.id]?.quotas || []).filter( (q: any) => q && typeof q.name === "string" && !q.isCredits ); setCutoffModalWindows(windows); diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx index 2b6c2511cf..47641ec896 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx @@ -25,6 +25,7 @@ interface Props { quotas: any[]; loading: boolean; error: string | null; + message?: string | null; refreshedAt?: string; hasStaleData: boolean; onRefresh: () => void; @@ -109,6 +110,7 @@ export default function QuotaCardExpanded({ quotas, loading, error, + message, refreshedAt, hasStaleData, onRefresh, @@ -143,6 +145,10 @@ export default function QuotaCardExpanded({ error {error}
+ ) : quotas.length === 0 && message ? ( +
+ {message} +
) : quotas.length === 0 ? (
{t("noQuotaData")}
) : ( diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts new file mode 100644 index 0000000000..dfed75ba20 --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts @@ -0,0 +1,189 @@ +import { getModelsByProviderId } from "@omniroute/open-sse/config/providerModels.ts"; +import { safePercentage } from "@/shared/utils/formatting"; + +const GLM_QUOTA_ORDER: Record = { session: 0, weekly: 1, mcp_monthly: 2 }; + +function quotaEntries(data: any): Array<[string, any]> { + return data?.quotas && typeof data.quotas === "object" ? Object.entries(data.quotas) : []; +} + +function isUnlimitedEmpty(quota: any): boolean { + return Boolean(quota?.unlimited && (!quota?.total || quota.total <= 0)); +} + +function isPastResetWindow(resetAt: any): boolean { + if (!resetAt) return false; + const resetTime = + typeof resetAt === "number" ? resetAt : typeof resetAt === "string" ? Date.parse(resetAt) : NaN; + return Number.isFinite(resetTime) && Date.now() >= resetTime; +} + +function getResetAdjustedQuota(quota: any) { + const usedRaw = Number(quota?.used || 0); + const totalRaw = Number(quota?.total || 0); + const total = Number.isFinite(totalRaw) ? totalRaw : 0; + const remainingRaw = safePercentage(quota?.remainingPercentage); + const hasPendingUsage = usedRaw > 0 || (remainingRaw !== undefined && remainingRaw < 100); + const staleAfterReset = isPastResetWindow(quota?.resetAt || null) && hasPendingUsage; + + return { + staleAfterReset, + total, + used: staleAfterReset ? 0 : usedRaw, + remainingPercentage: staleAfterReset && total > 0 ? 100 : remainingRaw, + }; +} + +function normalizeQuotaEntry(name: string, quota: any = {}, extras: any = {}) { + const adjusted = getResetAdjustedQuota(quota); + return { + name, + used: Number.isFinite(adjusted.used) ? adjusted.used : 0, + total: adjusted.total, + resetAt: quota?.resetAt || null, + staleAfterReset: adjusted.staleAfterReset, + ...(adjusted.remainingPercentage !== undefined + ? { remainingPercentage: adjusted.remainingPercentage } + : {}), + ...extras, + }; +} + +function parseGeneric(data: any) { + return quotaEntries(data).map(([name, quota]) => normalizeQuotaEntry(name, quota)); +} + +function parseGithub(data: any) { + return quotaEntries(data) + .filter(([, quota]) => !isUnlimitedEmpty(quota)) + .map(([name, quota]) => normalizeQuotaEntry(name, quota)); +} + +function parseGlmFamily(data: any) { + return quotaEntries(data).map(([name, quota]) => + normalizeQuotaEntry(name, quota, { + displayName: quota?.displayName, + details: Array.isArray(quota?.details) ? quota.details : undefined, + isPercentageOnly: + Number(quota?.total || 0) === 100 && quota?.remainingPercentage !== undefined, + }) + ); +} + +function buildCreditsQuota( + name: string, + remaining: number, + remainingPercentage: number, + extra = {} +) { + return { + name, + used: 0, + total: 0, + remaining, + resetAt: null, + unlimited: false, + isCredits: true, + remainingPercentage, + creditCount: remaining, + ...extra, + }; +} + +function parseAntigravityQuota(modelKey: string, quota: any) { + if (modelKey === "credits") { + const remaining = Number(quota?.remaining ?? 0); + return buildCreditsQuota("credits", remaining, remaining > 50 ? 100 : remaining > 10 ? 60 : 20); + } + if (modelKey === "models" || isUnlimitedEmpty(quota)) return null; + return normalizeQuotaEntry(modelKey, quota, { + modelKey, + isPercentageOnly: quota?.fractionReported === true, + ...(quota?.quotaSource ? { quotaSource: quota.quotaSource } : {}), + ...(quota?.fractionReported !== undefined ? { fractionReported: quota.fractionReported } : {}), + }); +} + +function parseAntigravity(data: any) { + return quotaEntries(data) + .map(([modelKey, quota]) => parseAntigravityQuota(modelKey, quota)) + .filter(Boolean); +} + +function parseCodex(data: any) { + return quotaEntries(data).map(([quotaType, quota]) => + normalizeQuotaEntry(quotaType, quota, { + displayName: quota?.displayName, + isPercentageOnly: true, + }) + ); +} + +function parseClaude(data: any) { + if (data?.message) + return [{ name: "error", used: 0, total: 0, resetAt: null, message: data.message }]; + return quotaEntries(data).map(([name, quota]) => + normalizeQuotaEntry(name, quota, { isPercentageOnly: true }) + ); +} + +function parseGeminiCli(data: any) { + return quotaEntries(data).map(([modelKey, quota]) => + normalizeQuotaEntry(modelKey, quota, { modelKey }) + ); +} + +function parseDeepseekQuota(quotaKey: string, quota: any) { + const match = quotaKey.match(/^credits(?:_([a-z]{3}))?$/); + if (!match) return normalizeQuotaEntry(quotaKey, quota); + const remaining = Number(quota?.remaining ?? 0); + const currency = quota?.currency ?? (match[1] ? match[1].toUpperCase() : "USD"); + return buildCreditsQuota(currency, remaining, remaining > 20 ? 100 : remaining > 5 ? 60 : 20, { + currency, + }); +} + +function parseDeepseek(data: any) { + return quotaEntries(data).map(([quotaKey, quota]) => parseDeepseekQuota(quotaKey, quota)); +} + +function parseProviderQuotas(providerId: string, data: any) { + if (providerId === "github") return parseGithub(data); + if (["glm", "glm-cn", "glmt", "opencode-go"].includes(providerId)) return parseGlmFamily(data); + if (providerId === "antigravity" || providerId === "agy") return parseAntigravity(data); + if (providerId === "codex") return parseCodex(data); + if (providerId === "claude") return parseClaude(data); + if (providerId === "gemini-cli") return parseGeminiCli(data); + if (providerId === "deepseek") return parseDeepseek(data); + return parseGeneric(data); +} + +function sortProviderModelOrder(provider: string, quotas: any[]) { + const modelOrder = getModelsByProviderId(provider); + if (modelOrder.length === 0) return; + const orderMap = new Map(modelOrder.map((m, i) => [m.id, i])); + quotas.sort( + (a, b) => + (orderMap.get(a.modelKey || a.name) ?? 999) - (orderMap.get(b.modelKey || b.name) ?? 999) + ); +} + +function sortGlmOrder(providerId: string, quotas: any[]) { + if (!["glm", "glm-cn", "glmt", "opencode-go"].includes(providerId)) return; + quotas.sort((a, b) => (GLM_QUOTA_ORDER[a.name] ?? 99) - (GLM_QUOTA_ORDER[b.name] ?? 99)); +} + +export function parseQuotaData(provider: string | undefined, data: any) { + if (!data || typeof data !== "object") return []; + const providerId = String(provider || "").toLowerCase(); + + try { + const normalizedQuotas = parseProviderQuotas(providerId, data); + sortProviderModelOrder(provider, normalizedQuotas); + sortGlmOrder(providerId, normalizedQuotas); + return normalizedQuotas; + } catch (error) { + console.error(`Error parsing quota data for ${provider}:`, error); + return []; + } +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/useVisibleQuotaData.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/useVisibleQuotaData.ts new file mode 100644 index 0000000000..ecf6b9618d --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/useVisibleQuotaData.ts @@ -0,0 +1,66 @@ +import { useEffect, useMemo, useState } from "react"; + +import { collectHiddenQuotaModelIds, filterHiddenModelQuotas } from "./utils"; + +function getProviderKey(connections: any[]): string { + const providers = new Set(); + for (const conn of connections) { + if (typeof conn?.provider === "string" && conn.provider) providers.add(conn.provider); + } + return Array.from(providers).sort().join("|"); +} + +export function useVisibleQuotaData( + connections: any[], + quotaData: Record +): Record { + const [hiddenModelsByProvider, setHiddenModelsByProvider] = useState>( + {} + ); + const providerKey = useMemo(() => getProviderKey(connections), [connections]); + + useEffect(() => { + if (!providerKey) return; + + let alive = true; + const providers = providerKey.split("|").filter(Boolean); + + Promise.all( + providers.map(async (provider) => { + try { + const response = await fetch( + `/api/provider-models?provider=${encodeURIComponent(provider)}` + ); + if (!response.ok) return [provider, []] as const; + const data = await response.json(); + return [provider, collectHiddenQuotaModelIds(provider, data)] as const; + } catch { + return [provider, []] as const; + } + }) + ).then((entries) => { + if (alive) setHiddenModelsByProvider(Object.fromEntries(entries)); + }); + + return () => { + alive = false; + }; + }, [providerKey]); + + return useMemo(() => { + const next: Record = {}; + for (const conn of connections) { + const data = quotaData[conn.id]; + if (!data) continue; + next[conn.id] = { + ...data, + quotas: filterHiddenModelQuotas( + conn.provider, + data.quotas, + hiddenModelsByProvider[conn.provider] + ), + }; + } + return next; + }, [connections, hiddenModelsByProvider, quotaData]); +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index b8dec9cddb..e19f8b973b 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -1,5 +1,4 @@ -import { getModelsByProviderId } from "@omniroute/open-sse/config/providerModels.ts"; -import { safePercentage } from "@/shared/utils/formatting"; +export { parseQuotaData } from "./quotaParsing"; const PROVIDER_PLAN_FALLBACKS = new Set([ "claude code", @@ -36,12 +35,6 @@ const QUOTA_LABEL_MAP: Record = { time_limit: "Time Limit", }; -const GLM_QUOTA_ORDER: Record = { - session: 0, - weekly: 1, - mcp_monthly: 2, -}; - function toRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -179,267 +172,6 @@ export function calculatePercentage(used, total) { return Math.round(((total - used) / total) * 100); } -function isPastResetWindow(resetAt) { - if (!resetAt) return false; - const resetTime = - typeof resetAt === "number" ? resetAt : typeof resetAt === "string" ? Date.parse(resetAt) : NaN; - if (!Number.isFinite(resetTime)) return false; - return Date.now() >= resetTime; -} - -function normalizeQuotaEntry(name: string, quota: any = {}, extras: any = {}) { - const usedRaw = Number(quota?.used || 0); - const totalRaw = Number(quota?.total || 0); - const resetAt = quota?.resetAt || null; - - // T13: Only consider it stale if the reset time passed AND there's still usage shown. - // If usage is already 0 (or remaining is 100%), it's naturally reset and doesn't need to be marked as stale. - const passedReset = isPastResetWindow(resetAt); - const remainingPercentageRaw = safePercentage(quota?.remainingPercentage); - const hasPendingUsage = - usedRaw > 0 || (remainingPercentageRaw !== undefined && remainingPercentageRaw < 100); - const staleAfterReset = passedReset && hasPendingUsage; - - const used = staleAfterReset ? 0 : usedRaw; - const total = Number.isFinite(totalRaw) ? totalRaw : 0; - - const remainingPercentage = - staleAfterReset && total > 0 - ? 100 - : remainingPercentageRaw !== undefined - ? remainingPercentageRaw - : undefined; - - return { - name, - used: Number.isFinite(used) ? used : 0, - total, - resetAt, - staleAfterReset, - ...(remainingPercentage !== undefined ? { remainingPercentage } : {}), - ...extras, - }; -} - -/** - * Parse provider-specific quota structures into normalized array - * @param {string} provider - Provider name (github, antigravity, codex, kiro, claude) - * @param {Object} data - Raw quota data from provider - * @returns {Array} Normalized quota objects with { name, used, total, resetAt } - */ -export function parseQuotaData(provider, data) { - if (!data || typeof data !== "object") return []; - - const normalizedQuotas = []; - const providerId = String(provider || "").toLowerCase(); - - try { - switch (providerId) { - case "github": - if (data.quotas) { - Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => { - if (quota?.unlimited && (!quota?.total || quota.total <= 0)) { - return; - } - normalizedQuotas.push(normalizeQuotaEntry(name, quota)); - }); - } - break; - - case "glm": - case "glm-cn": - case "glmt": - case "opencode-go": - if (data.quotas) { - Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => { - normalizedQuotas.push( - normalizeQuotaEntry(name, quota, { - displayName: quota?.displayName, - details: Array.isArray(quota?.details) ? quota.details : undefined, - isPercentageOnly: - Number(quota?.total || 0) === 100 && quota?.remainingPercentage !== undefined, - }) - ); - }); - } - break; - - case "antigravity": - case "agy": - if (data.quotas) { - Object.entries(data.quotas).forEach(([modelKey, quota]: [string, any]) => { - if (modelKey === "credits") { - // Credit balance: render as "N credits remaining" counter, not a progress bar - const remaining = Number(quota?.remaining ?? 0); - normalizedQuotas.push({ - name: "credits", - used: 0, - total: 0, - remaining, - resetAt: null, - unlimited: false, - isCredits: true, - // Show green if >50, yellow if >10, red if ≤10 - remainingPercentage: remaining > 50 ? 100 : remaining > 10 ? 60 : 20, - creditCount: remaining, - }); - return; - } - if (modelKey === "models") { - // Summary row: skip — individual models are shown via modelQuotas if needed - return; - } - if (quota?.unlimited && (!quota?.total || quota.total <= 0)) { - return; - } - normalizedQuotas.push( - normalizeQuotaEntry(modelKey, quota, { - modelKey: modelKey, - isPercentageOnly: quota?.fractionReported === true, - ...(quota?.quotaSource ? { quotaSource: quota.quotaSource } : {}), - ...(quota?.fractionReported !== undefined - ? { fractionReported: quota.fractionReported } - : {}), - }) - ); - }); - } - break; - - case "codex": - if (data.quotas) { - Object.entries(data.quotas).forEach(([quotaType, quota]: [string, any]) => { - normalizedQuotas.push( - normalizeQuotaEntry(quotaType, quota, { - displayName: quota?.displayName, - isPercentageOnly: true, - }) - ); - }); - } - break; - - case "kiro": - case "amazon-q": - if (data.quotas) { - Object.entries(data.quotas).forEach(([quotaType, quota]: [string, any]) => { - normalizedQuotas.push(normalizeQuotaEntry(quotaType, quota)); - }); - } - break; - - case "claude": - if (data.message) { - // Handle error message case - normalizedQuotas.push({ - name: "error", - used: 0, - total: 0, - resetAt: null, - message: data.message, - }); - } else if (data.quotas) { - Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => { - normalizedQuotas.push( - normalizeQuotaEntry(name, quota, { - isPercentageOnly: true, - }) - ); - }); - } - break; - - case "gemini-cli": - if (data.quotas) { - Object.entries(data.quotas).forEach(([modelKey, quota]: [string, any]) => { - normalizedQuotas.push(normalizeQuotaEntry(modelKey, quota, { modelKey })); - }); - } - break; - - case "nanogpt": - if (data.quotas) { - Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => { - normalizedQuotas.push(normalizeQuotaEntry(name, quota)); - }); - } - break; - - case "deepseek": - // DeepSeek balance: credits-style display with currency - // Match any "credits" key with optional 3-letter currency suffix - if (data.quotas) { - Object.entries(data.quotas).forEach(([quotaKey, quota]: [string, any]) => { - // Match credits, credits_usd, credits_cny, credits_eur, etc. - const match = quotaKey.match(/^credits(?:_([a-z]{3}))?$/); - if (match) { - const remaining = Number(quota?.remaining ?? 0); - // Extract currency from key suffix or use quota.currency, fallback to USD - const currency = quota?.currency ?? (match[1] ? match[1].toUpperCase() : "USD"); - normalizedQuotas.push({ - name: currency, - used: 0, - total: 0, - remaining, - resetAt: null, - unlimited: false, - isCredits: true, - currency, - creditCount: remaining, - // Color coding based on balance amount: green >20, yellow 5-20, red <5 - remainingPercentage: remaining > 20 ? 100 : remaining > 5 ? 60 : 20, - }); - } else { - normalizedQuotas.push(normalizeQuotaEntry(quotaKey, quota)); - } - }); - } - break; - - default: - // Generic fallback for unknown providers - if (data.quotas) { - Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => { - normalizedQuotas.push(normalizeQuotaEntry(name, quota)); - }); - } - } - } catch (error) { - console.error(`Error parsing quota data for ${provider}:`, error); - return []; - } - - // Sort quotas according to PROVIDER_MODELS order - const modelOrder = getModelsByProviderId(provider); - if (modelOrder.length > 0) { - const orderMap = new Map(modelOrder.map((m, i) => [m.id, i])); - - normalizedQuotas.sort((a, b) => { - // Use modelKey for antigravity, otherwise use name - const keyA = a.modelKey || a.name; - const keyB = b.modelKey || b.name; - const orderA = orderMap.get(keyA) ?? 999; - const orderB = orderMap.get(keyB) ?? 999; - return (orderA as number) - (orderB as number); - }); - } - - if ( - providerId === "glm" || - providerId === "glm-cn" || - providerId === "glmt" || - providerId === "opencode-go" - ) { - normalizedQuotas.sort((a, b) => { - const orderA = GLM_QUOTA_ORDER[a.name] ?? 99; - const orderB = GLM_QUOTA_ORDER[b.name] ?? 99; - return orderA - orderB; - }); - } - - return normalizedQuotas; -} - /** * Resolve the best available plan label using live usage first, then persisted * provider-specific connection metadata. @@ -472,107 +204,93 @@ export function resolvePlanValue(plan, providerSpecificData) { return livePlan || null; } +function unknownPlanTier(raw: string | null = null) { + return { key: "unknown", label: "Unknown", variant: "default", rank: 0, raw }; +} + +function formatUnknownPlanLabel(raw: string) { + return raw + .toLowerCase() + .split(/[\s_-]+/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function matchClaudePlanTier(raw: string, upper: string) { + const match = upper.match(/(?:DEFAULT_)?CLAUDE_(MAX|PRO|TEAM|ENTERPRISE|FREE)(?:_(\d+X))?/); + if (!match) return null; + + const multiplier = match[2] ? ` ${match[2].toLowerCase()}` : ""; + const tiers = { + MAX: { key: "ultra", label: `Max${multiplier}`, variant: "success", rank: 4, raw }, + PRO: { key: "pro", label: "Pro", variant: "success", rank: 3, raw }, + TEAM: { key: "team", label: "Team", variant: "info", rank: 6, raw }, + ENTERPRISE: { key: "enterprise", label: "Enterprise", variant: "info", rank: 7, raw }, + FREE: { key: "free", label: "Free", variant: "default", rank: 1, raw }, + }; + return tiers[match[1]]; +} + +function matchKeywordPlanTier(raw: string, upper: string) { + if (upper.includes("PRO+") || upper.includes("PRO PLUS") || upper.includes("PROPLUS")) + return { key: "plus", label: "Pro+", variant: "success", rank: 4, raw }; + if (upper.includes("ENTERPRISE") || upper.includes("CORP") || upper.includes("ORG")) + return { key: "enterprise", label: "Enterprise", variant: "info", rank: 7, raw }; + if (upper.includes("TEAM") || upper.includes("CHATGPTTEAM")) + return { key: "team", label: "Team", variant: "info", rank: 6, raw }; + if (upper.includes("BUSINESS") || upper.includes("STANDARD") || upper.includes("BIZ")) + return { key: "business", label: "Business", variant: "warning", rank: 5, raw }; + if (upper.includes("STUDENT")) + return { key: "pro", label: "Student", variant: "success", rank: 3, raw }; + if (upper.includes("ULTRA")) + return { key: "ultra", label: "Ultra", variant: "success", rank: 4, raw }; + return null; +} + +function matchTokenPlanTier(raw: string, upper: string) { + if (hasTierToken(upper, "MAX")) + return { key: "ultra", label: "Max", variant: "success", rank: 4, raw }; + if (hasTierToken(upper, "PRO") || hasTierToken(upper, "PREMIUM")) + return { key: "pro", label: "Pro", variant: "success", rank: 3, raw }; + if (hasTierToken(upper, "STARTER")) + return { key: "lite", label: "Starter", variant: "primary", rank: 2, raw }; + if (hasTierToken(upper, "LITE") || hasTierToken(upper, "LIGHT")) + return { key: "lite", label: "Lite", variant: "primary", rank: 2, raw }; + if (hasTierToken(upper, "PLUS") || hasTierToken(upper, "PAID")) + return { key: "plus", label: "Plus", variant: "success", rank: 2, raw }; + return null; +} + +function matchFreePlanTier(raw: string, upper: string) { + return upper.includes("FREE") || + upper.includes("BASIC") || + upper.includes("TRIAL") || + upper.includes("LEGACY") + ? { key: "free", label: "Free", variant: "default", rank: 1, raw } + : null; +} + /** * Normalize provider-specific plan labels into a shared tier taxonomy. * Supported tiers: enterprise, business, team, ultra, pro, plus, lite, free, unknown. */ export function normalizePlanTier(plan) { const raw = typeof plan === "string" ? plan.trim() : ""; - if (!raw) { - return { key: "unknown", label: "Unknown", variant: "default", rank: 0, raw: null }; - } + if (!raw) return unknownPlanTier(null); const upper = raw.toUpperCase(); // Provider names that are not real plan tiers — treat as unknown - if (PROVIDER_PLAN_FALLBACKS.has(raw.toLowerCase())) { - return { key: "unknown", label: "Unknown", variant: "default", rank: 0, raw }; - } + if (PROVIDER_PLAN_FALLBACKS.has(raw.toLowerCase())) return unknownPlanTier(raw); // Match Anthropic bootstrap strings (claude_max, default_claude_max_20x, etc.) // before the generic PRO/TEAM checks so underscored values don't fall through. - const claudeMatch = upper.match(/(?:DEFAULT_)?CLAUDE_(MAX|PRO|TEAM|ENTERPRISE|FREE)(?:_(\d+X))?/); - if (claudeMatch) { - const family = claudeMatch[1]; - const multiplier = claudeMatch[2] ? ` ${claudeMatch[2].toLowerCase()}` : ""; - if (family === "MAX") { - return { key: "ultra", label: `Max${multiplier}`, variant: "success", rank: 4, raw }; - } - if (family === "PRO") { - return { key: "pro", label: "Pro", variant: "success", rank: 3, raw }; - } - if (family === "TEAM") { - return { key: "team", label: "Team", variant: "info", rank: 6, raw }; - } - if (family === "ENTERPRISE") { - return { key: "enterprise", label: "Enterprise", variant: "info", rank: 7, raw }; - } - if (family === "FREE") { - return { key: "free", label: "Free", variant: "default", rank: 1, raw }; - } - } - - if (upper.includes("PRO+") || upper.includes("PRO PLUS") || upper.includes("PROPLUS")) { - return { key: "plus", label: "Pro+", variant: "success", rank: 4, raw }; - } - - if (upper.includes("ENTERPRISE") || upper.includes("CORP") || upper.includes("ORG")) { - return { key: "enterprise", label: "Enterprise", variant: "info", rank: 7, raw }; - } - - // Team plan (e.g., ChatGPT Team, GitHub Team) - if (upper.includes("TEAM") || upper.includes("CHATGPTTEAM")) { - return { key: "team", label: "Team", variant: "info", rank: 6, raw }; - } - - if (upper.includes("BUSINESS") || upper.includes("STANDARD") || upper.includes("BIZ")) { - return { key: "business", label: "Business", variant: "warning", rank: 5, raw }; - } - - if (upper.includes("STUDENT")) { - return { key: "pro", label: "Student", variant: "success", rank: 3, raw }; - } - - if (upper.includes("ULTRA")) { - return { key: "ultra", label: "Ultra", variant: "success", rank: 4, raw }; - } - - if (hasTierToken(upper, "MAX")) { - return { key: "ultra", label: "Max", variant: "success", rank: 4, raw }; - } - - if (hasTierToken(upper, "PRO") || hasTierToken(upper, "PREMIUM")) { - return { key: "pro", label: "Pro", variant: "success", rank: 3, raw }; - } - - if (hasTierToken(upper, "STARTER")) { - return { key: "lite", label: "Starter", variant: "primary", rank: 2, raw }; - } - - if (hasTierToken(upper, "LITE") || hasTierToken(upper, "LIGHT")) { - return { key: "lite", label: "Lite", variant: "primary", rank: 2, raw }; - } - - if (hasTierToken(upper, "PLUS") || hasTierToken(upper, "PAID")) { - return { key: "plus", label: "Plus", variant: "success", rank: 2, raw }; - } - - if ( - upper.includes("FREE") || - upper.includes("BASIC") || - upper.includes("TRIAL") || - upper.includes("LEGACY") - ) { - return { key: "free", label: "Free", variant: "default", rank: 1, raw }; - } - - const titleCased = raw - .toLowerCase() - .split(/[\s_-]+/) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); - - return { key: "unknown", label: titleCased || "Unknown", variant: "default", rank: 0, raw }; + const matched = + matchClaudePlanTier(raw, upper) || + matchKeywordPlanTier(raw, upper) || + matchTokenPlanTier(raw, upper) || + matchFreePlanTier(raw, upper); + return matched || { ...unknownPlanTier(raw), label: formatUnknownPlanLabel(raw) || "Unknown" }; } // === Card Grid Helpers (T7) ================================================= @@ -691,6 +409,68 @@ export function getNextResetSummary(quotas: any[] | undefined): string | null { return soonestIso ? formatCountdown(soonestIso) : null; } +function addQuotaModelIdVariants(out: Set, provider: string, modelId: string) { + const raw = modelId.trim().toLowerCase(); + const providerId = provider.trim().toLowerCase(); + if (!raw) return; + out.add(raw); + if (!providerId) return; + + const prefix = `${providerId}/`; + if (raw.startsWith(prefix)) { + const stripped = raw.slice(prefix.length); + if (stripped) out.add(stripped); + } else { + out.add(`${providerId}/${raw}`); + } +} + +export function collectHiddenQuotaModelIds(provider: string, payload: unknown): string[] { + const hidden = new Set(); + const data = toRecord(payload); + const collect = (entries: unknown) => { + if (!Array.isArray(entries)) return; + for (const entry of entries) { + const record = toRecord(entry); + if (record.isHidden !== true && record.isDeleted !== true) continue; + if (typeof record.id === "string") addQuotaModelIdVariants(hidden, provider, record.id); + } + }; + + collect(data.models); + collect(data.modelCompatOverrides); + return Array.from(hidden); +} + +export function filterHiddenModelQuotas( + provider: string, + quotas: any[] | undefined, + hiddenModelIds: string[] | undefined +): any[] { + if (!Array.isArray(quotas)) return []; + if (!hiddenModelIds || hiddenModelIds.length === 0) return quotas; + + const hidden = new Set( + hiddenModelIds.map((id) => id.trim().toLowerCase()).filter((id) => id.length > 0) + ); + if (hidden.size === 0) return quotas; + + return quotas.filter((quota) => { + if (!quota || quota.isCredits) return true; + const modelId = + typeof quota.modelKey === "string" + ? quota.modelKey + : typeof quota.modelId === "string" + ? quota.modelId + : ""; + if (!modelId) return true; + + const candidates = new Set(); + addQuotaModelIdVariants(candidates, provider, modelId); + return !Array.from(candidates).some((candidate) => hidden.has(candidate)); + }); +} + // --- Provider dropdown filter (PR #769 port) ----------------------------- // Pure helpers extracted from so the filter+dropdown logic // can be exercised by unit tests without rendering React. Keep them free of diff --git a/src/app/(dashboard)/home/ProviderQuotaWidget.tsx b/src/app/(dashboard)/home/ProviderQuotaWidget.tsx index 41cd067db2..79c4204bd2 100644 --- a/src/app/(dashboard)/home/ProviderQuotaWidget.tsx +++ b/src/app/(dashboard)/home/ProviderQuotaWidget.tsx @@ -28,6 +28,53 @@ function formatAutoRefreshCountdown(ms: number): string { return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; } +export function AutoRefreshButtonLabel({ + autoRefreshIntervalMs, + lastRefreshAllAt, + refreshingAll, + tr, +}: { + autoRefreshIntervalMs: number; + lastRefreshAllAt: number; + refreshingAll: boolean; + tr: (key: string, fallback: string) => string; +}) { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (autoRefreshIntervalMs <= 0 || refreshingAll) return; + + const tick = () => setNow(Date.now()); + tick(); + + const timer = window.setInterval(tick, 1000); + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") tick(); + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + window.clearInterval(timer); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [autoRefreshIntervalMs, refreshingAll, lastRefreshAllAt]); + + if (refreshingAll) { + return <>{tr("refreshing", "Refreshing")}; + } + + if (autoRefreshIntervalMs <= 0) { + return <>{tr("refreshAll", "Refresh All")}; + } + + return ( + <> + {tr("autoRefreshing", "Auto-refreshing")}{" "} + {formatAutoRefreshCountdown(Math.max(0, autoRefreshIntervalMs - (now - lastRefreshAllAt)))} + + ); +} + export default function ProviderQuotaWidget({ autoRefreshInterval = 0 }: ProviderQuotaWidgetProps) { const t = useTranslations("usage"); const tr = useCallback( @@ -42,8 +89,8 @@ export default function ProviderQuotaWidget({ autoRefreshInterval = 0 }: Provide const refreshingAllRef = useRef(false); const lastRefreshAllAtRef = useRef(Date.now()); + const [lastRefreshAllAt, setLastRefreshAllAt] = useState(() => lastRefreshAllAtRef.current); const autoRefreshIntervalMs = autoRefreshInterval > 0 ? autoRefreshInterval * 1000 : 0; - const [autoRefreshClock, setAutoRefreshClock] = useState(() => Date.now()); const fetchConnections = useCallback(async () => { try { @@ -87,30 +134,12 @@ export default function ProviderQuotaWidget({ autoRefreshInterval = 0 }: Provide loadData(); }, [loadData]); - useEffect(() => { - if (autoRefreshIntervalMs <= 0) return; - - const tick = () => setAutoRefreshClock(Date.now()); - tick(); - - const timer = window.setInterval(tick, 1000); - const handleVisibilityChange = () => { - if (document.visibilityState === "visible") tick(); - }; - - document.addEventListener("visibilitychange", handleVisibilityChange); - return () => { - window.clearInterval(timer); - document.removeEventListener("visibilitychange", handleVisibilityChange); - }; - }, [autoRefreshIntervalMs]); - const refreshAll = useCallback(async () => { if (refreshingAllRef.current) return; refreshingAllRef.current = true; const now = Date.now(); lastRefreshAllAtRef.current = now; - setAutoRefreshClock(now); + setLastRefreshAllAt(now); setRefreshingAll(true); try { @@ -120,31 +149,36 @@ export default function ProviderQuotaWidget({ autoRefreshInterval = 0 }: Provide throw new Error(err.error || "Refresh failed"); } const data = await res.json(); - // Re-fetch connections + caches to get fresh state - const [conns, caches] = await Promise.all([fetchConnections(), fetchCached()]); - const relevant = conns.filter( - (c) => - USAGE_SUPPORTED_PROVIDERS.includes(c.provider) && - (c.authType === "oauth" || c.authType === "apikey") - ); - setConnections(relevant); - setQuotaData(caches || data.caches || {}); + setQuotaData(data.caches || {}); } catch (e) { console.error("ProviderQuotaWidget refreshAll error:", e); } finally { refreshingAllRef.current = false; setRefreshingAll(false); } - }, [fetchConnections, fetchCached]); + }, []); useEffect(() => { if (autoRefreshIntervalMs <= 0) return; - if (document.visibilityState !== "visible") return; - if (refreshingAllRef.current) return; - if (autoRefreshClock - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) { - void refreshAll(); - } - }, [autoRefreshClock, autoRefreshIntervalMs, refreshAll]); + + const maybeRefresh = () => { + if (document.visibilityState !== "visible") return; + if (refreshingAllRef.current) return; + if (Date.now() - lastRefreshAllAtRef.current >= autoRefreshIntervalMs) { + void refreshAll(); + } + }; + + maybeRefresh(); + const timer = window.setInterval(maybeRefresh, 1000); + const handleVisibilityChange = () => maybeRefresh(); + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + window.clearInterval(timer); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [autoRefreshIntervalMs, refreshAll]); // Simple summary: group by provider for display const providerGroups = connections.reduce>((acc, conn) => { @@ -187,16 +221,12 @@ export default function ProviderQuotaWidget({ autoRefreshInterval = 0 }: Provide {autoRefreshIntervalMs > 0 ? "schedule" : "refresh"} - {refreshingAll - ? tr("refreshing", "Refreshing") - : autoRefreshIntervalMs > 0 - ? `${tr("autoRefreshing", "Auto-refreshing")} ${formatAutoRefreshCountdown( - Math.max( - 0, - autoRefreshIntervalMs - (autoRefreshClock - lastRefreshAllAtRef.current) - ) - )}` - : tr("refreshAll", "Refresh All")} + diff --git a/src/app/api/openapi/spec/route.ts b/src/app/api/openapi/spec/route.ts index 647c1f69e1..a4092bd143 100644 --- a/src/app/api/openapi/spec/route.ts +++ b/src/app/api/openapi/spec/route.ts @@ -6,7 +6,7 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; let cachedSpec: { data: any; mtime: number } | null = null; diff --git a/src/app/api/provider-nodes/validate/route.ts b/src/app/api/provider-nodes/validate/route.ts index 22d9136f73..024818fcaa 100644 --- a/src/app/api/provider-nodes/validate/route.ts +++ b/src/app/api/provider-nodes/validate/route.ts @@ -30,6 +30,54 @@ function sanitizeClaudeCodeCompatibleBaseUrl(baseUrl: string) { .replace(/\/(?:v\d+\/)?messages(?:\?[^#]*)?$/i, ""); } +// Status-specific error message for /models probe failures. +function getModelsErrorMessage(status: number) { + if (status === 401 || status === 403) return "API key unauthorized"; + if (status === 404) { + return "/models endpoint not found - enter a Model ID to validate via chat/completions instead"; + } + if (status >= 500) return "Server error - try again later"; + return `Unexpected response (${status})`; +} + +// Status-specific error message for the /chat/completions fallback probe. +function getChatErrorMessage(status: number) { + if (status === 401 || status === 403) return "API key unauthorized"; + if (status === 400) return "Invalid model or bad request"; + if (status === 404) return "Chat endpoint not found"; + if (status >= 500) return "Server error - try again later"; + return `Chat request failed (${status})`; +} + +async function probeChatFallback({ + baseUrl, + apiKey, + modelId, + extraHeaders = {}, +}: { + baseUrl: string; + apiKey: string; + modelId: string; + extraHeaders?: Record; +}) { + const chatUrl = `${baseUrl.replace(/\/$/, "")}/chat/completions`; + return safeOutboundFetch(chatUrl, { + ...SAFE_OUTBOUND_FETCH_PRESETS.validationRead, + guard: getProviderOutboundGuard(), + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + ...extraHeaders, + }, + body: JSON.stringify({ + model: modelId, + messages: [{ role: "user", content: "ping" }], + max_tokens: 1, + }), + }); +} + function sanitizeAuditBaseUrl(baseUrl: string) { if (!baseUrl) return null; try { @@ -66,7 +114,8 @@ export async function POST(request) { if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } - const { baseUrl, apiKey, type, compatMode, chatPath, modelsPath } = validation.data; + const { baseUrl, apiKey, type, compatMode, chatPath, modelsPath, modelId } = validation.data; + const trimmedModelId = typeof modelId === "string" ? modelId.trim() : ""; // Anthropic Compatible Validation if (type === "anthropic-compatible") { @@ -111,18 +160,57 @@ export async function POST(request) { }, }); - return NextResponse.json({ valid: res.ok, error: res.ok ? null : "Invalid API key" }); + if (res.ok) return NextResponse.json({ valid: true, error: null }); + // Auth errors: chat fallback would not recover. Skip and surface clear message. + if (res.status === 401 || res.status === 403) { + return NextResponse.json({ valid: false, error: "API key unauthorized" }); + } + // Optional /chat/completions fallback when caller supplied a model ID + // (some Anthropic-compatible proxies expose only the chat endpoint). + if (trimmedModelId) { + const chatRes = await probeChatFallback({ + baseUrl: normalizedBase, + apiKey: apiKey ?? "", + modelId: trimmedModelId, + extraHeaders: { "x-api-key": apiKey ?? "", "anthropic-version": "2023-06-01" }, + }); + if (chatRes.ok) return NextResponse.json({ valid: true, error: null, method: "chat" }); + return NextResponse.json({ + valid: false, + error: getChatErrorMessage(chatRes.status), + method: "chat", + }); + } + return NextResponse.json({ valid: false, error: getModelsErrorMessage(res.status) }); } // OpenAI Compatible Validation (Default) - const modelsUrl = `${baseUrl.replace(/\/$/, "")}${modelsPath || "/models"}`; + const openAiBase = baseUrl.replace(/\/$/, ""); + const modelsUrl = `${openAiBase}${modelsPath || "/models"}`; const res = await safeOutboundFetch(modelsUrl, { ...SAFE_OUTBOUND_FETCH_PRESETS.validationRead, guard: getProviderOutboundGuard(), headers: { Authorization: `Bearer ${apiKey}` }, }); - return NextResponse.json({ valid: res.ok, error: res.ok ? null : "Invalid API key" }); + if (res.ok) return NextResponse.json({ valid: true, error: null }); + if (res.status === 401 || res.status === 403) { + return NextResponse.json({ valid: false, error: "API key unauthorized" }); + } + if (trimmedModelId) { + const chatRes = await probeChatFallback({ + baseUrl: openAiBase, + apiKey: apiKey ?? "", + modelId: trimmedModelId, + }); + if (chatRes.ok) return NextResponse.json({ valid: true, error: null, method: "chat" }); + return NextResponse.json({ + valid: false, + error: getChatErrorMessage(chatRes.status), + method: "chat", + }); + } + return NextResponse.json({ valid: false, error: getModelsErrorMessage(res.status) }); } catch (error) { const status = getSafeOutboundFetchErrorStatus(error); if (status) { diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 811da78b65..00bc0cf398 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -26,6 +26,19 @@ import { isCliTokenAuthValid } from "@/lib/middleware/cliTokenAuth"; import { extractApiKey } from "@/sse/services/auth"; import { getApiKeyMetadata } from "@/lib/db/apiKeys"; +/** + * Force this route to run dynamically per-request and never be cached/prerendered. + * Combined with the `Cache-Control: no-store` response header below, this keeps + * persisted settings (e.g. dashboard preferences, debugMode, hidden sidebar + * items) visible immediately after refresh or restart instead of falling back + * to stale Next.js fetch cache. Ported from upstream decolua/9router#951. + */ +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +/** Response headers applied to every successful GET/PATCH on /api/settings. */ +const SETTINGS_RESPONSE_HEADERS = { "Cache-Control": "no-store" } as const; + /** * Settings keys whose change broadens attack surface. Spec §Security: * password re-auth is required when any of these is present in a PATCH body. @@ -162,19 +175,22 @@ export async function GET(request: Request) { // best effort — don't fail GET /api/settings if this lookup fails } - return NextResponse.json({ - ...safeSettings, - hasPassword: hasManagementPasswordConfigured(settings), - runtimePorts, - apiPort: runtimePorts.apiPort, - dashboardPort: runtimePorts.dashboardPort, - cloudConfigured: Boolean(cloudUrl), - cloudUrl, - machineId, - ...(cliproxyapiModelMapping !== null - ? { cliproxyapi_model_mapping: cliproxyapiModelMapping } - : {}), - }); + return NextResponse.json( + { + ...safeSettings, + hasPassword: hasManagementPasswordConfigured(settings), + runtimePorts, + apiPort: runtimePorts.apiPort, + dashboardPort: runtimePorts.dashboardPort, + cloudConfigured: Boolean(cloudUrl), + cloudUrl, + machineId, + ...(cliproxyapiModelMapping !== null + ? { cliproxyapi_model_mapping: cliproxyapiModelMapping } + : {}), + }, + { headers: SETTINGS_RESPONSE_HEADERS } + ); } catch (error) { console.log("Error getting settings:", error); return NextResponse.json({ error: "Failed to load settings" }, { status: 500 }); @@ -376,7 +392,7 @@ export async function PATCH(request: Request) { } const { password, ...safeSettings } = settings; - return NextResponse.json(safeSettings); + return NextResponse.json(safeSettings, { headers: SETTINGS_RESPONSE_HEADERS }); } catch (error) { console.log("Error updating settings:", error); return NextResponse.json({ error: "Failed to update settings" }, { status: 500 }); diff --git a/src/app/api/v1/models/[...model]/route.ts b/src/app/api/v1/models/[...model]/route.ts new file mode 100644 index 0000000000..cb3d22e8b0 --- /dev/null +++ b/src/app/api/v1/models/[...model]/route.ts @@ -0,0 +1,26 @@ +import { handleCorsOptions } from "@/shared/utils/cors"; +import { getUnifiedModelsResponse } from "../catalog"; +import { handleGetModelById } from "../modelById"; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return handleCorsOptions(); +} + +/** + * GET /v1/models/{model} — OpenAI-compatible single-model retrieval (#4674). + * + * Catch-all (`[...model]`) so provider-prefixed ids that contain a slash + * (e.g. `cgpt-web/gpt-5.5`, `claude/claude-sonnet-4-6`) are captured intact. + */ +export async function GET( + request: Request, + { params }: { params: Promise<{ model: string[] }> } +) { + const { model } = await params; + const segments = Array.isArray(model) ? model : [model]; + const requestedId = decodeURIComponent(segments.join("/")); + return handleGetModelById(request, requestedId, getUnifiedModelsResponse); +} diff --git a/src/app/api/v1/models/modelById.ts b/src/app/api/v1/models/modelById.ts new file mode 100644 index 0000000000..5a8b2eadb8 --- /dev/null +++ b/src/app/api/v1/models/modelById.ts @@ -0,0 +1,62 @@ +import { CORS_HEADERS } from "@/shared/utils/cors"; + +/** + * #4674 — Shared logic for `GET /v1/models/{model}`. + * + * Before this route existed, a request for a single model fell through to the + * Next.js catch-all and returned the HTML dashboard, which broke Claude Code's + * model-validation probe (it expects the OpenAI `{ id, object: "model", ... }` + * shape). Kept in a sibling module (not the route file) so the lookup stays + * unit-testable without the DB-backed catalog. + */ + +type CatalogModel = { id?: unknown } & Record; + +/** Find a model entry in the unified catalog `data` array by its exact id. */ +export function findModelById( + data: CatalogModel[] | null | undefined, + requestedId: string +): CatalogModel | null { + if (!Array.isArray(data)) return null; + return data.find((m) => typeof m?.id === "string" && m.id === requestedId) ?? null; +} + +/** + * Resolve a single model from the unified catalog response. + * + * @param getModels Returns the `{ object: "list", data: [...] }` Response — in + * the route this is `getUnifiedModelsResponse`; tests inject a fake. + */ +export async function handleGetModelById( + request: Request, + requestedId: string, + getModels: (request: Request, corsHeaders?: Record) => Promise +): Promise { + const listResp = await getModels(request, CORS_HEADERS); + // Propagate auth rejections / 5xx (already JSON) unchanged. + if (!listResp.ok) return listResp; + + let data: CatalogModel[] | undefined; + try { + const body = (await listResp.json()) as { data?: CatalogModel[] }; + data = body?.data; + } catch { + data = undefined; + } + + const found = findModelById(data, requestedId); + if (found) { + return Response.json(found, { headers: CORS_HEADERS }); + } + + return Response.json( + { + error: { + message: `The model '${requestedId}' does not exist`, + type: "invalid_request_error", + code: "model_not_found", + }, + }, + { status: 404, headers: CORS_HEADERS } + ); +} diff --git a/src/app/api/v1/relay/chat/completions/bifrost/route.ts b/src/app/api/v1/relay/chat/completions/bifrost/route.ts index a6b4bf2aba..936cbc7a64 100644 --- a/src/app/api/v1/relay/chat/completions/bifrost/route.ts +++ b/src/app/api/v1/relay/chat/completions/bifrost/route.ts @@ -36,8 +36,14 @@ import { recordRelayUsage, } from "@/lib/db/relayProxies"; import { buildErrorBody } from "@omniroute/open-sse/utils/error"; -import { createHash } from "node:crypto"; import { z } from "zod"; +import { + checkIpRateLimit, + extractToken, + getClientIp, + hashToken, + sanitizeForensicHeader, +} from "../relaySecurity"; // Minimal request-shape validation (Rule #7). `.passthrough()` keeps every other // OpenAI chat-completion field intact (temperature, tools, response_format, …) — @@ -64,33 +70,53 @@ const BIFROST_ENABLED = process.env.BIFROST_ENABLED !== "0"; const injectionGuard = createInjectionGuard(); +type RelayUsageRecorder = (status: "success" | "error", statusCode: number) => void; + export async function OPTIONS() { return handleCorsOptions(); } -function sanitizeForensicHeader(value: string | null, max = 256): string { - if (!value) return "unknown"; - return value.replace(/[\r\n]+/g, " ").slice(0, max); -} +function finalizeReadableStream( + body: ReadableStream, + onFinalize: (error?: unknown) => void +): ReadableStream { + const reader = body.getReader(); + let finalized = false; -function extractToken(request: Request): string | null { - const auth = request.headers.get("authorization") || ""; - const match = auth.match(/^Bearer\s+(.+)$/i); - if (match) return match[1]; - return request.headers.get("x-relay-token"); -} + const finalizeOnce = (error?: unknown) => { + if (finalized) return; + finalized = true; + onFinalize(error); + }; -function hashToken(token: string): string { - return createHash("sha256").update(token).digest("hex"); + return new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await reader.read(); + if (done) { + finalizeOnce(); + controller.close(); + return; + } + controller.enqueue(value); + } catch (error) { + finalizeOnce(error); + controller.error(error); + } + }, + async cancel(reason) { + try { + await reader.cancel(reason); + } finally { + finalizeOnce(reason); + } + }, + }); } export async function POST(request: Request) { const startTime = Date.now(); - const clientIp = sanitizeForensicHeader( - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - request.headers.get("x-real-ip") || - null - ); + const clientIp = getClientIp(request); const userAgent = sanitizeForensicHeader(request.headers.get("user-agent")); if (!BIFROST_ENABLED) { @@ -168,6 +194,28 @@ export async function POST(request: Request) { }); } + // 2a. Per-(token,IP) gate — mirrors the TypeScript relay fallback so the + // sidecar path does not weaken leaked-token abuse protection. + const ipCheck = checkIpRateLimit(token.id, clientIp); + if (!ipCheck.allowed) { + recordRelayUsage(token.id, { + requestId: request.headers.get("x-request-id") || undefined, + status: "rate_limited", + statusCode: 429, + latencyMs: Date.now() - startTime, + clientIp, + userAgent, + }); + return new Response(JSON.stringify(buildErrorBody(429, "Per-IP rate limit exceeded")), { + status: 429, + headers: { + ...JSON_CORS_HEADERS, + "Retry-After": String(ipCheck.resetIn), + "X-RateLimit-Scope": "ip", + }, + }); + } + const rateCheck = checkRateLimit(token.id); if (!rateCheck.allowed) { recordRelayUsage(token.id, { @@ -254,7 +302,11 @@ export async function POST(request: Request) { } const ac = new AbortController(); - const tid = setTimeout(() => ac.abort(), BIFROST_TIMEOUT_MS); + let timedOut = false; + const tid = setTimeout(() => { + timedOut = true; + ac.abort(); + }, BIFROST_TIMEOUT_MS); let upstream: Response; try { @@ -264,20 +316,23 @@ export async function POST(request: Request) { body: JSON.stringify(body), signal: ac.signal, }); - } finally { + } catch (error) { clearTimeout(tid); + throw error; } - // 5. Forward response. For streaming we pass the body stream through - // unmodified — Node's fetch streams chunked correctly. - recordRelayUsage(token.id, { - requestId: request.headers.get("x-request-id") || undefined, - status: upstream.status < 500 ? "success" : "error", - statusCode: upstream.status, - latencyMs: Date.now() - startTime, - clientIp, - userAgent, - }); + // 5. Forward response. Non-streaming responses can be accounted for as soon + // as headers arrive; streaming responses finalize on body close/cancel/error. + const recordUsage: RelayUsageRecorder = (status, statusCode) => { + recordRelayUsage(token.id, { + requestId: request.headers.get("x-request-id") || undefined, + status, + statusCode, + latencyMs: Date.now() - startTime, + clientIp, + userAgent, + }); + }; const newHeaders = new Headers(upstream.headers); newHeaders.set("X-Routed-By", "bifrost"); @@ -286,6 +341,23 @@ export async function POST(request: Request) { newHeaders.set("Content-Type", upstream.headers.get("Content-Type") ?? "application/json"); } + if (wantsStream && upstream.body) { + const stream = finalizeReadableStream(upstream.body, (error) => { + clearTimeout(tid); + const statusCode = timedOut ? 504 : upstream.status; + const status = error || statusCode >= 500 ? "error" : "success"; + recordUsage(status, statusCode); + }); + + return new Response(stream, { + status: upstream.status, + headers: newHeaders, + }); + } + + clearTimeout(tid); + recordUsage(upstream.status < 500 ? "success" : "error", upstream.status); + return new Response(upstream.body, { status: upstream.status, headers: newHeaders, @@ -311,4 +383,4 @@ export async function POST(request: Request) { } ); } -} \ No newline at end of file +} diff --git a/src/app/api/v1/relay/chat/completions/relaySecurity.ts b/src/app/api/v1/relay/chat/completions/relaySecurity.ts new file mode 100644 index 0000000000..2090093f70 --- /dev/null +++ b/src/app/api/v1/relay/chat/completions/relaySecurity.ts @@ -0,0 +1,72 @@ +import { createHash } from "node:crypto"; + +// Forensic-only sanitization: client IP / user-agent come from untrusted +// headers and feed recordRelayUsage() rows. Strip CR/LF so a malicious header +// cannot forge log lines, and cap length. +export function sanitizeForensicHeader(value: string | null, max = 256): string { + if (!value) return "unknown"; + return value.replace(/[\r\n]+/g, " ").slice(0, max); +} + +export function getClientIp(request: Request): string { + return sanitizeForensicHeader( + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + null + ); +} + +export function extractToken(request: Request): string | null { + const auth = request.headers.get("authorization") || ""; + const match = auth.match(/^Bearer\s+(.+)$/i); + if (match) return match[1]; + + // Also check X-Relay-Token header. + return request.headers.get("x-relay-token"); +} + +export function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +// ── In-memory per-(token,IP) rate limit ───────────────────────────────────── +// Defence-in-depth on top of the DB-backed per-token limit: a leaked relay +// token redistributed across N IPs would otherwise consume the per-token quota +// in parallel. This second gate caps a *single* IP using a token to +// RELAY_IP_PER_MINUTE req/min. +// +// In-memory by design: cheap, no DB round-trip, no extra migration. Per +// instance only — if you run multiple relay replicas behind a load balancer, +// the effective ceiling is RELAY_IP_PER_MINUTE * replicas. +const RELAY_IP_PER_MINUTE = Number(process.env.RELAY_IP_PER_MINUTE || "30"); +const ipBuckets = new Map(); + +export function checkIpRateLimit(tokenId: string, ip: string): { allowed: boolean; resetIn: number } { + if (!Number.isFinite(RELAY_IP_PER_MINUTE) || RELAY_IP_PER_MINUTE <= 0) { + return { allowed: true, resetIn: 0 }; + } + + const now = Math.floor(Date.now() / 1000); + const windowStart = Math.floor(now / 60) * 60; + const key = tokenId + "|" + ip; + const bucket = ipBuckets.get(key); + + if (!bucket || bucket.windowStart !== windowStart) { + ipBuckets.set(key, { count: 1, windowStart }); + if (ipBuckets.size > 10_000) { + // Bound memory: drop stale buckets when the table grows past 10k. + const cutoff = windowStart - 60; + for (const [k, b] of ipBuckets) { + if (b.windowStart < cutoff) ipBuckets.delete(k); + } + } + return { allowed: true, resetIn: 60 - (now % 60) }; + } + + if (bucket.count >= RELAY_IP_PER_MINUTE) { + return { allowed: false, resetIn: 60 - (now % 60) }; + } + + bucket.count++; + return { allowed: true, resetIn: 60 - (now % 60) }; +} diff --git a/src/app/api/v1/relay/chat/completions/route.ts b/src/app/api/v1/relay/chat/completions/route.ts index 8046b7c835..5d97fd2b2f 100644 --- a/src/app/api/v1/relay/chat/completions/route.ts +++ b/src/app/api/v1/relay/chat/completions/route.ts @@ -11,84 +11,25 @@ import { handleChat } from "@/sse/handlers/chat"; import { createInjectionGuard } from "@/middleware/promptInjectionGuard"; import { getRelayTokenByHash, checkRateLimit, recordRelayUsage } from "@/lib/db/relayProxies"; import { buildErrorBody } from "@omniroute/open-sse/utils/error"; -import { createHash } from "node:crypto"; +import { + checkIpRateLimit, + extractToken, + getClientIp, + hashToken, + sanitizeForensicHeader, +} from "./relaySecurity"; const JSON_CORS_HEADERS = { ...CORS_HEADERS, "Content-Type": "application/json" } as const; -// Forensic-only sanitization: client IP / user-agent come from untrusted -// headers and feed `recordRelayUsage()` rows. Strip CR/LF so a malicious -// header cannot forge log lines, and cap length. -function sanitizeForensicHeader(value: string | null, max = 256): string { - if (!value) return "unknown"; - return value.replace(/[\r\n]+/g, " ").slice(0, max); -} - -// ── In-memory per-(token,IP) rate limit ───────────────────────────────────── -// Defence-in-depth on top of the DB-backed per-token limit: a leaked relay -// token redistributed across N IPs would otherwise consume the per-token -// quota in parallel. This second gate caps a *single* IP using a token to -// `RELAY_IP_PER_MINUTE` req/min — legit clients keep working, distributed -// abuse of one token hits the wall fast. -// -// In-memory by design: cheap, no DB round-trip, no extra migration. Per -// instance only — if you run multiple relay replicas behind a load balancer, -// the effective ceiling is `RELAY_IP_PER_MINUTE * replicas`, which is still -// orders of magnitude tighter than no gate. -const RELAY_IP_PER_MINUTE = Number(process.env.RELAY_IP_PER_MINUTE || "30"); -const ipBuckets = new Map(); - -function checkIpRateLimit(tokenId: string, ip: string): { allowed: boolean; resetIn: number } { - if (!Number.isFinite(RELAY_IP_PER_MINUTE) || RELAY_IP_PER_MINUTE <= 0) { - return { allowed: true, resetIn: 0 }; - } - const now = Math.floor(Date.now() / 1000); - const windowStart = Math.floor(now / 60) * 60; - const key = tokenId + "|" + ip; - const bucket = ipBuckets.get(key); - if (!bucket || bucket.windowStart !== windowStart) { - ipBuckets.set(key, { count: 1, windowStart }); - if (ipBuckets.size > 10_000) { - // Bound memory: drop the oldest half when the table grows past 10k. - const cutoff = windowStart - 60; - for (const [k, b] of ipBuckets) { - if (b.windowStart < cutoff) ipBuckets.delete(k); - } - } - return { allowed: true, resetIn: 60 - (now % 60) }; - } - if (bucket.count >= RELAY_IP_PER_MINUTE) { - return { allowed: false, resetIn: 60 - (now % 60) }; - } - bucket.count++; - return { allowed: true, resetIn: 60 - (now % 60) }; -} - const injectionGuard = createInjectionGuard(); export async function OPTIONS() { return handleCorsOptions(); } -function extractToken(request: Request): string | null { - const auth = request.headers.get("authorization") || ""; - const match = auth.match(/^Bearer\s+(.+)$/i); - if (match) return match[1]; - - // Also check X-Relay-Token header - return request.headers.get("x-relay-token"); -} - -function hashToken(token: string): string { - return createHash("sha256").update(token).digest("hex"); -} - export async function POST(request: Request) { const startTime = Date.now(); - const clientIp = sanitizeForensicHeader( - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - request.headers.get("x-real-ip") || - null - ); + const clientIp = getClientIp(request); const userAgent = sanitizeForensicHeader(request.headers.get("user-agent")); try { diff --git a/src/domain/assessment/types.ts b/src/domain/assessment/types.ts index d5103571c5..b20e07c8a0 100644 --- a/src/domain/assessment/types.ts +++ b/src/domain/assessment/types.ts @@ -347,6 +347,15 @@ export const AUTO_COMBO_TEMPLATES: AutoComboTemplate[] = [ tiers: ["premium", "balanced"], strategy: "priority", }, + { + name: "auto/best-free", + displayName: "Best Free", + categories: ["coding", "chat", "fast"], + tiers: ["free"], + strategy: "weighted", + systemMessage: + "You are a helpful coding assistant. Write clean, efficient code.", + }, ]; export {}; diff --git a/src/hooks/useLiveDashboard.ts b/src/hooks/useLiveDashboard.ts index e216b7a584..bd72bdb57f 100644 --- a/src/hooks/useLiveDashboard.ts +++ b/src/hooks/useLiveDashboard.ts @@ -50,6 +50,8 @@ export interface DashboardConnectionState { export interface UseLiveDashboardOptions { /** WebSocket URL (default: ws://hostname:20129) */ wsUrl?: string; + /** Whether the WebSocket connection should be active (default: true) */ + enabled?: boolean; /** API key for authentication */ apiKey?: string; /** Channels to subscribe to */ @@ -66,6 +68,7 @@ export interface UseLiveDashboardOptions { */ export function useLiveDashboard({ wsUrl = DEFAULT_WS_URL, + enabled = true, apiKey, channels = ["requests", "combo", "credentials"], autoReconnect = true, @@ -202,6 +205,23 @@ export function useLiveDashboard({ // Connect on mount and on reconnect trigger useEffect(() => { + mountedRef.current = true; + if (!enabled) { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + wsRef.current?.close(); + wsRef.current = null; + setConnection({ + isConnected: false, + isConnecting: false, + error: null, + reconnectAttempt: 0, + }); + return; + } + connect(); return () => { mountedRef.current = false; @@ -210,7 +230,7 @@ export function useLiveDashboard({ } wsRef.current?.close(); }; - }, [connect]); + }, [connect, enabled]); // Connect (for manual retry) const reconnect = useCallback(() => { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index c676e87f12..6d650df399 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4326,6 +4326,9 @@ "editCompatibleTitle": "Edit {type} Compatible", "compatibleBaseUrlHint": "Root URL of your {type}-compatible API. Use Advanced Settings for custom endpoint paths.", "apiKeyForCheck": "API Key (for Check)", + "testModelIdLabel": "Model ID (optional)", + "testModelIdPlaceholder": "e.g. my-model-id", + "testModelIdHint": "If the provider lacks a /models endpoint, enter a model ID to validate via /chat/completions instead.", "compatibleProdPlaceholder": "{type} Compatible (Prod)", "tokenRefreshed": "Token refreshed successfully", "tokenRefreshFailed": "Token refresh failed", diff --git a/src/lib/agentSkills/openapiParser.ts b/src/lib/agentSkills/openapiParser.ts index ffdf95ed84..c6cc0648fb 100644 --- a/src/lib/agentSkills/openapiParser.ts +++ b/src/lib/agentSkills/openapiParser.ts @@ -9,7 +9,7 @@ import fs from "node:fs"; import path from "node:path"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; import type { SkillArea } from "./types"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -159,7 +159,6 @@ export function parseOpenapi(): ParsedOpenapi { ); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any const doc = yaml.load(rawContent) as Record; if (!doc || typeof doc !== "object") { diff --git a/src/lib/cli-helper/log-streamer.ts b/src/lib/cli-helper/log-streamer.ts index 9a4c237fed..1fdc151848 100644 --- a/src/lib/cli-helper/log-streamer.ts +++ b/src/lib/cli-helper/log-streamer.ts @@ -1,11 +1,9 @@ -import os from "node:os"; -import path from "node:path"; - export interface LogStreamOptions { baseUrl?: string; filters?: string[]; follow?: boolean; timeout?: number; + headers?: HeadersInit; } export interface LogStream { @@ -18,6 +16,7 @@ export function createLogStream(options: LogStreamOptions = {}): LogStream { const filters = options.filters || []; const follow = options.follow ?? false; const timeout = options.timeout || 30000; + const headers = options.headers; const controller = new AbortController(); const { signal } = controller; @@ -35,7 +34,7 @@ export function createLogStream(options: LogStreamOptions = {}): LogStream { }, timeout); try { - const response = await fetch(url, { signal }); + const response = await fetch(url, { signal, headers }); if (!response.ok) { controller.error(new Error(`HTTP ${response.status}: ${response.statusText}`)); diff --git a/src/lib/db/cleanup.ts b/src/lib/db/cleanup.ts index 1772240fdf..83563f11a2 100644 --- a/src/lib/db/cleanup.ts +++ b/src/lib/db/cleanup.ts @@ -64,7 +64,7 @@ export async function cleanupCallLogs(): Promise { const result: CleanupResult = { deleted: 0, errors: 0 }; try { - const stmt = db.prepare("DELETE FROM call_logs WHERE created_at < ?"); + const stmt = db.prepare("DELETE FROM call_logs WHERE timestamp < ?"); const runResult = stmt.run(cutoffISO); result.deleted = runResult.changes; @@ -141,7 +141,7 @@ export async function cleanupCompressionAnalytics(): Promise { const result: CleanupResult = { deleted: 0, errors: 0 }; try { - const stmt = db.prepare("DELETE FROM compression_analytics WHERE created_at < ?"); + const stmt = db.prepare("DELETE FROM compression_analytics WHERE timestamp < ?"); const runResult = stmt.run(cutoffISO); result.deleted = runResult.changes; @@ -171,7 +171,7 @@ export async function cleanupMcpAudit(): Promise { const result: CleanupResult = { deleted: 0, errors: 0 }; try { - const stmt = db.prepare("DELETE FROM mcp_audit_log WHERE timestamp < ?"); + const stmt = db.prepare("DELETE FROM mcp_tool_audit WHERE timestamp < ?"); const runResult = stmt.run(cutoffISO); result.deleted = runResult.changes; @@ -201,7 +201,7 @@ export async function cleanupA2aEvents(): Promise { const result: CleanupResult = { deleted: 0, errors: 0 }; try { - const stmt = db.prepare("DELETE FROM a2a_events WHERE timestamp < ?"); + const stmt = db.prepare("DELETE FROM a2a_task_events WHERE timestamp < ?"); const runResult = stmt.run(cutoffISO); result.deleted = runResult.changes; @@ -229,7 +229,7 @@ export async function cleanupMemoryEntries(): Promise { const result: CleanupResult = { deleted: 0, errors: 0 }; try { - const stmt = db.prepare("DELETE FROM memory_entries WHERE created_at < ?"); + const stmt = db.prepare("DELETE FROM memories WHERE created_at < ?"); const runResult = stmt.run(cutoffISO); result.deleted = runResult.changes; @@ -270,6 +270,7 @@ export async function runAutoCleanup(): Promise<{ mcpAudit: await cleanupMcpAudit(), a2aEvents: await cleanupA2aEvents(), memoryEntries: await cleanupMemoryEntries(), + proxyLogs: await cleanupProxyLogs(), }; const totalDeleted = Object.values(results).reduce((sum, r) => sum + r.deleted, 0); @@ -349,3 +350,114 @@ export async function purgeDetailedLogs(): Promise { return result; } + +/** + * Clean up old proxy_logs based on retention settings. + * Uses the same retention period as call_logs (30 days default). + */ +export async function cleanupProxyLogs(): Promise { + const db = getDbInstance(); + const retention = getRetentionSettings(); + + const retentionDays = retention.callLogs; + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - retentionDays); + const cutoffISO = cutoffDate.toISOString(); + + const result: CleanupResult = { deleted: 0, errors: 0 }; + + try { + const stmt = db.prepare("DELETE FROM proxy_logs WHERE timestamp < ?"); + const runResult = stmt.run(cutoffISO); + result.deleted = runResult.changes; + + console.log( + `[Cleanup] Deleted ${result.deleted} proxy_logs older than ${retentionDays} days` + ); + } catch (err: unknown) { + console.error("[Cleanup] Error cleaning proxy_logs:", err); + result.errors++; + } + + return result; +} + +// ──────────────── Background Cleanup Scheduler ──────────────── + +const CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours +let _cleanupSchedulerTimer: ReturnType | null = null; + +/** + * Start the background cleanup scheduler. Runs cleanup on startup + * and then every 6 hours. Runs VACUUM after deletes to reclaim disk space. + * + * Without this, tables grow unboundedly (compression_analytics 600K+ rows, + * usage_history 250K+ rows) causing 1.4GB+ SQLite files and 3-8GB RSS + * from better-sqlite3 memory mapping. + */ +export function startCleanupScheduler(): void { + if (_cleanupSchedulerTimer) return; + + // Run cleanup 30s after startup (let the server initialize first). + setTimeout(async () => { + try { + const result = await runAutoCleanup(); + const proxyResult = await cleanupProxyLogs(); + const totalDeleted = result.totalDeleted + proxyResult.deleted; + if (totalDeleted > 0) { + console.log( + `[Cleanup] Startup cleanup freed ${totalDeleted} rows. Running VACUUM...` + ); + try { + const db = getDbInstance(); + db.exec("VACUUM"); + console.log("[Cleanup] VACUUM completed after startup cleanup."); + } catch (vacErr) { + console.error("[Cleanup] VACUUM after cleanup failed:", vacErr); + } + } + } catch (err) { + console.error("[Cleanup] Startup cleanup failed:", err); + } + }, 30_000); + + // Schedule periodic cleanup every 6 hours. + _cleanupSchedulerTimer = setInterval(async () => { + try { + const result = await runAutoCleanup(); + const proxyResult = await cleanupProxyLogs(); + const totalDeleted = result.totalDeleted + proxyResult.deleted; + if (totalDeleted > 0) { + console.log( + `[Cleanup] Periodic cleanup freed ${totalDeleted} rows. Running VACUUM...` + ); + try { + const db = getDbInstance(); + db.exec("VACUUM"); + console.log("[Cleanup] VACUUM completed after periodic cleanup."); + } catch (vacErr) { + console.error("[Cleanup] VACUUM after cleanup failed:", vacErr); + } + } + } catch (err) { + console.error("[Cleanup] Periodic cleanup failed:", err); + } + }, CLEANUP_INTERVAL_MS); + + // Don't keep the process alive solely for cleanup. + if (_cleanupSchedulerTimer && typeof _cleanupSchedulerTimer.unref === "function") { + _cleanupSchedulerTimer.unref(); + } + + console.log("[Cleanup] Background cleanup scheduler started (every 6 hours)."); +} + +/** + * Stop the background cleanup scheduler (for tests). + */ +export function stopCleanupScheduler(): void { + if (_cleanupSchedulerTimer) { + clearInterval(_cleanupSchedulerTimer); + _cleanupSchedulerTimer = null; + } +} diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index 27f3ada397..940e94a260 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -53,7 +53,7 @@ export interface ResolvedModelCapabilities { temperature: boolean | null; contextWindow: number | null; maxInputTokens: number | null; - maxOutputTokens: number; + maxOutputTokens: number | null; defaultThinkingBudget: number; thinkingBudgetCap: number | null; thinkingOverhead: number | null; @@ -382,7 +382,7 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo synced?.limit_output ?? (typeof registryModel?.maxOutputTokens === "number" ? registryModel.maxOutputTokens : null) ?? spec?.maxOutputTokens ?? - MODEL_SPECS.__default__.maxOutputTokens, + null, defaultThinkingBudget: spec?.defaultThinkingBudget ?? 0, thinkingBudgetCap: spec?.thinkingBudgetCap ?? null, thinkingOverhead: spec?.thinkingOverhead ?? null, @@ -416,9 +416,11 @@ export function supportsMaxTokens(input: CapabilityInput): boolean { return getResolvedModelCapabilities(input).supportsMaxTokens; } -export function capMaxOutputTokens(input: CapabilityInput, requested?: number): number { +export function capMaxOutputTokens(input: CapabilityInput, requested?: number): number | null { const cap = getResolvedModelCapabilities(input).maxOutputTokens; - return requested ? Math.min(requested, cap) : cap; + const hasRequested = typeof requested === "number" && Number.isFinite(requested); + if (cap === null) return hasRequested ? requested : null; + return hasRequested ? Math.min(requested, cap) : cap; } export function getDefaultThinkingBudget(input: CapabilityInput): number { diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index fcd07ef76e..f415963fa6 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -99,8 +99,8 @@ export const GEMINI_CONFIG = { // Qwen OAuth Configuration (Device Code Flow with PKCE) export const QWEN_CONFIG = { clientId: resolvePublicCred("qwen_id", "QWEN_OAUTH_CLIENT_ID"), - deviceCodeUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code", - tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token", + deviceCodeUrl: "https://qwen.ai/api/v1/oauth2/device/code", + tokenUrl: "https://qwen.ai/api/v1/oauth2/token", scope: "openid profile email model.completion", codeChallengeMethod: "S256", }; diff --git a/src/lib/providers/requestDefaults.ts b/src/lib/providers/requestDefaults.ts index 479d6f532f..a517484964 100644 --- a/src/lib/providers/requestDefaults.ts +++ b/src/lib/providers/requestDefaults.ts @@ -209,6 +209,13 @@ export function sanitizeProviderSpecificDataForResponse(value: unknown): JsonRec delete sanitized.awsSecretAccessKey; delete sanitized.sessionToken; delete sanitized.awsSessionToken; + delete sanitized.openCodeGoAuthCookie; + delete sanitized.opencodeGoAuthCookie; + delete sanitized.authCookie; + delete sanitized.ollamaUsageCookie; + delete sanitized.ollamaCloudUsageCookie; + delete sanitized.ollamaCloudCookie; + delete sanitized.usageCookie; return sanitized; } diff --git a/src/lib/resilience/settings.ts b/src/lib/resilience/settings.ts index 4dd3b5dc71..b726ab1c9e 100644 --- a/src/lib/resilience/settings.ts +++ b/src/lib/resilience/settings.ts @@ -1,4 +1,5 @@ import { DEFAULT_API_LIMITS, PROVIDER_PROFILES } from "@omniroute/open-sse/config/constants"; +import { resolveFeatureFlag } from "@/shared/utils/featureFlags"; type JsonRecord = Record; type AuthCategory = "oauth" | "apikey"; @@ -105,7 +106,7 @@ export interface StreamRecoverySettings { * open-sse/config/constants.ts) so an early cutoff can be retried before any byte * reaches the client. OFF by default because holding the window adds up to * STREAM_RECOVERY.HOLDBACK_MS of time-to-first-token latency on every stream. - * Default seeds from the STREAM_RECOVERY_ENABLED env var. + * Default seeds from the STREAM_RECOVERY_ENABLED feature flag / env var. */ enabled: boolean; /** @@ -113,8 +114,8 @@ export interface StreamRecoverySettings { * bytes already reached the client, re-request with the partial text as an assistant * prefill and stitch the missing suffix (plain-text OpenAI-compatible streams only; * never with a tool call in flight). OFF by default because the recovered tail arrives - * as one burst rather than token-by-token. Default seeds from - * STREAM_RECOVERY_MIDSTREAM_ENABLED. + * as one burst rather than token-by-token. Default seeds from the + * STREAM_RECOVERY_MIDSTREAM_ENABLED feature flag / env var. */ continueMidStream: boolean; } @@ -168,6 +169,40 @@ function toBoolean(value: unknown, fallback: boolean): boolean { return typeof value === "boolean" ? value : fallback; } +function parseFeatureFlagBoolean(value: string, fallback: boolean): boolean { + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") { + return true; + } + if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") { + return false; + } + return fallback; +} + +function resolveBooleanFeatureFlag(key: string, fallback: boolean): boolean { + try { + return parseFeatureFlagBoolean(resolveFeatureFlag(key), fallback); + } catch (error) { + const envValue = process.env[key]; + if (typeof envValue === "string" && envValue.trim() !== "") { + return parseFeatureFlagBoolean(envValue, fallback); + } + console.error( + `[resilience] Failed to resolve ${key}, falling back to ${String(fallback)}:`, + error instanceof Error ? error.message : error + ); + return fallback; + } +} + +function resolveStreamRecoveryDefaults(): StreamRecoverySettings { + return { + enabled: resolveBooleanFeatureFlag("STREAM_RECOVERY_ENABLED", false), + continueMidStream: resolveBooleanFeatureFlag("STREAM_RECOVERY_MIDSTREAM_ENABLED", false), + }; +} + export const DEFAULT_REQUEST_QUEUE_MAX_WAIT_MS = (() => { const parsed = Number(process.env.RATE_LIMIT_MAX_WAIT_MS || "120000"); return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : 120000; @@ -500,6 +535,7 @@ function normalizeStreamRecoverySettings( function buildLegacyFallback(settings: JsonRecord): ResilienceSettings { const profiles = asRecord(settings.providerProfiles); const defaults = asRecord(settings.rateLimitDefaults); + const streamRecoveryDefaults = resolveStreamRecoveryDefaults(); const oauthLegacy = asRecord(profiles.oauth); const apikeyLegacy = asRecord(profiles.apikey); @@ -583,7 +619,7 @@ function buildLegacyFallback(settings: JsonRecord): ResilienceSettings { }, providerCooldown: DEFAULT_RESILIENCE_SETTINGS.providerCooldown, quotaPreflight: DEFAULT_RESILIENCE_SETTINGS.quotaPreflight, - streamRecovery: DEFAULT_RESILIENCE_SETTINGS.streamRecovery, + streamRecovery: streamRecoveryDefaults, }; } @@ -670,10 +706,7 @@ export function mergeResilienceSettings( current.providerCooldown ), quotaPreflight: normalizeQuotaPreflightSettings(updates.quotaPreflight, current.quotaPreflight), - streamRecovery: normalizeStreamRecoverySettings( - updates.streamRecovery, - current.streamRecovery - ), + streamRecovery: normalizeStreamRecoverySettings(updates.streamRecovery, current.streamRecovery), }; } diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 80db7e6846..40898f7727 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -35,7 +35,6 @@ import { isUserCallableAgyModelId } from "@omniroute/open-sse/config/agyModels.t import { onUsageRecorded } from "./usageEvents"; type JsonRecord = Record; - type SyncSource = "manual" | "scheduled"; interface ProviderConnectionLike { @@ -64,6 +63,7 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([ "zai", "glmt", "opencode-go", + "ollama-cloud", "minimax", "minimax-cn", "crof", diff --git a/src/lib/usage/usageHistory.ts b/src/lib/usage/usageHistory.ts index ae7772be7a..599619eb0a 100644 --- a/src/lib/usage/usageHistory.ts +++ b/src/lib/usage/usageHistory.ts @@ -215,26 +215,18 @@ const pendingRequests: { */ const pendingById = new Map(); -/** - * Orphaned-pending-request reaper. - * - * Pending details are normally removed when a request finalizes (clean completion, - * tracked error, client cancel). But a request that never finalizes cleanly — an - * upstream/fetch error thrown before the finalize call, a client disconnect, or a - * process-level timeout — leaves its detail in `pendingById` (and `pendingRequests.details`) - * forever, each retaining truncated request/response payload previews. Under real proxy - * traffic a steady fraction of requests terminate abnormally, so this grows monotonically - * (previously only an admin reset via clearPendingRequests() could free it). - * - * The reaper drops entries whose `startedAt` is older than MAX_PENDING_REQUEST_AGE_MS — far - * longer than any genuine request — so only truly-orphaned entries are evicted; a live - * request always finalizes long before that. MAX_PENDING_DETAILS is a hard backstop. - */ -const MAX_PENDING_REQUEST_AGE_MS = 15 * 60 * 1000; +const DEFAULT_MAX_PENDING_REQUEST_AGE_MS = 60 * 60 * 1000; const MAX_PENDING_DETAILS = 5000; const PENDING_SWEEP_INTERVAL_MS = 5 * 60 * 1000; let _pendingSweepTimer: ReturnType | null = null; +export function getMaxPendingRequestAgeMs( + rawValue: string | undefined = process.env.MAX_PENDING_REQUEST_AGE_MS +): number { + const parsed = Number.parseInt(rawValue ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_PENDING_REQUEST_AGE_MS; +} + function ensurePendingSweepTimer(): void { if (_pendingSweepTimer || typeof setInterval !== "function") return; _pendingSweepTimer = setInterval(() => { @@ -256,7 +248,7 @@ function ensurePendingSweepTimer(): void { */ export function sweepStalePendingRequests( now: number = Date.now(), - maxAgeMs: number = MAX_PENDING_REQUEST_AGE_MS + maxAgeMs: number = getMaxPendingRequestAgeMs() ): number { let removed = 0; @@ -347,12 +339,13 @@ export function trackPendingRequest( if (!pendingRequests.details[connectionId][modelKey]) { pendingRequests.details[connectionId][modelKey] = []; } + const now = Date.now(); const newDetail = { - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + id: `${now}-${Math.random().toString(36).slice(2, 8)}`, model, provider, connectionId, - startedAt: Date.now(), + startedAt: now, ...normalizedMetadata, }; pendingRequests.details[connectionId][modelKey].push(newDetail); diff --git a/src/server-init.ts b/src/server-init.ts index 0fd6070bc6..b182d7b783 100644 --- a/src/server-init.ts +++ b/src/server-init.ts @@ -6,6 +6,7 @@ import { initAuditLog, cleanupExpiredLogs, logAuditEvent } from "./lib/complianc import { initConsoleInterceptor } from "./lib/consoleInterceptor"; import { startBudgetResetJob } from "./lib/jobs/budgetResetJob"; import { startReasoningCacheCleanupJob } from "./lib/jobs/reasoningCacheCleanupJob"; +import { startCleanupScheduler } from "./lib/db/cleanup"; import { getSettings } from "./lib/db/settings"; import { applyRuntimeSettings } from "./lib/config/runtimeSettings"; import { setSystemPromptConfig } from "@omniroute/open-sse/services/systemPrompt.ts"; @@ -105,6 +106,7 @@ async function startServer() { await initializeCloudSync(); startBudgetResetJob(); startReasoningCacheCleanupJob(); + startCleanupScheduler(); startRuntimeConfigHotReload(); startupLog.info("Server started with cloud sync initialized"); diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index b4ba40a71b..aafd9aa8ef 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -222,7 +222,7 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ warningLevel: "info", }, - // ──────────────── Runtime (10) ──────────────── + // ──────────────── Runtime (12) ──────────────── { key: "OMNIROUTE_MCP_ENFORCE_SCOPES", label: "MCP Enforce Scopes", @@ -313,6 +313,30 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "caution", }, + { + key: "STREAM_RECOVERY_ENABLED", + label: "Stream Recovery", + description: + "Enable transparent early retry for truncated upstream SSE streams before any response bytes reach the client.", + descriptionI18nKey: "featureFlagStreamRecoveryEnabledDescription", + category: "runtime", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, + { + key: "STREAM_RECOVERY_MIDSTREAM_ENABLED", + label: "Mid-Stream Continuation", + description: + "Allow stream recovery to re-request and stitch a response after bytes have already reached the client.", + descriptionI18nKey: "featureFlagStreamRecoveryMidstreamEnabledDescription", + category: "runtime", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "danger", + }, { key: "MODEL_CATALOG_INCLUDE_NAMES", label: "Model Catalog Names", diff --git a/src/shared/constants/headers.ts b/src/shared/constants/headers.ts index 9044575fe1..e8184efe28 100644 --- a/src/shared/constants/headers.ts +++ b/src/shared/constants/headers.ts @@ -1,6 +1,7 @@ export const OMNIROUTE_RESPONSE_HEADERS = { cache: "X-OmniRoute-Cache", cacheHit: "X-OmniRoute-Cache-Hit", + compression: "X-OmniRoute-Compression", costSaved: "X-OmniRoute-Cost-Saved", fallbackAttempts: "X-OmniRoute-Fallback-Attempts", latencyMs: "X-OmniRoute-Latency-Ms", diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index 5787d7d1d5..3f9e54eb3b 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -5,7 +5,7 @@ */ export interface ModelSpec { - maxOutputTokens: number; + maxOutputTokens?: number; contextWindow?: number; defaultThinkingBudget?: number; thinkingBudgetCap?: number; @@ -26,7 +26,7 @@ export interface ModelSpec { // (2026-05-19): "Any request that tries to set a fixed thinking budget gets a 400 error." adaptiveThinkingOnly?: boolean; // Explicit operator override for the no-thinking gateway alias (Fase 8.1). When unset, - // the catalog auto-advertises a `claude-3-omniroute-no-thinking/…` variant for + // the catalog auto-advertises a `no-think/…` variant for // Claude-family thinking-capable models that honor `disabled`. Set `true` to force the // variant on for any other model, or `false` to suppress it. See open-sse/utils/noThinkingAlias.ts. noThinkingAlias?: boolean; @@ -457,9 +457,7 @@ export const MODEL_SPECS: Record = { }, // Defaults - __default__: { - maxOutputTokens: 8192, - }, + __default__: {}, }; export function getModelSpec(modelId: string): ModelSpec | undefined { @@ -515,10 +513,12 @@ export function normalizeThinkingForModel>( return body; } -export function capMaxOutputTokens(modelId: string, requested?: number): number { +export function capMaxOutputTokens(modelId: string, requested?: number): number | undefined { const spec = getModelSpec(modelId); - const cap = spec?.maxOutputTokens ?? MODEL_SPECS.__default__.maxOutputTokens; - return requested ? Math.min(requested, cap) : cap; + const cap = spec?.maxOutputTokens; + const hasRequested = typeof requested === "number" && Number.isFinite(requested); + if (typeof cap !== "number") return hasRequested ? requested : undefined; + return hasRequested ? Math.min(requested, cap) : cap; } export function getDefaultThinkingBudget(modelId: string): number { diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 2816ad6439..1150c2e78d 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -1,5 +1,3 @@ -// Provider definitions - /** * Service kind — declarative tag for what a provider can do beyond basic LLM chat. * Affects UI filtering and playground routing; does not influence request routing. @@ -2970,17 +2968,15 @@ export function isSelfHostedChatProvider(providerId: unknown): boolean { export function providerAllowsOptionalApiKey(providerId: unknown): boolean { return ( + // ponytail: any noAuth provider auto-qualifies — no per-provider maintenance + (typeof providerId === "string" && providerId in NOAUTH_PROVIDERS) || providerId === "searxng-search" || providerId === "pollinations" || providerId === "copilot-web" || - providerId === "duckduckgo-web" || - providerId === "veoaifree-web" || providerId === "hackclub" || providerId === "huggingchat" || providerId === "gitlawb" || providerId === "gitlawb-gmi" || - providerId === "mimocode" || - providerId === "opencode" || isLocalProvider(providerId) || isSelfHostedChatProvider(providerId) || isOpenAICompatibleProvider(providerId) || @@ -3229,6 +3225,7 @@ export const USAGE_SUPPORTED_PROVIDERS = [ "zai", "glmt", "opencode-go", + "ollama-cloud", "minimax", "minimax-cn", "crof", diff --git a/src/shared/validation/providerSpecificData.ts b/src/shared/validation/providerSpecificData.ts index 3258d389e7..8a81208892 100644 --- a/src/shared/validation/providerSpecificData.ts +++ b/src/shared/validation/providerSpecificData.ts @@ -179,6 +179,50 @@ export function validateProviderSpecificData( }); } + for (const key of ["openCodeGoWorkspaceId", "opencodeGoWorkspaceId", "workspaceId"] as const) { + const value = data[key]; + if (value !== undefined && value !== null && typeof value !== "string") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `providerSpecificData.${key} must be a string`, + path: [key], + }); + } + if (typeof value === "string" && value.length > 1000) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `providerSpecificData.${key} must be at most 1000 characters`, + path: [key], + }); + } + } + + for (const key of [ + "openCodeGoAuthCookie", + "opencodeGoAuthCookie", + "authCookie", + "ollamaUsageCookie", + "ollamaCloudUsageCookie", + "ollamaCloudCookie", + "usageCookie", + ] as const) { + const value = data[key]; + if (value !== undefined && value !== null && typeof value !== "string") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `providerSpecificData.${key} must be a string`, + path: [key], + }); + } + if (typeof value === "string" && value.length > 10000) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `providerSpecificData.${key} must be at most 10000 characters`, + path: [key], + }); + } + } + const groupTag = data.tag; if ( groupTag !== undefined && diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index 3c89152d82..c3817d93fb 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -18,7 +18,14 @@ import { } from "@/shared/constants/upstreamHeaders"; import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts"; -import { isHttpUrl, CODEX_REASONING_EFFORT_VALUES, REQUEST_DEFAULT_SERVICE_TIER_VALUES, upstreamHeadersRecordSchema, modelCompatPerProtocolSchema, customHeadersSchema } from "./misc.ts"; +import { + isHttpUrl, + CODEX_REASONING_EFFORT_VALUES, + REQUEST_DEFAULT_SERVICE_TIER_VALUES, + upstreamHeadersRecordSchema, + modelCompatPerProtocolSchema, + customHeadersSchema, +} from "./misc.ts"; export function validateProviderSpecificData( data: Record | undefined, @@ -184,6 +191,50 @@ export function validateProviderSpecificData( }); } + for (const key of ["openCodeGoWorkspaceId", "opencodeGoWorkspaceId", "workspaceId"] as const) { + const value = data[key]; + if (value !== undefined && value !== null && typeof value !== "string") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `providerSpecificData.${key} must be a string`, + path: [key], + }); + } + if (typeof value === "string" && value.length > 1000) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `providerSpecificData.${key} must be at most 1000 characters`, + path: [key], + }); + } + } + + for (const key of [ + "openCodeGoAuthCookie", + "opencodeGoAuthCookie", + "authCookie", + "ollamaUsageCookie", + "ollamaCloudUsageCookie", + "ollamaCloudCookie", + "usageCookie", + ] as const) { + const value = data[key]; + if (value !== undefined && value !== null && typeof value !== "string") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `providerSpecificData.${key} must be a string`, + path: [key], + }); + } + if (typeof value === "string" && value.length > 10000) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `providerSpecificData.${key} must be at most 10000 characters`, + path: [key], + }); + } + } + const groupTag = data.tag; if ( groupTag !== undefined && @@ -361,7 +412,10 @@ export const bulkWebSessionImportSchema = z.object({ .array( z.object({ name: z.string().min(1).max(200), - credential: z.string().min(1).max(64 * 1024, "Credential must be under 64 KB"), + credential: z + .string() + .min(1) + .max(64 * 1024, "Credential must be under 64 KB"), }) ) .min(1, "entries must contain at least 1 item") @@ -490,6 +544,7 @@ export const providerNodeValidateSchema = z.object({ compatMode: z.enum(["cc"]).optional(), chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")), modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")), + modelId: z.string().trim().max(200).optional().or(z.literal("")), }); export const updateProviderConnectionSchema = z @@ -631,4 +686,4 @@ export const validateProviderApiKeySchema = z path: ["cx"], }); } - }); \ No newline at end of file + }); diff --git a/src/shared/validation/schemas/settings.ts b/src/shared/validation/schemas/settings.ts index a57b349cc8..75e813c93b 100644 --- a/src/shared/validation/schemas/settings.ts +++ b/src/shared/validation/schemas/settings.ts @@ -101,7 +101,20 @@ export const providerCooldownSettingsSchema = z minRetryCooldownMs: z.number().int().min(0).max(300000).optional(), maxRetryCooldownMs: z.number().int().min(0).max(3600000).optional(), }) - .strict(); + .strict() + .superRefine((value, ctx) => { + if ( + typeof value.minRetryCooldownMs === "number" && + typeof value.maxRetryCooldownMs === "number" && + value.maxRetryCooldownMs < value.minRetryCooldownMs + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "maxRetryCooldownMs must be greater than or equal to minRetryCooldownMs", + path: ["maxRetryCooldownMs"], + }); + } + }); export const updateResilienceSchema = z .object({ diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 6ae3715699..802734ef0d 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -243,7 +243,7 @@ export async function handleChat( // Log request endpoint and model const url = new URL(request.url); - // No-thinking gateway alias (Fase 8.1): `claude-3-omniroute-no-thinking//` + // No-thinking gateway alias (Fase 8.1): `no-think//` // resolves back to the real model with reasoning suppressed in place, before any // model resolution / combo routing sees it. Claude/Messages path forces // `thinking:{type:"disabled"}`; OpenAI path drops the reasoning fields. diff --git a/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts index 80ac8f0f08..7627108d30 100644 --- a/tests/integration/chat-pipeline.test.ts +++ b/tests/integration/chat-pipeline.test.ts @@ -711,15 +711,14 @@ test("chat pipeline applies Codex CLI fingerprint to OAuth responses requests", assert.ok(headerOrder.indexOf("Accept") < headerOrder.indexOf("User-Agent")); const bodyOrder = Object.keys(JSON.parse(call.bodyString)); - assert.deepEqual(bodyOrder.slice(0, 7), [ - "model", - "stream", - "input", - "instructions", - "store", - "reasoning", - "prompt_cache_key", - ]); + // Order must match the canonical Codex fingerprint bodyFieldOrder (cliFingerprints.ts): + // …reasoning, prompt_cache_key, …, include — i.e. prompt_cache_key precedes include. + // (#4584 inadvertently flipped these two; fast-gates skip integration tests so it only + // surfaced on the release PR full CI.) + assert.deepEqual( + bodyOrder.slice(0, 8), + "model stream input instructions store reasoning prompt_cache_key include".split(" ") + ); assert.equal(call.body.model, "gpt-5.5"); assert.equal(call.body.store, false); assert.equal( diff --git a/tests/integration/memory-pipeline.test.ts b/tests/integration/memory-pipeline.test.ts index ac1bfcb09e..184601d3e1 100644 --- a/tests/integration/memory-pipeline.test.ts +++ b/tests/integration/memory-pipeline.test.ts @@ -60,12 +60,15 @@ test.after(async () => { await harness.cleanup(); }); -async function enableMemory(maxTokens = 400) { +async function enableMemory( + maxTokens = 400, + strategy: "recent" | "semantic" | "hybrid" = "recent" +) { await settingsDb.updateSettings({ memoryEnabled: true, memoryMaxTokens: maxTokens, memoryRetentionDays: 30, - memoryStrategy: "recent", + memoryStrategy: strategy, }); } @@ -172,6 +175,7 @@ test("later requests inject retrieved memories into upstream messages", async () test("memory search ranks query-relevant memories first", async () => { const apiKey = await seedApiKey(); + await enableMemory(400, "hybrid"); await memoryTools.omniroute_memory_add.handler({ apiKeyId: apiKey.id, diff --git a/tests/snapshots/translation/openai-to-claude/multi-turn.json b/tests/snapshots/translation/openai-to-claude/multi-turn.json index c565c4c70d..d928907bcb 100644 --- a/tests/snapshots/translation/openai-to-claude/multi-turn.json +++ b/tests/snapshots/translation/openai-to-claude/multi-turn.json @@ -1,5 +1,5 @@ { - "max_tokens": 8192, + "max_tokens": 64000, "messages": [ { "content": [ diff --git a/tests/snapshots/translation/openai-to-gemini/with-system.json b/tests/snapshots/translation/openai-to-gemini/with-system.json index 01b0a2af5f..2c1f51acf1 100644 --- a/tests/snapshots/translation/openai-to-gemini/with-system.json +++ b/tests/snapshots/translation/openai-to-gemini/with-system.json @@ -10,7 +10,6 @@ } ], "generationConfig": { - "maxOutputTokens": 8192, "temperature": 0.7, "thinkingConfig": { "includeThoughts": true, diff --git a/tests/unit/account-fallback-anthropic-quota.test.ts b/tests/unit/account-fallback-anthropic-quota.test.ts index c67b980912..978641ece2 100644 --- a/tests/unit/account-fallback-anthropic-quota.test.ts +++ b/tests/unit/account-fallback-anthropic-quota.test.ts @@ -8,7 +8,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { classifyErrorText, parseRetryFromErrorText, checkFallbackError } = +const { classifyErrorText, parseRetryFromErrorText, checkFallbackError, getProviderProfile } = await import("../../open-sse/services/accountFallback.ts"); const { RateLimitReason } = await import("../../open-sse/config/constants.ts"); @@ -70,9 +70,8 @@ test("#2321 checkFallbackError returns ~1h cooldown for OAuth 429 + Usage Limit ); }); -test("#2321 checkFallbackError honors ISO timestamp from body when present", () => { - const futureMs = 45 * 60 * 1000; - const future = new Date(Date.now() + futureMs).toISOString(); +test("#2321 checkFallbackError ignores ISO timestamp when upstream retry hints are disabled", () => { + const future = new Date(Date.now() + 45 * 60 * 1000).toISOString(); const out = checkFallbackError( 429, `Claude Pro usage limit reached. Try again at ${future}`, @@ -81,6 +80,24 @@ test("#2321 checkFallbackError honors ISO timestamp from body when present", () "claude" ); assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED); + assert.equal(out.usedUpstreamRetryHint, false); + assert.equal(out.cooldownMs, 60 * 60 * 1000); +}); + +test("#2321 checkFallbackError honors ISO timestamp when upstream retry hints are enabled", () => { + const futureMs = 45 * 60 * 1000; + const future = new Date(Date.now() + futureMs).toISOString(); + const profile = { ...getProviderProfile("claude"), useUpstreamRetryHints: true }; + const out = checkFallbackError( + 429, + `Claude Pro usage limit reached. Try again at ${future}`, + 0, + null, + "claude", + null, + profile + ); + assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED); // Within ~30s of the requested wait time. assert.ok( Math.abs(out.cooldownMs - futureMs) < 30_000, diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index f0baf38eff..13afd819a7 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -112,7 +112,7 @@ test("checkFallbackError locks Antigravity quota-reached 429 for the full reset "gemini-3-flash-agent", "antigravity", null, - makeProfile() + makeProfile({ useUpstreamRetryHints: true }) ); assert.equal(result.shouldFallback, true); diff --git a/tests/unit/api-manager-key-visibility.test.ts b/tests/unit/api-manager-key-visibility.test.ts index 261426c3e0..ed8e5fea22 100644 --- a/tests/unit/api-manager-key-visibility.test.ts +++ b/tests/unit/api-manager-key-visibility.test.ts @@ -17,6 +17,10 @@ describe("maskKey", () => { assert.equal(maskKey("sk-12345"), "sk-12345"); }); + it("does not double-mask API keys that are already masked by the server", () => { + assert.equal(maskKey("sk-live-****1002"), "sk-live-****1002"); + }); + it("keeps the first 8 chars and appends an ellipsis when the key is longer", () => { const full = "sk-or-1234567890abcdef"; const masked = maskKey(full); diff --git a/tests/unit/api/v1/bifrost-sidecar.test.ts b/tests/unit/api/v1/bifrost-sidecar.test.ts index 8cdc7a5fd7..286e8bffcb 100644 --- a/tests/unit/api/v1/bifrost-sidecar.test.ts +++ b/tests/unit/api/v1/bifrost-sidecar.test.ts @@ -1,6 +1,13 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; +import { + checkIpRateLimit, + getClientIp, + sanitizeForensicHeader, +} from "../../../../src/app/api/v1/relay/chat/completions/relaySecurity.ts"; +import { getDbInstance } from "../../../../src/lib/db/core.ts"; +import { getRelayLogs } from "../../../../src/lib/db/relayProxies.ts"; // ─── T-12 (#3932 PR-4): bifrost sidecar proxy route ────────────────────── // @@ -14,6 +21,39 @@ const ORIGINAL_BIFROST_API_KEY = process.env.BIFROST_API_KEY; const ORIGINAL_BIFROST_OMNI_KEY = process.env.OMNIROUTE_BIFROST_KEY; const ORIGINAL_BIFROST_TIMEOUT = process.env.BIFROST_TIMEOUT_MS; const ORIGINAL_BIFROST_STREAMING = process.env.BIFROST_STREAMING_ENABLED; +const ORIGINAL_FETCH = globalThis.fetch; + +function seedRelayToken(rawToken: string) { + const id = `rl_test_${Date.now()}_${Math.random().toString(16).slice(2)}`; + const now = Math.floor(Date.now() / 1000); + getDbInstance() + .prepare( + ` + INSERT INTO relay_tokens (id, name, token_hash, token_prefix, description, combo_id, + allowed_models, max_tokens_per_request, max_requests_per_minute, max_requests_per_day, + max_cost_per_day, enabled, created_at, updated_at, expires_at, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?) + ` + ) + .run( + id, + "bifrost-sse-lifecycle", + createHash("sha256").update(rawToken).digest("hex"), + "rl_test", + "", + null, + JSON.stringify(["*"]), + 128000, + 60, + 10000, + 0, + now, + now, + null, + "{}" + ); + return { id, rawToken }; +} function restoreEnv() { if (ORIGINAL_BIFROST_BASE_URL === undefined) delete process.env.BIFROST_BASE_URL; @@ -26,6 +66,7 @@ function restoreEnv() { else process.env.BIFROST_TIMEOUT_MS = ORIGINAL_BIFROST_TIMEOUT; if (ORIGINAL_BIFROST_STREAMING === undefined) delete process.env.BIFROST_STREAMING_ENABLED; else process.env.BIFROST_STREAMING_ENABLED = ORIGINAL_BIFROST_STREAMING; + globalThis.fetch = ORIGINAL_FETCH; } // Case 1: BIFROST_BASE_URL unset. We test this first because the route's @@ -117,3 +158,84 @@ test("bifrost route: OPTIONS responds with CORS headers", async () => { "missing Access-Control-Allow-Headers header" ); }); + +test("bifrost route: shared relay IP limiter blocks a repeated token from one IP", () => { + const tokenId = `bifrost-ip-limit-${Date.now()}-${Math.random()}`; + const ip = "203.0.113.77"; + + for (let i = 0; i < 30; i++) { + assert.equal(checkIpRateLimit(tokenId, ip).allowed, true); + } + + const blocked = checkIpRateLimit(tokenId, ip); + assert.equal(blocked.allowed, false); + assert.ok(blocked.resetIn >= 0 && blocked.resetIn <= 60); +}); + +test("bifrost route: shared relay security helpers sanitize forwarded client metadata", () => { + const req = new Request("http://localhost/api/v1/relay/chat/completions/bifrost", { + headers: { + "x-forwarded-for": "198.51.100.8, 10.0.0.2", + }, + }); + + assert.equal(getClientIp(req), "198.51.100.8"); + assert.equal(sanitizeForensicHeader("ua\r\nforged"), "ua forged"); +}); + +test("bifrost route: records relay usage after SSE stream completion", async () => { + process.env.BIFROST_BASE_URL = "http://bifrost.test.local:8080"; + process.env.BIFROST_TIMEOUT_MS = "5000"; + delete process.env.BIFROST_API_KEY; + delete process.env.OMNIROUTE_BIFROST_KEY; + delete process.env.BIFROST_STREAMING_ENABLED; + + const relayToken = seedRelayToken(`relay_bifrost_sse_${Date.now()}`); + + globalThis.fetch = async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("data: {\"delta\":\"hi\"}\n\n")); + controller.close(); + }, + }), + { + status: 200, + headers: { "content-type": "text/event-stream" }, + } + ); + + const { POST } = await import( + `../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts?case=${Date.now()}-${Math.random()}` + ); + + const req = new Request("http://localhost/api/v1/relay/chat/completions/bifrost", { + method: "POST", + headers: { + authorization: `Bearer ${relayToken.rawToken}`, + "content-type": "application/json", + "x-request-id": "bifrost-sse-lifecycle-test", + }, + body: JSON.stringify({ + model: "gpt-4", + stream: true, + messages: [{ role: "user", content: "hi" }], + }), + }); + + const res = await POST(req); + assert.equal(res.status, 200); + assert.equal(res.headers.get("X-Routed-By"), "bifrost"); + assert.equal(getRelayLogs(relayToken.id, 10).length, 0); + + assert.match(await res.text(), /delta/); + + const logs = getRelayLogs(relayToken.id, 10); + assert.equal(logs.length, 1); + assert.equal(logs[0].status, "success"); + assert.equal(logs[0].status_code, 200); + assert.equal(logs[0].request_id, "bifrost-sse-lifecycle-test"); + + restoreEnv(); +}); diff --git a/tests/unit/bailian-quota-fetcher.test.ts b/tests/unit/bailian-quota-fetcher.test.ts index f9c9d5baaf..77e5d10945 100644 --- a/tests/unit/bailian-quota-fetcher.test.ts +++ b/tests/unit/bailian-quota-fetcher.test.ts @@ -2,6 +2,9 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + BAILIAN_WINDOW_5H, + BAILIAN_WINDOW_MONTHLY, + BAILIAN_WINDOW_WEEKLY, fetchBailianQuota, invalidateBailianQuotaCache, registerBailianCodingPlanQuotaFetcher, @@ -216,6 +219,9 @@ test("fetchBailianQuota parses triple-window and returns percentUsed = max(5h%, assert.equal(quota?.window5h?.percentUsed, 0.6); assert.equal(quota?.windowWeekly?.percentUsed, 0.8); assert.equal(quota?.windowMonthly?.percentUsed, 0.4); + assert.equal(quota?.windows?.[BAILIAN_WINDOW_5H]?.percentUsed, 0.6); + assert.equal(quota?.windows?.[BAILIAN_WINDOW_WEEKLY]?.percentUsed, 0.8); + assert.equal(quota?.windows?.[BAILIAN_WINDOW_MONTHLY]?.percentUsed, 0.4); invalidateBailianQuotaCache(connectionId); }); diff --git a/tests/unit/base-executor-sanitize-effort.test.ts b/tests/unit/base-executor-sanitize-effort.test.ts index 369fe275c2..fea0f6ffda 100644 --- a/tests/unit/base-executor-sanitize-effort.test.ts +++ b/tests/unit/base-executor-sanitize-effort.test.ts @@ -456,7 +456,7 @@ test("sanitizeReasoningEffortForProvider: OpenRouter DeepSeek still preserves xh const body = { model: "deepseek/deepseek-v4-pro", reasoning_effort: "xhigh", - messages: [{ role: "user", content: "hi" }], + messages: [], }; const result = sanitizeReasoningEffortForProvider( body, @@ -467,3 +467,54 @@ test("sanitizeReasoningEffortForProvider: OpenRouter DeepSeek still preserves xh assert.equal(result, body); assert.equal((result as any).reasoning_effort, "xhigh"); }); + +// ── opencode-go DeepSeek V4 Pro effort variants (#4647) ────────────────────── +// opencode-go proxies DeepSeek with the native DeepSeek API contract, which +// accepts {high, max} literally. The OpencodeExecutor's transformRequest sets +// reasoning_effort to the variant suffix (low|medium|high|max), and the +// sanitizer must NOT rewrite `max` → `xhigh` for this provider+model combo. + +test("sanitizeReasoningEffortForProvider: opencode-go DeepSeek V4 Pro preserves max", () => { + const body = { + model: "deepseek-v4-pro", + reasoning_effort: "max", + messages: [], + }; + const result = sanitizeReasoningEffortForProvider(body, "opencode-go", "deepseek-v4-pro", null); + assert.equal(result, body, "opencode-go DeepSeek max must pass through unchanged"); + assert.equal((result as any).reasoning_effort, "max"); +}); + +test("sanitizeReasoningEffortForProvider: opencode-go DeepSeek V4 Pro preserves variant suffix levels", () => { + for (const level of ["low", "medium", "high", "max"]) { + const body = { + model: `deepseek-v4-pro-${level}`, + reasoning_effort: level, + messages: [], + }; + const result = sanitizeReasoningEffortForProvider( + body, + "opencode-go", + `deepseek-v4-pro-${level}`, + null + ); + assert.equal( + (result as any).reasoning_effort, + level, + `opencode-go deepseek-v4-pro-${level} preserves reasoning_effort=${level}` + ); + } +}); + +test("sanitizeReasoningEffortForProvider: opencode-go with non-DeepSeek model still normalizes max → xhigh", () => { + // The opt-in must be scoped to DeepSeek models on opencode-go only — other + // opencode-go models (e.g. glm/kimi/mimo) follow the default xhigh policy. + const body = { + model: "mimo-v2.5-pro", + reasoning_effort: "max", + messages: [], + }; + const result = sanitizeReasoningEffortForProvider(body, "opencode-go", "mimo-v2.5-pro", null); + assert.notEqual(result, body); + assert.equal((result as any).reasoning_effort, "xhigh"); +}); diff --git a/tests/unit/cc-compatible-provider.test.ts b/tests/unit/cc-compatible-provider.test.ts index 47cc5ad958..621539cd4f 100644 --- a/tests/unit/cc-compatible-provider.test.ts +++ b/tests/unit/cc-compatible-provider.test.ts @@ -1011,7 +1011,7 @@ test("provider-nodes validate route supports enabled CC validation and OpenAI-st assert.equal(openAiResponse.status, 200); assert.deepEqual(await openAiResponse.json(), { valid: false, - error: "Invalid API key", + error: "API key unauthorized", }); assert.equal(calls[1].url, "https://proxy.example.com/models"); assert.equal(calls[1].init.headers.Authorization, "Bearer sk-openai-test"); @@ -1086,7 +1086,7 @@ test("provider-nodes validate route covers default CC paths, null method, anthro assert.equal(anthropicResponse.status, 200); assert.deepEqual(await anthropicResponse.json(), { valid: false, - error: "Invalid API key", + error: "API key unauthorized", }); assert.equal(anthropicCalls[0].url, "https://proxy.example.com/v1/models"); assert.equal(anthropicCalls[0].init.method, "GET"); diff --git a/tests/unit/chatcore-headers.test.ts b/tests/unit/chatcore-headers.test.ts index 290336703e..0472987411 100644 --- a/tests/unit/chatcore-headers.test.ts +++ b/tests/unit/chatcore-headers.test.ts @@ -1,7 +1,7 @@ -import test from "node:test"; +import test, { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { getHeaderValueCaseInsensitive } from "../../open-sse/handlers/chatCore/headers.ts"; +import { getHeaderValueCaseInsensitive, resolveCompressionHeader } from "../../open-sse/handlers/chatCore/headers.ts"; test("getHeaderValueCaseInsensitive reads a Headers instance via .get()", () => { const h = new Headers({ "Content-Type": "text/event-stream" }); @@ -46,3 +46,16 @@ test("getHeaderValueCaseInsensitive returns null for null/undefined/non-object i null ); }); + +describe("resolveCompressionHeader", () => { + it("reads the raw value case-insensitively and trims it", () => { + assert.equal(resolveCompressionHeader({ "x-omniroute-compression": " engine:rtk " }), "engine:rtk"); + assert.equal(resolveCompressionHeader(new Headers({ "X-OmniRoute-Compression": "off" })), "off"); + }); + + it("returns null when absent or blank", () => { + assert.equal(resolveCompressionHeader({}), null); + assert.equal(resolveCompressionHeader({ "x-omniroute-compression": " " }), null); + assert.equal(resolveCompressionHeader(null), null); + }); +}); diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index 4a9fa93b7c..263debfd60 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -274,11 +274,10 @@ async function resetStorage() { fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); } -// 10s ceiling: on 2-core CI runners under shard contention the 1500ms budget -// expired mid-flight (observed: 1580ms fail on the upstream-timeout test) — -// green runs return as soon as the condition holds, so the ceiling only -// bounds the failure case. -async function waitFor(fn, timeoutMs = 10000) { +// 30s ceiling: c8 instrumentation plus --test-concurrency=8 can stall CI workers +// well past the upstream timeout budget. Green runs return as soon as the condition +// holds, so the ceiling only bounds the failure case. +async function waitFor(fn, timeoutMs = 30000) { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { const result = await fn(); diff --git a/tests/unit/cleanup-column-fix.test.mjs b/tests/unit/cleanup-column-fix.test.mjs new file mode 100644 index 0000000000..eeb3aeafd9 --- /dev/null +++ b/tests/unit/cleanup-column-fix.test.mjs @@ -0,0 +1,114 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +// Source-level invariant tests for cleanup.ts fixes. +// These verify the critical column name fixes that were causing silent cleanup failures. + +const CLEANUP_PATH = path.resolve(import.meta.dirname, "../../src/lib/db/cleanup.ts"); +const source = fs.readFileSync(CLEANUP_PATH, "utf-8"); + +test("cleanup: compression_analytics uses 'timestamp' column (not 'created_at')", () => { + // The bug: cleanup used WHERE created_at < ? but the table has 'timestamp' column. + // This caused silent failures — 600K+ rows accumulated over 52 days. + assert.ok( + source.includes("DELETE FROM compression_analytics WHERE timestamp < ?"), + "compression_analytics cleanup must use 'timestamp' column, not 'created_at'" + ); + assert.ok( + !source.includes("DELETE FROM compression_analytics WHERE created_at"), + "must NOT use created_at for compression_analytics (column doesn't exist)" + ); +}); + +test("cleanup: call_logs uses 'timestamp' column (not 'created_at')", () => { + // Same bug as compression_analytics. + assert.ok( + source.includes("DELETE FROM call_logs WHERE timestamp < ?"), + "call_logs cleanup must use 'timestamp' column" + ); + assert.ok( + !source.includes("DELETE FROM call_logs WHERE created_at"), + "must NOT use created_at for call_logs (column doesn't exist)" + ); +}); + +test("cleanup: has proxy_logs cleanup function", () => { + assert.ok( + source.includes("cleanupProxyLogs"), + "must have cleanupProxyLogs function for the proxy_logs table" + ); + assert.ok( + source.includes("DELETE FROM proxy_logs WHERE timestamp < ?"), + "proxy_logs cleanup must use timestamp column" + ); +}); + +test("cleanup: proxy_logs is included in runAutoCleanup", () => { + assert.ok( + source.includes("proxyLogs: await cleanupProxyLogs()"), + "runAutoCleanup must include proxyLogs cleanup" + ); +}); + +test("cleanup: has background scheduler (startCleanupScheduler)", () => { + assert.ok( + source.includes("startCleanupScheduler"), + "must export startCleanupScheduler for periodic background cleanup" + ); + assert.ok( + source.includes("CLEANUP_INTERVAL_MS"), + "must have a cleanup interval constant" + ); + assert.ok( + source.includes("VACUUM"), + "scheduler must run VACUUM after deletes to reclaim disk space" + ); +}); + +test("cleanup: scheduler is wired into server-init.ts", () => { + const serverInitPath = path.resolve(import.meta.dirname, "../../src/server-init.ts"); + const serverInit = fs.readFileSync(serverInitPath, "utf-8"); + assert.ok( + serverInit.includes('import { startCleanupScheduler } from "./lib/db/cleanup"'), + "server-init.ts must import startCleanupScheduler" + ); + assert.ok( + serverInit.includes("startCleanupScheduler()"), + "server-init.ts must call startCleanupScheduler() at startup" + ); +}); + +test("cleanup: mcp_tool_audit uses correct table name (not 'mcp_audit_log')", () => { + assert.ok( + source.includes("DELETE FROM mcp_tool_audit WHERE"), + "must use correct table name mcp_tool_audit" + ); + assert.ok( + !source.includes("DELETE FROM mcp_audit_log WHERE"), + "must NOT use non-existent table name mcp_audit_log" + ); +}); + +test("cleanup: a2a_task_events uses correct table name (not 'a2a_events')", () => { + assert.ok( + source.includes("DELETE FROM a2a_task_events WHERE"), + "must use correct table name a2a_task_events" + ); + assert.ok( + !source.includes("DELETE FROM a2a_events WHERE"), + "must NOT use non-existent table name a2a_events" + ); +}); + +test("cleanup: memories uses correct table name (not 'memory_entries')", () => { + assert.ok( + source.includes("DELETE FROM memories WHERE"), + "must use correct table name memories" + ); + assert.ok( + !source.includes("DELETE FROM memory_entries WHERE"), + "must NOT use non-existent table name memory_entries" + ); +}); diff --git a/tests/unit/cli-data-dir-env-loading.test.ts b/tests/unit/cli-data-dir-env-loading.test.ts new file mode 100644 index 0000000000..c3d2ae3423 --- /dev/null +++ b/tests/unit/cli-data-dir-env-loading.test.ts @@ -0,0 +1,157 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const BIN = path.join(ROOT, "bin", "omniroute.mjs"); +const DATA_DIR_MODULE = path.join(ROOT, "bin", "cli", "data-dir.mjs"); + +async function withEnv( + updates: Record, + fn: () => Promise +): Promise { + const original = new Map(); + for (const key of Object.keys(updates)) { + original.set(key, process.env[key]); + if (updates[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = updates[key]; + } + } + + try { + return await fn(); + } finally { + for (const [key, value] of original) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +function readCliEnvShow(env: NodeJS.ProcessEnv, cwd: string): Record { + const result = spawnSync("node", [BIN, "env", "show", "--json"], { + cwd, + env, + encoding: "utf-8", + timeout: 60_000, + }); + + assert.equal( + result.status, + 0, + `omniroute env show failed\nstdout=${result.stdout}\nstderr=${result.stderr}` + ); + + const stdout = result.stdout ?? ""; + const jsonStart = stdout.indexOf("{"); + assert.ok(jsonStart >= 0, `expected JSON in stdout: ${stdout}`); + const parsed = JSON.parse(stdout.slice(jsonStart)) as { current: Record }; + return parsed.current; +} + +test("CLI data-dir resolver preserves an existing legacy ~/.omniroute before XDG/app data", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cli-data-dir-")); + const home = path.join(tmp, "home"); + const legacyDir = path.join(home, ".omniroute"); + const appData = path.join(tmp, "appdata"); + const xdgConfigHome = path.join(tmp, "xdg"); + + try { + fs.mkdirSync(legacyDir, { recursive: true }); + + await withEnv( + { + DATA_DIR: undefined, + HOME: home, + USERPROFILE: home, + APPDATA: appData, + XDG_CONFIG_HOME: xdgConfigHome, + }, + async () => { + const { resolveDataDir: cliResolveDataDir } = await import( + `${pathToFileURL(DATA_DIR_MODULE).href}?t=${Date.now()}` + ); + const { resolveDataDir: runtimeResolveDataDir } = await import( + `../../src/lib/dataPaths.ts?t=${Date.now()}` + ); + + assert.equal(cliResolveDataDir(), legacyDir); + assert.equal(cliResolveDataDir(), runtimeResolveDataDir()); + } + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("CLI startup loads later non-conflicting .env files without overriding earlier values", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cli-env-layers-")); + const home = path.join(tmp, "home"); + const dataDir = path.join(tmp, "data"); + const cwd = path.join(tmp, "cwd"); + const appDataDir = + process.platform === "win32" + ? path.join(tmp, "appdata", "omniroute") + : path.join(home, ".omniroute"); + + try { + fs.mkdirSync(dataDir, { recursive: true }); + fs.mkdirSync(appDataDir, { recursive: true }); + fs.mkdirSync(cwd, { recursive: true }); + + fs.writeFileSync( + path.join(dataDir, ".env"), + "OMNIROUTE_BASE_URL=https://data.example/v1\n", + "utf-8" + ); + fs.writeFileSync( + path.join(appDataDir, ".env"), + ["OMNIROUTE_BASE_URL=https://appdata.example/v1", "OMNIROUTE_HTTP_TIMEOUT_MS=1234", ""].join( + "\n" + ), + "utf-8" + ); + fs.writeFileSync( + path.join(cwd, ".env"), + ["OMNIROUTE_BASE_URL=https://cwd.example/v1", "PORT=34567", ""].join("\n"), + "utf-8" + ); + + const cleanEnv = { ...process.env }; + for (const key of [ + "OMNIROUTE_BASE_URL", + "OMNIROUTE_HTTP_TIMEOUT_MS", + "PORT", + "STORAGE_ENCRYPTION_KEY", + ]) { + delete cleanEnv[key]; + } + + const env = { + ...cleanEnv, + DATA_DIR: dataDir, + HOME: home, + USERPROFILE: home, + APPDATA: path.join(tmp, "appdata"), + CI: "1", + OMNIROUTE_CLI_SKIP_REPO_ENV: "1", + OMNIROUTE_NO_UPDATE_NOTIFIER: "1", + }; + + const current = readCliEnvShow(env, cwd); + assert.equal(current.OMNIROUTE_BASE_URL, "https://data.example/v1"); + assert.equal(current.OMNIROUTE_HTTP_TIMEOUT_MS, "1234"); + assert.equal(current.PORT, "34567"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/cli-data-dir-env.test.ts b/tests/unit/cli-data-dir-env.test.ts new file mode 100644 index 0000000000..798177db66 --- /dev/null +++ b/tests/unit/cli-data-dir-env.test.ts @@ -0,0 +1,68 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { resolveDataDir as cliResolveDataDir } from "../../bin/cli/data-dir.mjs"; +import { resolveDataDir as runtimeResolveDataDir } from "../../src/lib/dataPaths.ts"; + +const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const BIN = path.join(REPO_ROOT, "bin", "omniroute.mjs"); + +async function withTempEnv( + fn: (paths: { root: string; home: string; cwd: string }) => void | Promise +) { + const originalEnv = { ...process.env }; + const originalCwd = process.cwd(); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "omni-cli-env-")); + const home = path.join(root, "home"); + const cwd = path.join(root, "cwd"); + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(cwd, { recursive: true }); + + delete process.env.DATA_DIR; + delete process.env.XDG_CONFIG_HOME; + delete process.env.APPDATA; + process.env.HOME = home; + process.chdir(cwd); + + try { + await fn({ root, home, cwd }); + } finally { + process.chdir(originalCwd); + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + for (const [key, value] of Object.entries(originalEnv)) { + process.env[key] = value; + } + fs.rmSync(root, { recursive: true, force: true }); + } +} + +test("CLI data dir preserves existing legacy ~/.omniroute before XDG", async () => { + await withTempEnv(({ home, root }) => { + const legacyDir = path.join(home, ".omniroute"); + fs.mkdirSync(legacyDir, { recursive: true }); + process.env.XDG_CONFIG_HOME = path.join(root, "xdg"); + + assert.equal(cliResolveDataDir(), legacyDir); + assert.equal(cliResolveDataDir(), runtimeResolveDataDir()); + }); +}); + +test("CLI env loader scans all env paths while preserving first value wins", () => { + const source = fs.readFileSync(BIN, "utf8"); + const loaderStart = source.indexOf("function loadEnvFile()"); + const loaderEnd = source.indexOf("loadEnvFile();", loaderStart); + const loaderSource = source.slice(loaderStart, loaderEnd); + + assert.match(loaderSource, /for \(const envPath of envPaths\)/); + assert.match(loaderSource, /if \(process\.env\[key\] === undefined\)/); + assert.doesNotMatch( + loaderSource, + /Loaded env from \$\{envPath\}[\s\S]{0,80}\breturn;/ + ); +}); diff --git a/tests/unit/cli-logs-route.test.ts b/tests/unit/cli-logs-route.test.ts index 721f712ba1..8dcf83316e 100644 --- a/tests/unit/cli-logs-route.test.ts +++ b/tests/unit/cli-logs-route.test.ts @@ -35,9 +35,7 @@ const lines = [ ]; fs.writeFileSync(logPath, lines.join("\n") + "\n", "utf-8"); -const { GET } = await import( - "../../src/app/api/cli-tools/logs/route.ts" -); +const { GET } = await import("../../src/app/api/cli-tools/logs/route.ts"); test.before(async () => { await updateSettings({ requireLogin: false }); @@ -148,7 +146,14 @@ test("log-streamer.ts calls /api/cli-tools/logs (correct URL, not the missing ro globalThis.fetch = (async (url: string) => { captured.push(typeof url === "string" ? url : String(url)); // Return a mock Response with a body so the stream doesn't error immediately - return new Response(new ReadableStream({ start(c) { c.close(); } }), { status: 200 }); + return new Response( + new ReadableStream({ + start(c) { + c.close(); + }, + }), + { status: 200 } + ); }) as typeof fetch; try { @@ -171,3 +176,41 @@ test("log-streamer.ts calls /api/cli-tools/logs (correct URL, not the missing ro "log-streamer should not call /api/logs/console" ); }); + +test("log-streamer forwards auth headers to fetch (regression: 401 against authed servers)", async () => { + const { createLogStream } = await import("../../src/lib/cli-helper/log-streamer.ts"); + let capturedInit: RequestInit | undefined; + const origFetch = globalThis.fetch; + + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + capturedInit = init; + return new Response( + new ReadableStream({ + start(c) { + c.close(); + }, + }), + { status: 200 } + ); + }) as typeof fetch; + + try { + const { stream, stop } = createLogStream({ + baseUrl: "http://localhost:20128", + headers: { authorization: "Bearer test-token" }, + }); + const reader = stream.getReader(); + await reader.read().catch(() => {}); + stop(); + } finally { + globalThis.fetch = origFetch; + } + + assert.ok(capturedInit, "fetch should receive an init object"); + const headers = new Headers(capturedInit!.headers); + assert.equal( + headers.get("authorization"), + "Bearer test-token", + "createLogStream must forward the provided auth headers to fetch" + ); +}); diff --git a/tests/unit/combo-routing-engine.test.ts b/tests/unit/combo-routing-engine.test.ts index ed07c9ac1e..fc0ce964b6 100644 --- a/tests/unit/combo-routing-engine.test.ts +++ b/tests/unit/combo-routing-engine.test.ts @@ -2978,8 +2978,8 @@ test("#3587 reasoning buffer is disabled without explicit model capability data" ); assert.equal( resolveReasoningBufferedMaxTokens("openai/default-cap-reasoning", 100), - null, - "default-sized caps are treated as unknown because registry fallbacks use the same value" + 1100, + "explicit default-sized caps are treated as real capability data" ); }); diff --git a/tests/unit/combo-strategies.test.ts b/tests/unit/combo-strategies.test.ts index 25e87c0d9a..f5d5c9a26f 100644 --- a/tests/unit/combo-strategies.test.ts +++ b/tests/unit/combo-strategies.test.ts @@ -423,30 +423,32 @@ test("reset-aware strategy avoids accounts near 5h exhaustion", async (t) => { assert.equal(await selectedConnectionFor(combo), healthy5h.id); }); -test("reset-aware strategy rotates similar scores with round-robin tie breaking", async (t) => { - const first = { id: `first-${randomUUID()}`, token: `token-first-${randomUUID()}` }; - const second = { - id: `second-${randomUUID()}`, - token: `token-second-${randomUUID()}`, - }; - const reset5hAt = Math.floor((Date.now() + 2 * 3600 * 1000) / 1000); - const reset7dAt = Math.floor((Date.now() + 3 * 24 * 3600 * 1000) / 1000); +test("reset-aware strategy rotates similar scores with round-robin tie breaking", async () => { + const provider = `tie-provider-${randomUUID()}`; + const first = `first-${randomUUID()}`; + const second = `second-${randomUUID()}`; const quota = { - rate_limit: { - primary_window: { used_percent: 50, reset_at: reset5hAt }, - secondary_window: { used_percent: 50, reset_at: reset7dAt }, - }, + used: 50, + total: 100, + percentUsed: 0.5, + resetAt: "2099-01-01T00:00:00.000Z", }; - t.after( - installCodexQuotaMock({ - [first.token]: quota, - [second.token]: quota, - }) - ); - const combo = resetAwareCombo(`reset-aware-rr-${randomUUID()}`, [first, second], { - resetAwareTieBandPercent: 100, - }); + registerQuotaFetcher(provider, async () => quota); + + const combo = { + name: `reset-aware-rr-${randomUUID()}`, + strategy: "reset-aware", + config: { resetAwareTieBandPercent: 100 }, + models: [first, second].map((connectionId, index) => ({ + kind: "model", + provider, + providerId: provider, + model: "balanced-model", + connectionId, + id: `tie-${index}`, + })), + }; const selections = [ await selectedConnectionFor(combo), @@ -454,8 +456,8 @@ test("reset-aware strategy rotates similar scores with round-robin tie breaking" await selectedConnectionFor(combo), ]; - assert.equal(selections.includes(first.id), true); - assert.equal(selections.includes(second.id), true); + assert.equal(selections.includes(first), true); + assert.equal(selections.includes(second), true); }); test("reset-aware strategy uses registered quota fetchers for non-Codex providers", async () => { diff --git a/tests/unit/combo-vision-aware-routing.test.ts b/tests/unit/combo-vision-aware-routing.test.ts index 7f36874b0c..f617537743 100644 --- a/tests/unit/combo-vision-aware-routing.test.ts +++ b/tests/unit/combo-vision-aware-routing.test.ts @@ -41,10 +41,7 @@ test.after(() => { // --- Part A: capability resolution ----------------------------------------- test("Pixtral resolves supportsVision=true via model-id heuristic (no synced data)", () => { - assert.equal( - getResolvedModelCapabilities("mistral/pixtral-12b-latest").supportsVision, - true - ); + assert.equal(getResolvedModelCapabilities("mistral/pixtral-12b-latest").supportsVision, true); }); test("a text-only Mistral model is NOT a vision false-positive", () => { @@ -115,3 +112,14 @@ test("text-only request: targets are untouched by the vision filter", () => { ); assert.equal(out.length, 1); }); + +test("large output request: unknown maxOutputTokens does not filter a target", () => { + const out = filterTargetsByRequestCompatibility( + [target("openai-compatible-local/custom-large-output-model"), target("openai/gpt-4o-mini")], + { messages: [{ role: "user", content: "hello" }], max_tokens: 32000 }, + noopLog + ); + const ids = out.map((t) => t.modelStr); + + assert.deepEqual(ids, ["openai-compatible-local/custom-large-output-model"]); +}); diff --git a/tests/unit/combo/auto-quota-cutoff.test.ts b/tests/unit/combo/auto-quota-cutoff.test.ts index 823d9c95f3..2b73e0f1c8 100644 --- a/tests/unit/combo/auto-quota-cutoff.test.ts +++ b/tests/unit/combo/auto-quota-cutoff.test.ts @@ -4,6 +4,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { scoreAutoTargets } from "../../../open-sse/services/combo/autoStrategy.ts"; +import { getConnectionStatusQuotaCutoffReason } from "../../../open-sse/services/combo.ts"; import type { AutoProviderCandidate, ResolvedComboTarget, @@ -121,3 +122,78 @@ test("blocked quota candidates are not included in the scoring pool", () => { "the blocked GLM latency must not inflate surviving candidates' scores" ); }); + +test("connection terminal status maps to quota cutoff reason", () => { + assert.equal( + getConnectionStatusQuotaCutoffReason({ testStatus: "credits_exhausted" }), + "credits_exhausted" + ); + assert.equal(getConnectionStatusQuotaCutoffReason({ testStatus: "expired" }), "expired"); + assert.equal(getConnectionStatusQuotaCutoffReason({ testStatus: "active" }), undefined); +}); + +test("future unavailable connection maps to rate_limited quota cutoff reason", () => { + assert.equal( + getConnectionStatusQuotaCutoffReason({ + testStatus: "unavailable", + rateLimitedUntil: new Date(Date.now() + 60_000).toISOString(), + }), + "rate_limited" + ); + assert.equal( + getConnectionStatusQuotaCutoffReason({ + testStatus: "unavailable", + rateLimitedUntil: new Date(Date.now() - 60_000).toISOString(), + }), + undefined + ); +}); + +test("status-blocked candidates are removed before auto scoring", () => { + const targets = [ + target("puter", "fast-free", "puter-empty"), + target("cerebras", "healthy", "cerebras-ok"), + ]; + const ranked = scoreAutoTargets( + targets, + [ + candidate("puter", "fast-free", "puter-empty", { + quotaRemaining: 0, + p95LatencyMs: 5, + quotaCutoffBlocked: true, + quotaCutoffReason: "credits_exhausted", + }), + candidate("cerebras", "healthy", "cerebras-ok", { + quotaRemaining: 100, + p95LatencyMs: 5000, + }), + ], + "coding", + latencyOnlyWeights + ); + + assert.equal(ranked.length, 1); + assert.equal(ranked[0]?.target.provider, "cerebras"); +}); + +// --- Maintainer gate (#4592 review): terminal-status cutoff is opt-in (#4483) --- +// buildAutoCandidates only marks a terminal-status connection as quotaCutoffBlocked +// when the quota-cutoff opt-in is enabled. With the opt-in OFF, the connection must +// fall through to normal connection-cooldown / model-lockout handling instead of being +// hard-excluded here (which would surface a misleading "below quota cutoff" 429 when +// every candidate is merely transiently unavailable). This locks the gating expression +// `quotaCutoffEnabled ? getConnectionStatusQuotaCutoffReason(conn) : undefined` +// against accidental removal. +test("terminal-status cutoff is consulted only when quota cutoff is enabled", () => { + const terminalConn = { testStatus: "credits_exhausted" }; + + // Helper itself still classifies the terminal status (unchanged). + assert.equal(getConnectionStatusQuotaCutoffReason(terminalConn), "credits_exhausted"); + + // The gate: enabled → reason flows through; disabled → suppressed (fall-through). + const gated = (enabled: boolean) => + enabled ? getConnectionStatusQuotaCutoffReason(terminalConn) : undefined; + + assert.equal(gated(true), "credits_exhausted", "enabled: terminal status blocks the candidate"); + assert.equal(gated(false), undefined, "disabled: terminal status must NOT pre-block (opt-in)"); +}); diff --git a/tests/unit/compression/compression-header-dispatch.test.ts b/tests/unit/compression/compression-header-dispatch.test.ts new file mode 100644 index 0000000000..09515c1014 --- /dev/null +++ b/tests/unit/compression/compression-header-dispatch.test.ts @@ -0,0 +1,191 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + selectCompressionStrategy, + selectCompressionPlan, + planFromHeader, + formatCompressionMeta, + buildNamedComboLookup, +} from "../../../open-sse/services/compression/strategySelector.ts"; +import { + DEFAULT_COMPRESSION_CONFIG, + type CompressionConfig, +} from "../../../open-sse/services/compression/types.ts"; + +const combos = { + c1: [ + { engine: "rtk", intensity: "standard" }, + { engine: "caveman", intensity: "full" }, + ], + "fast combo": [{ engine: "lite" }], +}; + +function cfg(overrides: Partial = {}): CompressionConfig { + return { ...DEFAULT_COMPRESSION_CONFIG, enabled: true, ...overrides }; +} + +// header is the 7th positional arg of selectCompressionPlan/selectCompressionStrategy. +function planWithHeader(config: CompressionConfig, header: string | null) { + return selectCompressionPlan(config, null, 0, undefined, undefined, combos, header); +} + +describe("planFromHeader (Phase 3)", () => { + it("off => mode off, source request-header", () => { + const p = planFromHeader(cfg(), "off", combos); + assert.deepEqual(p, { mode: "off", stackedPipeline: [], source: "request-header" }); + }); + + it("default => the panel-derived default, ignoring the active profile", () => { + const config = cfg({ + activeComboId: "c1", + enginesExplicit: true, + engines: { rtk: { enabled: true } }, + }); + const p = planFromHeader(config, "default", combos); + assert.equal(p?.mode, "rtk"); // engines map default, NOT the c1 active profile + assert.equal(p?.source, "request-header"); + }); + + it("engine: => that single engine when enabled (case-insensitive)", () => { + const config = cfg({ enginesExplicit: true, engines: { rtk: { enabled: true } } }); + assert.equal(planFromHeader(config, "engine:RTK", combos)?.mode, "rtk"); + }); + + it("engine: => null (fall-through) when the engine is disabled", () => { + const config = cfg({ engines: { rtk: { enabled: false } } }); + assert.equal(planFromHeader(config, "engine:rtk", combos), null); + }); + + it("engine: => tolerates whitespace after the colon", () => { + const config = cfg({ enginesExplicit: true, engines: { rtk: { enabled: true } } }); + assert.equal(planFromHeader(config, "engine: rtk", combos)?.mode, "rtk"); + }); + + it(" matches by name (case-insensitive) and by id", () => { + assert.deepEqual( + planFromHeader(cfg(), "FAST COMBO", combos)?.stackedPipeline, + combos["fast combo"] + ); + assert.deepEqual(planFromHeader(cfg(), "c1", combos)?.stackedPipeline, combos.c1); + }); + + it("unknown value => null (fall-through)", () => { + assert.equal(planFromHeader(cfg(), "nonsense", combos), null); + }); +}); + +describe("header precedence in resolveBasePlan (Phase 3)", () => { + it("a valid header beats the active profile", () => { + const config = cfg({ activeComboId: "c1" }); + assert.equal(planWithHeader(config, "off").mode, "off"); + assert.equal(planWithHeader(config, "off").source, "request-header"); + }); + + it("a valid header beats a routing-combo override", () => { + const config = cfg({ comboOverrides: { "route-x": "stacked" } }); + const plan = selectCompressionPlan(config, "route-x", 0, undefined, undefined, combos, "off"); + assert.equal(plan.mode, "off"); + assert.equal(plan.source, "request-header"); + }); + + it("a valid header bypasses auto-trigger (Decision B)", () => { + const config = cfg({ + autoTriggerTokens: 1000, + autoTriggerMode: "aggressive", + enginesExplicit: true, + engines: { rtk: { enabled: true } }, + }); + // Large prompt would auto-escalate to aggressive; the header pins the panel default. + assert.equal(planWithHeader(config, "default").mode, "rtk"); + }); + + it("an unknown header falls through to the normal resolution", () => { + const config = cfg({ activeComboId: "c1" }); + const plan = planWithHeader(config, "bogus"); + assert.equal(plan.mode, "stacked"); + assert.equal(plan.source, "active-profile"); + }); + + it("master-off beats the header (hard kill switch)", () => { + const config = cfg({ enabled: false, engines: { rtk: { enabled: true } } }); + const plan = planWithHeader(config, "engine:rtk"); + assert.equal(plan.mode, "off"); + assert.equal(plan.source, "off"); + }); +}); + +describe("source on non-header paths", () => { + it("routing-override / active-profile / auto-trigger / default / off", () => { + assert.equal( + selectCompressionPlan( + cfg({ comboOverrides: { r: "lite" } }), + "r", + 0, + undefined, + undefined, + combos, + null + ).source, + "routing-override" + ); + assert.equal(planWithHeader(cfg({ activeComboId: "c1" }), null).source, "active-profile"); + // auto-trigger only fires once estimatedTokens crosses autoTriggerTokens, so pass an + // estimate above the threshold (planWithHeader pins estimatedTokens=0 and never trips it). + assert.equal( + selectCompressionPlan( + cfg({ autoTriggerTokens: 10, autoTriggerMode: "lite" }), + null, + 50, + undefined, + undefined, + combos, + null + ).source, + "auto-trigger" + ); + assert.equal( + planWithHeader(cfg({ enginesExplicit: true, engines: { rtk: { enabled: true } } }), null) + .source, + "default" + ); + assert.equal(planWithHeader(cfg({ enabled: false }), null).source, "off"); + }); +}); + +describe("formatCompressionMeta", () => { + it("renders '; source='", () => { + assert.equal( + formatCompressionMeta({ mode: "aggressive", stackedPipeline: [], source: "request-header" }), + "aggressive; source=request-header" + ); + assert.equal(formatCompressionMeta({ mode: "off", stackedPipeline: [] }), "off; source=off"); + }); +}); + +describe("buildNamedComboLookup", () => { + const pipe = [{ engine: "lite" }]; + + it("keys each combo by both id and lowercased name", () => { + const map = buildNamedComboLookup([{ id: "abc-123", name: "My Fast Combo", pipeline: pipe }]); + assert.deepEqual(map["abc-123"], pipe); + assert.deepEqual(map["my fast combo"], pipe); + }); + + it("skips the name key (no '' key, no crash) when the name is blank/whitespace/missing", () => { + const map = buildNamedComboLookup([ + { id: "id-empty", name: "", pipeline: pipe }, + { id: "id-space", name: " ", pipeline: pipe }, + { id: "id-null", name: null, pipeline: pipe }, + ]); + assert.deepEqual(map["id-empty"], pipe); // id key always present + assert.deepEqual(map["id-space"], pipe); + assert.deepEqual(map["id-null"], pipe); + assert.equal(Object.prototype.hasOwnProperty.call(map, ""), false); // no blank key + assert.equal(Object.keys(map).length, 3); // only the three id keys + }); + + it("trims surrounding whitespace on the name key", () => { + const map = buildNamedComboLookup([{ id: "x", name: " Spaced ", pipeline: pipe }]); + assert.deepEqual(map["spaced"], pipe); + }); +}); diff --git a/tests/unit/db-usage-analytics-3500.test.ts b/tests/unit/db-usage-analytics-3500.test.ts index d61f7b1787..e9d49c4326 100644 --- a/tests/unit/db-usage-analytics-3500.test.ts +++ b/tests/unit/db-usage-analytics-3500.test.ts @@ -97,13 +97,12 @@ test.after(() => { // --------------------------------------------------------------------------- test("#3500 buildUnifiedSource — raw-only branch when sinceIso is recent", () => { - // A very recent sinceIso means no aggregated rows are needed - const recentIso = new Date(Date.now() - 3600_000).toISOString(); // 1 hour ago - const today = new Date().toISOString().split("T")[0]; + // sinceIso >= rawCutoffDate means no aggregated rows are needed. + const recentIso = "2025-06-02T12:00:00.000Z"; const result = mod.buildUnifiedSource({ sinceIso: recentIso, untilIso: null, - rawCutoffDate: today, + rawCutoffDate: "2025-06-01", apiKeyWhere: "", apiKeyParams: {}, }); diff --git a/tests/unit/deepseek-web-tools-execute.test.ts b/tests/unit/deepseek-web-tools-execute.test.ts new file mode 100644 index 0000000000..9c8a19d46d --- /dev/null +++ b/tests/unit/deepseek-web-tools-execute.test.ts @@ -0,0 +1,181 @@ +// @ts-nocheck +// deepseek-web executor-level tool behavior (full execute() pipeline, fetch mocked): +// 1. A reply interleaving natural-language text with a (DeepSeek-flavoured) tool call must +// surface BOTH the text AND the parsed tool_calls — streaming and non-streaming alike. +// 2. An agentic (tool-using) conversation must replay the whole trajectory (prior tool calls +// + their results) into the flat web `prompt`, so the model keeps context across turns +// instead of restarting. +// Pure parser / prompt-builder unit tests live in deepseek-web-tools-variants.test.ts. +import test from "node:test"; +import assert from "node:assert/strict"; + +const dsMod = await import("../../open-sse/executors/deepseek-web.ts"); +const { DeepSeekWebExecutor } = dsMod; + +const POW_CHALLENGE = { + algorithm: "DeepSeekHashV1", + challenge: "311b26ae1e0fe7375e242958ce46db5552a6c67fea3f96880dcd846c63a74286", + salt: "1122334455667788", + signature: "sig123", + difficulty: 1, + expire_at: 1778891543095, + expire_after: 300000, + target_path: "/api/v0/chat/completion", +}; + +function sseWithContent(text) { + return [ + "event: ready\n", + 'data: {"request_message_id":1,"response_message_id":2}\n', + "\n", + `data: ${JSON.stringify({ v: { response: { message_id: 2, fragments: [{ id: 1, type: "RESPONSE", content: text }] } } })}\n`, + "\n", + 'data: {"p":"response/status","o":"SET","v":"FINISHED"}\n', + "\n", + "event: close\n", + 'data: {"click_behavior":"none"}\n', + ].join(""); +} + +function installMock(completionText) { + const original = globalThis.fetch; + const calls = { completionBodies: [] }; + dsMod.tokenCache?.clear(); + dsMod.sessionCache?.clear(); + globalThis.fetch = async (url, opts = {}) => { + const u = String(url); + if (u.includes("/users/current")) + return new Response( + JSON.stringify({ code: 0, data: { biz_data: { token: "access-token-xyz" } } }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + if (u.includes("/chat_session/create")) + return new Response( + JSON.stringify({ code: 0, data: { biz_data: { chat_session: { id: "s-1" } } } }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + if (u.includes("/chat_session/delete")) + return new Response(JSON.stringify({ code: 0 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + if (u.includes("/create_pow_challenge")) + return new Response( + JSON.stringify({ code: 0, data: { biz_data: { challenge: POW_CHALLENGE } } }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + if (u.includes("/chat/completion")) { + try { + calls.completionBodies.push(JSON.parse(opts.body)); + } catch { + calls.completionBodies.push(null); + } + return new Response(new TextEncoder().encode(sseWithContent(completionText)), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + return new Response("not found", { status: 404 }); + }; + return { + calls, + restore: () => { + globalThis.fetch = original; + dsMod.tokenCache?.clear(); + dsMod.sessionCache?.clear(); + }, + }; +} + +const TOOLS = [ + { type: "function", function: { name: "todowrite", description: "Write todos" } }, + { type: "function", function: { name: "bash", description: "Run a shell command" } }, +]; + +// ── 1. Surrounding text + tool_calls are both surfaced ────────────────────── + +// Explanation text followed by a `` block. +const TEXT_PLUS_TOOL = 'I\'ll create a script.\n\n\n{"command": "echo hi"}\n'; +const EXPLANATION = "I'll create a script."; + +test("non-stream: surrounding text AND tool_calls are both returned", async () => { + const mock = installMock(TEXT_PLUS_TOOL); + try { + const result = await new DeepSeekWebExecutor().execute({ + model: "default", + body: { messages: [{ role: "user", content: "go" }], tools: TOOLS }, + stream: false, + credentials: { apiKey: "tkn-text-ns" }, + signal: AbortSignal.timeout(10000), + }); + const choice = JSON.parse(await result.response.text()).choices[0]; + assert.equal(choice.finish_reason, "tool_calls"); + assert.equal(choice.message.tool_calls[0].function.name, "bash"); + assert.deepEqual(JSON.parse(choice.message.tool_calls[0].function.arguments), { + command: "echo hi", + }); + assert.ok(choice.message.content.includes(EXPLANATION), "explanation text preserved"); + assert.ok(!choice.message.content.includes(" { + const mock = installMock(TEXT_PLUS_TOOL); + try { + const result = await new DeepSeekWebExecutor().execute({ + model: "default", + body: { messages: [{ role: "user", content: "go" }], tools: TOOLS }, + stream: true, + credentials: { apiKey: "tkn-text-stream" }, + signal: AbortSignal.timeout(10000), + }); + const text = await result.response.text(); + assert.ok(text.includes(EXPLANATION), "stream carries the explanation text"); + assert.ok(text.includes("tool_calls"), "stream carries tool_calls"); + assert.ok(text.includes("echo hi"), "stream carries the arguments"); + assert.ok(text.includes('"finish_reason":"tool_calls"')); + assert.ok(text.includes("[DONE]")); + } finally { + mock.restore(); + } +}); + +// ── 2. Agentic context is replayed into the upstream prompt ────────────────── + +test("execute forwards the full agentic trajectory into the upstream prompt", async () => { + const mock = installMock("All done."); + try { + await new DeepSeekWebExecutor().execute({ + model: "default", + body: { + tools: TOOLS, + messages: [ + { role: "user", content: "write train.py" }, + { + role: "assistant", + content: "", + tool_calls: [ + { + id: "c1", + type: "function", + function: { name: "todowrite", arguments: '{"todos":[]}' }, + }, + ], + }, + { role: "tool", tool_call_id: "c1", content: "todos created" }, + ], + }, + stream: false, + credentials: { apiKey: "tkn-ctx" }, + signal: AbortSignal.timeout(10000), + }); + const prompt = mock.calls.completionBodies[0].prompt; + assert.ok(prompt.includes("write train.py"), "task retained"); + assert.ok(prompt.includes("todowrite"), "prior tool call retained"); + assert.ok(prompt.includes("todos created"), "prior tool result retained"); + } finally { + mock.restore(); + } +}); diff --git a/tests/unit/deepseek-web-tools-variants.test.ts b/tests/unit/deepseek-web-tools-variants.test.ts new file mode 100644 index 0000000000..2ef046779f --- /dev/null +++ b/tests/unit/deepseek-web-tools-variants.test.ts @@ -0,0 +1,225 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { + parseDeepSeekToolCalls, + serializeDeepSeekToolPrompt, + buildToolConversationPrompt, +} from "../../open-sse/translator/deepseekWebTools.ts"; + +// chat.deepseek.com emits tool invocations in many ad-hoc shapes. The deepseek-specific +// parser must recognize all of them, recover the real tool name/arguments, and preserve +// any surrounding natural-language text so it can still be streamed to the client. + +const TOOLS = [ + { type: "function", function: { name: "todowrite", description: "Write todos" } }, + { type: "function", function: { name: "bash", description: "Run a shell command" } }, + { type: "function", function: { name: "write", description: "Write a file" } }, + { + type: "function", + function: { + name: "get_weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, +]; + +function firstCall(text: string) { + const { toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS); + assert.ok(toolCalls && toolCalls.length >= 1, `expected a tool call for: ${text.slice(0, 60)}`); + return toolCalls[0]; +} + +describe("deepseekWebTools — variants", () => { + test("Ex1: {json} — name in tag suffix, body is the arguments", () => { + const text = `I'll write a script.\n\n\n{"todos": [{"content": "a", "status": "in_progress", "priority": "high"}]}\n`; + const { content, toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS); + assert.equal(toolCalls?.length, 1); + assert.equal(toolCalls![0].function.name, "todowrite"); + assert.deepEqual(JSON.parse(toolCalls![0].function.arguments), { + todos: [{ content: "a", status: "in_progress", priority: "high" }], + }); + assert.ok(content.includes("I'll write a script."), "surrounding text preserved"); + assert.ok(!content.includes(" with id/type/params shape", () => { + const text = `I'll draft a plan.\n\n{"id": "todo_1", "type": "todo", "params": {"todos": [{"content": "x", "status": "pending", "priority": "high"}]}}\n`; + const call = firstCall(text); + assert.equal(call.function.name, "todowrite", "type 'todo' fuzzy-resolves to todowrite"); + assert.deepEqual(JSON.parse(call.function.arguments), { + todos: [{ content: "x", status: "pending", priority: "high" }], + }); + }); + + test("Ex5: nested {json} — inner canonical call", () => { + const text = `\n{"name": "todowrite", "arguments": {"todos": [{"content": "y", "status": "pending", "priority": "high"}]}}`; + const { content, toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS); + assert.equal(toolCalls?.length, 1); + assert.equal(toolCalls![0].function.name, "todowrite"); + assert.ok(!content.includes("{command} — body is the arguments payload", () => { + const text = `I'll create a script.\n\n\n{"command": "echo hi"}\n`; + const call = firstCall(text); + assert.equal(call.function.name, "bash"); + assert.deepEqual(JSON.parse(call.function.arguments), { command: "echo hi" }); + }); + + test('Ex7: write{json}', () => { + const text = `I'll create a script.\n\n write\n \n {"filePath": "/tmp/train.py", "content": "print(1)"}\n \n`; + const call = firstCall(text); + assert.equal(call.function.name, "write"); + assert.deepEqual(JSON.parse(call.function.arguments), { + filePath: "/tmp/train.py", + content: "print(1)", + }); + }); + + test('Ex8: {json} — name attribute on inner', () => { + const text = `\n{"todos":[{"content":"c","status":"in_progress","priority":"high"}]}\n`; + const call = firstCall(text); + assert.equal(call.function.name, "todowrite"); + assert.deepEqual(JSON.parse(call.function.arguments), { + todos: [{ content: "c", status: "in_progress", priority: "high" }], + }); + }); + + test('Ex9: — parameter style', () => { + const text = `\n`; + const call = firstCall(text); + assert.equal(call.function.name, "write"); + assert.deepEqual(JSON.parse(call.function.arguments), { content: "print('hi')" }); + }); + + test('Ex12: {json} — id resolves to a tool name', () => { + const text = `I'll create a script.\n\n{"todos": [{"content": "z", "status": "in_progress", "priority": "high"}]}\n`; + const call = firstCall(text); + assert.equal(call.function.name, "todowrite"); + assert.deepEqual(JSON.parse(call.function.arguments), { + todos: [{ content: "z", status: "in_progress", priority: "high" }], + }); + }); + + test("canonical {name,arguments} still works (no regression)", () => { + const text = `{"name": "get_weather", "arguments": {"city": "Paris"}}`; + const call = firstCall(text); + assert.equal(call.function.name, "get_weather"); + assert.deepEqual(JSON.parse(call.function.arguments), { city: "Paris" }); + }); + + test("bare JSON (no tags) still resolves via fuzzy name match", () => { + const text = `{"name":"getWeather","arguments":{"city":"Paris"}}`; + const call = firstCall(text); + assert.equal(call.function.name, "get_weather"); + assert.deepEqual(JSON.parse(call.function.arguments), { city: "Paris" }); + }); + + test("#3260: tag name attribute is bogus, real name is in JSON body", () => { + const text = `{"name": "get_weather", "arguments": {"city": "SP"}}`; + const call = firstCall(text); + assert.equal(call.function.name, "get_weather"); + }); + + test("multiple tags mixing content-attr and body styles are all captured", () => { + // Regression: an attribute-style with no closing tag must not let the + // body matcher swallow a following body-style ... (lost param). + const text = `\n\nprint(1)\n`; + const call = firstCall(text); + assert.equal(call.function.name, "write"); + assert.deepEqual(JSON.parse(call.function.arguments), { + filePath: "/tmp/a.py", + content: "print(1)", + }); + }); + + test("surrounding text is preserved both before and after the tool block", () => { + const text = `Before text.\n\n{"command": "ls"}\n\nAfter text.`; + const { content, toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS); + assert.equal(toolCalls?.length, 1); + assert.ok(content.includes("Before text.")); + assert.ok(content.includes("After text.")); + }); +}); + +describe("deepseekWebTools — pure-text (no tool) replies", () => { + for (const [label, text] of [ + ["Ex2 plan only", "I'll build a train animation. Plan:\n1. step\n2. step\nStarting now."], + ["Ex4 code fence only", "Plan:\n```python\nimport os\nprint(os.getcwd())\n```"], + ["Ex13 bash fence only", "```bash\nuv pip install rich\n```"], + ] as const) { + test(`${label} — returns plain content, no tool calls`, () => { + const { content, toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS); + assert.equal(toolCalls, null, "no tool call should be parsed"); + assert.equal(content, text, "content returned unchanged"); + }); + } +}); + +describe("deepseekWebTools — strict prompt", () => { + test("lists tools and mandates the exact JSON format", () => { + const prompt = serializeDeepSeekToolPrompt(TOOLS); + assert.ok(prompt.includes("todowrite")); + assert.ok(prompt.includes("get_weather")); + assert.ok(prompt.includes('{"name"'), "shows the canonical format"); + assert.ok(/never|not|do not/i.test(prompt), "warns against alternative formats"); + }); + + test("returns empty string when there are no usable tools", () => { + assert.equal(serializeDeepSeekToolPrompt([]), ""); + assert.equal(serializeDeepSeekToolPrompt(undefined), ""); + }); +}); + +describe("deepseekWebTools — buildToolConversationPrompt (agentic context)", () => { + // A tool-using conversation must replay the WHOLE trajectory (prior assistant tool_calls + + // their tool results) into the flat web `prompt`, otherwise the model is amnesiac and + // restarts every turn — re-creating todos, re-listing files, etc. + // The legacy builder only forwarded the last user message and dropped + // tool_calls / role:"tool" messages. The executor-level coverage lives in + // deepseek-web-tools-execute.test.ts. + test("replays prior tool calls and their results", () => { + const messages = [ + { role: "user", content: "write train.py" }, + { + role: "assistant", + content: "", + tool_calls: [ + { + id: "c1", + type: "function", + function: { name: "todowrite", arguments: '{"todos":[{"content":"a"}]}' }, + }, + ], + }, + { role: "tool", tool_call_id: "c1", content: "todos created" }, + { + role: "assistant", + content: "", + tool_calls: [ + { id: "c2", type: "function", function: { name: "bash", arguments: '{"command":"ls"}' } }, + ], + }, + { role: "tool", tool_call_id: "c2", content: "train.py\ncat.md" }, + ]; + + const prompt = buildToolConversationPrompt(messages, "TOOLS-HERE"); + + assert.ok(prompt.includes("TOOLS-HERE"), "tool system prompt leads"); + assert.ok(prompt.includes("User: write train.py"), "original task present"); + assert.ok(prompt.includes('"name": "todowrite"'), "prior todowrite call replayed"); + assert.ok( + prompt.includes("Tool result (todowrite): todos created"), + "todowrite result replayed" + ); + assert.ok(prompt.includes('"name": "bash"'), "prior bash call replayed"); + assert.ok(prompt.includes("Tool result (bash): train.py"), "bash result replayed"); + assert.ok(/Continue the task/i.test(prompt), "anchored to continue, not restart"); + }); + + test("first-turn (no prior tool activity) omits the continue anchor", () => { + const prompt = buildToolConversationPrompt([{ role: "user", content: "hi" }], "TOOLS"); + assert.ok(prompt.includes("User: hi")); + assert.ok(!/Continue the task/i.test(prompt), "no continue anchor on the first turn"); + }); +}); diff --git a/tests/unit/direct-dispatcher-pipelining-4580.test.ts b/tests/unit/direct-dispatcher-pipelining-4580.test.ts new file mode 100644 index 0000000000..a56ed90831 --- /dev/null +++ b/tests/unit/direct-dispatcher-pipelining-4580.test.ts @@ -0,0 +1,52 @@ +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { + __getDefaultDispatcherOptionsForTest, + __getProxyDispatcherOptionsForTest, + getDefaultDispatcherConnectionLimit, + clearDispatcherCache, +} from "../../open-sse/utils/proxyDispatcher.ts"; + +afterEach(() => clearDispatcherCache()); + +// #4580 — On the DIRECT egress path, concurrent same-provider requests serialized +// behind a long/streaming request. The proxy dispatcher already got pipelining:0 + +// a connections cap in #4288, but the first-attempt direct dispatcher +// (getDispatcherOptions → new Agent) kept undici's default pipelining (1), so long +// SSE streams bottlenecked the single pooled socket. The direct dispatcher now +// mirrors that fix while KEEPING keep-alive (a proxy-only concern was the 1ms TTL). + +describe("#4580 direct dispatcher options", () => { + it("disables pipelining so concurrent streams open separate sockets", () => { + const opts = __getDefaultDispatcherOptionsForTest({}); + assert.equal(opts.pipelining, 0); + }); + + it("caps connections to a finite number (default 32)", () => { + const opts = __getDefaultDispatcherOptionsForTest({}); + assert.equal(typeof opts.connections, "number"); + assert.equal(opts.connections, 32); + }); + + it("preserves keep-alive (NOT the 1ms TTL the proxy path forces)", () => { + const direct = __getDefaultDispatcherOptionsForTest({}); + const proxy = __getProxyDispatcherOptionsForTest({}); + assert.equal(proxy.keepAliveTimeout, 1); + assert.ok( + (direct.keepAliveTimeout ?? 0) > 1, + `direct keepAliveTimeout should stay > 1 (got ${direct.keepAliveTimeout})` + ); + }); + + it("connection limit honors OMNIROUTE_DIRECT_DISPATCHER_CONNECTIONS", () => { + assert.equal(getDefaultDispatcherConnectionLimit({ OMNIROUTE_DIRECT_DISPATCHER_CONNECTIONS: "8" }), 8); + }); + + it("connection limit clamps invalid values to the default", () => { + assert.equal( + getDefaultDispatcherConnectionLimit({ OMNIROUTE_DIRECT_DISPATCHER_CONNECTIONS: "nonsense" }), + 32 + ); + assert.equal(getDefaultDispatcherConnectionLimit({}), 32); + }); +}); diff --git a/tests/unit/error-classification.test.ts b/tests/unit/error-classification.test.ts index ab06557545..fac70d4ff6 100644 --- a/tests/unit/error-classification.test.ts +++ b/tests/unit/error-classification.test.ts @@ -190,11 +190,10 @@ test("parseRetryFromErrorText: parses will reset after variant", () => { // ─── T06: Keyword Matching for Long Cooldowns ──────────────────────────────── -// Fix #2321: QUOTA_EXHAUSTED text now sets the upstream cooldown duration even when -// useUpstreamRetryHints = false (e.g., OAuth providers like antigravity). The generic -// upstream-retry-hint opt-in only governs transient rate-limit hints; subscription -// quota resets always carry a definite recovery time, so the text is always honored. -test("quota reset text is honored for oauth providers even when generic retry hints are disabled", () => { +// Fix #1308 / #4429: QUOTA_EXHAUSTED text can provide a precise upstream reset +// window, but the operator's upstream retry hint setting must still decide +// whether that window is used for cooldowns. +test("quota reset text is ignored for oauth providers when upstream retry hints are disabled", () => { const result = checkFallbackError( 429, "Your quota will reset after 27h41m36s", @@ -203,10 +202,13 @@ test("quota reset text is honored for oauth providers even when generic retry hi "antigravity", null ); - // 27*3600 + 41*60 + 36 = 99696 seconds = 99696000 ms assert.equal(result.shouldFallback, true); - assert.equal(result.cooldownMs, 99696000); - assert.equal(result.usedUpstreamRetryHint, true); + assert.notEqual(result.cooldownMs, 99696000); + assert.ok( + result.cooldownMs < 5 * 60 * 1000, + `expected local short cooldown, got ${result.cooldownMs}ms` + ); + assert.equal(result.usedUpstreamRetryHint, false); assert.equal(result.reason, "quota_exhausted"); }); diff --git a/tests/unit/executor-github.test.ts b/tests/unit/executor-github.test.ts index dd2c1a4b3c..6d8194e043 100644 --- a/tests/unit/executor-github.test.ts +++ b/tests/unit/executor-github.test.ts @@ -426,6 +426,44 @@ test("GithubExecutor.execute preserves complete SSE responses including terminal } }); +test("GithubExecutor.transformRequest strips temperature for gpt-5.4 (port from 9router#612 / closes upstream #536)", () => { + // GitHub Copilot's gpt-5.4 family rejects requests carrying `temperature` with HTTP 400: + // "Unsupported parameter: 'temperature' is not supported with this model." + // OmniRoute's existing `stripGpt5SamplingWhenReasoning` guard only fires for + // provider==="openai" (raw api.openai.com Chat Completions) — Copilot requests run + // through GithubExecutor and never hit that guard. Strip temperature here so the + // 400 cannot reach the user. Other GitHub Copilot models keep temperature intact. + const executor = new GithubExecutor(); + + const stripped = executor.transformRequest( + "gpt-5.4", + { temperature: 0.7, messages: [{ role: "user", content: "hi" }] }, + true, + {} + ); + assert.equal(stripped.temperature, undefined, "temperature must be stripped for gpt-5.4"); + + const strippedMini = executor.transformRequest( + "gpt-5.4-mini", + { temperature: 0.3, messages: [{ role: "user", content: "hi" }] }, + true, + {} + ); + assert.equal( + strippedMini.temperature, + undefined, + "temperature must be stripped for gpt-5.4-mini" + ); + + const kept = executor.transformRequest( + "gpt-4.1", + { temperature: 0.7, messages: [{ role: "user", content: "hi" }] }, + true, + {} + ); + assert.equal(kept.temperature, 0.7, "temperature must be preserved for non-gpt-5.4 models"); +}); + test("GithubExecutor.transformRequest strips invalid synthetic Responses reasoning ids", () => { const executor = new GithubExecutor(); const result = executor.transformRequest( diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index 18699577e8..ab7fa4afdc 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -34,13 +34,13 @@ const { // Test group 1 — Flag definitions registry // ────────────────────────────────────────────────────── describe("featureFlagDefinitions", () => { - it("has exactly 35 flag definitions", () => { - assert.strictEqual(FEATURE_FLAG_DEFINITIONS.length, 35); + it("has exactly 37 flag definitions", () => { + assert.strictEqual(FEATURE_FLAG_DEFINITIONS.length, 37); }); it("has unique keys for all flags", () => { const keys = FEATURE_FLAG_DEFINITIONS.map((d) => d.key); - assert.strictEqual(new Set(keys).size, 35); + assert.strictEqual(new Set(keys).size, 37); }); it("has valid categories for all flags", () => { @@ -126,6 +126,27 @@ describe("featureFlagDefinitions", () => { assert.strictEqual(def.requiresRestart, false); }); + it("defines stream recovery as runtime boolean flags disabled by default", () => { + const early = FEATURE_FLAG_DEFINITIONS.find((d) => d.key === "STREAM_RECOVERY_ENABLED"); + const midstream = FEATURE_FLAG_DEFINITIONS.find( + (d) => d.key === "STREAM_RECOVERY_MIDSTREAM_ENABLED" + ); + + assert.ok(early, "STREAM_RECOVERY_ENABLED should exist"); + assert.strictEqual(early.category, "runtime"); + assert.strictEqual(early.type, "boolean"); + assert.strictEqual(early.defaultValue, "false"); + assert.strictEqual(early.requiresRestart, false); + assert.strictEqual(early.warningLevel, "caution"); + + assert.ok(midstream, "STREAM_RECOVERY_MIDSTREAM_ENABLED should exist"); + assert.strictEqual(midstream.category, "runtime"); + assert.strictEqual(midstream.type, "boolean"); + assert.strictEqual(midstream.defaultValue, "false"); + assert.strictEqual(midstream.requiresRestart, false); + assert.strictEqual(midstream.warningLevel, "danger"); + }); + it("defines control-plane proxy direct fallback as a network boolean flag disabled by default", () => { const def = FEATURE_FLAG_DEFINITIONS.find( (d) => d.key === "OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK" @@ -274,9 +295,9 @@ describe("resolveFeatureFlag", () => { }); describe("resolveAllFeatureFlags", () => { - it("returns all 35 flags", () => { + it("returns all 37 flags", () => { const all = resolveAllFeatureFlags(); - assert.strictEqual(all.length, 35); + assert.strictEqual(all.length, 37); }); it("marks DB-overridden flags with source 'db'", () => { diff --git a/tests/unit/firecrawl-executor.test.ts b/tests/unit/firecrawl-executor.test.ts index 395a77c219..7a909886d9 100644 --- a/tests/unit/firecrawl-executor.test.ts +++ b/tests/unit/firecrawl-executor.test.ts @@ -183,3 +183,41 @@ test("firecrawlFetch forwards depth and wait_for_selector", async () => { globalThis.fetch = originalFetch; } }); + +// ── #4692 regression: includeMetadata must NOT send invalid includeTags ──────── +// Firecrawl returns metadata automatically in response.data.metadata. Sending +// includeTags with non-CSS-selector values ("og:title", "description") crashed +// Firecrawl's parser with HTTP 500. The includeMetadata flag must only gate +// whether we surface metadata, never inject includeTags into the request. +test("firecrawlFetch with includeMetadata=true does not send includeTags (4692)", async () => { + const originalFetch = globalThis.fetch; + let capturedBody: Record = {}; + + globalThis.fetch = async (_url, init = {}) => { + capturedBody = JSON.parse(String((init as RequestInit).body ?? "{}")); + return new Response( + JSON.stringify({ + data: { markdown: "# Result", metadata: { title: "Test", description: "Desc" } }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const result = await firecrawlFetch({ + url: "https://example.com", + format: "markdown", + depth: 0, + includeMetadata: true, + credentials: { apiKey: "fc-test-key" }, + }); + + assert.equal(result.success, true); + assert.ok( + !("includeTags" in capturedBody), + "includeMetadata must not inject includeTags (Firecrawl 500)" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/livews-forward-backoff-4604.test.ts b/tests/unit/livews-forward-backoff-4604.test.ts new file mode 100644 index 0000000000..d7fe6a2026 --- /dev/null +++ b/tests/unit/livews-forward-backoff-4604.test.ts @@ -0,0 +1,83 @@ +import { test, beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +import { + forwardDashboardEventToLiveWs, + __resetLiveWsForwardingState, +} from "../../open-sse/handlers/chatCore/telemetryHelpers.ts"; + +// #4604 — In single-port Docker deployments the live-WS sidecar (port 20129) is +// not running, but forwardDashboardEventToLiveWs POSTed to it on every compression +// event. Because the global fetch is proxyFetch, each ECONNREFUSED logged a +// "[ProxyFetch] Undici dispatcher failed" warning — 272 times in 42 minutes. The +// forwarder now backs off after consecutive failures (lazy recovery), so a missing +// sidecar stops spamming instead of firing on every request. + +beforeEach(() => __resetLiveWsForwardingState()); + +function makeClock(start = 1000) { + let t = start; + const now = () => t; + return { now, advance: (ms: number) => (t += ms) }; +} + +test("forwards the event when the sidecar is reachable", async () => { + let calls = 0; + const ok = async () => { + calls++; + return new Response("ok"); + }; + await forwardDashboardEventToLiveWs("compression.step", { a: 1 }, ok, makeClock().now); + assert.equal(calls, 1); +}); + +test("backs off after consecutive failures and stops calling fetch", async () => { + let calls = 0; + const fail = async () => { + calls++; + throw new Error("connect ECONNREFUSED 127.0.0.1:20129"); + }; + const clock = makeClock(); + // First N attempts go through (and fail); after the threshold the forwarder + // short-circuits without touching fetch. + for (let i = 0; i < 10; i++) { + await forwardDashboardEventToLiveWs("compression.step", {}, fail, clock.now); + } + assert.ok(calls >= 1, "should attempt at least once"); + assert.ok(calls <= 3, `should stop after the failure threshold (got ${calls})`); +}); + +test("retries once after the cooldown window elapses", async () => { + let calls = 0; + const fail = async () => { + calls++; + throw new Error("ECONNREFUSED"); + }; + const clock = makeClock(); + for (let i = 0; i < 5; i++) { + await forwardDashboardEventToLiveWs("e", {}, fail, clock.now); + } + const afterTrip = calls; + clock.advance(120_000); // past the cooldown + await forwardDashboardEventToLiveWs("e", {}, fail, clock.now); + assert.equal(calls, afterTrip + 1, "should attempt again once cooldown expires"); +}); + +test("a success resets the failure counter", async () => { + let calls = 0; + let mode: "fail" | "ok" = "fail"; + const impl = async () => { + calls++; + if (mode === "fail") throw new Error("ECONNREFUSED"); + return new Response("ok"); + }; + const clock = makeClock(); + await forwardDashboardEventToLiveWs("e", {}, impl, clock.now); // fail (1) + mode = "ok"; + await forwardDashboardEventToLiveWs("e", {}, impl, clock.now); // success → reset + mode = "fail"; + const before = calls; + // After a reset, the next failures should once again be attempted (not pre-tripped). + await forwardDashboardEventToLiveWs("e", {}, impl, clock.now); + assert.equal(calls, before + 1); +}); diff --git a/tests/unit/m365-bizchat-frames-4042.test.ts b/tests/unit/m365-bizchat-frames-4042.test.ts new file mode 100644 index 0000000000..b67323148f --- /dev/null +++ b/tests/unit/m365-bizchat-frames-4042.test.ts @@ -0,0 +1,215 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #4042 — Microsoft 365 Copilot (individual / Substrate BizChat) frame mapping. +// Fixtures below are taken from @skyzea1's real, sanitized capture of an +// `m365.cloud.microsoft/chat` round-trip (test prompt → one-word "pong" reply). +// These pin the wire format so the executor's encode/decode cannot drift; the +// live socket round-trip is the separate Rule #18 validation gate. + +import { + RECORD_SEPARATOR, + encodeFrame, + handshakeFrame, + keepaliveFrame, + splitFrames, + parseFrame, + handshakeError, + buildChatInvocation, + isUpdateFrame, + isCompletionFrame, + isLastUpdate, + extractBotText, + incrementalDelta, +} from "../../open-sse/executors/copilot-m365-frames.ts"; + +// ── Real captured frames (#4042) ────────────────────────────────────────── + +const HANDSHAKE_ACK = {}; // SignalR success ack +const PROGRESS_UPDATE = { + type: 1, + target: "update", + arguments: [{ messages: [{ messageType: "Progress", author: "bot" }] }], +}; +const BOT_UPDATE = { + type: 1, + target: "update", + arguments: [ + { + messages: [ + { + text: "pong", + author: "bot", + responseIdentifier: "Default", + messageId: "00000000-0000-0000-0000-000000000001", + requestId: "trace-id", + adaptiveCards: [ + { type: "AdaptiveCard", version: "1.0", body: [{ type: "TextBlock", text: "pong" }] }, + ], + sourceAttributions: [], + contentOrigin: "DeepLeo", + }, + ], + nonce: "nonce", + requestId: "trace-id", + }, + ], +}; +const FINAL_UPDATE = { + type: 1, + target: "update", + arguments: [ + { + messages: [ + { text: "pong", author: "bot", sourceAttributions: [], references: {}, contentOrigin: "DeepLeo" }, + ], + isLastUpdate: true, + requestId: "trace-id", + }, + ], +}; +const FINAL_ITEM = { type: 2, invocationId: "0", item: { messages: [], requestId: "trace-id" } }; +const COMPLETION = { type: 3, invocationId: "0" }; + +// ── Framing ──────────────────────────────────────────────────────────────── + +test("RECORD_SEPARATOR is the SignalR 0x1e control char", () => { + assert.equal(RECORD_SEPARATOR, String.fromCharCode(0x1e)); + assert.equal(RECORD_SEPARATOR.charCodeAt(0), 0x1e); +}); + +test("handshakeFrame / keepaliveFrame emit the exact SignalR bytes", () => { + assert.equal(handshakeFrame(), `{"protocol":"json","version":1}` + RECORD_SEPARATOR); + assert.equal(keepaliveFrame(), `{"type":6}` + RECORD_SEPARATOR); +}); + +test("encodeFrame appends the record separator", () => { + assert.equal(encodeFrame({ a: 1 }), `{"a":1}` + RECORD_SEPARATOR); +}); + +test("splitFrames separates complete frames and keeps the trailing partial", () => { + const buffer = encodeFrame(HANDSHAKE_ACK) + encodeFrame(BOT_UPDATE) + `{"type":1,"par`; + const { frames, rest } = splitFrames(buffer); + assert.equal(frames.length, 2); + assert.deepEqual(parseFrame(frames[0]), {}); + assert.equal(isUpdateFrame(parseFrame(frames[1])), true); + assert.equal(rest, `{"type":1,"par`); +}); + +test("splitFrames returns empty rest when the buffer ends on a separator", () => { + const { frames, rest } = splitFrames(encodeFrame(COMPLETION)); + assert.equal(frames.length, 1); + assert.equal(rest, ""); +}); + +// ── Handshake ──────────────────────────────────────────────────────────── + +test("handshakeError is null on the {} ack and surfaces an error string", () => { + assert.equal(handshakeError(parseFrame(encodeFrame(HANDSHAKE_ACK).slice(0, -1))), null); + assert.equal(handshakeError({ error: "bad handshake" }), "bad handshake"); +}); + +// ── Send (type:4) ────────────────────────────────────────────────────────── + +test("buildChatInvocation produces a type:4 chat invocation carrying the user text", () => { + const frame = buildChatInvocation({ + text: "protocol capture test. Reply with one word: pong.", + traceId: "trace-id", + sessionId: "session-id", + isStartOfSession: true, + }); + assert.equal(frame.type, 4); + assert.equal(frame.target, "chat"); + assert.equal(frame.invocationId, "0"); + const arg = (frame.arguments as Array>)[0]; + assert.equal(arg.source, "officeweb"); + assert.equal(arg.streamingMode, "ConciseWithPadding"); + assert.equal(arg.traceId, "trace-id"); + assert.equal(arg.clientCorrelationId, "trace-id"); + assert.equal(arg.sessionId, "session-id"); + assert.equal(arg.isStartOfSession, true); + assert.ok(Array.isArray(arg.allowedMessageTypes)); + assert.ok((arg.allowedMessageTypes as string[]).includes("Chat")); + const message = arg.message as Record; + assert.equal(message.author, "user"); + assert.equal(message.inputMethod, "Keyboard"); + assert.equal(message.messageType, "Chat"); + assert.equal(message.text, "protocol capture test. Reply with one word: pong."); +}); + +test("buildChatInvocation serializes/round-trips through the framing", () => { + const frame = buildChatInvocation({ text: "hi", traceId: "t", sessionId: "s" }); + const wire = encodeFrame(frame); + assert.ok(wire.endsWith(RECORD_SEPARATOR)); + const { frames } = splitFrames(wire); + assert.deepEqual(parseFrame(frames[0]), frame); +}); + +// ── Response decode (type:1/2/3) ───────────────────────────────────────── + +test("isUpdateFrame / isCompletionFrame classify the captured frames", () => { + assert.equal(isUpdateFrame(BOT_UPDATE), true); + assert.equal(isUpdateFrame(FINAL_UPDATE), true); + assert.equal(isUpdateFrame(COMPLETION), false); + assert.equal(isUpdateFrame(FINAL_ITEM), false); + assert.equal(isCompletionFrame(COMPLETION), true); + assert.equal(isCompletionFrame(BOT_UPDATE), false); + assert.equal(isCompletionFrame(FINAL_ITEM), false); // type:2 is the final item, not completion +}); + +test("isLastUpdate only fires on the isLastUpdate:true update", () => { + assert.equal(isLastUpdate(BOT_UPDATE), false); + assert.equal(isLastUpdate(FINAL_UPDATE), true); + assert.equal(isLastUpdate(COMPLETION), false); +}); + +test("extractBotText reads the bot answer and ignores Progress frames", () => { + assert.equal(extractBotText(BOT_UPDATE), "pong"); + assert.equal(extractBotText(FINAL_UPDATE), "pong"); + assert.equal(extractBotText(PROGRESS_UPDATE), null); + assert.equal(extractBotText(COMPLETION), null); +}); + +// ── Accumulated → incremental delta ───────────────────────────────────── + +test("incrementalDelta emits only the new suffix of accumulated text", () => { + assert.equal(incrementalDelta("", "pong"), "pong"); + assert.equal(incrementalDelta("pong", "pong"), ""); + assert.equal(incrementalDelta("po", "pong"), "ng"); + assert.equal(incrementalDelta("", ""), ""); +}); + +test("incrementalDelta falls back to the full text on a non-extending replace", () => { + assert.equal(incrementalDelta("abc", "xyz"), "xyz"); +}); + +// ── End-to-end decode of the captured stream ───────────────────────────── + +test("decoding the captured frame sequence reconstructs the bot answer once", () => { + const wire = + encodeFrame(HANDSHAKE_ACK) + + encodeFrame(PROGRESS_UPDATE) + + encodeFrame(BOT_UPDATE) + + encodeFrame(FINAL_UPDATE) + + encodeFrame(FINAL_ITEM) + + encodeFrame(COMPLETION); + + const { frames } = splitFrames(wire); + let emitted = ""; + let prev = ""; + let completed = false; + for (const raw of frames) { + const frame = parseFrame(raw); + if (isUpdateFrame(frame)) { + const text = extractBotText(frame); + if (text != null) { + emitted += incrementalDelta(prev, text); + prev = text; + } + } else if (isCompletionFrame(frame)) { + completed = true; + } + } + assert.equal(emitted, "pong"); + assert.equal(completed, true); +}); diff --git a/tests/unit/m365-connection-4042.test.ts b/tests/unit/m365-connection-4042.test.ts new file mode 100644 index 0000000000..af4890de62 --- /dev/null +++ b/tests/unit/m365-connection-4042.test.ts @@ -0,0 +1,109 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #4042 — M365 Copilot (individual) connection helpers: credential resolution, +// WS URL building, token redaction, and prompt flattening. Pure functions, no +// live socket — the round-trip is the separate Rule #18 validation gate. + +import { + M365_INDIVIDUAL_DEFAULTS, + newChatSessionId, + resolveConnectionParams, + buildWsUrl, + redactWsUrl, + buildPrompt, +} from "../../open-sse/executors/copilot-m365-connection.ts"; + +// ── Credential resolution ──────────────────────────────────────────────── + +test("resolveConnectionParams errors when the access_token is missing", () => { + const r = resolveConnectionParams(undefined); + assert.ok("error" in r); + assert.match((r as { error: string }).error, /access_token/i); +}); + +test("resolveConnectionParams errors when the Chathub path is missing", () => { + const r = resolveConnectionParams({ apiKey: "tok" }); + assert.ok("error" in r); + assert.match((r as { error: string }).error, /Chathub path/i); +}); + +test("resolveConnectionParams reads token from apiKey and path from providerSpecificData", () => { + const r = resolveConnectionParams({ + apiKey: "opaque-jwe-token", + providerSpecificData: { chathubPath: "user-oid@tenant-id" }, + }); + assert.ok(!("error" in r)); + const p = r as { host: string; chathubPath: string; accessToken: string }; + assert.equal(p.accessToken, "opaque-jwe-token"); + assert.equal(p.chathubPath, "user-oid@tenant-id"); + assert.equal(p.host, M365_INDIVIDUAL_DEFAULTS.host); +}); + +test("resolveConnectionParams accepts access_token in providerSpecificData and a custom host", () => { + const r = resolveConnectionParams({ + providerSpecificData: { + access_token: "tok2", + userTenant: "u@t", + host: "substrate.svc.cloud.microsoft", + }, + }); + assert.ok(!("error" in r)); + const p = r as { host: string; chathubPath: string; accessToken: string }; + assert.equal(p.accessToken, "tok2"); + assert.equal(p.chathubPath, "u@t"); + assert.equal(p.host, "substrate.svc.cloud.microsoft"); +}); + +// ── WS URL building ────────────────────────────────────────────────────── + +test("buildWsUrl targets the substrate Chathub with the individual-tier query", () => { + const url = buildWsUrl({ host: "substrate.office.com", chathubPath: "u@t", accessToken: "TOK" }); + assert.ok(url.startsWith("wss://substrate.office.com/m365Copilot/Chathub/u@t?")); + const qs = new URLSearchParams(url.split("?")[1]); + assert.equal(qs.get("licenseType"), "Starter"); + assert.equal(qs.get("agent"), "web"); + assert.equal(qs.get("scenario"), "OfficeWebPaidConsumerCopilot"); + assert.equal(qs.get("source"), "officeweb"); + assert.equal(qs.get("access_token"), "TOK"); + // chatsessionid == XRoutingParameterSessionKey == clientrequestid (same value) + const sid = qs.get("chatsessionid"); + assert.ok(sid && /^[0-9a-f]{32}$/.test(sid)); + assert.equal(qs.get("XRoutingParameterSessionKey"), sid); + assert.equal(qs.get("clientrequestid"), sid); +}); + +test("redactWsUrl strips the access_token so the URL is safe to log", () => { + const url = buildWsUrl({ host: "substrate.office.com", chathubPath: "u@t", accessToken: "SECRET" }); + const redacted = redactWsUrl(url); + assert.ok(!redacted.includes("SECRET"), "token must not survive redaction"); + assert.match(redacted, /access_token=REDACTED/); +}); + +test("newChatSessionId is 32 lowercase hex chars", () => { + const id = newChatSessionId(); + assert.match(id, /^[0-9a-f]{32}$/); + assert.notEqual(newChatSessionId(), id); +}); + +// ── Prompt flattening ──────────────────────────────────────────────────── + +test("buildPrompt returns the last user message", () => { + const prompt = buildPrompt({ + messages: [ + { role: "user", content: "first" }, + { role: "user", content: "second" }, + ], + }); + assert.equal(prompt, "second"); +}); + +test("buildPrompt prepends system instructions", () => { + const prompt = buildPrompt({ + messages: [ + { role: "system", content: "Be terse." }, + { role: "user", content: "hi" }, + ], + }); + assert.match(prompt, /\[System Instructions\]\nBe terse\.\n\nhi$/); +}); diff --git a/tests/unit/minimax-tts-1043.test.ts b/tests/unit/minimax-tts-1043.test.ts new file mode 100644 index 0000000000..08ec181aab --- /dev/null +++ b/tests/unit/minimax-tts-1043.test.ts @@ -0,0 +1,101 @@ +// Port of decolua/9router#1043 by toanalien +// MiniMax T2A v2 returns hex-encoded audio in a JSON envelope guarded by `base_resp`. +import test from "node:test"; +import assert from "node:assert/strict"; + +const { handleAudioSpeech } = await import("../../open-sse/handlers/audioSpeech.ts"); + +const TEXT = "hello minimax"; +const HEX_AUDIO = "deadbeefcafe1234"; // 8 bytes; base64 = "3q2+78r+EjQ=" + +test("handleAudioSpeech routes MiniMax format to T2A v2 with hex output", async () => { + const originalFetch = globalThis.fetch; + let captured: any; + + globalThis.fetch = async (url: any, options: any = {}) => { + captured = { + url: String(url), + headers: options.headers, + body: JSON.parse(String(options.body || "{}")), + }; + return new Response( + JSON.stringify({ + data: { audio: HEX_AUDIO }, + base_resp: { status_code: 0, status_msg: "success" }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const response = await handleAudioSpeech({ + body: { model: "minimax/speech-2.8-hd", input: TEXT, voice: "English_expressive_narrator" }, + credentials: { apiKey: "mm-key" }, + }); + + assert.equal(response.status, 200, "should 200 on success"); + assert.equal(captured.url, "https://api.minimax.io/v1/t2a_v2"); + assert.equal((captured.headers as any).Authorization, "Bearer mm-key"); + assert.equal(captured.body.model, "speech-2.8-hd"); + assert.equal(captured.body.text, TEXT); + assert.equal(captured.body.stream, false); + assert.equal(captured.body.output_format, "hex"); + assert.equal(captured.body.voice_setting.voice_id, "English_expressive_narrator"); + assert.ok(captured.body.audio_setting, "audio_setting present"); + + const ct = response.headers.get("content-type") || ""; + assert.ok(ct.startsWith("audio/"), `content-type should be audio/*, got ${ct}`); + + const buf = new Uint8Array(await response.arrayBuffer()); + assert.deepEqual( + Array.from(buf), + [0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0x12, 0x34], + "hex audio should be decoded to bytes" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleAudioSpeech surfaces MiniMax base_resp error", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ base_resp: { status_code: 2013, status_msg: "invalid voice" } }), + { status: 200, headers: { "content-type": "application/json" } } + ); + try { + const response = await handleAudioSpeech({ + body: { model: "minimax/speech-2.8-hd", input: TEXT }, + credentials: { apiKey: "mm-key" }, + }); + assert.notEqual(response.status, 200, "non-zero base_resp.status_code must not be 200"); + const payload = (await response.json()) as any; + assert.match(String(payload?.error?.message || ""), /invalid voice|MiniMax/i); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleAudioSpeech rejects invalid hex audio from MiniMax", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + data: { audio: "zzznot-hex" }, + base_resp: { status_code: 0, status_msg: "" }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + try { + const response = await handleAudioSpeech({ + body: { model: "minimax/speech-2.8-hd", input: TEXT }, + credentials: { apiKey: "mm-key" }, + }); + assert.notEqual(response.status, 200); + const payload = (await response.json()) as any; + assert.match(String(payload?.error?.message || ""), /invalid audio|MiniMax/i); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/model-capabilities-registry.test.ts b/tests/unit/model-capabilities-registry.test.ts index b927ca2704..53f0364c09 100644 --- a/tests/unit/model-capabilities-registry.test.ts +++ b/tests/unit/model-capabilities-registry.test.ts @@ -137,6 +137,27 @@ test("canonical model capability resolver lets exact synced metadata override gl assert.equal(bareGpt55.contextWindow, 1050000); }); +test("unknown models keep maxOutputTokens null instead of using a generic default", () => { + const unknown = modelCapabilities.getResolvedModelCapabilities( + "openai-compatible-local/custom-large-output-model" + ); + + assert.equal(unknown.contextWindow, null); + assert.equal(unknown.maxInputTokens, null); + assert.equal(unknown.maxOutputTokens, null); + assert.equal( + modelCapabilities.capMaxOutputTokens( + "openai-compatible-local/custom-large-output-model", + 32000 + ), + 32000 + ); + assert.equal( + modelCapabilities.capMaxOutputTokens("openai-compatible-local/custom-large-output-model"), + null + ); +}); + test("GPT OSS and DeepSeek Reasoner models support tool calling", () => { // GPT OSS models should not be blocked by the heuristic assert.equal(modelCapabilities.supportsToolCalling("fake-provider/gpt-oss-120b"), true); diff --git a/tests/unit/no-thinking-alias.test.ts b/tests/unit/no-thinking-alias.test.ts index e385698bc5..d70d517f25 100644 --- a/tests/unit/no-thinking-alias.test.ts +++ b/tests/unit/no-thinking-alias.test.ts @@ -14,11 +14,11 @@ import { // ── prefix predicates ──────────────────────────────────────────────────────── test("NO_THINKING_PREFIX is the documented gateway prefix", () => { - assert.equal(NO_THINKING_PREFIX, "claude-3-omniroute-no-thinking/"); + assert.equal(NO_THINKING_PREFIX, "no-think/"); }); test("isNoThinkingAlias detects the prefix only", () => { - assert.equal(isNoThinkingAlias("claude-3-omniroute-no-thinking/anthropic/claude-opus-4-5"), true); + assert.equal(isNoThinkingAlias("no-think/anthropic/claude-opus-4-5"), true); assert.equal(isNoThinkingAlias("anthropic/claude-opus-4-5"), false); assert.equal(isNoThinkingAlias("claude-opus-4-5"), false); // non-strings never match @@ -28,7 +28,7 @@ test("isNoThinkingAlias detects the prefix only", () => { test("stripNoThinkingAlias unwraps the prefix and passes plain ids through", () => { assert.equal( - stripNoThinkingAlias("claude-3-omniroute-no-thinking/anthropic/claude-opus-4-5"), + stripNoThinkingAlias("no-think/anthropic/claude-opus-4-5"), "anthropic/claude-opus-4-5" ); assert.equal(stripNoThinkingAlias("claude-opus-4-5"), "claude-opus-4-5"); @@ -37,7 +37,7 @@ test("stripNoThinkingAlias unwraps the prefix and passes plain ids through", () test("toNoThinkingAlias round-trips with stripNoThinkingAlias", () => { const real = "anthropic/claude-sonnet-4-6"; const alias = toNoThinkingAlias(real); - assert.equal(alias, "claude-3-omniroute-no-thinking/anthropic/claude-sonnet-4-6"); + assert.equal(alias, "no-think/anthropic/claude-sonnet-4-6"); assert.equal(isNoThinkingAlias(alias), true); assert.equal(stripNoThinkingAlias(alias), real); }); @@ -46,7 +46,7 @@ test("toNoThinkingAlias round-trips with stripNoThinkingAlias", () => { test("applyNoThinkingAlias rewrites the model and disables thinking (Claude format)", () => { const body: Record = { - model: "claude-3-omniroute-no-thinking/anthropic/claude-opus-4-5", + model: "no-think/anthropic/claude-opus-4-5", thinking: { type: "enabled", budget_tokens: 8000 }, reasoning_effort: "high", messages: [], @@ -61,7 +61,7 @@ test("applyNoThinkingAlias rewrites the model and disables thinking (Claude form test("applyNoThinkingAlias strips reasoning fields without a thinking block (OpenAI format)", () => { const body: Record = { - model: "claude-3-omniroute-no-thinking/openai/gpt-5.4", + model: "no-think/openai/gpt-5.4", reasoning_effort: "high", reasoning: { effort: "high" }, messages: [], @@ -90,12 +90,12 @@ test("applyNoThinkingAlias is a no-op for plain models", () => { }); test("applyNoThinkingAlias ignores a malformed prefix-only model", () => { - const body: Record = { model: "claude-3-omniroute-no-thinking/" }; + const body: Record = { model: "no-think/" }; const res = applyNoThinkingAlias(body, { claudeFormat: true }); assert.equal(res.applied, false); assert.equal( body.model, - "claude-3-omniroute-no-thinking/", + "no-think/", "left untouched when nothing follows the prefix" ); }); @@ -118,7 +118,7 @@ test("shouldExposeNoThinkingAlias rejects models where suppression is meaningles assert.equal(shouldExposeNoThinkingAlias(entry("my-combo", "combo")), false); // never double-alias assert.equal( - shouldExposeNoThinkingAlias(entry("claude-3-omniroute-no-thinking/anthropic/claude-opus-4-5")), + shouldExposeNoThinkingAlias(entry("no-think/anthropic/claude-opus-4-5")), false ); }); @@ -128,15 +128,15 @@ test("appendNoThinkingVariants adds one variant per eligible model and preserves const out = appendNoThinkingVariants(models); const ids = out.map((m) => m.id); assert.ok( - ids.includes("claude-3-omniroute-no-thinking/claude-opus-4-5"), + ids.includes("no-think/claude-opus-4-5"), "eligible model gets a variant" ); assert.ok( - !ids.includes("claude-3-omniroute-no-thinking/gpt-4o"), + !ids.includes("no-think/gpt-4o"), "non-thinking model has no variant" ); assert.ok( - !ids.includes("claude-3-omniroute-no-thinking/claude-fable-5"), + !ids.includes("no-think/claude-fable-5"), "reject-disabled model has no variant" ); assert.equal(out.length, models.length + 1, "exactly one variant appended"); @@ -155,11 +155,11 @@ test("appendNoThinkingVariants normalizes alias prefix to canonical when aliasTo const out = appendNoThinkingVariants(models, aliasToCanonical); const ids = out.map((m) => m.id); assert.ok( - ids.includes("claude-3-omniroute-no-thinking/claude/claude-opus-4-5"), + ids.includes("no-think/claude/claude-opus-4-5"), "uses canonical prefix" ); assert.ok( - !ids.includes("claude-3-omniroute-no-thinking/cc/claude-opus-4-5"), + !ids.includes("no-think/cc/claude-opus-4-5"), "alias prefix not used" ); }); @@ -169,7 +169,7 @@ test("appendNoThinkingVariants keeps alias prefix when no map is provided", () = const out = appendNoThinkingVariants(models); const ids = out.map((m) => m.id); assert.ok( - ids.includes("claude-3-omniroute-no-thinking/cc/claude-opus-4-5"), + ids.includes("no-think/cc/claude-opus-4-5"), "alias prefix preserved" ); }); diff --git a/tests/unit/noauth-provider-validation.test.ts b/tests/unit/noauth-provider-validation.test.ts new file mode 100644 index 0000000000..fe28289d30 --- /dev/null +++ b/tests/unit/noauth-provider-validation.test.ts @@ -0,0 +1,31 @@ +/** + * Tests for noAuth provider validation: + * - Bug 1: `theoldllm` and `chipotle` missing from providerAllowsOptionalApiKey + * - Bug 2: `kimi` API key provider incorrectly routed through KimiWebExecutor + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { providerAllowsOptionalApiKey } from "../../src/shared/constants/providers.ts"; +import { hasSpecializedExecutor } from "../../open-sse/executors/index.ts"; + +// Bug 1: all noAuth providers should allow optional API key +for (const provider of ["theoldllm", "chipotle", "mimocode", "opencode", "duckduckgo-web", "veoaifree-web"]) { + test(`${provider} allows optional API key (noAuth provider)`, () => { + assert.equal(providerAllowsOptionalApiKey(provider), true); + }); +} + +// Bug 2: kimi API key provider should NOT have a specialized web executor +test("kimi API key provider falls through to DefaultExecutor", () => { + assert.equal(hasSpecializedExecutor("kimi"), false); +}); + +// no regression: kimi-web and kimi-coding still have their executors +test("kimi-web still has specialized executor", () => { + assert.equal(hasSpecializedExecutor("kimi-web"), true); +}); + +test("kimi-coding-apikey still has specialized executor", () => { + assert.equal(hasSpecializedExecutor("kimi-coding-apikey"), true); +}); diff --git a/tests/unit/oauth-providers-config.test.ts b/tests/unit/oauth-providers-config.test.ts index 20afd19c86..04f4ff2520 100644 --- a/tests/unit/oauth-providers-config.test.ts +++ b/tests/unit/oauth-providers-config.test.ts @@ -293,6 +293,18 @@ test("all provider endpoint URLs use HTTPS when a URL is configured", () => { } }); +test("Qwen OAuth uses qwen.ai (not chat.qwen.ai) for device/token URLs — upstream PR #683 / decolua issue #572", () => { + // The legacy host `chat.qwen.ai` started returning errors; the correct authoritative + // host for Qwen's device-code OAuth endpoints is `qwen.ai`. Regression guard for the + // port of decolua/9router#683 (closes decolua issue #572). + const deviceUrl = new URL(QWEN_CONFIG.deviceCodeUrl); + const tokenUrl = new URL(QWEN_CONFIG.tokenUrl); + assert.equal(deviceUrl.hostname, "qwen.ai", "deviceCodeUrl must use qwen.ai"); + assert.equal(tokenUrl.hostname, "qwen.ai", "tokenUrl must use qwen.ai"); + assert.equal(deviceUrl.pathname, "/api/v1/oauth2/device/code"); + assert.equal(tokenUrl.pathname, "/api/v1/oauth2/token"); +}); + test("browser-based providers expose buildAuthUrl and return provider-specific auth URLs", () => { const redirectUri = "http://localhost:43121/callback"; const state = "state-123"; diff --git a/tests/unit/ollama-cloud-usage.test.ts b/tests/unit/ollama-cloud-usage.test.ts new file mode 100644 index 0000000000..9c54789911 --- /dev/null +++ b/tests/unit/ollama-cloud-usage.test.ts @@ -0,0 +1,169 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const usage = await import("../../open-sse/services/usage.ts"); +const { USAGE_SUPPORTED_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); + +test("USAGE_SUPPORTED_PROVIDERS includes ollama-cloud", () => { + assert.ok( + (USAGE_SUPPORTED_PROVIDERS as string[]).includes("ollama-cloud"), + "ollama-cloud must be in the usage-supported providers allowlist" + ); +}); + +test("getUsageForProvider returns helpful message when Ollama Cloud has no usage cookie", async () => { + const originalCookie = process.env.OLLAMA_USAGE_COOKIE; + const originalOmniCookie = process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE; + delete process.env.OLLAMA_USAGE_COOKIE; + delete process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE; + + let called = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + called = true; + return new Response("unexpected", { status: 500 }); + }; + + try { + const result = (await usage.getUsageForProvider({ + id: "ollama-cloud-no-cookie", + provider: "ollama-cloud", + apiKey: "ollama-chat-key", + })) as { message?: string }; + + assert.equal(called, false, "settings scrape must not run without a cookie"); + assert.match(result.message ?? "", /Ollama Cloud/); + assert.match(result.message ?? "", /OLLAMA_USAGE_COOKIE/); + } finally { + globalThis.fetch = originalFetch; + if (originalCookie === undefined) delete process.env.OLLAMA_USAGE_COOKIE; + else process.env.OLLAMA_USAGE_COOKIE = originalCookie; + if (originalOmniCookie === undefined) delete process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE; + else process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE = originalOmniCookie; + } +}); + +test("getUsageForProvider scrapes Ollama Cloud settings quota", async () => { + const originalFetch = globalThis.fetch; + const originalCookie = process.env.OLLAMA_USAGE_COOKIE; + const originalOmniCookie = process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE; + delete process.env.OLLAMA_USAGE_COOKIE; + process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE = "__Secure-session=test-cookie"; + + let requestUrl = ""; + let requestHeaders: Headers | null = null; + let redirectMode: RequestRedirect | undefined; + + globalThis.fetch = async (input, init) => { + requestUrl = String(input); + requestHeaders = new Headers(init?.headers as HeadersInit | undefined); + redirectMode = init?.redirect; + return new Response( + [ + 'pro', + '
', + '', + '
', + '', + ].join(""), + { status: 200, headers: { "content-type": "text/html" } } + ); + }; + + try { + const result = (await usage.getUsageForProvider({ + id: "ollama-cloud-settings", + provider: "ollama-cloud", + apiKey: "ollama-chat-key", + })) as { + plan?: string | null; + quotas?: Record; + }; + + assert.equal(requestUrl, "https://ollama.com/settings"); + assert.equal(requestHeaders?.get("Cookie"), "__Secure-session=test-cookie"); + assert.equal(redirectMode, "manual"); + assert.equal(result.plan, "Ollama Cloud pro"); + assert.deepEqual(Object.keys(result.quotas ?? {}), ["session", "weekly"]); + assert.equal(result.quotas!.session.used, 34); + assert.equal(result.quotas!.session.remainingPercentage, 66); + assert.equal(result.quotas!.weekly.used, 67); + assert.equal(result.quotas!.weekly.remainingPercentage, 33); + } finally { + globalThis.fetch = originalFetch; + if (originalCookie === undefined) delete process.env.OLLAMA_USAGE_COOKIE; + else process.env.OLLAMA_USAGE_COOKIE = originalCookie; + if (originalOmniCookie === undefined) delete process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE; + else process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE = originalOmniCookie; + } +}); + +test("getUsageForProvider keeps Ollama Cloud reset times aligned to usage tracks", async () => { + const originalFetch = globalThis.fetch; + const originalCookie = process.env.OLLAMA_USAGE_COOKIE; + const originalOmniCookie = process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE; + delete process.env.OLLAMA_USAGE_COOKIE; + process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE = "test-cookie"; + + globalThis.fetch = async () => + new Response( + [ + '', + '
', + '', + "
", + '
', + '', + '', + "
", + ].join(""), + { status: 200, headers: { "content-type": "text/html" } } + ); + + try { + const result = (await usage.getUsageForProvider({ + id: "ollama-cloud-aligned-times", + provider: "ollama-cloud", + apiKey: "ollama-chat-key", + })) as { + quotas?: Record; + }; + + assert.equal(result.quotas!.session.used, 34); + assert.equal(result.quotas!.session.resetAt, "2026-06-22T15:00:00.000Z"); + assert.equal(result.quotas!.weekly.used, 67); + assert.equal(result.quotas!.weekly.resetAt, "2026-06-29T15:00:00.000Z"); + } finally { + globalThis.fetch = originalFetch; + if (originalCookie === undefined) delete process.env.OLLAMA_USAGE_COOKIE; + else process.env.OLLAMA_USAGE_COOKIE = originalCookie; + if (originalOmniCookie === undefined) delete process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE; + else process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE = originalOmniCookie; + } +}); + +test("getUsageForProvider reports expired Ollama Cloud cookies on redirect", async () => { + const originalFetch = globalThis.fetch; + const originalCookie = process.env.OLLAMA_USAGE_COOKIE; + process.env.OLLAMA_USAGE_COOKIE = "expired-cookie"; + + globalThis.fetch = async () => + new Response("", { + status: 302, + headers: { location: "/signin" }, + }); + + try { + const result = (await usage.getUsageForProvider({ + id: "ollama-cloud-redirect", + provider: "ollama-cloud", + apiKey: "ollama-chat-key", + })) as { message?: string }; + + assert.match(result.message ?? "", /authentication expired/i); + } finally { + globalThis.fetch = originalFetch; + if (originalCookie === undefined) delete process.env.OLLAMA_USAGE_COOKIE; + else process.env.OLLAMA_USAGE_COOKIE = originalCookie; + } +}); diff --git a/tests/unit/openapi-coverage.test.ts b/tests/unit/openapi-coverage.test.ts index d7cd781b44..be5c8425f4 100644 --- a/tests/unit/openapi-coverage.test.ts +++ b/tests/unit/openapi-coverage.test.ts @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; const ROOT = process.cwd(); const API_ROOT = path.join(ROOT, "src", "app", "api"); diff --git a/tests/unit/openapi-security-tiers.test.ts b/tests/unit/openapi-security-tiers.test.ts index 810955d82c..beab4d4815 100644 --- a/tests/unit/openapi-security-tiers.test.ts +++ b/tests/unit/openapi-security-tiers.test.ts @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; const ROOT = process.cwd(); const OPENAPI_PATH = path.join(ROOT, "docs", "reference", "openapi.yaml"); diff --git a/tests/unit/openapi-ws-endpoint.test.ts b/tests/unit/openapi-ws-endpoint.test.ts index a6d4efc055..46a917bade 100644 --- a/tests/unit/openapi-ws-endpoint.test.ts +++ b/tests/unit/openapi-ws-endpoint.test.ts @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import yaml from "js-yaml"; +import * as yaml from "js-yaml"; // Regression guard: the OpenAI-compatible chat WebSocket endpoint (/api/v1/ws) // must be documented in openapi.yaml so it shows up on the dashboard's diff --git a/tests/unit/opencode-executor.test.ts b/tests/unit/opencode-executor.test.ts index f57c9ef4b8..1cee807258 100644 --- a/tests/unit/opencode-executor.test.ts +++ b/tests/unit/opencode-executor.test.ts @@ -77,6 +77,23 @@ describe("OpencodeExecutor", () => { assert.equal(model.name, "DeepSeek V4 Flash Free"); assert.equal(model.supportsReasoning, true); }); + + it("exposes DeepSeek V4 Pro effort variants on opencode-go only", () => { + const goModels = PROVIDER_MODELS["opencode-go"] || []; + const zenModels = PROVIDER_MODELS["opencode-zen"] || []; + const variants = ["low", "medium", "high", "max"].map((level) => `deepseek-v4-pro-${level}`); + for (const variant of variants) { + const model = goModels.find((m) => m.id === variant); + assert.ok(model, `${variant} should be in opencode-go model list`); + assert.equal(model?.supportsReasoning, true); + assert.equal( + zenModels.some((m) => m.id === variant), + false, + `${variant} should not be exposed on opencode-zen` + ); + } + }); + it("routes opencode zen default models to chat completions", async () => { const minimaxResult = await zenExecutor.execute(createInput("minimax-m2.5-free")); assert.equal(minimaxResult.url, "https://opencode.ai/zen/v1/chat/completions"); @@ -526,6 +543,62 @@ describe("OpencodeExecutor", () => { ); }); }); + + describe("DeepSeek V4 Pro reasoning-effort variants", () => { + function baseBody(model) { + return { + model, + stream: false, + messages: [{ role: "user", content: "ok" }], + max_tokens: 16, + }; + } + + const levels = ["low", "medium", "high", "max"]; + for (const level of levels) { + it(`maps deepseek-v4-pro-${level} to base id + reasoning_effort=${level}`, () => { + const variant = `deepseek-v4-pro-${level}`; + const out = goExecutor.transformRequest(variant, baseBody(variant), false, { + apiKey: "test-key", + }); + assert.equal(out.model, "deepseek-v4-pro"); + assert.equal(out.reasoning_effort, level); + assert.ok(!String(out.model).endsWith(`-${level}`)); + }); + } + + it("preserves explicit reasoning_effort over the variant suffix", () => { + const body = baseBody("deepseek-v4-pro-high") as Record; + body.reasoning_effort = "max"; + const out = goExecutor.transformRequest("deepseek-v4-pro-high", body, false, { + apiKey: "test-key", + }); + assert.equal(out.reasoning_effort, "max"); + assert.equal(out.model, "deepseek-v4-pro"); + }); + + it("leaves the base id (no suffix) untouched", () => { + const out = goExecutor.transformRequest( + "deepseek-v4-pro", + baseBody("deepseek-v4-pro"), + false, + { apiKey: "test-key" } + ); + assert.equal(out.model, "deepseek-v4-pro"); + assert.equal(out.reasoning_effort, undefined); + }); + + it("does not rewrite unrelated models with matching suffixes", () => { + const out = goExecutor.transformRequest( + "some-other-model-high", + baseBody("some-other-model-high"), + false, + { apiKey: "test-key" } + ); + assert.equal(out.model, "some-other-model-high"); + assert.equal(out.reasoning_effort, undefined); + }); + }); }); describe("DefaultExecutor", () => { diff --git a/tests/unit/opencode-go-usage.test.ts b/tests/unit/opencode-go-usage.test.ts index efabd38099..64e6357800 100644 --- a/tests/unit/opencode-go-usage.test.ts +++ b/tests/unit/opencode-go-usage.test.ts @@ -28,6 +28,7 @@ test("getUsageForProvider returns helpful message when opencode-go has no apiKey assert.equal(called, false, "quota fetch must not run without an API key"); assert.match(result.message ?? "", /OpenCode Go/); + assert.match(result.message ?? "", /OPENCODE_GO_WORKSPACE_ID/); } finally { globalThis.fetch = originalFetch; } @@ -104,7 +105,7 @@ test("getUsageForProvider exposes OpenCode Go 5h, weekly, and monthly quotas", a }; assert.equal(requestUrl, "https://api.z.ai/api/monitor/usage/quota/limit"); - assert.equal(requestHeaders?.get("Authorization"), "opencode-go-key"); + assert.equal(requestHeaders?.get("Authorization"), "Bearer opencode-go-key"); assert.equal(requestHeaders?.get("Content-Type"), "application/json"); assert.equal(result.plan, "OpenCode Go Pro"); assert.deepEqual(Object.keys(result.quotas ?? {}), ["session", "weekly", "mcp_monthly"]); @@ -136,6 +137,155 @@ test("getUsageForProvider exposes OpenCode Go 5h, weekly, and monthly quotas", a } }); +test("getUsageForProvider ignores out-of-range OpenCode Go reset timestamps", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + code: 200, + success: true, + data: { + level: "pro", + limits: [ + { + type: "TOKENS_LIMIT", + unit: 3, + number: 5, + percentage: 25, + nextResetTime: Number.MAX_VALUE, + }, + ], + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + + try { + const result = (await usage.getUsageForProvider({ + id: "opencode-go-huge-reset", + provider: "opencode-go", + apiKey: "opencode-go-key", + })) as { quotas?: Record }; + + assert.equal(result.quotas!.session.resetAt, null); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("getUsageForProvider scrapes OpenCode Go dashboard quota when workspace cookie is configured", async () => { + const originalFetch = globalThis.fetch; + const originalWorkspace = process.env.OPENCODE_GO_WORKSPACE_ID; + const originalCookie = process.env.OPENCODE_GO_AUTH_COOKIE; + let requestUrl = ""; + let requestHeaders: Headers | null = null; + + process.env.OPENCODE_GO_WORKSPACE_ID = "workspace-123"; + process.env.OPENCODE_GO_AUTH_COOKIE = "auth-cookie-value"; + + globalThis.fetch = async (input, init) => { + requestUrl = String(input); + requestHeaders = new Headers(init?.headers as HeadersInit | undefined); + return new Response( + [ + '
', + 'Rolling Usage', + '25%', + 'Resets in 1 hour 30 minutes', + "
", + '
', + 'Weekly Usage', + '50%', + 'Resets in 2 days', + "
", + '
', + 'Monthly Usage', + '10%', + 'Resets in 10 days', + "
", + ].join(""), + { status: 200, headers: { "content-type": "text/html" } } + ); + }; + + try { + const result = (await usage.getUsageForProvider({ + id: "opencode-go-dashboard", + provider: "opencode-go", + apiKey: "opencode-go-key", + })) as { + plan?: string | null; + quotas?: Record; + }; + + assert.equal(requestUrl, "https://opencode.ai/workspace/workspace-123/go"); + assert.equal(requestHeaders?.get("Cookie"), "auth=auth-cookie-value"); + assert.equal(result.plan, "OpenCode Go"); + assert.deepEqual(Object.keys(result.quotas ?? {}), ["session", "weekly", "mcp_monthly"]); + assert.equal(result.quotas!.session.used, 3); + assert.equal(result.quotas!.session.total, 12); + assert.equal(result.quotas!.session.remainingPercentage, 75); + assert.equal(result.quotas!.weekly.used, 15); + assert.equal(result.quotas!.weekly.remainingPercentage, 50); + assert.equal(result.quotas!.mcp_monthly.used, 6); + assert.equal(result.quotas!.mcp_monthly.remainingPercentage, 90); + } finally { + globalThis.fetch = originalFetch; + if (originalWorkspace === undefined) delete process.env.OPENCODE_GO_WORKSPACE_ID; + else process.env.OPENCODE_GO_WORKSPACE_ID = originalWorkspace; + if (originalCookie === undefined) delete process.env.OPENCODE_GO_AUTH_COOKIE; + else process.env.OPENCODE_GO_AUTH_COOKIE = originalCookie; + } +}); + +// Regression: React SSR wraps the reset-time text in hydration comment markers +// (). The reset string must be fully sanitized (complete +// removal, not just the two literal markers) so the reset time still parses — and so no +// partial "Resets in 1 hour 30 minutes', + "", + ].join(""), + { status: 200, headers: { "content-type": "text/html" } } + ); + + try { + const result = (await usage.getUsageForProvider({ + id: "opencode-go-dashboard", + provider: "opencode-go", + apiKey: "opencode-go-key", + })) as { + quotas?: Record; + }; + + // session quota resolved → the comment-wrapped reset time was sanitized and parsed + assert.ok( + result.quotas?.session, + "session quota should resolve from comment-wrapped reset-time" + ); + assert.equal(result.quotas!.session.remainingPercentage, 75); + } finally { + globalThis.fetch = originalFetch; + if (originalWorkspace === undefined) delete process.env.OPENCODE_GO_WORKSPACE_ID; + else process.env.OPENCODE_GO_WORKSPACE_ID = originalWorkspace; + if (originalCookie === undefined) delete process.env.OPENCODE_GO_AUTH_COOKIE; + else process.env.OPENCODE_GO_AUTH_COOKIE = originalCookie; + } +}); + test("getUsageForProvider returns message for invalid OpenCode Go API keys", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = async () => new Response("nope", { status: 401 }); @@ -148,15 +298,34 @@ test("getUsageForProvider returns message for invalid OpenCode Go API keys", asy })) as { message: string }; assert.equal( result.message, - "OpenCode Go does not expose a public quota API. Chat requests still work. " + - "Set OMNIROUTE_OPENCODE_GO_QUOTA_URL to a working endpoint, or follow " + - "https://github.com/anomalyco/opencode/issues/16017 for upstream status." + "OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " + + "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping." ); } finally { globalThis.fetch = originalFetch; } }); +test("getUsageForProvider returns message when OpenCode Go quota fetch fails", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + throw new Error("network offline"); + }; + + try { + const result = (await usage.getUsageForProvider({ + id: "opencode-go-network-error", + provider: "opencode-go", + apiKey: "opencode-go-key", + })) as { message: string }; + + assert.match(result.message, /OpenCode Go quota API error:/); + assert.match(result.message, /network offline/); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("getUsageForProvider returns message when OpenCode Go quota API returns 200 with auth error in body", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = async () => @@ -173,9 +342,8 @@ test("getUsageForProvider returns message when OpenCode Go quota API returns 200 })) as { message: string }; assert.equal( result.message, - "OpenCode Go does not expose a public quota API. Chat requests still work. " + - "Set OMNIROUTE_OPENCODE_GO_QUOTA_URL to a working endpoint, or follow " + - "https://github.com/anomalyco/opencode/issues/16017 for upstream status." + "OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " + + "Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping." ); } finally { globalThis.fetch = originalFetch; diff --git a/tests/unit/provider-limits-ui.test.ts b/tests/unit/provider-limits-ui.test.ts index fb9741fe72..240dc6918c 100644 --- a/tests/unit/provider-limits-ui.test.ts +++ b/tests/unit/provider-limits-ui.test.ts @@ -205,6 +205,41 @@ test("GLM quota rows are ordered by session, weekly, then monthly", () => { ); }); +test("hidden provider models are filtered from per-model quota rows", () => { + const quotas = providerLimitUtils.parseQuotaData("antigravity", { + quotas: { + "gpt-oss-120b-medium": { used: 2, total: 100, remainingPercentage: 98 }, + "gemini-3.5-pro": { used: 10, total: 100, remainingPercentage: 90 }, + credits: { remaining: 42 }, + }, + }); + const hidden = providerLimitUtils.collectHiddenQuotaModelIds("antigravity", { + models: [{ id: "antigravity/gpt-oss-120b-medium", isHidden: true }], + modelCompatOverrides: [{ id: "gemini-3.5-flash", isDeleted: true }], + }); + const visible = providerLimitUtils.filterHiddenModelQuotas("antigravity", quotas, hidden); + + assert.deepEqual( + visible.map((quota) => quota.modelKey || quota.name), + ["gemini-3.5-pro", "credits"] + ); +}); + +test("hidden quota filtering keeps non-model provider quota rows", () => { + const quotas = [ + { name: "weekly", used: 2, total: 100 }, + { name: "credits", isCredits: true, remaining: 10 }, + ]; + const hidden = providerLimitUtils.collectHiddenQuotaModelIds("antigravity", { + modelCompatOverrides: [{ id: "weekly", isHidden: true }], + }); + + assert.deepEqual( + providerLimitUtils.filterHiddenModelQuotas("antigravity", quotas, hidden), + quotas + ); +}); + test("dashboard i18n keys used by OrFallback helpers exist in en.json", () => { const enPath = path.resolve("src/i18n/messages/en.json"); const messages = JSON.parse(readFileSync(enPath, "utf8")); diff --git a/tests/unit/provider-models-config.test.ts b/tests/unit/provider-models-config.test.ts index d95b50c1ac..be6acd59ab 100644 --- a/tests/unit/provider-models-config.test.ts +++ b/tests/unit/provider-models-config.test.ts @@ -78,8 +78,9 @@ test("Reka registry exposes preset models", () => { assert.equal(PROVIDER_ID_TO_ALIAS.reka, "reka"); assert.equal(getDefaultModel("reka"), "reka-flash-3"); - assert.deepEqual(ids, ["reka-flash-3", "reka-edge-2603"]); + assert.deepEqual(ids, ["reka-flash-3", "reka-flash", "reka-edge-2603"]); assert.equal(isValidModel("reka", "reka-edge-2603"), true); + assert.equal(isValidModel("reka", "reka-flash"), true); }); test("GitHub Copilot registry reflects the current supported model lineup", () => { diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index aaea614f61..a860d21284 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -1509,7 +1509,7 @@ test("provider models route always returns the Reka preset catalog", async () => assert.equal(body.source, "local_catalog"); assert.deepEqual( body.models.map((model) => model.id), - ["reka-flash-3", "reka-edge-2603"] + ["reka-flash-3", "reka-flash", "reka-edge-2603"] ); }); @@ -1532,7 +1532,7 @@ test("provider models route returns Reka local catalog without an API key", asyn assert.equal(body.source, "local_catalog"); assert.deepEqual( body.models.map((model) => model.id), - ["reka-flash-3", "reka-edge-2603"] + ["reka-flash-3", "reka-flash", "reka-edge-2603"] ); }); diff --git a/tests/unit/provider-nodes-validate-modelid.test.ts b/tests/unit/provider-nodes-validate-modelid.test.ts new file mode 100644 index 0000000000..3329d43fd3 --- /dev/null +++ b/tests/unit/provider-nodes-validate-modelid.test.ts @@ -0,0 +1,159 @@ +// Tests for optional modelId fallback on /api/provider-nodes/validate. +// Ports decolua/9router#315 (Doan Minh Tu). +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-validate-modelid-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providerNodesValidateRoute = await import( + "../../src/app/api/provider-nodes/validate/route.ts" +); + +const originalFetch = globalThis.fetch; + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.afterEach(async () => { + globalThis.fetch = originalFetch; + await resetStorage(); +}); + +test.after(() => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +type FetchCall = { url: string; init: any }; + +function installFetchSequence(responses: Array<() => Response>) { + const calls: FetchCall[] = []; + let i = 0; + globalThis.fetch = async (url: any, init: any = {}) => { + calls.push({ url: String(url), init }); + const factory = responses[i++] ?? responses[responses.length - 1]; + return factory(); + }; + return calls; +} + +function validate(body: Record) { + return providerNodesValidateRoute.POST( + new Request("http://localhost/api/provider-nodes/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + ); +} + +test("openai-compatible: modelId fallback to /chat/completions succeeds when /models 404", async () => { + const calls = installFetchSequence([ + () => new Response("not found", { status: 404 }), + () => new Response(JSON.stringify({ id: "ok" }), { status: 200 }), + ]); + + const res = await validate({ + baseUrl: "https://proxy.example.com/v1", + apiKey: "sk-test", + type: "openai-compatible", + modelId: "my-model", + }); + + assert.equal(res.status, 200); + const data = (await res.json()) as any; + assert.equal(data.valid, true); + assert.equal(data.method, "chat"); + assert.equal(calls.length, 2); + assert.equal(calls[0].url, "https://proxy.example.com/v1/models"); + assert.equal(calls[1].url, "https://proxy.example.com/v1/chat/completions"); + assert.equal(calls[1].init.method, "POST"); + const sent = JSON.parse(calls[1].init.body as string); + assert.equal(sent.model, "my-model"); + assert.equal(sent.max_tokens, 1); + assert.deepEqual(sent.messages, [{ role: "user", content: "ping" }]); +}); + +test("openai-compatible: 401 from /models skips chat fallback even when modelId set", async () => { + const calls = installFetchSequence([() => new Response("nope", { status: 401 })]); + + const res = await validate({ + baseUrl: "https://proxy.example.com/v1", + apiKey: "sk-test", + type: "openai-compatible", + modelId: "my-model", + }); + + assert.equal(res.status, 200); + const data = (await res.json()) as any; + assert.equal(data.valid, false); + assert.equal(calls.length, 1); + assert.match(String(data.error), /unauthorized/i); +}); + +test("openai-compatible: chat fallback failure returns descriptive error", async () => { + installFetchSequence([ + () => new Response("not found", { status: 404 }), + () => new Response("bad model", { status: 400 }), + ]); + + const res = await validate({ + baseUrl: "https://proxy.example.com/v1", + apiKey: "sk-test", + type: "openai-compatible", + modelId: "bad-model", + }); + + assert.equal(res.status, 200); + const data = (await res.json()) as any; + assert.equal(data.valid, false); + assert.equal(data.method, "chat"); + assert.match(String(data.error), /invalid model|bad request/i); +}); + +test("openai-compatible: without modelId, 404 from /models returns helpful hint", async () => { + installFetchSequence([() => new Response("nope", { status: 404 })]); + + const res = await validate({ + baseUrl: "https://proxy.example.com/v1", + apiKey: "sk-test", + type: "openai-compatible", + }); + + assert.equal(res.status, 200); + const data = (await res.json()) as any; + assert.equal(data.valid, false); + assert.match(String(data.error), /models.*endpoint.*not found|model id/i); +}); + +test("anthropic-compatible: modelId fallback to /chat/completions on 404", async () => { + const calls = installFetchSequence([ + () => new Response("not found", { status: 404 }), + () => new Response(JSON.stringify({ id: "ok" }), { status: 200 }), + ]); + + const res = await validate({ + baseUrl: "https://proxy.example.com/v1", + apiKey: "sk-anthropic", + type: "anthropic-compatible", + modelId: "claude-3-opus", + }); + + assert.equal(res.status, 200); + const data = (await res.json()) as any; + assert.equal(data.valid, true); + assert.equal(data.method, "chat"); + assert.equal(calls.length, 2); + assert.equal(calls[1].url, "https://proxy.example.com/v1/chat/completions"); + assert.equal(calls[1].init.headers["x-api-key"], "sk-anthropic"); + assert.equal(calls[1].init.headers["anthropic-version"], "2023-06-01"); +}); diff --git a/tests/unit/provider-specific-data-schema.test.ts b/tests/unit/provider-specific-data-schema.test.ts index 6df8c155d8..f71f581d45 100644 --- a/tests/unit/provider-specific-data-schema.test.ts +++ b/tests/unit/provider-specific-data-schema.test.ts @@ -175,3 +175,43 @@ test("provider schemas reject oversized OpenRouter preset values", () => { assert.equal(created.success, false); assert.equal(updated.success, false); }); + +test("provider schemas accept quota scraping provider-specific strings", () => { + const created = createProviderSchema.safeParse({ + provider: "opencode-go", + apiKey: "token", + name: "OpenCode Go", + providerSpecificData: { + opencodeGoWorkspaceId: "workspace-123", + opencodeGoAuthCookie: "auth=cookie-value", + }, + }); + const updated = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { + ollamaCloudUsageCookie: "__Secure-session=cookie-value", + }, + }); + + assert.equal(created.success, true); + assert.equal(updated.success, true); +}); + +test("provider schemas reject malformed quota scraping provider-specific values", () => { + const created = createProviderSchema.safeParse({ + provider: "opencode-go", + apiKey: "token", + name: "OpenCode Go", + providerSpecificData: { + opencodeGoWorkspaceId: 123, + opencodeGoAuthCookie: "x".repeat(10001), + }, + }); + const updated = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { + ollamaCloudUsageCookie: 123, + }, + }); + + assert.equal(created.success, false); + assert.equal(updated.success, false); +}); diff --git a/tests/unit/providers-page-utils.test.ts b/tests/unit/providers-page-utils.test.ts index 7c088ccccc..9cb86ac610 100644 --- a/tests/unit/providers-page-utils.test.ts +++ b/tests/unit/providers-page-utils.test.ts @@ -1001,3 +1001,51 @@ test("model search filter is case-insensitive and partial-match", () => { ); assert.equal(byPartial.length, 1, "partial model id 'minimax' should match 'minimax-m3'"); }); + +// #4613: buildCompatibleProviderGroups partitions provider nodes into the +// openai-compatible / anthropic-compatible / claude-code-compatible buckets the +// providers page renders. The memoization in page.tsx wraps this pure helper, so +// guarding the partition logic here is the regression that matters (Rule #18). +test("buildCompatibleProviderGroups partitions nodes by type + claude-code prefix", () => { + const labels = { + openaiCompatibleName: "OpenAI-compatible", + anthropicCompatibleName: "Anthropic-compatible", + claudeCodeCompatibleName: "Claude Code-compatible", + }; + + const groups = providerPageUtils.buildCompatibleProviderGroups( + [ + { id: "my-oai", name: "My OAI", type: "openai-compatible", apiType: "responses" }, + { id: "my-anthropic", name: "My Claude", type: "anthropic-compatible" }, + { id: "anthropic-compatible-cc-acme", name: "Acme CC", type: "anthropic-compatible" }, + { id: "ignored-node", name: "Ignored", type: "gemini-cli" }, + // name omitted → falls back to the provided label + { id: "anon-oai", type: "openai-compatible" }, + ], + labels + ); + + assert.deepEqual( + groups.openai.map((p) => p.id), + ["my-oai", "anon-oai"], + "openai-compatible nodes land in the openai bucket" + ); + assert.equal(groups.openai[0].apiType, "responses", "apiType is preserved"); + assert.equal( + groups.openai[1].name, + labels.openaiCompatibleName, + "missing name falls back to the openai-compatible label" + ); + + assert.deepEqual( + groups.anthropic.map((p) => p.id), + ["my-anthropic"], + "plain anthropic-compatible nodes land in the anthropic bucket" + ); + + assert.deepEqual( + groups.claudeCode.map((p) => p.id), + ["anthropic-compatible-cc-acme"], + "anthropic-compatible nodes with the cc- prefix land in the claudeCode bucket" + ); +}); diff --git a/tests/unit/request-defaults-store-session.test.ts b/tests/unit/request-defaults-store-session.test.ts index 19f9b1925d..23090810be 100644 --- a/tests/unit/request-defaults-store-session.test.ts +++ b/tests/unit/request-defaults-store-session.test.ts @@ -6,6 +6,7 @@ const { ensureOpenAIStoreSessionFallback, getClaudeCodeCompatibleRequestDefaults, normalizeProviderSpecificData, + sanitizeProviderSpecificDataForResponse, } = await import("../../src/lib/providers/requestDefaults.ts"); test("buildOpenAIStoreSessionId normalizes external and generated session ids", () => { @@ -108,3 +109,19 @@ test("normalizeProviderSpecificData trims OpenRouter preset and clears empty val assert.equal(ignored?.preset, undefined); assert.equal(ignored?.tag, "primary"); }); + +test("sanitizeProviderSpecificDataForResponse removes quota scraping cookies", () => { + const sanitized = sanitizeProviderSpecificDataForResponse({ + opencodeGoWorkspaceId: "workspace-123", + opencodeGoAuthCookie: "auth-cookie", + ollamaCloudUsageCookie: "ollama-cookie", + usageCookie: "fallback-cookie", + consoleApiKey: "console-key", + tag: "primary", + }); + + assert.deepEqual(sanitized, { + opencodeGoWorkspaceId: "workspace-123", + tag: "primary", + }); +}); diff --git a/tests/unit/resilience-provider-cooldown-api-3556.test.ts b/tests/unit/resilience-provider-cooldown-api-3556.test.ts index 7de0718772..88b08a07c3 100644 --- a/tests/unit/resilience-provider-cooldown-api-3556.test.ts +++ b/tests/unit/resilience-provider-cooldown-api-3556.test.ts @@ -41,6 +41,16 @@ describe("providerCooldown in updateResilienceSchema", () => { }); assert.equal(result.success, false, "Schema should reject unknown keys"); }); + + it("rejects providerCooldown max below min", () => { + const result = updateResilienceSchema.safeParse({ + providerCooldown: { + minRetryCooldownMs: 120000, + maxRetryCooldownMs: 30000, + }, + }); + assert.equal(result.success, false, "Schema should reject contradictory cooldown bounds"); + }); }); describe("providerCooldown roundtrip through mergeResilienceSettings", () => { diff --git a/tests/unit/resilience-stream-recovery-feature-flags.test.ts b/tests/unit/resilience-stream-recovery-feature-flags.test.ts new file mode 100644 index 0000000000..431c3951f3 --- /dev/null +++ b/tests/unit/resilience-stream-recovery-feature-flags.test.ts @@ -0,0 +1,42 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-stream-recovery-flags-")); +process.env.DATA_DIR = tmpDir; + +const core = await import("../../src/lib/db/core.ts"); +const { setFeatureFlagOverride, clearAllFeatureFlagOverrides } = + await import("../../src/lib/db/featureFlags.ts"); +const { resolveResilienceSettings } = await import("../../src/lib/resilience/settings.ts"); + +after(() => { + core.resetDbInstance(); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test("stream recovery feature flags seed resilience defaults", () => { + clearAllFeatureFlagOverrides(); + setFeatureFlagOverride("STREAM_RECOVERY_ENABLED", "true"); + setFeatureFlagOverride("STREAM_RECOVERY_MIDSTREAM_ENABLED", "true"); + + const resolved = resolveResilienceSettings({}); + + assert.equal(resolved.streamRecovery.enabled, true); + assert.equal(resolved.streamRecovery.continueMidStream, true); +}); + +test("stored stream recovery settings override feature flag defaults", () => { + clearAllFeatureFlagOverrides(); + setFeatureFlagOverride("STREAM_RECOVERY_ENABLED", "true"); + setFeatureFlagOverride("STREAM_RECOVERY_MIDSTREAM_ENABLED", "true"); + + const resolved = resolveResilienceSettings({ + resilienceSettings: { streamRecovery: { enabled: false, continueMidStream: false } }, + }); + + assert.equal(resolved.streamRecovery.enabled, false); + assert.equal(resolved.streamRecovery.continueMidStream, false); +}); diff --git a/tests/unit/services/combo-metrics-memory.test.ts b/tests/unit/services/combo-metrics-memory.test.ts index bc6b2390f9..c877065ee0 100644 --- a/tests/unit/services/combo-metrics-memory.test.ts +++ b/tests/unit/services/combo-metrics-memory.test.ts @@ -224,6 +224,30 @@ test("eviction: shadow metrics respect their own MAX_METRICS_ENTRIES cap", () => ); }); +test("eviction: shadow overflow does not delete production metrics with the same name", () => { + resetAllComboMetrics(); + const MAX = 500; + + recordComboRequest("shared-combo", "gpt-4", { success: true, latencyMs: 10 }); + + for (let i = 0; i < MAX; i++) { + recordComboShadowRequest(i === 0 ? "shared-combo" : "shadow-fill-" + i, "gpt-4", { + success: true, + latencyMs: 10, + }); + } + + recordComboShadowRequest("shadow-after-capacity", "gpt-4", { + success: true, + latencyMs: 50, + }); + + const metrics = getComboMetrics("shared-combo"); + assert.ok(metrics, "production metrics must survive shadow-only eviction"); + assert.equal(metrics.productionTraffic, true); + assert.equal(metrics.totalRequests, 1); +}); + test("eviction: recordComboIntent respects MAX_METRICS_ENTRIES cap", () => { resetAllComboMetrics(); const MAX = 500; diff --git a/tests/unit/services/providerCooldownTracker.test.ts b/tests/unit/services/providerCooldownTracker.test.ts index 335575ee86..8d3f88f35f 100644 --- a/tests/unit/services/providerCooldownTracker.test.ts +++ b/tests/unit/services/providerCooldownTracker.test.ts @@ -7,6 +7,7 @@ import { recordProviderSuccess, clearCooldownState, getCooldownEntryCount, + cleanupExpiredCooldownEntries, } from "../../../open-sse/services/providerCooldownTracker.ts"; import { resolveResilienceSettings, @@ -140,6 +141,33 @@ test("cooldown respects maxRetryCooldownMs cap", () => { assert.ok(remaining <= 20000, "cooldown capped at maxRetryCooldownMs"); }); +test("cleanup keeps entries for configured long maxRetryCooldownMs", () => { + const settings = makeSettings(5000, 60 * 60 * 1000); + const originalNow = Date.now; + + try { + let fakeNow = Date.now(); + Date.now = () => fakeNow; + + recordProviderCooldown("openai", "conn-1", settings); + assert.equal(getCooldownEntryCount(), 1); + + fakeNow += 31 * 60 * 1000; + cleanupExpiredCooldownEntries(); + assert.equal( + getCooldownEntryCount(), + 1, + "cleanup must not evict entries before configured maxRetryCooldownMs" + ); + + fakeNow += 30 * 60 * 1000; + cleanupExpiredCooldownEntries(); + assert.equal(getCooldownEntryCount(), 0); + } finally { + Date.now = originalNow; + } +}); + test("different connections have independent cooldowns", () => { const settings = makeSettings(); recordProviderCooldown("openai", "conn-1", settings); diff --git a/tests/unit/settings-api.test.ts b/tests/unit/settings-api.test.ts index 4c2c8ecc8f..8ff0dd80a1 100644 --- a/tests/unit/settings-api.test.ts +++ b/tests/unit/settings-api.test.ts @@ -209,6 +209,35 @@ describe("Settings API - persisted preferences", () => { assert.equal(settings.responsesPreviousResponseIdMode, "strip"); }); + test("GET /api/settings returns Cache-Control: no-store (ported from upstream #951)", async () => { + const response = await harness.settingsRoute.GET( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "GET", + }) + ); + assert.equal(response.status, 200); + assert.equal( + response.headers.get("Cache-Control"), + "no-store", + "GET /api/settings must return Cache-Control: no-store so persisted settings stay fresh after refresh/restart" + ); + }); + + test("PATCH /api/settings returns Cache-Control: no-store (ported from upstream #951)", async () => { + const response = await harness.settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { debugMode: true }, + }) + ); + assert.equal(response.status, 200); + assert.equal( + response.headers.get("Cache-Control"), + "no-store", + "PATCH /api/settings must return Cache-Control: no-store" + ); + }); + test("PUT /api/settings reuses the PATCH update flow", async () => { const response = await harness.settingsRoute.PUT( await makeManagementSessionRequest("http://localhost/api/settings", { diff --git a/tests/unit/stream-prompt-tokens-zero-upstream.test.ts b/tests/unit/stream-prompt-tokens-zero-upstream.test.ts new file mode 100644 index 0000000000..dc579dc2b0 --- /dev/null +++ b/tests/unit/stream-prompt-tokens-zero-upstream.test.ts @@ -0,0 +1,114 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-stream-zero-prompt-")); +process.env.DATA_DIR = TEST_DATA_DIR; +const core = await import("../../src/lib/db/core.ts"); + +const { createSSEStream } = await import("../../open-sse/utils/stream.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); + +const textEncoder = new TextEncoder(); + +async function readTransformed(chunks, options) { + const source = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(textEncoder.encode(chunk)); + } + controller.close(); + }, + }); + + return new Response(source.pipeThrough(createSSEStream(options))).text(); +} + +test("createSSEStream passthrough estimates input tokens when upstream reports prompt_tokens=0 (Ollama Cloud pattern)", async () => { + let onCompletePayload = null; + const body = { messages: [{ role: "user", content: "Write a 500-line research report" }] }; + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + id: "chatcmpl_ollama", + object: "chat.completion.chunk", + created: 1, + model: "minimax-m3", + system_fingerprint: "fp_ollama", + choices: [{ index: 0, delta: { content: "Here" } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_ollama", + object: "chat.completion.chunk", + created: 1, + model: "minimax-m3", + system_fingerprint: "fp_ollama", + choices: [{ index: 0, delta: { content: " is" } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_ollama", + object: "chat.completion.chunk", + created: 1, + model: "minimax-m3", + system_fingerprint: "fp_ollama", + choices: [{ index: 0, delta: { content: " the" } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_ollama", + object: "chat.completion.chunk", + created: 1, + model: "minimax-m3", + system_fingerprint: "fp_ollama", + choices: [{ index: 0, delta: { content: " response" } }], + })}\n\n`, + // Ollama Cloud returns usage with prompt_tokens: 0 — it doesn't count input tokens + `data: ${JSON.stringify({ + id: "chatcmpl_ollama", + object: "chat.completion.chunk", + created: 1, + model: "minimax-m3", + system_fingerprint: "fp_ollama", + choices: [], + usage: { prompt_tokens: 0, completion_tokens: 10, total_tokens: 0 }, + })}\n\n`, + `data: [DONE]\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.OPENAI, + provider: "ollamacloud", + model: "minimax-m3", + body, + onComplete(payload) { + onCompletePayload = payload; + }, + } + ); + + // The fix should estimate input tokens from the request body instead of showing 0 + assert.equal( + onCompletePayload.responseBody.usage.completion_tokens, + 10, + "completion tokens should be unchanged" + ); + assert.ok( + onCompletePayload.responseBody.usage.prompt_tokens > 0, + `prompt_tokens should be estimated (> 0), got ${onCompletePayload.responseBody.usage.prompt_tokens}` + ); + assert.equal( + onCompletePayload.responseBody.usage.total_tokens, + onCompletePayload.responseBody.usage.prompt_tokens + 10, + "total_tokens should equal prompt_tokens + completion_tokens" + ); +}); + +test.after(() => { + core.resetDbInstance(); + if (fs.existsSync(TEST_DATA_DIR)) { + for (const entry of fs.readdirSync(TEST_DATA_DIR)) { + fs.rmSync(path.join(TEST_DATA_DIR, entry), { recursive: true, force: true }); + } + } +}); diff --git a/tests/unit/translator-claude-to-gemini.test.ts b/tests/unit/translator-claude-to-gemini.test.ts index 70d63f595a..39c48d42b4 100644 --- a/tests/unit/translator-claude-to-gemini.test.ts +++ b/tests/unit/translator-claude-to-gemini.test.ts @@ -121,6 +121,19 @@ test("Claude -> Gemini clamps maxOutputTokens to the model cap", () => { assert.equal(result.generationConfig.maxOutputTokens, 65536); }); +test("Claude -> Gemini preserves requested maxOutputTokens when the model cap is unknown", () => { + const result = claudeToGeminiRequest( + "gemini-2.5-pro", + { + messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + max_tokens: 32000, + }, + false + ); + + assert.equal(result.generationConfig.maxOutputTokens, 32000); +}); + test("Claude -> Gemini converts text and base64 images to Gemini parts", () => { const result = claudeToGeminiRequest( "gemini-2.5-flash", diff --git a/tests/unit/translator-openai-to-claude.test.ts b/tests/unit/translator-openai-to-claude.test.ts index 6b18b85681..89e49121aa 100644 --- a/tests/unit/translator-openai-to-claude.test.ts +++ b/tests/unit/translator-openai-to-claude.test.ts @@ -256,7 +256,9 @@ test("OpenAI -> Claude maps tool_choice and injects response_format instructions }); test("OpenAI -> Claude turns reasoning settings into thinking budgets and expands max tokens", () => { - // `claude-4-sonnet` is a fixture that doesn't match any spec → default cap = 8192. + // `claude-4-sonnet` is a fixture that doesn't match any spec. Unknown caps + // should not get an implicit default; the translator only preserves the + // response room + thinking budget relationship. // fitThinkingToMaxTokens floors response room at MIN_RESPONSE_ROOM (1024) // and targets max_tokens = responseRoom + budget capped at modelCap. const effortResult = openaiToClaudeRequest( @@ -270,7 +272,7 @@ test("OpenAI -> Claude turns reasoning settings into thinking budgets and expand ); assert.deepEqual(effortResult.thinking, { type: "enabled", budget_tokens: 1024 }); - // responseRoom=max(10,1024)=1024; target=min(1024+1024, 8192)=2048 + // responseRoom=max(10,1024)=1024; target=1024+1024=2048 assert.equal(effortResult.max_tokens, 2048); const explicitThinkingResult = openaiToClaudeRequest( @@ -288,10 +290,25 @@ test("OpenAI -> Claude turns reasoning settings into thinking budgets and expand budget_tokens: 2000, max_tokens: 3000, }); - // responseRoom=max(1000,1024)=1024; target=min(1024+2000, 8192)=3024 + // responseRoom=max(1000,1024)=1024; target=1024+2000=3024 assert.equal(explicitThinkingResult.max_tokens, 3024); }); +test("OpenAI -> Claude does not cap unknown models to a fallback maxOutputTokens", () => { + const result = openaiToClaudeRequest( + "claude-4-sonnet", + { + messages: [{ role: "user", content: "Reason about something hard" }], + max_tokens: 32000, + reasoning_effort: "high", + }, + false + ); + + assert.equal(result.max_tokens, 163072); + assert.deepEqual(result.thinking, { type: "enabled", budget_tokens: 131072 }); +}); + test("OpenAI -> Claude preserves xhigh only for Claude models that expose it", () => { const { xhighModel, standardModel } = getClaudeEffortFixtures(); const preserved = openaiToClaudeRequest( diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index b775371229..8f75dba4c7 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -18,6 +18,8 @@ const { clearGeminiThoughtSignatures } = await import("../../open-sse/services/geminiThoughtSignatureStore.ts"); type UnknownRecord = Record; +type GeminiRequestWithConfig = { generationConfig: UnknownRecord }; +type GeminiRequestWithSystem = { systemInstruction: { role?: unknown; parts?: unknown } }; test.beforeEach(() => { clearGeminiThoughtSignatures(); @@ -76,6 +78,28 @@ test("OpenAI -> Gemini helper converts text, images and files into Gemini parts" assert.deepEqual(convertOpenAIContentToParts("raw text"), [{ text: "raw text" }]); }); +test("OpenAI -> Gemini does not inject default maxOutputTokens for unknown caps", () => { + const withoutRequestLimit = openaiToGeminiRequest( + "gemini-2.5-pro", + { messages: [{ role: "user", content: "Hello" }] }, + false + ); + assert.equal( + (withoutRequestLimit as GeminiRequestWithConfig).generationConfig.maxOutputTokens, + undefined + ); + + const withRequestLimit = openaiToGeminiRequest( + "gemini-2.5-pro", + { messages: [{ role: "user", content: "Hello" }], max_tokens: 32000 }, + false + ); + assert.equal( + (withRequestLimit as GeminiRequestWithConfig).generationConfig.maxOutputTokens, + 32000 + ); +}); + test("OpenAI -> Gemini helper cleans complex JSON Schema structures for Gemini compatibility", () => { const cleaned = cleanJSONSchemaForAntigravity({ type: "object", @@ -237,11 +261,9 @@ test("OpenAI -> Gemini request maps messages, merged system instructions, tools false ); - assert.equal((result as any).systemInstruction.role, "system"); - assert.deepEqual((result as any).systemInstruction.parts, [ - { text: "Rule A" }, - { text: "Rule B" }, - ]); + const systemInstruction = (result as GeminiRequestWithSystem).systemInstruction; + assert.equal(systemInstruction.role, "system"); + assert.deepEqual(systemInstruction.parts, [{ text: "Rule A" }, { text: "Rule B" }]); assert.equal(result.contents[0].role, "user"); assert.deepEqual(result.contents[0].parts, [ { text: "What is the weather?" }, @@ -270,12 +292,13 @@ test("OpenAI -> Gemini request maps messages, merged system instructions, tools response: { result: { temp: 20 } }, }); - assert.equal((result as any).generationConfig.maxOutputTokens, 2222); - assert.equal((result as any).generationConfig.temperature, 0.3); - assert.equal((result as any).generationConfig.topP, 0.9); - assert.deepEqual((result as any).generationConfig.stopSequences, ["DONE"]); - assert.equal((result as any).generationConfig.responseMimeType, "application/json"); - const responseSchema = (result as any).generationConfig.responseSchema as { + const generationConfig = (result as GeminiRequestWithConfig).generationConfig; + assert.equal(generationConfig.maxOutputTokens, 2222); + assert.equal(generationConfig.temperature, 0.3); + assert.equal(generationConfig.topP, 0.9); + assert.deepEqual(generationConfig.stopSequences, ["DONE"]); + assert.equal(generationConfig.responseMimeType, "application/json"); + const responseSchema = generationConfig.responseSchema as { properties: { answer: { type: string; enum?: string[] } }; }; assert.equal(responseSchema.properties.answer.type, "string"); @@ -690,17 +713,19 @@ test("OpenAI -> Antigravity Gemini omits signature-less historical tool calls an "signature-less historical call MUST be emitted as native functionCall (bypass applied)" ); assert.equal( - modelTurn?.parts.some((part) => part.thoughtSignature === "skip_thought_signature_validator") ?? false, + modelTurn?.parts.some((part) => part.thoughtSignature === "skip_thought_signature_validator") ?? + false, true, "the bypass sentinel must be injected as thoughtSignature" ); const toolTurn = result.request.contents.find( - (content) => - content.role === "user" && - content.parts.some((part) => part.functionResponse) + (content) => content.role === "user" && content.parts.some((part) => part.functionResponse) + ); + assert.ok( + toolTurn, + "expected signature-less tool response to be preserved as native functionResponse (bypass applied)" ); - assert.ok(toolTurn, "expected signature-less tool response to be preserved as native functionResponse (bypass applied)"); assert.equal( toolTurn.parts.some((part) => part.functionResponse), true, diff --git a/tests/unit/ui/OpenClawToolCard-manual-config-fallback.test.tsx b/tests/unit/ui/OpenClawToolCard-manual-config-fallback.test.tsx new file mode 100644 index 0000000000..4c5ea89ef3 --- /dev/null +++ b/tests/unit/ui/OpenClawToolCard-manual-config-fallback.test.tsx @@ -0,0 +1,160 @@ +// @vitest-environment jsdom +// +// Regression test: when the Open Claw CLI is not detected locally (typical of +// remote OmniRoute deployments where the CLI lives on the user's laptop, not +// on the server), the card must still surface a "Manual Config" button so the +// user can copy the settings.json snippet and paste it into the CLI on their +// local machine. Before this fix the Manual Config button only rendered when +// `cliReady === true`, which made the card useless for remote deployments +// (upstream report: decolua/9router#579, port of decolua/9router#615). +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string, values?: Record) => { + const messages: Record = { + cliNotInstalled: "{tool} CLI not detected locally", + cliNotRunnable: "{tool} CLI installed but not runnable", + installCliPrompt: + "Manual configuration is still available if OmniRoute is deployed on a remote server.", + cliFoundFailedHealthcheck: "{tool} CLI was found but failed runtime healthcheck{reason}.", + manualConfig: "Manual Config", + checkingCli: "Checking {tool}...", + openClawManualConfiguration: "Open Claw Manual Configuration", + "toolDescriptions.openclaw": "Open Claw CLI", + }; + const raw = messages[key] ?? key; + if (!values) return raw; + return Object.entries(values).reduce( + (acc, [k, v]) => acc.replaceAll(`{${k}}`, String(v ?? "")), + raw + ); + }, + useLocale: () => "en", +})); + +vi.mock("next/image", () => ({ + default: () => , +})); + +vi.mock("@/app/(dashboard)/dashboard/cli-code/components/CliStatusBadge", () => ({ + default: () => , +})); + +// Surface ManualConfigModal as a marker so we can assert it gets rendered with +// isOpen=true after clicking the new Manual Config button. +vi.mock("@/shared/components", async () => { + const React = await import("react"); + return { + Card: ({ children }: { children: React.ReactNode }) =>
{children}
, + Button: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick?: () => void; + }) => ( + + ), + ModelSelectModal: () => null, + ManualConfigModal: ({ isOpen, title }: { isOpen: boolean; title?: string }) => + isOpen ?
{title}
: null, + }; +}); + +// ── Fetch stub ──────────────────────────────────────────────────────────────── + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + // Return: CLI not installed (the broken-on-remote case from upstream #579). + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/cli-tools/openclaw-settings")) { + return new Response(JSON.stringify({ installed: false }), { status: 200 }); + } + if (url.includes("/api/models/alias")) { + return new Response(JSON.stringify({ aliases: {} }), { status: 200 }); + } + if (url.includes("/api/cli-tools/backups")) { + return new Response(JSON.stringify({ backups: [] }), { status: 200 }); + } + return new Response("{}", { status: 200 }); + }) as unknown as typeof fetch; +}); + +const containers: HTMLElement[] = []; + +afterEach(() => { + while (containers.length > 0) { + containers.pop()?.remove(); + } + document.body.innerHTML = ""; + vi.restoreAllMocks(); +}); + +// ── Import under test (after mocks) ─────────────────────────────────────────── + +const { default: OpenClawToolCard } = await import( + "@/app/(dashboard)/dashboard/cli-code/components/OpenClawToolCard" +); + +async function renderExpanded() { + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + const root = createRoot(container); + + await act(async () => { + root.render( + {}} + activeProviders={[]} + baseUrl="http://localhost:20128" + hasActiveProviders={false} + apiKeys={[]} + cloudEnabled={false} + batchStatus={null} + lastConfiguredAt={null} + /> + ); + }); + // Allow microtasks for the fetch() promise + state update to flush. + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + return container; +} + +describe("OpenClawToolCard — manual-config CTA when CLI is not detected", () => { + it("renders the Manual Config button when CLI is not installed", async () => { + const container = await renderExpanded(); + const buttons = Array.from(container.querySelectorAll("button")); + const labels = buttons.map((b) => b.textContent ?? ""); + expect(labels.some((l) => l.includes("Manual Config"))).toBe(true); + }); + + it("opens the ManualConfigModal when the Manual Config button is clicked", async () => { + const container = await renderExpanded(); + const manualBtn = Array.from(container.querySelectorAll("button")).find((b) => + (b.textContent ?? "").includes("Manual Config") + ); + expect(manualBtn).toBeTruthy(); + + await act(async () => { + manualBtn!.click(); + }); + + expect(container.querySelector("[data-testid='manual-config-modal']")).not.toBeNull(); + }); +}); diff --git a/tests/unit/ui/add-api-key-modal-free-models.test.tsx b/tests/unit/ui/add-api-key-modal-free-models.test.tsx index e6082e7812..c366485a9e 100644 --- a/tests/unit/ui/add-api-key-modal-free-models.test.tsx +++ b/tests/unit/ui/add-api-key-modal-free-models.test.tsx @@ -8,9 +8,8 @@ vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key, })); -const { default: AddApiKeyModal } = await import( - "../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal" -); +const { default: AddApiKeyModal } = + await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal"); const FREE_TOGGLE = 'button[role="switch"][aria-label="importFreeModelsOnlyLabel"]'; @@ -35,10 +34,7 @@ function render(props: Record) { } function setInputValue(input: HTMLInputElement, value: string) { - const setter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - "value" - )!.set!; + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; act(() => { setter.call(input, value); input.dispatchEvent(new Event("input", { bubbles: true })); @@ -108,3 +104,58 @@ describe("AddApiKeyModal — import only free models", () => { expect(payload.providerSpecificData?.importFreeModelsOnly).toBe(true); }); }); + +describe("AddApiKeyModal — quota scraping fields", () => { + it("saves OpenCode Go workspace and auth cookie in providerSpecificData", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const el = render({ provider: "opencode-go", providerName: "OpenCode Go", onSave }); + + const nameInput = el.querySelector('input[placeholder="productionKey"]')!; + const apiKeyInput = el.querySelector('input[type="password"]')!; + const workspaceInput = el.querySelector( + 'input[name="opencodeGoWorkspaceId"]' + )!; + const cookieInput = el.querySelector('input[name="opencodeGoAuthCookie"]')!; + setInputValue(nameInput, "OpenCode Go"); + setInputValue(apiKeyInput, "sk-opencode-go-test"); + setInputValue(workspaceInput, "workspace-123"); + setInputValue(cookieInput, "auth=opencode-cookie"); + + const saveBtn = Array.from(el.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "save" + )!; + act(() => { + saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitFor(() => onSave.mock.calls.length > 0); + const payload = onSave.mock.calls[0][0]; + expect(payload.providerSpecificData?.opencodeGoWorkspaceId).toBe("workspace-123"); + expect(payload.providerSpecificData?.opencodeGoAuthCookie).toBe("auth=opencode-cookie"); + }); + + it("saves Ollama Cloud usage cookie in providerSpecificData", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const el = render({ provider: "ollama-cloud", providerName: "Ollama Cloud", onSave }); + + const nameInput = el.querySelector('input[placeholder="productionKey"]')!; + const apiKeyInput = el.querySelector('input[type="password"]')!; + const cookieInput = el.querySelector('input[name="ollamaCloudUsageCookie"]')!; + setInputValue(nameInput, "Ollama Cloud"); + setInputValue(apiKeyInput, "ollama-key"); + setInputValue(cookieInput, "__Secure-session=ollama-cookie"); + + const saveBtn = Array.from(el.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "save" + )!; + act(() => { + saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitFor(() => onSave.mock.calls.length > 0); + const payload = onSave.mock.calls[0][0]; + expect(payload.providerSpecificData?.ollamaCloudUsageCookie).toBe( + "__Secure-session=ollama-cookie" + ); + }); +}); diff --git a/tests/unit/ui/edit-connection-modal-free-models.test.tsx b/tests/unit/ui/edit-connection-modal-free-models.test.tsx index 617a57d66e..757b0cff84 100644 --- a/tests/unit/ui/edit-connection-modal-free-models.test.tsx +++ b/tests/unit/ui/edit-connection-modal-free-models.test.tsx @@ -11,9 +11,8 @@ vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key, })); -const { default: EditConnectionModal } = await import( - "../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal" -); +const { default: EditConnectionModal } = + await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal"); const FREE_TOGGLE = 'button[role="switch"][aria-label="importFreeModelsOnlyLabel"]'; @@ -38,6 +37,14 @@ function render(props: Record) { return el; } +function setInputValue(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; + act(() => { + setter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + async function waitFor(fn: () => boolean, timeoutMs = 2000) { const start = Date.now(); while (!fn()) { @@ -51,7 +58,9 @@ beforeEach(() => { vi.clearAllMocks(); vi.stubGlobal( "fetch", - vi.fn(() => Promise.resolve({ ok: true, json: async () => ({}), text: async () => "" } as Response)) + vi.fn(() => + Promise.resolve({ ok: true, json: async () => ({}), text: async () => "" } as Response) + ) ); vi.stubGlobal("localStorage", { getItem: () => null, @@ -159,3 +168,68 @@ describe("EditConnectionModal — import only free models", () => { expect(onResyncModels).toHaveBeenCalledWith("conn-4"); }); }); + +describe("EditConnectionModal — quota scraping fields", () => { + it("saves OpenCode Go workspace and replacement auth cookie", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const el = render({ + providerId: "opencode-go", + connection: { + id: "conn-opencode-go", + provider: "opencode-go", + name: "OpenCode Go", + authType: "apikey", + providerSpecificData: { workspaceId: "workspace-existing" }, + }, + onSave, + }); + + const workspaceInput = el.querySelector( + 'input[name="opencodeGoWorkspaceId"]' + )!; + const cookieInput = el.querySelector('input[name="opencodeGoAuthCookie"]')!; + expect(workspaceInput.value).toBe("workspace-existing"); + setInputValue(workspaceInput, "workspace-updated"); + setInputValue(cookieInput, "auth=opencode-cookie"); + + const saveBtn = Array.from(el.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "save" + )!; + act(() => { + saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitFor(() => onSave.mock.calls.length > 0); + const payload = onSave.mock.calls[0][0]; + expect(payload.providerSpecificData?.opencodeGoWorkspaceId).toBe("workspace-updated"); + expect(payload.providerSpecificData?.opencodeGoAuthCookie).toBe("auth=opencode-cookie"); + }); + + it("omits Ollama Cloud usage cookie when the edit field is left blank", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const el = render({ + providerId: "ollama-cloud", + connection: { + id: "conn-ollama-cloud", + provider: "ollama-cloud", + name: "Ollama Cloud", + authType: "apikey", + providerSpecificData: {}, + }, + onSave, + }); + + expect(el.querySelector('input[name="ollamaCloudUsageCookie"]')).toBeTruthy(); + + const saveBtn = Array.from(el.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "save" + )!; + act(() => { + saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitFor(() => onSave.mock.calls.length > 0); + const payload = onSave.mock.calls[0][0]; + expect("ollamaCloudUsageCookie" in payload.providerSpecificData).toBe(false); + }); +}); diff --git a/tests/unit/ui/home-provider-topology-section-4606.test.tsx b/tests/unit/ui/home-provider-topology-section-4606.test.tsx new file mode 100644 index 0000000000..2cd2d3ccf2 --- /dev/null +++ b/tests/unit/ui/home-provider-topology-section-4606.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +// +// #4606: the provider-topology card was extracted into HomeProviderTopologySection +// and its activity fetch gated behind widget visibility in HomePageClient +// (`appearanceSettingsLoaded && showProviderTopologyOnHome`). This guards the +// extracted section: it renders the topology card and feeds live active-requests +// through selectActiveRequests into ProviderTopology (Rule #18 for the change). +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); +vi.mock("next/dynamic", () => ({ + default: () => (props: Record) => ( +
+ ), +})); +vi.mock("@/shared/components", () => ({ + Card: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +const liveRequestsMock = vi.fn(() => ({ activeRequests: [] as unknown[] })); +vi.mock("@/hooks/useLiveDashboard", () => ({ + useLiveRequests: () => liveRequestsMock(), +})); + +const { HomeProviderTopologySection } = await import( + "../../../src/app/(dashboard)/dashboard/HomeProviderTopologySection" +); + +let container: HTMLDivElement; +let root: ReturnType; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); +}); + +it("renders the topology card and forwards providers to ProviderTopology", () => { + act(() => { + root.render( + + ); + }); + + expect(container.querySelector("[data-testid='card']")).not.toBeNull(); + const topology = container.querySelector("[data-testid='provider-topology']"); + expect(topology).not.toBeNull(); + expect(topology?.getAttribute("data-providers")).toBe("2"); +}); diff --git a/tests/unit/ui/home-topology-hidden-4596.test.tsx b/tests/unit/ui/home-topology-hidden-4596.test.tsx new file mode 100644 index 0000000000..cdb3a64662 --- /dev/null +++ b/tests/unit/ui/home-topology-hidden-4596.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useLiveRequests } from "../../../src/hooks/useLiveDashboard"; + +function LiveRequestsHarness({ enabled }: { enabled: boolean }) { + useLiveRequests({ enabled }); + return null; +} + +describe("home topology hidden networking (#4596)", () => { + const websocketMock = vi.fn(); + let root: ReturnType | null = null; + let container: HTMLDivElement | null = null; + + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + websocketMock.mockClear(); + vi.stubGlobal( + "WebSocket", + class WebSocketMock { + static OPEN = 1; + readyState = 0; + onopen: (() => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + + constructor(url: string) { + websocketMock(url); + } + + send() {} + close() {} + } + ); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root?.unmount(); + }); + container?.remove(); + root = null; + container = null; + vi.unstubAllGlobals(); + }); + + it("does NOT open a WebSocket when the topology section is disabled", () => { + act(() => { + root!.render(); + }); + expect(websocketMock).not.toHaveBeenCalled(); + }); + + it("opens a WebSocket when the topology section is enabled", () => { + act(() => { + root!.render(); + }); + expect(websocketMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/ui/provider-quota-widget-auto-refresh-label-4611.test.tsx b/tests/unit/ui/provider-quota-widget-auto-refresh-label-4611.test.tsx new file mode 100644 index 0000000000..c5835b711e --- /dev/null +++ b/tests/unit/ui/provider-quota-widget-auto-refresh-label-4611.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +// +// #4611: the auto-refresh countdown was extracted into its own +// `AutoRefreshButtonLabel` child so the per-second `setNow` tick re-renders only +// the label instead of the whole `ProviderQuotaWidget`. This guards the extracted +// child's three observable label states (Rule #18 for the maintainer-reviewed change). +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { AutoRefreshButtonLabel } from "../../../src/app/(dashboard)/home/ProviderQuotaWidget"; + +const tr = (_key: string, fallback: string) => fallback; + +let container: HTMLDivElement; +let root: ReturnType; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +describe("AutoRefreshButtonLabel (#4611)", () => { + it("shows 'Refreshing' while a refresh-all is in flight", () => { + act(() => { + root.render( + + ); + }); + expect(container.textContent).toBe("Refreshing"); + }); + + it("shows the static 'Refresh All' label when auto-refresh is disabled", () => { + act(() => { + root.render( + + ); + }); + expect(container.textContent).toBe("Refresh All"); + }); + + it("shows the auto-refreshing countdown when an interval is configured", () => { + act(() => { + root.render( + + ); + }); + expect(container.textContent).toContain("Auto-refreshing"); + }); +}); diff --git a/tests/unit/upstream-retry-hints-toggle.test.ts b/tests/unit/upstream-retry-hints-toggle.test.ts new file mode 100644 index 0000000000..3ecd417d46 --- /dev/null +++ b/tests/unit/upstream-retry-hints-toggle.test.ts @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { checkFallbackError, getProviderProfile } from "../../open-sse/services/accountFallback.ts"; +import { RateLimitReason } from "../../open-sse/config/constants.ts"; + +const ANTIGRAVITY_RESET_TEXT = + "Individual quota reached. Contact your administrator to enable overages. Resets in 164h27m24s."; +const RESET_164H = (164 * 3600 + 27 * 60 + 24) * 1000; + +function antigravityProfile(useUpstreamRetryHints: boolean) { + return { + ...getProviderProfile("antigravity"), + baseCooldownMs: 125, + transientCooldown: 125, + rateLimitCooldown: useUpstreamRetryHints ? 0 : 125, + useUpstreamRetryHints, + }; +} + +test("checkFallbackError ignores body reset text when upstream retry hints are disabled", () => { + const result = checkFallbackError( + 429, + ANTIGRAVITY_RESET_TEXT, + 0, + "gemini-3-flash-agent", + "antigravity", + null, + antigravityProfile(false) + ); + + assert.equal(result.shouldFallback, true); + assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED); + assert.equal(result.usedUpstreamRetryHint, false); + assert.equal(result.cooldownMs, 125); +}); + +test("checkFallbackError honors body reset text when upstream retry hints are enabled", () => { + const result = checkFallbackError( + 429, + ANTIGRAVITY_RESET_TEXT, + 0, + "gemini-3-flash-agent", + "antigravity", + null, + antigravityProfile(true) + ); + + assert.equal(result.shouldFallback, true); + assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED); + assert.equal(result.usedUpstreamRetryHint, true); + assert.equal(result.cooldownMs, RESET_164H); +}); diff --git a/tests/unit/usage-pending-sweep.test.ts b/tests/unit/usage-pending-sweep.test.ts index af7ed8b085..d3de475b16 100644 --- a/tests/unit/usage-pending-sweep.test.ts +++ b/tests/unit/usage-pending-sweep.test.ts @@ -1,52 +1,114 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -const { - trackPendingRequest, - getPendingById, - getPendingRequests, - sweepStalePendingRequests, - clearPendingRequests, -} = await import("../../src/lib/usage/usageHistory.ts"); - -test("sweepStalePendingRequests evicts orphaned pending details and self-heals counts", () => { - clearPendingRequests(); - - // One request that will be treated as orphaned (never finalized), one fresh. - const staleId = trackPendingRequest("gpt-x", "openai", "conn-stale", true); - const freshId = trackPendingRequest("gpt-x", "openai", "conn-fresh", true); - - assert.ok(staleId && freshId, "both started requests should produce ids"); - assert.equal(getPendingById().size, 2); - assert.equal(getPendingRequests().byModel["gpt-x (openai)"], 2); - - // Age the stale entry well beyond the max age. - const stale = getPendingById().get(staleId); - assert.ok(stale, "stale detail should exist"); - stale.startedAt = Date.now() - 60 * 60 * 1000; // 1 hour ago - - const removed = sweepStalePendingRequests(Date.now(), 15 * 60 * 1000); - - assert.equal(removed, 1, "exactly one orphaned entry should be swept"); - assert.equal(getPendingById().size, 1, "only the fresh entry should remain"); - assert.ok(getPendingById().has(freshId), "fresh entry must survive"); - - // Counts must reflect the eviction (decremented, not left dangling). - assert.equal(getPendingRequests().byModel["gpt-x (openai)"], 1); - assert.equal(getPendingRequests().byAccount["conn-stale"], undefined); - assert.equal(getPendingRequests().byAccount["conn-fresh"]["gpt-x (openai)"], 1); - - clearPendingRequests(); -}); - -test("sweepStalePendingRequests is a no-op when nothing is stale", () => { - clearPendingRequests(); - trackPendingRequest("m", "p", "c1", true); - trackPendingRequest("m", "p", "c2", true); - - const removed = sweepStalePendingRequests(Date.now(), 15 * 60 * 1000); - - assert.equal(removed, 0); - assert.equal(getPendingById().size, 2); - clearPendingRequests(); -}); +import test from "node:test"; +import assert from "node:assert/strict"; + +const { + trackPendingRequest, + getPendingById, + getPendingRequests, + sweepStalePendingRequests, + getMaxPendingRequestAgeMs, + clearPendingRequests, +} = await import("../../src/lib/usage/usageHistory.ts"); + +const MINUTE_MS = 60 * 1000; +const HOUR_MS = 60 * MINUTE_MS; + +test("sweepStalePendingRequests evicts orphaned pending details and self-heals counts", () => { + clearPendingRequests(); + + // One request that will be treated as orphaned (never finalized), one fresh. + const staleId = trackPendingRequest("gpt-x", "openai", "conn-stale", true); + const freshId = trackPendingRequest("gpt-x", "openai", "conn-fresh", true); + + assert.ok(staleId && freshId, "both started requests should produce ids"); + assert.equal(getPendingById().size, 2); + assert.equal(getPendingRequests().byModel["gpt-x (openai)"], 2); + + // Age the stale entry well beyond the max age. + const stale = getPendingById().get(staleId); + assert.ok(stale, "stale detail should exist"); + stale.startedAt = Date.now() - 2 * HOUR_MS; + + const removed = sweepStalePendingRequests(Date.now(), HOUR_MS); + + assert.equal(removed, 1, "exactly one orphaned entry should be swept"); + assert.equal(getPendingById().size, 1, "only the fresh entry should remain"); + assert.ok(getPendingById().has(freshId), "fresh entry must survive"); + + // Counts must reflect the eviction (decremented, not left dangling). + assert.equal(getPendingRequests().byModel["gpt-x (openai)"], 1); + assert.equal(getPendingRequests().byAccount["conn-stale"], undefined); + assert.equal(getPendingRequests().byAccount["conn-fresh"]["gpt-x (openai)"], 1); + + clearPendingRequests(); +}); + +test("sweepStalePendingRequests is a no-op when nothing is stale", () => { + clearPendingRequests(); + trackPendingRequest("m", "p", "c1", true); + trackPendingRequest("m", "p", "c2", true); + + const removed = sweepStalePendingRequests(Date.now(), HOUR_MS); + + assert.equal(removed, 0); + assert.equal(getPendingById().size, 2); + clearPendingRequests(); +}); + +test("sweepStalePendingRequests defaults to a one hour max pending age", () => { + clearPendingRequests(); + + const staleId = trackPendingRequest("m", "p", "old", true); + const recentId = trackPendingRequest("m", "p", "recent", true); + assert.ok(staleId && recentId); + + const now = Date.now(); + const stale = getPendingById().get(staleId); + const recent = getPendingById().get(recentId); + assert.ok(stale && recent); + + stale.startedAt = now - 61 * MINUTE_MS; + recent.startedAt = now - 59 * MINUTE_MS; + + const removed = sweepStalePendingRequests(now); + + assert.equal(removed, 1); + assert.equal(getPendingById().has(staleId), false); + assert.equal(getPendingById().has(recentId), true); + clearPendingRequests(); +}); + +test("pending sweep max age can be overridden through environment", () => { + clearPendingRequests(); + const previous = process.env.MAX_PENDING_REQUEST_AGE_MS; + process.env.MAX_PENDING_REQUEST_AGE_MS = String(2 * HOUR_MS); + + try { + const requestId = trackPendingRequest("m", "p", "custom-age", true); + assert.ok(requestId); + + const detail = getPendingById().get(requestId); + assert.ok(detail); + detail.startedAt = Date.now() - 90 * MINUTE_MS; + + assert.equal(getMaxPendingRequestAgeMs(), 2 * HOUR_MS); + assert.equal(sweepStalePendingRequests(Date.now()), 0); + assert.equal(getPendingById().has(requestId), true); + } finally { + if (previous === undefined) delete process.env.MAX_PENDING_REQUEST_AGE_MS; + else process.env.MAX_PENDING_REQUEST_AGE_MS = previous; + clearPendingRequests(); + } +}); + +test("invalid pending sweep max age falls back to one hour", () => { + const previous = process.env.MAX_PENDING_REQUEST_AGE_MS; + process.env.MAX_PENDING_REQUEST_AGE_MS = "not-a-number"; + + try { + assert.equal(getMaxPendingRequestAgeMs(), HOUR_MS); + } finally { + if (previous === undefined) delete process.env.MAX_PENDING_REQUEST_AGE_MS; + else process.env.MAX_PENDING_REQUEST_AGE_MS = previous; + } +}); diff --git a/tests/unit/v1-models-by-id-4674.test.ts b/tests/unit/v1-models-by-id-4674.test.ts new file mode 100644 index 0000000000..4af0bb7193 --- /dev/null +++ b/tests/unit/v1-models-by-id-4674.test.ts @@ -0,0 +1,77 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + findModelById, + handleGetModelById, +} from "@/app/api/v1/models/modelById"; + +// #4674 — GET /v1/models/{model} previously had no route handler, so the request +// fell through to the Next.js catch-all and returned the HTML dashboard instead of +// JSON, breaking Claude Code's model-validation probe. These tests pin the JSON +// contract for both the found and not-found paths. + +const CATALOG = [ + { id: "claude/claude-sonnet-4-6", object: "model", owned_by: "claude" }, + { id: "cgpt-web/gpt-5.5", object: "model", owned_by: "chatgpt-web" }, + { id: "gpt-5", object: "model", owned_by: "openai" }, +]; + +function listResponse() { + return Response.json({ object: "list", data: CATALOG }); +} + +test("findModelById returns the exact-id match", () => { + const found = findModelById(CATALOG, "claude/claude-sonnet-4-6"); + assert.ok(found); + assert.equal(found.id, "claude/claude-sonnet-4-6"); + assert.equal(found.object, "model"); +}); + +test("findModelById handles provider-prefixed ids containing a slash", () => { + const found = findModelById(CATALOG, "cgpt-web/gpt-5.5"); + assert.ok(found); + assert.equal(found.id, "cgpt-web/gpt-5.5"); +}); + +test("findModelById returns null for an unknown model", () => { + assert.equal(findModelById(CATALOG, "does-not-exist"), null); +}); + +test("findModelById tolerates a non-array catalog", () => { + assert.equal(findModelById(undefined, "gpt-5"), null); + assert.equal(findModelById(null, "gpt-5"), null); +}); + +test("handleGetModelById returns 200 JSON for an existing model", async () => { + const req = new Request("http://localhost:20128/v1/models/gpt-5"); + const res = await handleGetModelById(req, "gpt-5", listResponse); + assert.equal(res.status, 200); + assert.match(res.headers.get("content-type") || "", /application\/json/); + const body = await res.json(); + assert.equal(body.id, "gpt-5"); + assert.equal(body.object, "model"); +}); + +test("handleGetModelById returns 404 JSON (not HTML) for an unknown model", async () => { + const req = new Request("http://localhost:20128/v1/models/ghost"); + const res = await handleGetModelById(req, "ghost", listResponse); + assert.equal(res.status, 404); + // The whole point of #4674: never serve the HTML dashboard here. + const ct = res.headers.get("content-type") || ""; + assert.match(ct, /application\/json/); + assert.doesNotMatch(ct, /text\/html/); + const body = await res.json(); + assert.equal(body.error.code, "model_not_found"); + assert.match(body.error.message, /ghost/); +}); + +test("handleGetModelById propagates an upstream auth/error response unchanged", async () => { + const req = new Request("http://localhost:20128/v1/models/gpt-5"); + const rejection = async () => + Response.json({ error: { message: "unauthorized" } }, { status: 401 }); + const res = await handleGetModelById(req, "gpt-5", rejection); + assert.equal(res.status, 401); + const body = await res.json(); + assert.equal(body.error.message, "unauthorized"); +}); diff --git a/tests/unit/validate-release-green.test.ts b/tests/unit/validate-release-green.test.ts new file mode 100644 index 0000000000..0712a6747c --- /dev/null +++ b/tests/unit/validate-release-green.test.ts @@ -0,0 +1,74 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Pure helpers of the release-green validator (Solution C). The orchestration is +// guarded behind a direct-run check, so importing the module here is side-effect-free. +const mod = await import("../../scripts/quality/validate-release-green.mjs"); +const { + firstFailureLine, + eslintCounts, + parseEslintJson, + parseCognitiveCount, + isDrift, + computeVerdict, +} = mod; + +test("eslintCounts sums errors + warnings across files", () => { + const parsed = [ + { errorCount: 2, warningCount: 5 }, + { errorCount: 0, warningCount: 3 }, + {}, + ]; + assert.deepEqual(eslintCounts(parsed), { errors: 2, warnings: 8 }); +}); + +test("parseEslintJson tolerates a leading non-JSON banner", () => { + const out = "npm warn something\n[{\"errorCount\":0,\"warningCount\":1}]"; + assert.deepEqual(parseEslintJson(out), [{ errorCount: 0, warningCount: 1 }]); + assert.equal(parseEslintJson("no json here"), null); +}); + +test("parseCognitiveCount reads the gate's count (en + pt)", () => { + assert.equal(parseCognitiveCount("[cognitive-complexity] 797 function(s) exceed the threshold (15)."), 797); + assert.equal(parseCognitiveCount("[cognitive-complexity] REGRESSÃO — 801 violações > baseline 797"), 801); + assert.equal(parseCognitiveCount("no number"), null); +}); + +test("isDrift flags only growth past the committed baseline (down-direction ratchets)", () => { + assert.equal(isDrift(3900, 3867), true); // grew → drift + assert.equal(isDrift(3867, 3867), false); // equal → ok + assert.equal(isDrift(3800, 3867), false); // improved → ok + assert.equal(isDrift(10, null), false); // no baseline → never drift + assert.equal(isDrift(null, 10), false); // unparsed → never drift +}); + +test("firstFailureLine surfaces the meaningful failure, not boilerplate", () => { + const out = [ + "> omniroute@3.8.34 typecheck:core", + "src/x.ts(10,5): error TS2322: Type 'string' is not assignable to 'number'.", + "done", + ].join("\n"); + assert.match(firstFailureLine(out), /error TS2322/); +}); + +test("computeVerdict: releaseGreen iff zero HARD failures (drift never blocks)", () => { + const onlyDrift = computeVerdict([ + { kind: "hard", ok: true }, + { kind: "drift", ok: false }, + ]); + assert.equal(onlyDrift.releaseGreen, true); + assert.equal(onlyDrift.drift.length, 1); + + const hardFail = computeVerdict([ + { kind: "hard", ok: false }, + { kind: "drift", ok: false }, + ]); + assert.equal(hardFail.releaseGreen, false); + assert.equal(hardFail.hardFailures.length, 1); + + const allGreen = computeVerdict([ + { kind: "hard", ok: true }, + { kind: "drift", ok: true }, + ]); + assert.equal(allGreen.releaseGreen, true); +}); diff --git a/tests/unit/virtual-auto-combo.test.ts b/tests/unit/virtual-auto-combo.test.ts index 2113cd7f2c..598c8f502f 100644 --- a/tests/unit/virtual-auto-combo.test.ts +++ b/tests/unit/virtual-auto-combo.test.ts @@ -202,16 +202,23 @@ test("createVirtualAutoCombo includes no-auth OpenCode Free without provider_con test("createVirtualAutoCombo includes all chat-capable no-auth providers without connections", async () => { const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("fast"); - const byProvider = new Map(combo.models.map((model) => [model.providerId, model])); + // Each noAuth provider should have multiple models (not just the first) + const ddgwModels = combo.models.filter((m) => m.providerId === "duckduckgo-web"); + assert.ok(ddgwModels.length >= 1, "duckduckgo-web should have at least one model"); + assert.ok(ddgwModels.every((m) => m.connectionId === "noauth"), "all ddgw models should use noauth connection"); + assert.ok(ddgwModels.some((m) => m.model.startsWith("ddgw/")), "ddgw models should have correct prefix"); + + const tllmModels = combo.models.filter((m) => m.providerId === "theoldllm"); + assert.ok(tllmModels.length >= 1, "theoldllm should have at least one model"); + assert.ok(tllmModels.every((m) => m.connectionId === "noauth"), "all tllm models should use noauth connection"); + assert.ok(tllmModels.some((m) => m.model === "tllm/GPT_5_4"), "tllm should include GPT_5_4"); + + const chipotleModels = combo.models.filter((m) => m.providerId === "chipotle"); + assert.ok(chipotleModels.length >= 1, "chipotle should have at least one model"); + assert.ok(chipotleModels.every((m) => m.connectionId === "noauth"), "all chipotle models should use noauth connection"); - assert.equal(byProvider.get("duckduckgo-web")?.connectionId, "noauth"); - assert.equal(byProvider.get("duckduckgo-web")?.model, "ddgw/gpt-4o-mini"); - assert.equal(byProvider.get("theoldllm")?.connectionId, "noauth"); - assert.equal(byProvider.get("theoldllm")?.model, "tllm/GPT_5_4"); - assert.equal(byProvider.get("chipotle")?.connectionId, "noauth"); - assert.equal(byProvider.get("chipotle")?.model, "pepper/pepper-1"); assert.equal( - byProvider.has("veoaifree-web"), + combo.models.some((model) => model.providerId === "veoaifree-web"), false, "video-only no-auth providers must not be inserted into chat auto-combos" ); diff --git a/tests/unit/web-cookie-providers-new.test.ts b/tests/unit/web-cookie-providers-new.test.ts index b6ff83b790..2baa240f33 100644 --- a/tests/unit/web-cookie-providers-new.test.ts +++ b/tests/unit/web-cookie-providers-new.test.ts @@ -127,7 +127,10 @@ test("v0 Vercel Web executor is registered", () => { test("Kimi Web executor is registered", () => { assert.ok(hasSpecializedExecutor("kimi-web")); - assert.ok(hasSpecializedExecutor("kimi")); + // #4699: the `kimi` API-key provider must NOT be routed through KimiWebExecutor + // (Bug 2) — it correctly falls through to DefaultExecutor. Only the explicit + // kimi-web alias keeps the specialized web executor. + assert.equal(hasSpecializedExecutor("kimi"), false); const executor = getExecutor("kimi-web"); assert.ok(executor instanceof KimiWebExecutor); }); @@ -390,9 +393,7 @@ test("Poe Web: sends p-b cookie in header", async () => { // ── Venice Web Execution Tests ─────────────────────────────────────────────── test("Venice Web: streaming passes through SSE", async () => { - const sseData = [ - 'data: {"choices":[{"delta":{"content":"Hello"}}]}', - ]; + const sseData = ['data: {"choices":[{"delta":{"content":"Hello"}}]}']; const restore = mockFetchCapture(200, mockSSEStream(sseData)); try { const executor = new VeniceWebExecutor(); @@ -422,9 +423,7 @@ test("Venice Web: error response returns error result", async () => { // ── v0 Vercel Web Execution Tests ──────────────────────────────────────────── test("v0 Vercel Web: streaming passes through SSE", async () => { - const sseData = [ - 'data: {"choices":[{"delta":{"content":"function hello() {}"}}]}', - ]; + const sseData = ['data: {"choices":[{"delta":{"content":"function hello() {}"}}]}']; const restore = mockFetchCapture(200, mockSSEStream(sseData)); try { const executor = new V0VercelWebExecutor(); @@ -454,9 +453,7 @@ test("v0 Vercel Web: error response returns error result", async () => { // ── Kimi Web Execution Tests ───────────────────────────────────────────────── test("Kimi Web: streaming passes through SSE", async () => { - const sseData = [ - 'data: {"choices":[{"delta":{"content":"你好"}}]}', - ]; + const sseData = ['data: {"choices":[{"delta":{"content":"你好"}}]}']; const restore = mockFetchCapture(200, mockSSEStream(sseData)); try { const executor = new KimiWebExecutor(); @@ -486,9 +483,7 @@ test("Kimi Web: error response returns error result", async () => { // ── Doubao Web Execution Tests ─────────────────────────────────────────────── test("Doubao Web: streaming passes through SSE", async () => { - const sseData = [ - 'data: {"choices":[{"delta":{"content":"你好世界"}}]}', - ]; + const sseData = ['data: {"choices":[{"delta":{"content":"你好世界"}}]}']; const restore = mockFetchCapture(200, mockSSEStream(sseData)); try { const executor = new DoubaoWebExecutor();