diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25badf8168..b99cdc6cd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,11 @@ jobs: runs-on: ubuntu-latest needs: test-coverage if: ${{ always() && needs.test-coverage.result == 'success' }} + # security-events: read lets the CodeQL ratchet read open code-scanning alerts + # via `gh api .../code-scanning/alerts`. contents: read keeps checkout working. + permissions: + contents: read + security-events: read steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 @@ -88,6 +93,15 @@ jobs: # coverage mergeado). Tamanho de arquivo e duplicação têm gates dedicados. - name: Ratchet check run: node scripts/quality/check-quality-ratchet.mjs --summary .artifacts/quality-ratchet.md + # Fase 6A.5: require-tighten — ADVISORY por enquanto (continue-on-error). Reporta + # quando uma métrica MELHOROU sem o baseline ter sido apertado no mesmo PR (força + # capturar ganhos permanentes). As métricas coverage.* carregam tightenSlack para + # o gap anti-flake (CI mergeado > baseline) não disparar falso-positivo. Promover a + # BLOQUEANTE no fim do ciclo removendo o `continue-on-error` (ver a nota + # _require_tighten_advisory em config/quality/quality-baseline.json). + - name: Require-tighten (advisory — flip to blocking at cycle-end) + continue-on-error: true + run: node scripts/quality/check-quality-ratchet.mjs --require-tighten # Catraca de duplicação (jscpd@4 sobre src+open-sse). Roda neste job (paralelo) # para não pesar no caminho crítico do lint. - name: Duplication ratchet @@ -103,6 +117,14 @@ jobs: run: npm run check:cognitive-complexity - name: Type coverage ratchet run: npm run check:type-coverage + # CodeQL alerts ratchet — BLOQUEANTE (promovido de advisory na v3.8.26). + # Lê metrics.codeqlAlerts.value de quality-baseline.json e sai 1 SOMENTE numa + # regressão real (alertas abertos > baseline). Falha de medição (gh/auth/api) + # é skip gracioso com exit 0 — security-events:read no job-level permissions. + - name: CodeQL alerts ratchet (blocking) + run: npm run check:codeql-ratchet + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Append summary if: always() run: cat .artifacts/quality-ratchet.md >> "$GITHUB_STEP_SUMMARY" @@ -114,17 +136,23 @@ jobs: path: .artifacts/quality-ratchet.md if-no-files-found: warn - # Phase 7 extended quality gates — ADVISORY (continue-on-error). The 5 npm-based - # ratchets (dead-code/cognitive-complexity/type-coverage/circular-deps/bundle-size) - # run for real. The external scans (vuln/secrets/workflows) skip gracefully until the - # owner adds install steps for osv-scanner/gitleaks/actionlint/zizmor; CodeQL ratchet - # works via the runner's gh token. SonarQube needs SONAR_TOKEN/SONAR_HOST_URL secrets. + # Phase 7 extended quality gates — ADVISORY (continue-on-error). The npm-based + # ratchets that remain here (circular-deps/bundle-size) run for real. The external + # scans (vuln/secrets/workflows) install via GitHub release downloads and skip + # gracefully if a binary is still absent. The CodeQL ratchet was PROMOTED to a + # blocking step in the quality-gate job (v3.8.26) — no longer runs here. + # SonarQube needs SONAR_TOKEN/SONAR_HOST_URL secrets. quality-extended: name: Quality Gates (Extended, advisory) runs-on: ubuntu-latest continue-on-error: true steps: + # fetch-depth: 0 — the OpenAPI breaking-change gate (oasdiff) reads the base + # spec via `git show :docs/reference/openapi.yaml`; a shallow clone + # would lack the base ref and the gate would self-skip (base-unresolved). - uses: actions/checkout@v6 + with: + fetch-depth: 0 - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} @@ -136,30 +164,70 @@ jobs: run: npm run check:circular-deps - name: Bundle size run: npm run check:bundle-size - - name: CodeQL alerts ratchet - run: npm run check:codeql-ratchet - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Fase 7 INT: install the advisory security scanners so the gates below - # actually run (they self-skip when the binaries are absent). go install - # uses documented module paths (no fragile version-pinned URLs); zizmor is - # a PyPI package. Whole job is continue-on-error, so an install hiccup never - # blocks the build. Exercised at the next release→main run. + # CodeQL ratchet foi PROMOVIDO a BLOQUEANTE no job quality-gate (v3.8.26) — + # não roda aqui para evitar duplo run/duplo report. + # Install the advisory security scanners so the gates below actually run + # (they self-skip when the binaries are absent). Robustness lessons baked in: + # • `go install …/gitleaks/v8@latest` produces a binary WITHOUT the version + # ldflags gitleaks needs (and often fails) — avoided. + # • `curl …api.github.com/…/releases/latest` is UNAUTHENTICATED and + # rate-limited to 60 req/hr/IP; when throttled it returns an empty body, + # so the asset URL resolves to nothing and the install silently no-ops — + # every gate then self-skips and the metric is never produced. We instead + # use `gh release download`, which is preinstalled on GitHub runners and + # authenticated via GITHUB_TOKEN (5000 req/hr) — robust under load. + # • actionlint keeps its official download script; zizmor stays on pipx. + # We `set +e` (no single failure aborts the step), ALWAYS export $GITHUB_PATH + # at the end, and print diagnostics so the next CI run proves exactly what + # installed. The job is continue-on-error too, so an install hiccup never + # blocks the build. - name: Install advisory security scanners (gitleaks/osv/actionlint/zizmor) continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} run: | - go install github.com/rhysd/actionlint/cmd/actionlint@latest - go install github.com/gitleaks/gitleaks/v8@latest - go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest - echo "$HOME/go/bin" >> "$GITHUB_PATH" + set +e + mkdir -p "$HOME/.local/bin" + # gitleaks — download latest linux x64 tarball via gh (authed), extract binary + rm -rf /tmp/gl && mkdir -p /tmp/gl + gh release download --repo gitleaks/gitleaks --pattern '*linux_x64.tar.gz' --dir /tmp/gl + tar -xzf /tmp/gl/*linux_x64.tar.gz -C "$HOME/.local/bin" gitleaks + # osv-scanner — download latest linux amd64 bare binary via gh (authed) + rm -rf /tmp/osv && mkdir -p /tmp/osv + gh release download --repo google/osv-scanner --pattern '*linux_amd64' --dir /tmp/osv + install -m 0755 /tmp/osv/*linux_amd64 "$HOME/.local/bin/osv-scanner" + # actionlint — official download script + bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) latest "$HOME/.local/bin" + # zizmor — PyPI (pipx preferred, pip --user fallback); lands in ~/.local/bin pipx install zizmor || pip install --user zizmor + # oasdiff — download latest linux amd64 tarball via gh (authed), extract binary + rm -rf /tmp/oasd && mkdir -p /tmp/oasd + gh release download --repo oasdiff/oasdiff --pattern '*linux_amd64.tar.gz' --dir /tmp/oasd + tar -xzf /tmp/oasd/*linux_amd64.tar.gz -C "$HOME/.local/bin" oasdiff + # ALWAYS export the bin dir (even if any step above failed) echo "$HOME/.local/bin" >> "$GITHUB_PATH" + # diagnostics — prove what installed on the next CI run + ls -la "$HOME/.local/bin" + "$HOME/.local/bin/gitleaks" version || true + "$HOME/.local/bin/actionlint" -version || true + "$HOME/.local/bin/osv-scanner" --version || true + "$HOME/.local/bin/oasdiff" --version || true + zizmor --version || true - name: Secret scan (gitleaks; skips if absent) run: npm run check:secrets - name: Vulnerability ratchet (osv-scanner; skips if absent) run: npm run check:vuln-ratchet - name: Workflow lint (actionlint+zizmor; skips if absent) run: npm run check:workflows + # OpenAPI breaking-change detection (oasdiff). Diffs the PR's public API + # contract (docs/reference/openapi.yaml) against the base branch's spec. + # ADVISORY: reports `openapiBreaking=N` and self-skips when oasdiff is absent + # or the base spec can't be resolved. BASE_REF is read by the script from the + # env (never interpolated into a shell body) — workflow-injection-safe. + - name: OpenAPI breaking-change (oasdiff; advisory) + env: + BASE_REF: ${{ github.base_ref }} + run: npm run check:openapi-breaking docs-sync-strict: name: Docs Sync (Strict) @@ -188,6 +256,30 @@ jobs: - name: i18n translation drift (warn) run: node scripts/i18n/check-translation-drift.mjs --warn + docs-lint: + name: Docs Lint (prose — advisory) + runs-on: ubuntu-latest + # Advisory (warning-first): prose/markdown style must not block merges while the + # existing doc corpus is brought up to style. Promote to blocking once it converges. + continue-on-error: true + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.CI_NODE_VERSION }} + cache: npm + - name: markdownlint (docs + root, advisory) + run: npx --yes markdownlint-cli2 "docs/**/*.md" "*.md" "!docs/i18n" "!docs/research" || true + - name: Vale prose lint (Microsoft style, advisory) + # Non-fatal: a Vale/reviewdog setup error must not turn this advisory job red. + continue-on-error: true + uses: errata-ai/vale-action@reviewdog + with: + files: docs + fail_on_error: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + i18n-ui-coverage: name: i18n UI Coverage runs-on: ubuntu-latest @@ -229,8 +321,14 @@ jobs: python-version: "3.12" - name: Validate ${{ matrix.lang }} + env: + # Pass the matrix value via env (never interpolate ${{ ... }} straight + # into the run: script body) so the shell receives a variable, not + # inlined text — zizmor template-injection mitigation. Named MATRIX_LANG + # to avoid clobbering the POSIX `LANG` locale variable. + MATRIX_LANG: ${{ matrix.lang }} run: | - python3 scripts/i18n/validate_translation.py quick -l '${{ matrix.lang }}' > result.txt + python3 scripts/i18n/validate_translation.py quick -l "$MATRIX_LANG" > result.txt - name: Upload result if: always() @@ -511,10 +609,11 @@ jobs: path: coverage-shards/ merge-multiple: true - name: Merge + report + gate - # Merging 8 shards of raw v8 coverage is memory-heavy; the default Node - # heap OOMs (exit 134). Raise it for the c8 merge/report step. + # Merging 8 shards of raw v8 coverage is memory-heavy; the 6 GB heap can + # still OOM on large PR runs. Keep this job focused on the gate and + # JSON summary that downstream ratchets consume. env: - NODE_OPTIONS: --max-old-space-size=6144 + NODE_OPTIONS: --max-old-space-size=8192 run: | mkdir -p coverage if [ ! -d coverage-shards ] || ! find coverage-shards -maxdepth 1 -type f -name '*.json' | grep -q .; then @@ -532,9 +631,7 @@ jobs: --temp-directory coverage-shards \ --reports-dir coverage \ --reporter=text-summary \ - --reporter=html \ --reporter=json-summary \ - --reporter=lcov \ --exclude=tests/** \ --exclude=**/*.test.* \ --check-coverage \ @@ -563,7 +660,6 @@ jobs: name: coverage-report path: | coverage/coverage-summary.json - coverage/lcov.info coverage/coverage-report.md if-no-files-found: warn diff --git a/.github/workflows/electron-release.yml b/.github/workflows/electron-release.yml index f37d7b152a..372e093525 100644 --- a/.github/workflows/electron-release.yml +++ b/.github/workflows/electron-release.yml @@ -33,11 +33,18 @@ jobs: - name: Validate version format id: validate + env: + # Pass workflow context via env (never interpolate ${{ ... }} straight + # into the run: script body) so the shell receives variables, not + # inlined text — zizmor template-injection mitigation. INPUT_VERSION is + # the operator-supplied value and is regex-validated below before use. + EVENT_NAME: ${{ github.event_name }} + INPUT_VERSION: ${{ inputs.version }} run: | - if [[ "${{ github.event_name }}" == "push" ]]; then + if [[ "$EVENT_NAME" == "push" ]]; then VERSION="${GITHUB_REF#refs/tags/}" else - VERSION="${{ inputs.version }}" + VERSION="$INPUT_VERSION" fi if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then @@ -114,8 +121,13 @@ jobs: - name: Sync version in electron/package.json shell: bash + env: + # Pass the validated version via env (never interpolate ${{ ... }} + # straight into the run: script body) — zizmor template-injection + # mitigation. Already regex-validated (^v[0-9]+\.[0-9]+\.[0-9]+$) in + # the `validate` job, so it cannot carry shell metacharacters. + VERSION: ${{ needs.validate.outputs.version }} run: | - VERSION="${{ needs.validate.outputs.version }}" VERSION_NO_V="${VERSION#v}" node -e " const fs = require('fs'); @@ -212,14 +224,20 @@ jobs: merge-multiple: true - name: Create source archives + env: + # Pass the validated version via env (never interpolate ${{ ... }} + # straight into the run: script body) — zizmor template-injection + # mitigation. Already regex-validated (^v[0-9]+\.[0-9]+\.[0-9]+$) in + # the `validate` job, so it cannot carry shell metacharacters. + VERSION: ${{ needs.validate.outputs.version }} run: | # Create source code archives (excluding dev dependencies and build artifacts) - export TARBALL="OmniRoute-${{ needs.validate.outputs.version }}.source.tar.gz" - export ZIPBALL="OmniRoute-${{ needs.validate.outputs.version }}.source.zip" + export TARBALL="OmniRoute-${VERSION}.source.tar.gz" + export ZIPBALL="OmniRoute-${VERSION}.source.zip" # Use git archive for clean source export - git archive --format=tar.gz --prefix=OmniRoute-${{ needs.validate.outputs.version }}/ HEAD -o "release-assets/$TARBALL" - git archive --format=zip --prefix=OmniRoute-${{ needs.validate.outputs.version }}/ HEAD -o "release-assets/$ZIPBALL" + git archive --format=tar.gz --prefix="OmniRoute-${VERSION}/" HEAD -o "release-assets/$TARBALL" + git archive --format=zip --prefix="OmniRoute-${VERSION}/" HEAD -o "release-assets/$ZIPBALL" echo "✓ Created source archives:" ls -lh "release-assets/$TARBALL" "release-assets/$ZIPBALL" diff --git a/.github/workflows/nightly-llm-security.yml b/.github/workflows/nightly-llm-security.yml index 2ac2778e39..0838c9b11b 100644 --- a/.github/workflows/nightly-llm-security.yml +++ b/.github/workflows/nightly-llm-security.yml @@ -43,16 +43,35 @@ jobs: garak: name: garak probes (skip without provider secret) runs-on: ubuntu-latest - if: ${{ secrets.PROMPTFOO_PROVIDER_KEY != '' }} + # NOTE: the `secrets` context is NOT available in a job-level `if:` — referencing + # it there makes GitHub reject the file on push (startup_failure on every push). + # Map the secret into a job-level env and gate each step on a presence check, so + # the job stays green and simply skips the probes when the secret is absent. + env: + PROMPTFOO_PROVIDER_KEY: ${{ secrets.PROMPTFOO_PROVIDER_KEY }} steps: + - name: Gate on provider secret + id: gate + run: | + if [ -n "$PROMPTFOO_PROVIDER_KEY" ]; then + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "run=false" >> "$GITHUB_OUTPUT" + echo "::notice::PROMPTFOO_PROVIDER_KEY not set — skipping garak probes (advisory)." + fi - uses: actions/checkout@v6 + if: steps.gate.outputs.run == 'true' - uses: actions/setup-node@v6 + if: steps.gate.outputs.run == 'true' with: { node-version: "24", cache: npm } - run: npm ci + if: steps.gate.outputs.run == 'true' - name: Build CLI bundle + if: steps.gate.outputs.run == 'true' env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation } run: npm run build:cli - name: Start OmniRoute + if: steps.gate.outputs.run == 'true' env: JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation PORT: "20128" @@ -64,13 +83,16 @@ jobs: sleep 2 done - uses: actions/setup-python@v5 + if: steps.gate.outputs.run == 'true' with: { python-version: "3.12" } - run: pip install garak + if: steps.gate.outputs.run == 'true' - name: garak limited probes + if: steps.gate.outputs.run == 'true' env: OPENAI_API_KEY: ${{ secrets.PROMPTFOO_PROVIDER_KEY }} OPENAI_BASE_URL: http://localhost:20128/v1 run: garak --model_type openai --model_name gpt-4o-mini --probes promptinject,dan,leakreplay --report_prefix garak-omniroute || true - name: Stop server - if: always() + if: always() && steps.gate.outputs.run == 'true' run: kill "$(cat server.pid)" || true diff --git a/.github/workflows/nightly-mutation.yml b/.github/workflows/nightly-mutation.yml new file mode 100644 index 0000000000..7b14c6296a --- /dev/null +++ b/.github/workflows/nightly-mutation.yml @@ -0,0 +1,37 @@ +name: Nightly Mutation +on: + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + stryker: + name: Stryker mutation testing (8 critical modules — advisory) + runs-on: ubuntu-latest + # Mutation testing is expensive (~200-500 mutants, 30-90 min). It runs only + # on the nightly schedule / manual dispatch, never on PRs. The score is NOT + # yet enforced as a ratchet (wired in a later INT phase) — for now the job + # just produces the HTML/JSON report and uploads it as an artifact. + timeout-minutes: 120 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: npm + - run: npm ci + - name: Run Stryker (advisory) + id: stryker + continue-on-error: true + run: npx stryker run + - name: Upload mutation report + if: always() + uses: actions/upload-artifact@v4 + with: + name: mutation-report + path: reports/mutation/ + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/nightly-schemathesis.yml b/.github/workflows/nightly-schemathesis.yml new file mode 100644 index 0000000000..0255c4a85c --- /dev/null +++ b/.github/workflows/nightly-schemathesis.yml @@ -0,0 +1,70 @@ +name: Nightly Schemathesis +on: + schedule: + - cron: "23 4 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + schemathesis: + name: Schemathesis — OpenAPI contract fuzz (advisory) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: { node-version: "24", cache: npm } + - run: npm ci + - name: Build CLI bundle + env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation } + run: npm run build:cli + - name: Start OmniRoute (background) + env: + JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation + PORT: "20128" + run: | + node dist/server.js > server.log 2>&1 & + echo $! > server.pid + for i in $(seq 1 30); do + if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo "server up"; break; fi + sleep 2 + done + - uses: actions/setup-python@v5 + with: { python-version: "3.12" } + - name: Install schemathesis + run: pip install schemathesis + - name: Schemathesis contract fuzz (advisory) + # Advisory gate: never fails the job. `continue-on-error` covers a crash of the + # step itself; `|| true` covers schemathesis exiting non-zero when it finds spec + # violations / upstream 500s — both are expected here (most /v1 endpoints proxy an + # upstream that has no provider configured in CI). The point of the nightly is to + # PROVE the contract is fuzzable and surface regressions, not to gate the build. + continue-on-error: true + run: | + schemathesis run docs/reference/openapi.yaml \ + --url http://localhost:20128 \ + --max-examples 20 \ + --workers 4 \ + --checks all \ + --max-response-time 30 \ + --request-timeout 30 \ + --suppress-health-check all \ + --report junit \ + --report-junit-path schemathesis-report/junit.xml \ + --no-color \ + || true + - name: Stop server + if: always() + run: kill "$(cat server.pid)" || true + - name: Upload schemathesis report + if: always() + uses: actions/upload-artifact@v4 + with: + name: schemathesis-report + path: | + schemathesis-report/ + server.log + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/wiki-sync.yml b/.github/workflows/wiki-sync.yml new file mode 100644 index 0000000000..7381ed5648 --- /dev/null +++ b/.github/workflows/wiki-sync.yml @@ -0,0 +1,69 @@ +name: Wiki Sync + +# Keeps the GitHub wiki in sync with docs/ on every release that lands on main. +# The wiki has no native generator and historically drifts (it sat at "212+ providers / +# 14 strategies / 37 MCP tools" while code was at 226 / 15 / 87, and new docs like +# SUPPLY_CHAIN never appeared). This runs scripts/docs/sync-wiki.mjs, which: +# - ADDS any docs/ page missing from the wiki (curated; internal reports excluded), +# - syncs the four cover-page counts on Home.md. +# It does NOT overwrite existing wiki pages by default: several docs sources still carry +# stale counts (e.g. ARCHITECTURE.md says "177 providers" while the wiki cover is 226), +# so blind overwrite would regress the wiki. Full content parity (--update-existing) is +# gated on regenerating those sources — see docs/ops/DOCUMENTATION_AUDIT_REPORT.md. + +on: + push: + branches: [main] + paths: + - "docs/**" + - "README.md" + - "AGENTS.md" + - "src/shared/constants/routingStrategies.ts" + - "config/i18n.json" + - "open-sse/mcp-server/server.ts" + - "scripts/docs/sync-wiki.mjs" + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: wiki-sync + cancel-in-progress: false + +jobs: + sync-wiki: + name: Sync wiki with docs + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Clone wiki + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + git clone "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.wiki.git" wiki + + - name: Sync wiki (add missing pages + cover counts) + run: node scripts/docs/sync-wiki.mjs --wiki-dir wiki + + - name: Commit & push if changed + run: | + cd wiki + if [ -n "$(git status --porcelain)" ]; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "docs(wiki): auto-sync pages + cover counts with docs" + git push + echo "Wiki updated." + else + echo "Wiki already in sync — nothing to push." + fi diff --git a/.gitignore b/.gitignore index cbe3af7f64..de10d077d2 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,11 @@ coverage/ .build/** .out/** +# Stryker mutation testing — ephemeral sandbox + generated reports (never commit) +.stryker-tmp/ +reports/mutation/ +stryker-output-*.json + # Memory Bank and Cursor rules (local-only AI agent context) memory-bank/ @@ -203,8 +208,11 @@ pr_reviews*.json # internal setup prompts with personal credentials — never commit CODEX-SETUP-PROMPT.md -# Quality ratchet — métricas efêmeras (baseline é commitado, métricas não) -quality-metrics.json +# Quality ratchet — métricas efêmeras (baseline commitado em config/quality/; métricas não) +config/quality/quality-metrics.json + +# Runtime logs (diretório local, nunca versionado) +/logs/ -home-diegosouzapw-dev-automações-bots-yt-downloader-20260504 .txt -home-diegosouzapw-dev-automações-bots-yt-downloader-20260410 .txt docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute.md diff --git a/.gitleaks.toml b/.gitleaks.toml index 9e221be280..0051e694b2 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -15,7 +15,15 @@ # Referência: docs/security/PUBLIC_CREDS.md (credenciais OAuth públicas conhecidas) # CLAUDE.md Hard Rule #11 (resolvePublicCred obrigatório) -# Usar as regras padrão do gitleaks (não declaramos [rules] aqui para herdar tudo) +# Herdar TODAS as regras padrão do gitleaks. ATENÇÃO: um config customizado +# SEM [extend].useDefault = true (e sem [[rules]] próprias) resulta em ZERO +# regras — o gitleaks SUBSTITUI o ruleset padrão pelo arquivo, não o estende +# automaticamente. Sem esta seção, `gitleaks --config .gitleaks.toml` nunca +# detecta nada (todo finding vira 0), tornando o gate inerte. Com useDefault, +# a allowlist abaixo é aplicada POR CIMA das ~170 regras padrão. +[extend] + useDefault = true + # Para desabilitar uma regra específica, usar: # [[rules]] # id = "rule-id" diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000000..68ab04a1ff --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,14 @@ +{ + "_comment": "Advisory markdown lint for docs/ + root *.md. Rules that conflict with the existing doc style (heavy inline HTML, long lines, centered headings) are disabled so the gate stays signal-not-noise. Run: npm run lint:md", + "default": true, + "MD013": false, + "MD033": false, + "MD041": false, + "MD024": { "siblings_only": true }, + "MD026": false, + "MD036": false, + "MD040": false, + "MD029": false, + "MD007": { "indent": 2 }, + "MD046": false +} diff --git a/.mcp.json.example b/.mcp.json.example new file mode 100644 index 0000000000..0a11e61a62 --- /dev/null +++ b/.mcp.json.example @@ -0,0 +1,17 @@ +{ + "$comment_purpose": "OPT-IN agent-lsp / LSP-in-the-loop (Quality Gates Fase 7 Task 15). Copy this file to `.mcp.json` to enable. It exposes a TypeScript language server to coding agents (Claude Code, etc.) so they get diagnostics / hover / go-to-definition / blast-radius BEFORE writing code — turning 'invented symbol' review-catches into impossible-at-edit-time. Pairs with `npm run typecheck:core` as a compile-before-claim check.", + "$comment_safety": "Shipped as `.example` (NOT `.mcp.json`) on purpose so it never auto-loads an unvetted server into everyone's session. Pick an MCP<->LSP bridge you trust and have verified locally, then drop in its package + args below. A broken MCP entry only logs a connection error; it does not break agent sessions. The underlying language server is `typescript-language-server` (npm, mature) — install via `npm i -g typescript-language-server typescript` or rely on npx.", + "mcpServers": { + "typescript-lsp": { + "command": "npx", + "args": [ + "-y", + "", + "--lsp", + "typescript-language-server", + "--stdio" + ], + "$note": "Replace with the concrete MCP<->LSP adapter you chose. It must speak MCP on stdio and proxy to `typescript-language-server --stdio`. Scope it to this repo's tsconfig (open-sse/tsconfig.json / tsconfig.json) for accurate diagnostics." + } + } +} diff --git a/.vale.ini b/.vale.ini new file mode 100644 index 0000000000..de42bb1bad --- /dev/null +++ b/.vale.ini @@ -0,0 +1,15 @@ +# Advisory prose lint for OmniRoute docs (Vale — https://vale.sh). +# Warning-first: surfaces vague language, passive voice, and terminology drift without +# blocking CI. The Microsoft style is pulled by the CI step (vale sync / vale-action). +# Project terms live in the OmniRoute vocabulary so they never read as misspellings. +StylesPath = .vale/styles +MinAlertLevel = warning +Packages = Microsoft +Vocab = OmniRoute + +[*.md] +BasedOnStyles = Vale, Microsoft + +# Code-heavy reference docs and auto-generated catalogs are noisy under prose rules. +[docs/reference/PROVIDER_REFERENCE.md] +BasedOnStyles = Vale diff --git a/.vale/styles/Vocab/OmniRoute/accept.txt b/.vale/styles/Vocab/OmniRoute/accept.txt new file mode 100644 index 0000000000..50e154c3ce --- /dev/null +++ b/.vale/styles/Vocab/OmniRoute/accept.txt @@ -0,0 +1,50 @@ +OmniRoute +combo +combos +[Cc]ombo +[Aa]uto-[Cc]ombo +MCP +A2A +ACP +RTK +Caveman +Troglodita +Qdrant +FTS5 +SQLite +LowDB +OAuth +PKCE +SSE +JSON-RPC +LKGP +P2C +Antigravity +Codex +Kiro +Qoder +Pollinations +LongCat +Cerebras +DeepSeek +Groq +Cline +OpenCode +Termux +Electron +Fumadocs +Notion +Obsidian +WebDAV +IPv4 +IPv6 +SOCKS5 +loopback +backoff +changelog +i18n +locale +locales +roadmap +[Ww]ildcard +upstream diff --git a/.zizmor.yml b/.zizmor.yml index 83eefa7d85..7bb17a1dbe 100644 --- a/.zizmor.yml +++ b/.zizmor.yml @@ -30,22 +30,44 @@ # Uncomment to pin to a specific minimum severity level. # min-severity: low # low | medium | high | critical (default: low = all) -# ── Per-finding ignores ────────────────────────────────────────────────────── +# ── Per-rule ignores ───────────────────────────────────────────────────────── +# zizmor ≥1.0 replaced the old top-level `ignores:` list with a `rules:` map +# keyed by audit id. Per-rule config lives under `rules..ignore`, +# where each entry is `filename` or `filename:line` (line optional, column +# further-optional). See: https://docs.zizmor.sh/configuration/ +# # Format: -# ignores: -# - id: # e.g. "unpinned-uses", "script-injection" -# reason: "" -# # Optional: scope to a specific workflow or step -# # workflow: .github/workflows/ci.yml +# rules: +# : # e.g. "unpinned-uses", "template-injection" +# ignore: +# - # ignore this audit across the file +# - : # or scope to a specific line # # Example (do not uncomment without real justification): # -# ignores: -# - id: unpinned-uses -# reason: > -# actions/checkout@v6 is pinned at the major-version tag intentionally: -# GitHub-managed first-party action; SHA pinning buys little against a -# GitHub-side compromise and adds significant maintenance burden. -# workflow: .github/workflows/ci.yml +# rules: +# unpinned-uses: +# # actions/checkout@v6 is pinned at the major-version tag intentionally: +# # GitHub-managed first-party action; SHA pinning buys little against a +# # GitHub-side compromise and adds significant maintenance burden. +# ignore: +# - ci.yml -ignores: [] +# Every entry below is an explicit, justified ignore (same stale-review +# discipline as the other allowlists in this project). New ignores require a +# per-rule entry with a justification comment. +rules: + dangerous-triggers: + # deploy-vps.yml uses `on: workflow_run` (after "Publish to Docker Hub"). + # zizmor flags workflow_run as "almost always used insecurely", but this one + # is guarded and not exploitable: + # • The deploy job is gated on `github.event.workflow_run.conclusion == + # 'success'` (deploy-vps.yml job `if:`, L15), so it only runs after a + # successful, trusted upstream run — never on a forked/PR-triggered + # failure. + # • It does NOT checkout or execute untrusted code: the deploy runs over SSH + # using repository secrets; there is no `actions/checkout` of an attacker + # ref in the privileged context. + # Accepted risk, re-review at the next release cycle (added 2026-06-15). + ignore: + - deploy-vps.yml diff --git a/@omniroute/opencode-plugin/package.json b/@omniroute/opencode-plugin/package.json index ee6bc62363..e2f2bbde4c 100644 --- a/@omniroute/opencode-plugin/package.json +++ b/@omniroute/opencode-plugin/package.json @@ -3,19 +3,16 @@ "version": "0.1.0", "description": "OpenCode plugin for the OmniRoute AI Gateway. Drives dynamic model discovery, /connect auth flow, and multi-instance OmniRoute providers via the official @opencode-ai/plugin contract.", "type": "module", - "main": "./dist/index.cjs", - "module": "./dist/index.js", + "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./runtime": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" } }, "files": [ diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index 6c8c92540b..f70807434c 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -56,11 +56,7 @@ import { randomUUID } from "node:crypto"; import type { AuthHook, Config, Plugin, PluginOptions, ProviderHook } from "@opencode-ai/plugin"; import type { Model as ModelV2 } from "@opencode-ai/sdk/v2"; import { z } from "zod"; -import { - logger as _logger, - setLogLevel, - type LogLevel as _LogLevel, -} from "./logger.js"; +import { logger as _logger, setLogLevel, type LogLevel as _LogLevel } from "./logger.js"; import { PROVIDER_TAG_SEPARATOR as _PROVIDER_TAG_SEPARATOR, shortProviderLabel as _shortProviderLabel, @@ -207,13 +203,11 @@ export const OMNIROUTE_PROVIDER_KEY = "omniroute" as const; /** Deployed plugin version (injected at build time by tsup define). */ export const PLUGIN_VERSION: string = - ((globalThis as Record).__PLUGIN_VERSION__ as string) ?? - "dev"; + ((globalThis as Record).__PLUGIN_VERSION__ as string) ?? "dev"; /** Deployed plugin git commit hash (injected at build time by tsup define). */ export const PLUGIN_GIT_HASH: string = - ((globalThis as Record).__PLUGIN_GIT_HASH__ as string) ?? - "unknown"; + ((globalThis as Record).__PLUGIN_GIT_HASH__ as string) ?? "unknown"; export const DEFAULT_MODEL_CACHE_TTL_MS = 300_000 as const; @@ -243,17 +237,13 @@ function trimLeadingDashes(value: string): string { * sees a consistent identifier. */ export function resolveOmniRoutePluginOptions( - opts?: OmniRoutePluginOptions, -): Required< - Pick -> & + opts?: OmniRoutePluginOptions +): Required> & Pick { const providerId = opts?.providerId ?? OMNIROUTE_PROVIDER_KEY; const displayName = opts?.displayName ?? - (providerId === OMNIROUTE_PROVIDER_KEY - ? "OmniRoute" - : `OmniRoute (${providerId})`); + (providerId === OMNIROUTE_PROVIDER_KEY ? "OmniRoute" : `OmniRoute (${providerId})`); const modelCacheTtl = typeof opts?.modelCacheTtl === "number" && opts.modelCacheTtl > 0 ? opts.modelCacheTtl @@ -284,9 +274,7 @@ export function resolveOmniRoutePluginOptions( * Exported so callers and tests can validate options independent of the * full plugin factory invocation. */ -export function parseOmniRoutePluginOptions( - opts: unknown, -): OmniRoutePluginOptions { +export function parseOmniRoutePluginOptions(opts: unknown): OmniRoutePluginOptions { if (opts === null || opts === undefined) return {}; const result = optionsSchema.safeParse(opts); if (!result.success) { @@ -322,13 +310,7 @@ function coercePluginOptions(opts?: PluginOptions): OmniRoutePluginOptions { * `anthropic/`, plus the user-configured `kiro/` and `kr/` upstream * connections that proxy Anthropic models. */ -export const DEFAULT_ANTHROPIC_PREFIXES = [ - "cc", - "claude", - "anthropic", - "kiro", - "kr", -]; +export const DEFAULT_ANTHROPIC_PREFIXES = ["cc", "claude", "anthropic", "kiro", "kr"]; /** * Ensure a baseURL ends with `/v1` so the OpenAI-compat SDK constructs @@ -378,7 +360,6 @@ export function resolveApiBlock( }; } - /** * Build the AuthHook portion of the plugin for a given options bag. Exported * standalone so the auth contract can be unit-tested without faking the full @@ -403,11 +384,8 @@ export function resolveApiBlock( * keys by returning `{}` — OC then surfaces the `/connect` flow to the * user instead of dispatching a request with bogus credentials. */ -export function createOmniRouteAuthHook( - opts?: OmniRoutePluginOptions, -): AuthHook { - const { providerId, displayName, baseURL, features } = - resolveOmniRoutePluginOptions(opts); +export function createOmniRouteAuthHook(opts?: OmniRoutePluginOptions): AuthHook { + const { providerId, displayName, baseURL, features } = resolveOmniRoutePluginOptions(opts); // Both fetch-layer features default ON (parity with the rest of the plugin's // `features.X !== false` convention). Honoring them here lets users disable // the interceptor/sanitizer from opencode.json — previously these flags were @@ -449,8 +427,7 @@ export function createOmniRouteAuthHook( // no `baseURL`. We've already checked the runtime type via typeof so // the unknown-bridge is a safe assertion, not a lie. const authBaseURL = (auth as unknown as { baseURL?: unknown }).baseURL; - const resolvedBaseURL = - baseURL ?? (typeof authBaseURL === "string" ? authBaseURL : ""); + const resolvedBaseURL = baseURL ?? (typeof authBaseURL === "string" ? authBaseURL : ""); // Without a baseURL the interceptor can't tell which requests to // intercept (it would either gate-keep nothing or, worse, all // outbound traffic). Fall back to apiKey-only and let the SDK use @@ -477,11 +454,7 @@ export function createOmniRouteAuthHook( composedFetch = createGeminiSanitizingFetch(composedFetch ?? fetch); } if (wantDebugLog || debugLogEnabled(providerId)) { - composedFetch = createDebugLoggingFetch( - composedFetch ?? fetch, - providerId, - wantDebugLog - ); + composedFetch = createDebugLoggingFetch(composedFetch ?? fetch, providerId, wantDebugLog); } return composedFetch ? { apiKey, baseURL: resolvedBaseURL, fetch: composedFetch } @@ -519,14 +492,10 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => { // Debug breadcrumb: confirm server() invocation + resolved options. // Useful when diagnosing "is the plugin even running" from OC logs. const _ver: string = - ((globalThis as Record).__PLUGIN_VERSION__ as string) ?? - "dev"; + ((globalThis as Record).__PLUGIN_VERSION__ as string) ?? "dev"; const _hash: string = - ((globalThis as Record).__PLUGIN_GIT_HASH__ as string) ?? - "unknown"; - const _prefixes = - resolved.features?.apiFormat?.anthropicPrefixes ?? - DEFAULT_ANTHROPIC_PREFIXES; + ((globalThis as Record).__PLUGIN_GIT_HASH__ as string) ?? "unknown"; + const _prefixes = resolved.features?.apiFormat?.anthropicPrefixes ?? DEFAULT_ANTHROPIC_PREFIXES; _logger.always( `v${_ver} (${_hash}) initialized` + ` providerId=${resolved.providerId}` + @@ -534,15 +503,11 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => { ` modelCacheTtl=${resolved.modelCacheTtl}ms` + ` apiFormat=anthropic:[${_prefixes.join(",")}]` + ` debugLog=${resolved.features?.debugLog ?? false}` + - ` logLevel=${resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")}`, + ` logLevel=${resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")}` ); // Wire log level: startupDebug:true → "debug", explicit logLevel wins. - setLogLevel( - resolved.features?.startupDebug - ? "debug" - : (resolved.features?.logLevel ?? "warn"), - ); + setLogLevel(resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")); return { auth: createOmniRouteAuthHook(resolved), provider: createOmniRouteProviderHook(resolved, { cache: sharedCache }), @@ -621,7 +586,7 @@ export interface OmniRouteRawModelEntry { export type OmniRouteModelsFetcher = ( baseURL: string, apiKey: string, - timeoutMs?: number, + timeoutMs?: number ) => Promise; /** @@ -634,23 +599,15 @@ export type OmniRouteModelsFetcher = ( export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async ( baseURL, apiKey, - timeoutMs = 10_000, + timeoutMs = 10_000 ) => { - if (!apiKey) - throw new Error( - "@omniroute/opencode-plugin: apiKey required to fetch /v1/models", - ); - if (!baseURL) - throw new Error( - "@omniroute/opencode-plugin: baseURL required to fetch /v1/models", - ); + if (!apiKey) throw new Error("@omniroute/opencode-plugin: apiKey required to fetch /v1/models"); + if (!baseURL) throw new Error("@omniroute/opencode-plugin: baseURL required to fetch /v1/models"); const trimmed = trimTrailingSlashes(baseURL); // Tolerate both `https://host` and `https://host/v1` forms — the gateway // exposes /v1/models either way; we just don't want a double `/v1/v1`. - const url = /\/v\d+$/.test(trimmed) - ? `${trimmed}/models` - : `${trimmed}/v1/models`; + const url = /\/v\d+$/.test(trimmed) ? `${trimmed}/models` : `${trimmed}/v1/models`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); @@ -665,24 +622,18 @@ export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async ( }); if (!res.ok) { throw new Error( - `@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}`, + `@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}` ); } const body = (await res.json()) as unknown; const rawList: unknown[] = Array.isArray(body) ? body - : body && - typeof body === "object" && - Array.isArray((body as { data?: unknown }).data) + : body && typeof body === "object" && Array.isArray((body as { data?: unknown }).data) ? ((body as { data: unknown[] }).data as unknown[]) : []; const out: OmniRouteRawModelEntry[] = []; for (const r of rawList) { - if ( - r && - typeof r === "object" && - typeof (r as { id?: unknown }).id === "string" - ) { + if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") { out.push(r as OmniRouteRawModelEntry); } } @@ -793,11 +744,8 @@ export function mapRawModelToModelV2( }, limit: { context: typeof raw.context_length === "number" ? raw.context_length : 0, - ...(typeof raw.max_input_tokens === "number" - ? { input: raw.max_input_tokens } - : {}), - output: - typeof raw.max_output_tokens === "number" ? raw.max_output_tokens : 0, + ...(typeof raw.max_input_tokens === "number" ? { input: raw.max_input_tokens } : {}), + output: typeof raw.max_output_tokens === "number" ? raw.max_output_tokens : 0, }, status: "active", options: {}, @@ -872,7 +820,7 @@ export interface OmniRouteRawCombo { export type OmniRouteCombosFetcher = ( baseURL: string, apiKey: string, - timeoutMs?: number, + timeoutMs?: number ) => Promise; /** @@ -896,16 +844,11 @@ export type OmniRouteCombosFetcher = ( export const defaultOmniRouteCombosFetcher: OmniRouteCombosFetcher = async ( baseURL, apiKey, - timeoutMs = 10_000, + timeoutMs = 10_000 ) => { - if (!apiKey) - throw new Error( - "@omniroute/opencode-plugin: apiKey required to fetch /api/combos", - ); + if (!apiKey) throw new Error("@omniroute/opencode-plugin: apiKey required to fetch /api/combos"); if (!baseURL) - throw new Error( - "@omniroute/opencode-plugin: baseURL required to fetch /api/combos", - ); + throw new Error("@omniroute/opencode-plugin: baseURL required to fetch /api/combos"); // Strip trailing slashes, then strip a trailing `/v1` so we land on the // management plane. Models live under `/v1/models`; combos live under @@ -927,24 +870,18 @@ export const defaultOmniRouteCombosFetcher: OmniRouteCombosFetcher = async ( }); if (!res.ok) { throw new Error( - `@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}`, + `@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}` ); } const body = (await res.json()) as unknown; const rawList: unknown[] = Array.isArray(body) ? body - : body && - typeof body === "object" && - Array.isArray((body as { combos?: unknown }).combos) + : body && typeof body === "object" && Array.isArray((body as { combos?: unknown }).combos) ? ((body as { combos: unknown[] }).combos as unknown[]) : []; const out: OmniRouteRawCombo[] = []; for (const r of rawList) { - if ( - r && - typeof r === "object" && - typeof (r as { id?: unknown }).id === "string" - ) { + if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") { out.push(r as OmniRouteRawCombo); } } @@ -997,18 +934,14 @@ export function mapComboToModelV2( members: OmniRouteRawModelEntry[], providerId: string, baseURL: string, - apiFormat?: { anthropicPrefixes?: string[] }, + apiFormat?: { anthropicPrefixes?: string[] } ): ModelV2 { // `every` over an empty array returns true (would lie about an empty // combo's capabilities) — short-circuit to all-false when no members. const hasMembers = members.length > 0; - const memberInMods = members.map( - (m) => new Set(m.input_modalities ?? ["text"]), - ); - const memberOutMods = members.map( - (m) => new Set(m.output_modalities ?? ["text"]), - ); + const memberInMods = members.map((m) => new Set(m.input_modalities ?? ["text"])); + const memberOutMods = members.map((m) => new Set(m.output_modalities ?? ["text"])); const modalityAllHave = (sets: Array>, key: string): boolean => hasMembers && sets.every((s) => s.has(key)); @@ -1023,26 +956,18 @@ export function mapComboToModelV2( .map((m) => m.max_input_tokens) .filter((v): v is number => typeof v === "number" && v > 0); - const everyDeclaresInput = - hasMembers && inputValues.length === members.length; + const everyDeclaresInput = hasMembers && inputValues.length === members.length; const capabilities: ModelV2["capabilities"] = { temperature: - hasMembers && - members.every((m) => (m.capabilities?.temperature ?? true) !== false), + hasMembers && members.every((m) => (m.capabilities?.temperature ?? true) !== false), reasoning: hasMembers && - members.every((m) => - Boolean(m.capabilities?.reasoning || m.capabilities?.thinking), - ), + members.every((m) => Boolean(m.capabilities?.reasoning || m.capabilities?.thinking)), attachment: hasMembers && - members.every((m) => - Boolean(m.capabilities?.attachment ?? m.capabilities?.vision ?? false), - ), - toolcall: - hasMembers && - members.every((m) => Boolean(m.capabilities?.tool_calling ?? false)), + members.every((m) => Boolean(m.capabilities?.attachment ?? m.capabilities?.vision ?? false)), + toolcall: hasMembers && members.every((m) => Boolean(m.capabilities?.tool_calling ?? false)), input: { text: modalityAllHave(memberInMods, "text"), audio: modalityAllHave(memberInMods, "audio"), @@ -1057,8 +982,7 @@ export function mapComboToModelV2( video: modalityAllHave(memberOutMods, "video"), pdf: modalityAllHave(memberOutMods, "pdf"), }, - interleaved: - hasMembers && members.every((m) => Boolean(m.capabilities?.thinking)), + interleaved: hasMembers && members.every((m) => Boolean(m.capabilities?.thinking)), }; // Combos span multiple providers. Use Anthropic format only when ALL @@ -1067,7 +991,7 @@ export function mapComboToModelV2( const comboApiBlock = (() => { if (!hasMembers) return resolveApiBlock("", baseURL, apiFormat); const allAnthropic = members.every( - (m) => resolveApiBlock(m.id, baseURL, apiFormat).id === "anthropic", + (m) => resolveApiBlock(m.id, baseURL, apiFormat).id === "anthropic" ); return allAnthropic ? resolveApiBlock(members[0].id, baseURL, apiFormat) @@ -1145,7 +1069,7 @@ export interface OmniRouteRawAutoCombo { export type OmniRouteAutoCombosFetcher = ( baseURL: string, apiKey: string, - timeoutMs?: number, + timeoutMs?: number ) => Promise; /** @@ -1154,67 +1078,64 @@ export type OmniRouteAutoCombosFetcher = ( * Fault-tolerant: returns empty array on 404 (endpoint doesn't exist yet) * or any non-2xx / network error. Logs a warning in those cases. */ -export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher = - async (baseURL, apiKey, timeoutMs = 5_000) => { - if (!apiKey || !baseURL) return []; +export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher = async ( + baseURL, + apiKey, + timeoutMs = 5_000 +) => { + if (!apiKey || !baseURL) return []; - const trimmed = trimTrailingSlashes(baseURL); - const root = trimmed.replace(/\/v\d+$/, ""); - const url = `${root}/api/combos/auto`; + const trimmed = trimTrailingSlashes(baseURL); + const root = trimmed.replace(/\/v\d+$/, ""); + const url = `${root}/api/combos/auto`; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const res = await fetch(url, { - method: "GET", - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json", - }, - signal: controller.signal, - }); - // 404 = endpoint not deployed yet — expected during rollout - if (res.status === 404) { - console.warn( - `[omniroute-plugin] /api/combos/auto not available (404) — auto combos disabled`, - ); - return []; - } - if (!res.ok) { - console.warn( - `[omniroute-plugin] /api/combos/auto failed: ${res.status} ${res.statusText} — auto combos disabled`, - ); - return []; - } - const body = (await res.json()) as unknown; - const rawList: unknown[] = Array.isArray(body) - ? body - : body && - typeof body === "object" && - Array.isArray((body as { combos?: unknown }).combos) - ? ((body as { combos: unknown[] }).combos as unknown[]) - : []; - const out: OmniRouteRawAutoCombo[] = []; - for (const r of rawList) { - if ( - r && - typeof r === "object" && - typeof (r as { id?: unknown }).id === "string" - ) { - out.push(r as OmniRouteRawAutoCombo); - } - } - return out; - } catch (err) { - // Network error, timeout, abort — all non-fatal + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + signal: controller.signal, + }); + // 404 = endpoint not deployed yet — expected during rollout + if (res.status === 404) { console.warn( - `[omniroute-plugin] /api/combos/auto fetch failed: ${err instanceof Error ? err.message : String(err)} — auto combos disabled`, + `[omniroute-plugin] /api/combos/auto not available (404) — auto combos disabled` ); return []; - } finally { - clearTimeout(timer); } - }; + if (!res.ok) { + console.warn( + `[omniroute-plugin] /api/combos/auto failed: ${res.status} ${res.statusText} — auto combos disabled` + ); + return []; + } + const body = (await res.json()) as unknown; + const rawList: unknown[] = Array.isArray(body) + ? body + : body && typeof body === "object" && Array.isArray((body as { combos?: unknown }).combos) + ? ((body as { combos: unknown[] }).combos as unknown[]) + : []; + const out: OmniRouteRawAutoCombo[] = []; + for (const r of rawList) { + if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") { + out.push(r as OmniRouteRawAutoCombo); + } + } + return out; + } catch (err) { + // Network error, timeout, abort — all non-fatal + console.warn( + `[omniroute-plugin] /api/combos/auto fetch failed: ${err instanceof Error ? err.message : String(err)} — auto combos disabled` + ); + return []; + } finally { + clearTimeout(timer); + } +}; /** Fallbacks when the server does not advertise auto-combo limits (older * OmniRoute builds). MUST be positive: OpenCode's overflow guard treats @@ -1233,7 +1154,7 @@ const AUTO_COMBO_FALLBACK_OUTPUT = 8_192; * applies when the server omits them. Never 0. */ export function mapAutoComboToStaticEntry( - autoCombo: OmniRouteRawAutoCombo, + autoCombo: OmniRouteRawAutoCombo ): OmniRouteStaticModelEntry { const variant = autoCombo.variant; const name = formatAutoComboName(variant, autoCombo.candidateCount); @@ -1320,7 +1241,7 @@ export type OmniRouteEnrichmentMap = Map; export type OmniRouteEnrichmentFetcher = ( baseURL: string, apiKey: string, - timeoutMs?: number, + timeoutMs?: number ) => Promise; /** @@ -1344,225 +1265,208 @@ export type OmniRouteEnrichmentFetcher = ( * the two fetches are independent so one missing source still surfaces the * other. */ -export const defaultOmniRouteEnrichmentFetcher: OmniRouteEnrichmentFetcher = - async (baseURL, apiKey, timeoutMs = 10_000) => { - const out: OmniRouteEnrichmentMap = new Map(); - if (!baseURL || !apiKey) return out; - const root = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, ""); - const headers = { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json", - }; - - // ── 1. Catalog with nice display names ──────────────────────────────── - const catalogAc = new AbortController(); - const catalogTimer = setTimeout(() => catalogAc.abort(), timeoutMs); - try { - const res = await fetch(`${root}/api/pricing/models`, { - method: "GET", - headers, - signal: catalogAc.signal, - }); - if (res.ok) { - const body = (await res.json()) as unknown; - const providers = - (body as { providers?: Record }) - ?.providers ?? (body as Record); - if (providers && typeof providers === "object") { - for (const [providerAlias, slot] of Object.entries(providers)) { - if (!slot || typeof slot !== "object") continue; - const models = (slot as { models?: unknown[] }).models; - if (!Array.isArray(models)) continue; - // Canonical id sits at the per-provider top level (e.g. - // `pricing-models.cc.id === 'claude'`). Falls back to the alias - // itself when missing — common case alias===canonical. - const canonicalRaw = (slot as { id?: unknown }).id; - const providerCanonical = - typeof canonicalRaw === "string" && canonicalRaw.length > 0 - ? canonicalRaw - : providerAlias; - // Upstream provider human label (e.g. `Claude`, `Kiro`, - // `GitHub Models`). Optional — falls back to undefined when - // OmniRoute hasn't curated a label for this slot. - const slotNameRaw = (slot as { name?: unknown }).name; - const providerDisplayName = - typeof slotNameRaw === "string" && slotNameRaw.trim().length > 0 - ? slotNameRaw.trim() - : undefined; - for (const m of models) { - if (!m || typeof m !== "object") continue; - const id = (m as { id?: unknown }).id; - if (typeof id !== "string" || id.length === 0) continue; - const name = (m as { name?: unknown }).name; - const entry: OmniRouteEnrichmentEntry = { - providerAlias, - providerCanonical, - }; - if (providerDisplayName) - entry.providerDisplayName = providerDisplayName; - if (typeof name === "string" && name.trim().length > 0) - entry.name = name; - const namespaced = `${providerAlias}/${id}`; - if (!out.has(namespaced)) out.set(namespaced, entry); - if (!out.has(id)) out.set(id, entry); - } - } - } - } - } catch { - // Soft-fail; keep going to pricing fetch. - } finally { - clearTimeout(catalogTimer); - } - - // ── 2. Pricing values from /api/pricing ─────────────────────────────── - const priceAc = new AbortController(); - const priceTimer = setTimeout(() => priceAc.abort(), timeoutMs); - try { - const res = await fetch(`${root}/api/pricing`, { - method: "GET", - headers, - signal: priceAc.signal, - }); - if (res.ok) { - const body = (await res.json()) as unknown; - if (body && typeof body === "object" && !Array.isArray(body)) { - for (const [providerAlias, slot] of Object.entries( - body as Record, - )) { - if (!slot || typeof slot !== "object" || Array.isArray(slot)) - continue; - for (const [modelId, raw] of Object.entries( - slot as Record, - )) { - if (!raw || typeof raw !== "object") continue; - const p = raw as Record; - const parsed: NonNullable = - {}; - // OmniRoute `/api/pricing` keys: - // input → cost.input - // output → cost.output - // cached → cost.cache.read (alias: cacheRead) - // cache_creation → cost.cache.write (alias: cacheWrite) - // Tolerate alternative spellings for forward-compat. - if (typeof p.input === "number") parsed.input = p.input; - if (typeof p.output === "number") parsed.output = p.output; - const cacheRead = - typeof p.cached === "number" - ? p.cached - : typeof p.cacheRead === "number" - ? p.cacheRead - : undefined; - if (typeof cacheRead === "number") parsed.cacheRead = cacheRead; - const cacheWrite = - typeof p.cache_creation === "number" - ? p.cache_creation - : typeof p.cacheWrite === "number" - ? p.cacheWrite - : undefined; - if (typeof cacheWrite === "number") - parsed.cacheWrite = cacheWrite; - if (Object.keys(parsed).length === 0) continue; - const namespaced = `${providerAlias}/${modelId}`; - const existingNs = out.get(namespaced); - if (existingNs) - existingNs.pricing = { - ...(existingNs.pricing ?? {}), - ...parsed, - }; - else out.set(namespaced, { pricing: parsed }); - const existingBare = out.get(modelId); - if (existingBare) - existingBare.pricing = { - ...(existingBare.pricing ?? {}), - ...parsed, - }; - else out.set(modelId, { pricing: parsed }); - } - } - } - } - } catch { - // Soft-fail; return whatever names we collected. - } finally { - clearTimeout(priceTimer); - } - - // ── 3. Free model budgets from /api/free-tier/summary ────────────────── - // Best-effort fetch: populates freeType/monthlyTokens/creditTokens on - // enrichment entries that match. 404 = endpoint doesn't exist — skip. - // Uses the EXISTING /api/free-tier/summary endpoint (no new server code). - const freeAc = new AbortController(); - const freeTimer = setTimeout(() => freeAc.abort(), timeoutMs); - try { - const res = await fetch(`${root}/api/free-tier/summary`, { - method: "GET", - headers, - signal: freeAc.signal, - }); - if (res.ok) { - const body = (await res.json()) as unknown; - // Response shape: { perModel: FreeModelBudget[], ... } - const perModel: unknown[] = - body && - typeof body === "object" && - Array.isArray((body as { perModel?: unknown }).perModel) - ? ((body as { perModel: unknown[] }).perModel as unknown[]) - : Array.isArray(body) - ? (body as unknown[]) - : []; - let matched = 0; - for (const fm of perModel) { - if (!fm || typeof fm !== "object") continue; - const fmObj = fm as Record; - const provider = - typeof fmObj.provider === "string" ? fmObj.provider : ""; - const modelId = - typeof fmObj.modelId === "string" ? fmObj.modelId : ""; - const freeType = - typeof fmObj.freeType === "string" ? fmObj.freeType : ""; - if (!modelId || !freeType) continue; - const monthlyTokens = - typeof fmObj.monthlyTokens === "number" - ? fmObj.monthlyTokens - : undefined; - const creditTokens = - typeof fmObj.creditTokens === "number" - ? fmObj.creditTokens - : undefined; - // Match against enrichment entries: namespaced, bare, and displayName - const displayName = - typeof fmObj.displayName === "string" ? fmObj.displayName : ""; - const candidates = [ - `${provider}/${modelId}`, - modelId, - ...(displayName ? [displayName] : []), - ]; - for (const key of candidates) { - const entry = out.get(key); - if (entry) { - entry.freeType = freeType as FreeModelFreeType; - if (monthlyTokens !== undefined) - entry.monthlyTokens = monthlyTokens; - if (creditTokens !== undefined) entry.creditTokens = creditTokens; - matched++; - break; - } - } - } - _logger.debug( - `free-tier/summary: ${perModel.length} models returned, ${matched} matched enrichment entries`, - ); - } - } catch { - // Soft-fail; free metadata is optional. - } finally { - clearTimeout(freeTimer); - } - - return out; +export const defaultOmniRouteEnrichmentFetcher: OmniRouteEnrichmentFetcher = async ( + baseURL, + apiKey, + timeoutMs = 10_000 +) => { + const out: OmniRouteEnrichmentMap = new Map(); + if (!baseURL || !apiKey) return out; + const root = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, ""); + const headers = { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", }; + // ── 1. Catalog with nice display names ──────────────────────────────── + const catalogAc = new AbortController(); + const catalogTimer = setTimeout(() => catalogAc.abort(), timeoutMs); + try { + const res = await fetch(`${root}/api/pricing/models`, { + method: "GET", + headers, + signal: catalogAc.signal, + }); + if (res.ok) { + const body = (await res.json()) as unknown; + const providers = + (body as { providers?: Record })?.providers ?? + (body as Record); + if (providers && typeof providers === "object") { + for (const [providerAlias, slot] of Object.entries(providers)) { + if (!slot || typeof slot !== "object") continue; + const models = (slot as { models?: unknown[] }).models; + if (!Array.isArray(models)) continue; + // Canonical id sits at the per-provider top level (e.g. + // `pricing-models.cc.id === 'claude'`). Falls back to the alias + // itself when missing — common case alias===canonical. + const canonicalRaw = (slot as { id?: unknown }).id; + const providerCanonical = + typeof canonicalRaw === "string" && canonicalRaw.length > 0 + ? canonicalRaw + : providerAlias; + // Upstream provider human label (e.g. `Claude`, `Kiro`, + // `GitHub Models`). Optional — falls back to undefined when + // OmniRoute hasn't curated a label for this slot. + const slotNameRaw = (slot as { name?: unknown }).name; + const providerDisplayName = + typeof slotNameRaw === "string" && slotNameRaw.trim().length > 0 + ? slotNameRaw.trim() + : undefined; + for (const m of models) { + if (!m || typeof m !== "object") continue; + const id = (m as { id?: unknown }).id; + if (typeof id !== "string" || id.length === 0) continue; + const name = (m as { name?: unknown }).name; + const entry: OmniRouteEnrichmentEntry = { + providerAlias, + providerCanonical, + }; + if (providerDisplayName) entry.providerDisplayName = providerDisplayName; + if (typeof name === "string" && name.trim().length > 0) entry.name = name; + const namespaced = `${providerAlias}/${id}`; + if (!out.has(namespaced)) out.set(namespaced, entry); + if (!out.has(id)) out.set(id, entry); + } + } + } + } + } catch { + // Soft-fail; keep going to pricing fetch. + } finally { + clearTimeout(catalogTimer); + } + + // ── 2. Pricing values from /api/pricing ─────────────────────────────── + const priceAc = new AbortController(); + const priceTimer = setTimeout(() => priceAc.abort(), timeoutMs); + try { + const res = await fetch(`${root}/api/pricing`, { + method: "GET", + headers, + signal: priceAc.signal, + }); + if (res.ok) { + const body = (await res.json()) as unknown; + if (body && typeof body === "object" && !Array.isArray(body)) { + for (const [providerAlias, slot] of Object.entries(body as Record)) { + if (!slot || typeof slot !== "object" || Array.isArray(slot)) continue; + for (const [modelId, raw] of Object.entries(slot as Record)) { + if (!raw || typeof raw !== "object") continue; + const p = raw as Record; + const parsed: NonNullable = {}; + // OmniRoute `/api/pricing` keys: + // input → cost.input + // output → cost.output + // cached → cost.cache.read (alias: cacheRead) + // cache_creation → cost.cache.write (alias: cacheWrite) + // Tolerate alternative spellings for forward-compat. + if (typeof p.input === "number") parsed.input = p.input; + if (typeof p.output === "number") parsed.output = p.output; + const cacheRead = + typeof p.cached === "number" + ? p.cached + : typeof p.cacheRead === "number" + ? p.cacheRead + : undefined; + if (typeof cacheRead === "number") parsed.cacheRead = cacheRead; + const cacheWrite = + typeof p.cache_creation === "number" + ? p.cache_creation + : typeof p.cacheWrite === "number" + ? p.cacheWrite + : undefined; + if (typeof cacheWrite === "number") parsed.cacheWrite = cacheWrite; + if (Object.keys(parsed).length === 0) continue; + const namespaced = `${providerAlias}/${modelId}`; + const existingNs = out.get(namespaced); + if (existingNs) + existingNs.pricing = { + ...(existingNs.pricing ?? {}), + ...parsed, + }; + else out.set(namespaced, { pricing: parsed }); + const existingBare = out.get(modelId); + if (existingBare) + existingBare.pricing = { + ...(existingBare.pricing ?? {}), + ...parsed, + }; + else out.set(modelId, { pricing: parsed }); + } + } + } + } + } catch { + // Soft-fail; return whatever names we collected. + } finally { + clearTimeout(priceTimer); + } + + // ── 3. Free model budgets from /api/free-tier/summary ────────────────── + // Best-effort fetch: populates freeType/monthlyTokens/creditTokens on + // enrichment entries that match. 404 = endpoint doesn't exist — skip. + // Uses the EXISTING /api/free-tier/summary endpoint (no new server code). + const freeAc = new AbortController(); + const freeTimer = setTimeout(() => freeAc.abort(), timeoutMs); + try { + const res = await fetch(`${root}/api/free-tier/summary`, { + method: "GET", + headers, + signal: freeAc.signal, + }); + if (res.ok) { + const body = (await res.json()) as unknown; + // Response shape: { perModel: FreeModelBudget[], ... } + const perModel: unknown[] = + body && typeof body === "object" && Array.isArray((body as { perModel?: unknown }).perModel) + ? ((body as { perModel: unknown[] }).perModel as unknown[]) + : Array.isArray(body) + ? (body as unknown[]) + : []; + let matched = 0; + for (const fm of perModel) { + if (!fm || typeof fm !== "object") continue; + const fmObj = fm as Record; + const provider = typeof fmObj.provider === "string" ? fmObj.provider : ""; + const modelId = typeof fmObj.modelId === "string" ? fmObj.modelId : ""; + const freeType = typeof fmObj.freeType === "string" ? fmObj.freeType : ""; + if (!modelId || !freeType) continue; + const monthlyTokens = + typeof fmObj.monthlyTokens === "number" ? fmObj.monthlyTokens : undefined; + const creditTokens = + typeof fmObj.creditTokens === "number" ? fmObj.creditTokens : undefined; + // Match against enrichment entries: namespaced, bare, and displayName + const displayName = typeof fmObj.displayName === "string" ? fmObj.displayName : ""; + const candidates = [ + `${provider}/${modelId}`, + modelId, + ...(displayName ? [displayName] : []), + ]; + for (const key of candidates) { + const entry = out.get(key); + if (entry) { + entry.freeType = freeType as FreeModelFreeType; + if (monthlyTokens !== undefined) entry.monthlyTokens = monthlyTokens; + if (creditTokens !== undefined) entry.creditTokens = creditTokens; + matched++; + break; + } + } + } + _logger.debug( + `free-tier/summary: ${perModel.length} models returned, ${matched} matched enrichment entries` + ); + } + } catch { + // Soft-fail; free metadata is optional. + } finally { + clearTimeout(freeTimer); + } + + return out; +}; + // ── Startup diagnostics writer (file-based) ────────────────────────────── // OC doesn't capture plugin console.warn in its log file. Write diagnostics // to a file so they're readable after session starts. Capped at 64KB. @@ -1595,16 +1499,16 @@ async function writeStartupDiagnostics(params: { lines.push(`=== startupDebug ${new Date().toISOString()} ===`); lines.push(`providerId=${providerId} baseURL=${baseURL}`); lines.push( - `models=${modelCount} combos=${comboCount} enrichment=${enrichmentSize} autoCombos=${autoComboCount}`, + `models=${modelCount} combos=${comboCount} enrichment=${enrichmentSize} autoCombos=${autoComboCount}` ); lines.push( - `enrichment: ${withName.length} with name, ${withPricing.length} with pricing, ${withFree.length} free`, + `enrichment: ${withName.length} with name, ${withPricing.length} with pricing, ${withFree.length} free` ); if (withFree.length > 0) { lines.push(`free models (${withFree.length}):`); for (const [k, e] of withFree.slice(0, 10)) { lines.push( - ` ${k} → name=${e.name ?? "(none)"}, freeType=${e.freeType}, monthly=${e.monthlyTokens ?? 0}, credits=${e.creditTokens ?? 0}`, + ` ${k} → name=${e.name ?? "(none)"}, freeType=${e.freeType}, monthly=${e.monthlyTokens ?? 0}, credits=${e.creditTokens ?? 0}` ); } } else { @@ -1612,7 +1516,7 @@ async function writeStartupDiagnostics(params: { `NO free models detected. ` + (enrichmentSize === 0 ? "Enrichment map is EMPTY." - : `Enrichment has ${enrichmentSize} entries but none have freeType.`), + : `Enrichment has ${enrichmentSize} entries but none have freeType.`) ); } const sampleNames = enriched @@ -1625,7 +1529,7 @@ async function writeStartupDiagnostics(params: { } if (autoCombos.length > 0) { lines.push( - `auto combos: ${autoCombos.length} — ${autoCombos.map((ac) => `${ac.id}(${ac.candidateCount ?? "?"}p)`).join(", ")}`, + `auto combos: ${autoCombos.length} — ${autoCombos.map((ac) => `${ac.id}(${ac.candidateCount ?? "?"}p)`).join(", ")}` ); } lines.push(`=== end startupDebug ===\n`); @@ -1635,13 +1539,8 @@ async function writeStartupDiagnostics(params: { try { const diagDir = - process.env.OPENCODE_DATA_DIR ?? - path.join(os.homedir(), ".local/share/opencode"); - const diagPath = path.join( - diagDir, - "plugins", - "omniroute-startup-diagnostics.log", - ); + process.env.OPENCODE_DATA_DIR ?? path.join(os.homedir(), ".local/share/opencode"); + const diagPath = path.join(diagDir, "plugins", "omniroute-startup-diagnostics.log"); let existing = ""; try { existing = await readFile(diagPath, "utf8"); @@ -1650,10 +1549,7 @@ async function writeStartupDiagnostics(params: { } const KEEP = 65_536; const combined = existing + diagnostics; - const trimmed = - combined.length > KEEP - ? combined.slice(combined.length - KEEP) - : combined; + const trimmed = combined.length > KEEP ? combined.slice(combined.length - KEEP) : combined; await writeFile(diagPath, trimmed, "utf8"); } catch { /* best effort */ @@ -1674,7 +1570,7 @@ export const PROVIDER_TAG_SEPARATOR = _PROVIDER_TAG_SEPARATOR; // Re-export from naming.ts — thin wrapper preserving OmniRouteEnrichmentEntry signature export function shortProviderLabel( - enrichment: OmniRouteEnrichmentEntry | undefined, + enrichment: OmniRouteEnrichmentEntry | undefined ): string | undefined { return _shortProviderLabel(enrichment); } @@ -1706,7 +1602,7 @@ export function shortProviderLabel( */ export function applyProviderTag( model: ModelV2, - enrichment: OmniRouteEnrichmentEntry | undefined, + enrichment: OmniRouteEnrichmentEntry | undefined ): ModelV2 { const label = shortProviderLabel(enrichment); if (!label) return model; @@ -1737,17 +1633,14 @@ export function applyProviderTag( * like `kiro`). */ export function buildCanonicalToAliasMap( - enrichment: OmniRouteEnrichmentMap | undefined, + enrichment: OmniRouteEnrichmentMap | undefined ): Map { const out = new Map(); if (!enrichment) return out; for (const entry of enrichment.values()) { - const alias = - typeof entry.providerAlias === "string" ? entry.providerAlias.trim() : ""; + const alias = typeof entry.providerAlias === "string" ? entry.providerAlias.trim() : ""; const canonical = - typeof entry.providerCanonical === "string" - ? entry.providerCanonical.trim() - : ""; + typeof entry.providerCanonical === "string" ? entry.providerCanonical.trim() : ""; if (alias.length === 0 || canonical.length === 0) continue; if (alias === canonical) continue; if (!out.has(canonical)) out.set(canonical, alias); @@ -1774,7 +1667,7 @@ export function buildCanonicalToAliasMap( export function lookupEnrichment( rawId: string, enrichment: OmniRouteEnrichmentMap | undefined, - canonicalToAlias: Map, + canonicalToAlias: Map ): OmniRouteEnrichmentEntry | undefined { if (!enrichment) return undefined; const direct = enrichment.get(rawId); @@ -1809,7 +1702,7 @@ export function lookupEnrichment( */ export function canonicalDedupSet( rawModels: ReadonlyArray, - canonicalToAlias: Map, + canonicalToAlias: Map ): Set { const drop = new Set(); if (canonicalToAlias.size === 0) return drop; @@ -1853,13 +1746,12 @@ export function canonicalDedupSet( * `buildCanonicalToAliasMap` semantics). */ export function buildAliasIndex( - enrichment: OmniRouteEnrichmentMap | undefined, + enrichment: OmniRouteEnrichmentMap | undefined ): Map { const out = new Map(); if (!enrichment) return out; for (const entry of enrichment.values()) { - const alias = - typeof entry.providerAlias === "string" ? entry.providerAlias.trim() : ""; + const alias = typeof entry.providerAlias === "string" ? entry.providerAlias.trim() : ""; if (alias.length === 0) continue; if (out.has(alias)) { // First-wins, but upgrade to the first entry that carries a @@ -1867,8 +1759,7 @@ export function buildAliasIndex( const existing = out.get(alias); if ( existing && - (!existing.providerDisplayName || - existing.providerDisplayName.trim().length === 0) && + (!existing.providerDisplayName || existing.providerDisplayName.trim().length === 0) && typeof entry.providerDisplayName === "string" && entry.providerDisplayName.trim().length > 0 ) { @@ -1907,17 +1798,12 @@ export function resolveProviderTagEntry( rawId: string, direct: OmniRouteEnrichmentEntry | undefined, aliasIndex: Map, - canonicalToAlias?: Map, + canonicalToAlias?: Map ): OmniRouteEnrichmentEntry | undefined { if (direct) { - const alias = - typeof direct.providerAlias === "string" - ? direct.providerAlias.trim() - : ""; + const alias = typeof direct.providerAlias === "string" ? direct.providerAlias.trim() : ""; const display = - typeof direct.providerDisplayName === "string" - ? direct.providerDisplayName.trim() - : ""; + typeof direct.providerDisplayName === "string" ? direct.providerDisplayName.trim() : ""; if (alias.length > 0 || display.length > 0) return direct; } const slash = rawId.indexOf("/"); @@ -1957,7 +1843,7 @@ export { _normaliseFreeLabel as normaliseFreeLabel }; export function applyEnrichment( model: ModelV2, - enrichment: OmniRouteEnrichmentEntry | undefined, + enrichment: OmniRouteEnrichmentEntry | undefined ): ModelV2 { if (!enrichment) return model; if (enrichment.name && enrichment.name.trim().length > 0) { @@ -2004,7 +1890,7 @@ export interface OmniRouteCompressionCombo { export type OmniRouteCompressionMetaFetcher = ( baseURL: string, apiKey: string, - timeoutMs?: number, + timeoutMs?: number ) => Promise; /** @@ -2012,67 +1898,70 @@ export type OmniRouteCompressionMetaFetcher = ( * Tolerates envelope shapes `{ combos: [...] }`, `[...]`, or * `{ data: [...] }`. Soft-fails (returns []) on non-2xx or parse errors. */ -export const defaultOmniRouteCompressionMetaFetcher: OmniRouteCompressionMetaFetcher = - async (baseURL, apiKey, timeoutMs = 10_000) => { - const empty: OmniRouteCompressionCombo[] = []; - if (!baseURL || !apiKey) return empty; - const root = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, ""); - const url = `${root}/api/context/combos`; - const ac = new AbortController(); - const timer = setTimeout(() => ac.abort(), timeoutMs); - try { - const res = await fetch(url, { - method: "GET", - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json", - }, - signal: ac.signal, - }); - if (!res.ok) return empty; - const body = (await res.json()) as unknown; - const list = Array.isArray(body) - ? body - : Array.isArray((body as { combos?: unknown[] })?.combos) - ? (body as { combos: unknown[] }).combos - : Array.isArray((body as { data?: unknown[] })?.data) - ? (body as { data: unknown[] }).data - : []; - const out: OmniRouteCompressionCombo[] = []; - for (const raw of list) { - if (!raw || typeof raw !== "object") continue; - const id = (raw as { id?: unknown }).id; - const pipeline = (raw as { pipeline?: unknown }).pipeline; - if (typeof id !== "string" || id.length === 0) continue; - if (!Array.isArray(pipeline)) continue; - const steps: OmniRouteCompressionStep[] = []; - for (const step of pipeline) { - if (!step || typeof step !== "object") continue; - const engine = (step as { engine?: unknown }).engine; - if (typeof engine !== "string" || engine.length === 0) continue; - const intensity = (step as { intensity?: unknown }).intensity; - const entry: OmniRouteCompressionStep = { engine }; - if (typeof intensity === "string" && intensity.length > 0) { - entry.intensity = intensity; - } - steps.push(entry); +export const defaultOmniRouteCompressionMetaFetcher: OmniRouteCompressionMetaFetcher = async ( + baseURL, + apiKey, + timeoutMs = 10_000 +) => { + const empty: OmniRouteCompressionCombo[] = []; + if (!baseURL || !apiKey) return empty; + const root = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, ""); + const url = `${root}/api/context/combos`; + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + try { + const res = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + signal: ac.signal, + }); + if (!res.ok) return empty; + const body = (await res.json()) as unknown; + const list = Array.isArray(body) + ? body + : Array.isArray((body as { combos?: unknown[] })?.combos) + ? (body as { combos: unknown[] }).combos + : Array.isArray((body as { data?: unknown[] })?.data) + ? (body as { data: unknown[] }).data + : []; + const out: OmniRouteCompressionCombo[] = []; + for (const raw of list) { + if (!raw || typeof raw !== "object") continue; + const id = (raw as { id?: unknown }).id; + const pipeline = (raw as { pipeline?: unknown }).pipeline; + if (typeof id !== "string" || id.length === 0) continue; + if (!Array.isArray(pipeline)) continue; + const steps: OmniRouteCompressionStep[] = []; + for (const step of pipeline) { + if (!step || typeof step !== "object") continue; + const engine = (step as { engine?: unknown }).engine; + if (typeof engine !== "string" || engine.length === 0) continue; + const intensity = (step as { intensity?: unknown }).intensity; + const entry: OmniRouteCompressionStep = { engine }; + if (typeof intensity === "string" && intensity.length > 0) { + entry.intensity = intensity; } - const combo: OmniRouteCompressionCombo = { id, pipeline: steps }; - const name = (raw as { name?: unknown }).name; - if (typeof name === "string" && name.length > 0) combo.name = name; - const description = (raw as { description?: unknown }).description; - if (typeof description === "string") combo.description = description; - const isDefault = (raw as { isDefault?: unknown }).isDefault; - if (typeof isDefault === "boolean") combo.isDefault = isDefault; - out.push(combo); + steps.push(entry); } - return out; - } catch { - return empty; - } finally { - clearTimeout(timer); + const combo: OmniRouteCompressionCombo = { id, pipeline: steps }; + const name = (raw as { name?: unknown }).name; + if (typeof name === "string" && name.length > 0) combo.name = name; + const description = (raw as { description?: unknown }).description; + if (typeof description === "string") combo.description = description; + const isDefault = (raw as { isDefault?: unknown }).isDefault; + if (typeof isDefault === "boolean") combo.isDefault = isDefault; + out.push(combo); } - }; + return out; + } catch { + return empty; + } finally { + clearTimeout(timer); + } +}; /** * Map of well-known compression-intensity tokens to a single emoji @@ -2110,9 +1999,7 @@ export const COMPRESSION_INTENSITY_EMOJI: Record = { * `[caveman]` (engine without intensity, no emoji) * `[rtk:custom-thing]` (unknown intensity, raw-text fallback) */ -export function formatCompressionPipeline( - pipeline: OmniRouteCompressionStep[], -): string { +export function formatCompressionPipeline(pipeline: OmniRouteCompressionStep[]): string { if (!pipeline || pipeline.length === 0) return ""; return ( "[" + @@ -2157,7 +2044,7 @@ export interface OmniRouteProviderConnection { export type OmniRouteProvidersFetcher = ( baseURL: string, apiKey: string, - timeoutMs?: number, + timeoutMs?: number ) => Promise; /** @@ -2166,48 +2053,51 @@ export type OmniRouteProvidersFetcher = ( * (returns []) on non-2xx or parse errors so the `usableOnly` filter * gracefully degrades to "no filter" instead of hiding the whole catalog. */ -export const defaultOmniRouteProvidersFetcher: OmniRouteProvidersFetcher = - async (baseURL, apiKey, timeoutMs = 10_000) => { - const empty: OmniRouteProviderConnection[] = []; - if (!baseURL || !apiKey) return empty; - const root = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, ""); - const url = `${root}/api/providers`; - const ac = new AbortController(); - const timer = setTimeout(() => ac.abort(), timeoutMs); - try { - const res = await fetch(url, { - method: "GET", - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json", - }, - signal: ac.signal, - }); - if (!res.ok) return empty; - const body = (await res.json()) as unknown; - const list = Array.isArray(body) - ? body - : Array.isArray((body as { connections?: unknown[] })?.connections) - ? (body as { connections: unknown[] }).connections - : Array.isArray((body as { data?: unknown[] })?.data) - ? (body as { data: unknown[] }).data - : []; - const out: OmniRouteProviderConnection[] = []; - for (const raw of list) { - if (!raw || typeof raw !== "object") continue; - const provider = (raw as { provider?: unknown }).provider; - if (typeof provider !== "string" || provider.length === 0) continue; - const id = (raw as { id?: unknown }).id; - const idStr = typeof id === "string" && id.length > 0 ? id : provider; - out.push({ ...(raw as Record), id: idStr, provider }); - } - return out; - } catch { - return empty; - } finally { - clearTimeout(timer); +export const defaultOmniRouteProvidersFetcher: OmniRouteProvidersFetcher = async ( + baseURL, + apiKey, + timeoutMs = 10_000 +) => { + const empty: OmniRouteProviderConnection[] = []; + if (!baseURL || !apiKey) return empty; + const root = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, ""); + const url = `${root}/api/providers`; + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + try { + const res = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + signal: ac.signal, + }); + if (!res.ok) return empty; + const body = (await res.json()) as unknown; + const list = Array.isArray(body) + ? body + : Array.isArray((body as { connections?: unknown[] })?.connections) + ? (body as { connections: unknown[] }).connections + : Array.isArray((body as { data?: unknown[] })?.data) + ? (body as { data: unknown[] }).data + : []; + const out: OmniRouteProviderConnection[] = []; + for (const raw of list) { + if (!raw || typeof raw !== "object") continue; + const provider = (raw as { provider?: unknown }).provider; + if (typeof provider !== "string" || provider.length === 0) continue; + const id = (raw as { id?: unknown }).id; + const idStr = typeof id === "string" && id.length > 0 ? id : provider; + out.push({ ...(raw as Record), id: idStr, provider }); } - }; + return out; + } catch { + return empty; + } finally { + clearTimeout(timer); + } +}; /** * Compute the set of provider aliases that have at least one healthy, @@ -2231,7 +2121,7 @@ export const defaultOmniRouteProvidersFetcher: OmniRouteProvidersFetcher = */ export function usableProviderAliasSet( connections: OmniRouteProviderConnection[], - enrichment: OmniRouteEnrichmentMap | undefined, + enrichment: OmniRouteEnrichmentMap | undefined ): { aliases: Set; canonicals: Set; @@ -2290,7 +2180,7 @@ export function isUsableRawModelId( canonicals: Set; knownAliases: Set; }, - enrichment: OmniRouteEnrichmentMap | undefined, + enrichment: OmniRouteEnrichmentMap | undefined ): boolean { const slash = id.indexOf("/"); if (slash <= 0) return true; @@ -2316,7 +2206,7 @@ export function isUsableCombo( aliases: Set; canonicals: Set; knownAliases: Set; - }, + } ): boolean { const steps = Array.isArray(combo.models) ? combo.models : []; if (steps.length === 0) return true; @@ -2333,8 +2223,7 @@ export function isUsableCombo( if (slash <= 0) continue; // no provider prefix to evaluate sawResolvableMember = true; const prefix = modelId.slice(0, slash); - if (usable.aliases.has(prefix) || usable.canonicals.has(prefix)) - return true; + if (usable.aliases.has(prefix) || usable.canonicals.has(prefix)) return true; // Unknown prefix (not in the known-alias universe) → can't prove // unroutable; keep. Known-but-not-usable prefixes keep scanning. if (!usable.knownAliases.has(prefix)) return true; @@ -2355,9 +2244,7 @@ export function isUsableCombo( */ export function slugifyComboName(name: string): string { if (typeof name !== "string") return ""; - return trimLeadingDashes( - trimTrailingDashes(name.toLowerCase().replace(/[^a-z0-9]+/g, "-")), - ); + return trimLeadingDashes(trimTrailingDashes(name.toLowerCase().replace(/[^a-z0-9]+/g, "-"))); } /** @@ -2370,12 +2257,8 @@ export function slugifyComboName(name: string): string { * Falls back to `combo/` when the friendly name slugifies to the empty * string (e.g. a combo named just punctuation). */ -export function buildComboKey( - combo: OmniRouteRawCombo, - used: Set, -): string { - const friendlyName = - combo.name && combo.name.trim().length > 0 ? combo.name.trim() : combo.id; +export function buildComboKey(combo: OmniRouteRawCombo, used: Set): string { + const friendlyName = combo.name && combo.name.trim().length > 0 ? combo.name.trim() : combo.id; let slug = slugifyComboName(friendlyName); if (slug.length === 0) slug = combo.id; let key = `combo/${slug}`; @@ -2498,7 +2381,7 @@ export function createOmniRouteProviderHook( providersFetcher?: OmniRouteProvidersFetcher; now?: () => number; cache?: OmniRouteFetchCache; - } = {}, + } = {} ): ProviderHook { const resolved = resolveOmniRoutePluginOptions(opts); const fetcher = deps.fetcher ?? defaultOmniRouteModelsFetcher; @@ -2507,14 +2390,11 @@ export function createOmniRouteProviderHook( // reference resolves at hook-invocation time, not at hook-construction // time, so source-order beyond hoisting rules has no semantic effect. const combosFetcher = deps.combosFetcher ?? defaultOmniRouteCombosFetcher; - const autoCombosFetcher = - deps.autoCombosFetcher ?? defaultOmniRouteAutoCombosFetcher; - const enrichmentFetcher = - deps.enrichmentFetcher ?? defaultOmniRouteEnrichmentFetcher; + const autoCombosFetcher = deps.autoCombosFetcher ?? defaultOmniRouteAutoCombosFetcher; + const enrichmentFetcher = deps.enrichmentFetcher ?? defaultOmniRouteEnrichmentFetcher; const compressionMetaFetcher = deps.compressionMetaFetcher ?? defaultOmniRouteCompressionMetaFetcher; - const providersFetcher = - deps.providersFetcher ?? defaultOmniRouteProvidersFetcher; + const providersFetcher = deps.providersFetcher ?? defaultOmniRouteProvidersFetcher; // Features defaults (mirror v0.1.0 behavior when unset). const features = resolved.features ?? {}; const wantCombos = features.combos !== false; @@ -2561,9 +2441,8 @@ export function createOmniRouteProviderHook( // union (OAuth | ApiAuth | WellKnownAuth) doesn't declare baseURL on // any branch — we duck-type it as a defensive extension point. const authBaseURL = (auth as unknown as { baseURL?: unknown }).baseURL; - const providerBaseURL = ( - _provider as { options?: { baseURL?: unknown } } | undefined - )?.options?.baseURL; + const providerBaseURL = (_provider as { options?: { baseURL?: unknown } } | undefined) + ?.options?.baseURL; const baseURL = resolved.baseURL ?? (typeof authBaseURL === "string" && authBaseURL.length > 0 ? authBaseURL : undefined) ?? @@ -2616,7 +2495,7 @@ export function createOmniRouteProviderHook( } catch (err) { console.warn( "[omniroute-plugin] combos fetch failed, falling back to models-only catalog", - err, + err ); } } @@ -2643,7 +2522,7 @@ export function createOmniRouteProviderHook( } catch (err) { console.warn( "[omniroute-plugin] enrichment fetch failed, falling back to raw ids", - err, + err ); } } @@ -2653,16 +2532,9 @@ export function createOmniRouteProviderHook( rawCompressionCombos = []; if (wantCompressionMeta) { try { - rawCompressionCombos = await compressionMetaFetcher( - baseURL, - apiKey, - 10_000, - ); + rawCompressionCombos = await compressionMetaFetcher(baseURL, apiKey, 10_000); } catch (err) { - console.warn( - "[omniroute-plugin] compression-metadata fetch failed", - err, - ); + console.warn("[omniroute-plugin] compression-metadata fetch failed", err); } } @@ -2678,7 +2550,7 @@ export function createOmniRouteProviderHook( } catch (err) { console.warn( "[omniroute-plugin] /api/providers fetch failed; usableOnly filter disabled for this refresh", - err, + err ); } } @@ -2702,7 +2574,7 @@ export function createOmniRouteProviderHook( `${rawEnrichment.size} enrichment entries + ` + `${rawCompressionCombos.length} compression combos + ` + `${rawConnections.length} connections ` + - `(TTL=${resolved.modelCacheTtl}ms)`, + `(TTL=${resolved.modelCacheTtl}ms)` ); // ── Startup debug: deep-dive into enrichment + auto combos ────── @@ -2756,18 +2628,13 @@ export function createOmniRouteProviderHook( for (const entry of rawModels) { if (!entry.id) continue; if (canonicalDedup.has(entry.id)) continue; - if (usable && !isUsableRawModelId(entry.id, usable, rawEnrichment)) - continue; + if (usable && !isUsableRawModelId(entry.id, usable, rawEnrichment)) continue; const model = mapRawModelToModelV2(entry, { providerId: resolved.providerId, baseURL, apiFormat: resolved.features?.apiFormat, }); - const enrichEntry = lookupEnrichment( - entry.id, - rawEnrichment, - canonicalToAlias, - ); + const enrichEntry = lookupEnrichment(entry.id, rawEnrichment, canonicalToAlias); applyEnrichment(model, enrichEntry); // Prepend upstream provider label (e.g. `Claude - Claude Opus 4.7`) // so the picker groups same-model rows by upstream connection. @@ -2780,7 +2647,7 @@ export function createOmniRouteProviderHook( entry.id, enrichEntry, aliasIndex, - canonicalToAlias, + canonicalToAlias ); applyProviderTag(model, tagEntry); } @@ -2808,89 +2675,183 @@ export function createOmniRouteProviderHook( const comboNames = new Set(); for (const combo of rawCombos) { if (!combo || combo.isHidden === true) continue; - const n = - combo.name && combo.name.trim().length > 0 - ? combo.name.trim() - : combo.id; + const n = combo.name && combo.name.trim().length > 0 ? combo.name.trim() : combo.id; if (typeof n === "string" && n.length > 0) comboNames.add(n); } for (const key of Object.keys(models)) { if (comboNames.has(key)) delete models[key]; } + // ── Combo LCD across nested combo-refs (T-NN) ─────────────────────── + // Combos can nest other combos via `kind: "combo-ref"` members + // (e.g. MASTER-LIGHT contains OldLLM, KIRO, Opecode Zen FREE). The + // nested combo's own `limit.context` is computed below in this same + // loop, so we need a fixpoint iteration: if a combo-ref points at a + // combo not yet processed, defer this combo and try again after the + // sibling combos catch up. We bound the retries so a circular combo + // graph can't deadlock the picker. + const MAX_COMBO_PASSES = 8; const usedComboKeys = new Set(); - for (const combo of rawCombos) { - if (!combo.id) continue; - if (combo.isHidden === true) continue; - // usableOnly filter — drop combos whose members all map to - // non-usable providers. - if (usable && !isUsableCombo(combo, usable)) continue; + // Combos in `rawCombos` that still need (re)processing this round. + // Shrinks as combos resolve. + let pending = rawCombos.filter((combo) => { + if (!combo.id) return false; + if (combo.isHidden === true) return false; + if (usable && !isUsableCombo(combo, usable)) return false; + return true; + }); + // Resolved nested combos keyed by their friendly name, so parent + // combos that reference them via combo-ref can lift the full + // capability vector (not just the context window) into their own + // LCD pass. + const resolvedComboModelsByName = new Map(); - const memberSteps = Array.isArray(combo.models) ? combo.models : []; - const memberEntries: OmniRouteRawModelEntry[] = []; - for (const step of memberSteps) { - // Use the unknown-bridge pattern from commit 91b137e6 so the - // DTS pass stays clean: ComboMemberRef declares `model?: string` - // but we still verify the runtime shape before consuming it. - const modelId = (step as unknown as { model?: unknown }).model; - if (typeof modelId !== "string" || modelId.length === 0) continue; - const member = rawModelById.get(modelId); - if (member) memberEntries.push(member); + for (let pass = 0; pass < MAX_COMBO_PASSES && pending.length > 0; pass++) { + const stillPending: typeof pending = []; + for (const combo of pending) { + const memberSteps = Array.isArray(combo.models) ? combo.models : []; + const memberEntries: OmniRouteRawModelEntry[] = []; + let deferredThisPass = false; + + for (const step of memberSteps) { + // Unknown-bridge: ComboMemberRef's DTS type only declares + // `model?: string`, so verify the runtime shape before reading + // either `model` (raw member) or `comboName` (nested combo). + const stepKind = (step as unknown as { kind?: unknown }).kind; + + if (stepKind === "combo-ref") { + const comboName = (step as unknown as { comboName?: unknown }).comboName; + if (typeof comboName !== "string" || comboName.length === 0) { + continue; + } + const nestedModel = resolvedComboModelsByName.get(comboName); + if (!nestedModel) { + // Nested combo hasn't been processed yet. Defer this + // combo to the next pass. + deferredThisPass = true; + break; + } + // Synthesize a member entry that contributes only the + // nested combo's pre-computed context_length + max_output. + // Other capability axes default conservatively (no tool + // calls, no vision) — a nested combo's modalities are an + // OR, but if we let raw-model defaults leak in we'd + // over-claim. The combo's own LCD (computed by + // mapComboToModelV2 from the synthesized entries) will only + // further restrict capabilities, so this is safe. + // Synthesize a member entry carrying the nested combo's + // pre-computed context + capabilities + modalities so the + // parent combo's LCD is accurate across the whole graph + // (not just its direct raw-model members). + const inputModalities: string[] = []; + if (nestedModel.capabilities.input.text) inputModalities.push("text"); + if (nestedModel.capabilities.input.audio) inputModalities.push("audio"); + if (nestedModel.capabilities.input.image) inputModalities.push("image"); + if (nestedModel.capabilities.input.video) inputModalities.push("video"); + if (nestedModel.capabilities.input.pdf) inputModalities.push("pdf"); + + const outputModalities: string[] = []; + if (nestedModel.capabilities.output.text) outputModalities.push("text"); + if (nestedModel.capabilities.output.audio) outputModalities.push("audio"); + if (nestedModel.capabilities.output.image) outputModalities.push("image"); + if (nestedModel.capabilities.output.video) outputModalities.push("video"); + if (nestedModel.capabilities.output.pdf) outputModalities.push("pdf"); + + memberEntries.push({ + id: `combo-ref:${comboName}`, + context_length: nestedModel.limit.context, + max_output_tokens: nestedModel.limit.output, + max_input_tokens: nestedModel.limit.input ?? 0, + owned_by: "combo", + input_modalities: inputModalities, + output_modalities: outputModalities, + capabilities: { + temperature: nestedModel.capabilities.temperature, + reasoning: nestedModel.capabilities.reasoning, + thinking: nestedModel.capabilities.interleaved, + attachment: nestedModel.capabilities.attachment, + tool_calling: nestedModel.capabilities.toolcall, + }, + } as unknown as OmniRouteRawModelEntry); + continue; + } + + const modelId = (step as unknown as { model?: unknown }).model; + if (typeof modelId !== "string" || modelId.length === 0) continue; + const member = rawModelById.get(modelId); + if (member) memberEntries.push(member); + } + + if (deferredThisPass) { + stillPending.push(combo); + continue; + } + + const mapped = mapComboToModelV2( + combo, + memberEntries, + resolved.providerId, + baseURL, + features.apiFormat + ); + const hasMembers = memberEntries.length > 0; + + // Apply enrichment overlay to combos too (OmniRoute's + // /api/pricing/models surfaces combos alongside provider-scoped + // models with curated names). + applyEnrichment(mapped, rawEnrichment.get(combo.id)); + + // `Combo: ` prefix surfaces the combo nature in OC's model picker. + // Idempotent guard covers the case where enrichment overwrote + // mapped.name with an already-prefixed string. Mirrors the + // static-hook Combo:-prefix decoration. + if (!mapped.name.startsWith("Combo: ")) { + mapped.name = `Combo: ${mapped.name}`; + } + + // Optionally decorate combo name with its compression pipeline. + // Only fires when features.compressionMetadata: true, OmniRoute + // returned at least one default compression combo, AND the + // combo has resolvable members — claiming compression on an + // unroutable combo would mislead the picker. + if (hasMembers && defaultCompression && defaultCompression.pipeline.length > 0) { + const tag = formatCompressionPipeline(defaultCompression.pipeline); + if (tag.length > 0 && !mapped.name.includes(tag)) { + mapped.name = `${mapped.name} ${tag}`; + } + } + + const comboKey = buildComboKey(combo, usedComboKeys); + + // Collision policy: combos win. Warn ONCE per (cacheKey, comboKey) + // when overwriting a same-key raw model so the operator can spot + // the unusual naming choice without log spam. + if (Object.prototype.hasOwnProperty.call(models, comboKey)) { + const dedupeKey = `${cacheKey}::${comboKey}`; + if (!collisionWarned.has(dedupeKey)) { + collisionWarned.add(dedupeKey); + console.warn( + `[omniroute-plugin] combo key "${comboKey}" collides with a model id; combo wins.` + ); + } + } + models[comboKey] = mapped; + + // Make this combo's resolved model available to parent combos + // that reference it via combo-ref. Use the friendly name + // (combo.name) since that's the lookup key on the parent side. + const lookupName = + combo.name && combo.name.trim().length > 0 ? combo.name.trim() : combo.id; + resolvedComboModelsByName.set(lookupName, mapped); } + if (stillPending.length === pending.length) break; + pending = stillPending; + } - const mapped = mapComboToModelV2( - combo, - memberEntries, - resolved.providerId, - baseURL, - features.apiFormat, + if (pending.length > 0) { + console.warn( + `[omniroute-plugin] ${pending.length} combo(s) could not resolve all nested combo-refs after ${MAX_COMBO_PASSES} passes; they will advertise context=0 to avoid over-claiming.` ); - const hasMembers = memberEntries.length > 0; - - // Apply enrichment overlay to combos too (OmniRoute's - // /api/pricing/models surfaces combos alongside provider-scoped - // models with curated names). - applyEnrichment(mapped, rawEnrichment.get(combo.id)); - - // `Combo: ` prefix surfaces the combo nature in OC's model picker. - // Idempotent guard covers the case where enrichment overwrote - // mapped.name with an already-prefixed string. Mirrors the - // static-hook Combo:-prefix decoration. - if (!mapped.name.startsWith("Combo: ")) { - mapped.name = `Combo: ${mapped.name}`; - } - - // Optionally decorate combo name with its compression pipeline. - // Only fires when features.compressionMetadata: true, OmniRoute - // returned at least one default compression combo, AND the - // combo has resolvable members — claiming compression on an - // unroutable combo would mislead the picker. - if ( - hasMembers && - defaultCompression && - defaultCompression.pipeline.length > 0 - ) { - const tag = formatCompressionPipeline(defaultCompression.pipeline); - if (tag.length > 0 && !mapped.name.includes(tag)) { - mapped.name = `${mapped.name} ${tag}`; - } - } - - const comboKey = buildComboKey(combo, usedComboKeys); - - // Collision policy: combos win. Warn ONCE per (cacheKey, comboKey) - // when overwriting a same-key raw model so the operator can spot - // the unusual naming choice without log spam. - if (Object.prototype.hasOwnProperty.call(models, comboKey)) { - const dedupeKey = `${cacheKey}::${comboKey}`; - if (!collisionWarned.has(dedupeKey)) { - collisionWarned.add(dedupeKey); - console.warn( - `[omniroute-plugin] combo key "${comboKey}" collides with a model id; combo wins.`, - ); - } - } - models[comboKey] = mapped; } // ── Auto combos in dynamic catalog ──────────────────────────────── @@ -3001,11 +2962,7 @@ export function createOmniRouteFetchInterceptor(config: { const prefix = `${trimmed}/`; return async (input, init = {}) => { const url = - typeof input === "string" - ? input - : input instanceof URL - ? input.toString() - : input.url; + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; const targetsOmniRoute = url === trimmed || url.startsWith(prefix); if (!targetsOmniRoute) { @@ -3014,9 +2971,7 @@ export function createOmniRouteFetchInterceptor(config: { // Merge order: Request-attached headers (when input is a Request) → // init.headers overlay → our injected headers last (so we win). - const headers = new Headers( - input instanceof Request ? input.headers : undefined, - ); + const headers = new Headers(input instanceof Request ? input.headers : undefined); if (init.headers) { const initHeaders = new Headers(init.headers); initHeaders.forEach((value, key) => { @@ -3051,12 +3006,7 @@ export function createOmniRouteFetchInterceptor(config: { * Source: behavioural reverse-engineering from Alph4d0g's * opencode-omniroute-auth@1.2.1 (dist/src/plugin.js:517). */ -const GEMINI_SCHEMA_KEYS_TO_REMOVE = new Set([ - "$schema", - "$ref", - "ref", - "additionalProperties", -]); +const GEMINI_SCHEMA_KEYS_TO_REMOVE = new Set(["$schema", "$ref", "ref", "additionalProperties"]); function isRecord(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); @@ -3105,9 +3055,7 @@ function stripSchemaKeys(schema: Record): boolean { * occasionally attach a top-level `$schema` declaration when re-serialising * tool bundles, and Gemini rejects those too. */ -function sanitizeToolSchemaContainer( - payload: Record, -): boolean { +function sanitizeToolSchemaContainer(payload: Record): boolean { let changed = false; // Top-level keyword strip — covers payload-level `$schema` etc. for (const key of Object.keys(payload)) { @@ -3124,18 +3072,11 @@ function sanitizeToolSchemaContainer( if (!isRecord(tool)) continue; const fn = (tool as { function?: unknown }).function; if (isRecord(fn) && isRecord((fn as { parameters?: unknown }).parameters)) { - changed = - stripSchemaKeys(fn.parameters as Record) || changed; + changed = stripSchemaKeys(fn.parameters as Record) || changed; } - const fnDecl = (tool as { function_declaration?: unknown }) - .function_declaration; - if ( - isRecord(fnDecl) && - isRecord((fnDecl as { parameters?: unknown }).parameters) - ) { - changed = - stripSchemaKeys(fnDecl.parameters as Record) || - changed; + const fnDecl = (tool as { function_declaration?: unknown }).function_declaration; + if (isRecord(fnDecl) && isRecord((fnDecl as { parameters?: unknown }).parameters)) { + changed = stripSchemaKeys(fnDecl.parameters as Record) || changed; } const inputSchema = (tool as { input_schema?: unknown }).input_schema; if (isRecord(inputSchema)) { @@ -3257,8 +3198,7 @@ export function createGeminiSanitizingFetch(inner: typeof fetch): typeof fetch { : ""; // URL gate — match the path substring with prefix tolerance. - const targetsCompletions = - url.includes("/chat/completions") || url.includes("/responses"); + const targetsCompletions = url.includes("/chat/completions") || url.includes("/responses"); if (!targetsCompletions) { return inner(input, init); } @@ -3284,7 +3224,7 @@ export function createGeminiSanitizingFetch(inner: typeof fetch): typeof fetch { geminiStreamingWarningEmitted = true; console.warn( - "[omniroute-plugin] sanitizeGemini: streaming Request body, skipping schema strip (Gemini may reject)", + "[omniroute-plugin] sanitizeGemini: streaming Request body, skipping schema strip (Gemini may reject)" ); } return inner(input, init); @@ -3379,12 +3319,7 @@ export function __resetGeminiStreamingWarning(): void { * `undefined` noise. */ /** Modalities accepted by OC's static catalog reader (see `@opencode-ai/sdk`). */ -export type OmniRouteModalityKind = - | "text" - | "audio" - | "image" - | "video" - | "pdf"; +export type OmniRouteModalityKind = "text" | "audio" | "image" | "video" | "pdf"; const STATIC_MODALITY_VALUES: ReadonlySet = new Set([ "text", @@ -3530,7 +3465,7 @@ export function buildStaticProviderEntry( enrichment?: OmniRouteEnrichmentMap, compressionCombos?: OmniRouteCompressionCombo[], connections?: OmniRouteProviderConnection[], - rawAutoCombos?: OmniRouteRawAutoCombo[], + rawAutoCombos?: OmniRouteRawAutoCombo[] ): OmniRouteStaticProviderEntry { const models: Record = {}; @@ -3557,8 +3492,7 @@ export function buildStaticProviderEntry( const comboNames = new Set(); for (const combo of rawCombos) { if (!combo || combo.isHidden === true) continue; - const name = - combo.name && combo.name.trim().length > 0 ? combo.name.trim() : combo.id; + const name = combo.name && combo.name.trim().length > 0 ? combo.name.trim() : combo.id; if (typeof name === "string" && name.length > 0) comboNames.add(name); } @@ -3588,14 +3522,9 @@ export function buildStaticProviderEntry( // to the raw id when no enrichment entry is found. The alias-fallback // lookup rescues `/` rows whose enrichment indexed only // under `/`. - const enrichmentEntry = lookupEnrichment( - raw.id, - enrichment, - canonicalToAlias, - ); + const enrichmentEntry = lookupEnrichment(raw.id, enrichment, canonicalToAlias); const enrichmentName = enrichmentEntry?.name; - let displayName = - enrichmentName && enrichmentName.length > 0 ? enrichmentName : raw.id; + let displayName = enrichmentName && enrichmentName.length > 0 ? enrichmentName : raw.id; // Provider-tag PREFIX — `