Release v3.6.9 (#1404)

* test: resolve typescript strictness complaints in unit tests

* Update Claude Code obfuscation to version 2.1.114 (#1403)

* fix(cloud-code): scope thinking stripping to executor boundaries (#1401)

* fix(cloud-code): scope thinking stripping to executors

* fix(cloud-code): guard antigravity normalized body

* Update Claude Code obfuscation to version 2.1.114

- Update Claude Code version from 2.1.87 to 2.1.114
- Update X-Stainless-Package-Version from 0.80.0 to 0.81.0
- Add new beta flags: redact-thinking-2026-02-12, advisor-tool-2026-03-01, advanced-tool-use-2025-11-20
- Add missing headers: anthropic-version, anthropic-dangerous-direct-browser-access, x-app, X-Stainless-Timeout
- Add all X-Stainless-* headers (Arch, Lang, OS, Runtime, Runtime-Version, Retry-Count)
- Fix accept-encoding header: identity -> gzip, deflate, br, zstd
- Add connection: keep-alive header
- Update tool name mapping: add lsp, apply_patch, websearch

These changes ensure that requests from OpenCode through Omniroute are indistinguishable from genuine Claude Code 2.1.114 requests, allowing proper authentication with Anthropic's API without triggering extra credits errors.

* fix: resolve CodeQL password hash alert and TruffleHog CI failure

---------

Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Nikolay Popov <ekklesio.dev@gmail.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>

* fix(claude-code): scope obfuscation to cli clients and fix tests

* docs(workflows): enforce PR merge instead of manual close

* docs(changelog): update 3.6.9 notes with missing PR 1403 and fixes

* docs(workflows): update generate-release to use full changelog for PR body

* fix(tsc): silence baseUrl deprecation warnings for TS 5.5+

* fix(chatcore): apply proactive compression before provider translation (#1406)

Integrated into release/v3.6.9

* docs(changelog): add PR 1406

* Makes text visible in dark-mode (#1409)

Integrated into release/v3.6.9

* docs(changelog): add PR 1409

* chore: save local work

* chore(release): sync version references to 3.6.9

* fix(codex): prevent proactive token refresh consumption and strip background parameter

* ci: shard long-running suites and relax timeouts

* ci: allow manual CI dispatch for release branches

* feat(skills): provider-aware marketplace UX, scored AUTO injection, and memory pipeline hardening (#1411)

* fix/400 for GeminiCLI(add "ref" in GEMINI_UNSUPPORTED_SCHEMA_KEYS)

* feat(cc-compatible): align request shape with Claude CLI

* fix(cc-compatible): add Claude CLI system skeleton for OpenAI input

* preserve reasoning when translating chat to responses (#1414)

Integrated into release/v3.6.9

* fix(skills): optimize AUTO scoring and include Responses input context (#1418)

Integrated into release/v3.6.9

* chore: fix TS errors and update review-prs workflow

* fix(api): stop sending unsupported Gemini and Codex parameters

Prevent Gemini request translation from injecting default
thoughtSignature values that the upstream API strictly validates and
rejects. Only preserve real signatures resolved from prior upstream
responses, and strip additionalProperties from Gemini function schemas
to avoid 400 "Unknown name" errors.

Also remove fallback-injected session_id and conversation_id fields
before sending Codex requests, and restore compatibility with the
legacy OUTBOUND_SSRF_GUARD_ENABLED flag when determining whether
private provider URLs are allowed.

Updates the Gemini translator and regression tests for issue #1410
and related 400 error cases.

* fix(core): stabilization fixes for token refresh, usage translation, and testing

- Update Codex token refresh detection logic
- Mark provider connections invalid on unrecoverable refresh error
- Fix Claude usage translation under-reporting cached tokens
- Update test expectations
- Update CHANGELOG.md for v3.6.9

* fix(auth): reload fresh token state and unify expiry persistence

Refresh checks now re-read the latest stored provider connection before
attempting rotation so they do not use stale refresh tokens captured by
an earlier sweep.

Token updates also persist both expiresAt and tokenExpiresAt across the
health check, usage-limit refresh path, and SSE refresh flow. This keeps
known token expiry metadata in sync and avoids interval-based refreshes
for connections whose tokens are still valid well into the future.

* fix: resolve SSRF environment static evaluation bug (#1427)

Fix import aliases and strict TS typings for tests and ACP agents.

* test: resolve remaining strict type errors in test files

* test: fix provider service assertion for anthropic-compatible header

* fix(codex): respect openaiStoreEnabled setting during native passthrough (#1432)

* fix(codex): fix token refresh unrecoverable detection for expired tokens

* fix(ci): restore release v3.6.9 build and flaky tests

* fix(cc-compatible): trim default OpenAI system skeleton (#1433)

Integrated into release/v3.6.9

* fix: prevent masked API keys from being written to CLI tool configs (#1435)

* feat: mark Qwen provider as deprecated and add deprecation warning to CLI tool (#1437)

* docs(changelog): comprehensive v3.6.9 update with all 59 commits since v3.6.8

* test(ci): align qwen guide settings assertions

* fix(security): resolve CodeQL alert 163 for incomplete URL sanitization in Qwen CLI settings

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: Nikolay Popov <74762779+nikolay-popov-ideogram@users.noreply.github.com>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: Nikolay Popov <ekklesio.dev@gmail.com>
Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com>
Co-authored-by: Tim Massey <tim-massey@users.noreply.github.com>
Co-authored-by: Paijo <oyi77@users.noreply.github.com>
Co-authored-by: dail45 <dail45@yandex.ru>
Co-authored-by: R.D. <rogerproself@gmail.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza 2026-04-19 19:50:30 -03:00 committed by GitHub
parent 5be86907d7
commit 3432dfd280
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
118 changed files with 4493 additions and 1295 deletions

View file

@ -159,21 +159,29 @@ git push origin release/v2.x.y
### 9. Open PR to main
### 9. Open PR to main
// turbo
```bash
VERSION=$(node -p "require('./package.json').version")
# Extract the exact changelog entry for this version from the root CHANGELOG.md
awk "/^## \\[$VERSION\\]/{flag=1; print; next} /^---/{if(flag) {flag=0; exit}} flag" CHANGELOG.md > /tmp/changelog_body.txt
# Append test status and next steps
echo "" >> /tmp/changelog_body.txt
echo "### Tests" >> /tmp/changelog_body.txt
echo "- All tests pass" >> /tmp/changelog_body.txt
echo "" >> /tmp/changelog_body.txt
echo "### ⚠️ After merging: run Phase 2 steps to tag, publish, and deploy." >> /tmp/changelog_body.txt
gh pr create \
--repo diegosouzapw/OmniRoute \
--base main \
--head release/v2.x.y \
--title "chore(release): v2.x.y — summary" \
--body "## 🚀 Release v2.x.y
### Changes
...
### Tests
- X/X tests pass
### ⚠️ After merging: run Phase 2 steps to tag, publish, and deploy."
--head release/v$VERSION \
--title "Release v$VERSION" \
--body-file /tmp/changelog_body.txt
```
### 10. 🛑 STOP — Notify User & Await PR Confirmation

View file

@ -6,7 +6,7 @@ description: Fetch all open GitHub issues, analyze bugs, resolve what's possible
## Overview
This workflow fetches all open issues from the project's GitHub repository, classifies them, analyzes bugs, resolves what can be fixed, and triages issues with insufficient information. **All fixes are committed on the current release branch** (`release/vX.Y.Z`). It does NOT merge or release automatically — the release branch is later merged via PR to main.
This workflow fetches all open issues from the project's GitHub repository, classifies them, analyzes bugs, proposes a resolution plan, waits for user validation, and ONLY THEN implements the fixes, commits, and closes the issues on the current release branch (`release/vX.Y.Z`). It does NOT merge or release automatically — the release branch is later merged via PR to main.
> **BRANCH RULE**: All work MUST happen on the current `release/vX.Y.Z` branch. Never create separate `fix/` branches. If no release branch exists yet, create one first using `/generate-release` Phase 1 steps 15.
@ -96,26 +96,25 @@ Verify the issue contains enough to act on:
For each bug, classify into one of 5 actions:
| Disposition | When to Apply | Action |
| ---------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| **✅ CLOSE — Already Fixed** | Owner responded with fix + no user follow-up, OR community confirmed fix | Close with comment citing which version fixed it |
| **✅ CLOSE — Duplicate** | Bot flagged >85% similarity + user provides no new info | Close referencing the original issue |
| **✅ CLOSE — Stale** | We requested logs/info > 7 days ago with no reply | Close thanking the user, invite to reopen if needed |
| **📝 RESPOND — Needs Info** | Issue is real but missing critical reproduction details | Comment asking for specifics per `/issue-triage` |
| **📝 RESPOND — User Config** | Error is caused by unsupported env (Node version, wrong model path, missing API enablement) | Comment explaining the user-side fix |
| **🔧 FIX — Code Change** | Root cause is confirmed in the codebase | Research, implement, test, commit on release branch |
| Disposition | When to Apply | Action |
| ---------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| **✅ CLOSE — Already Fixed** | Owner responded with fix + no user follow-up, OR community confirmed fix | Close with comment citing which version fixed it |
| **✅ CLOSE — Duplicate** | Bot flagged >85% similarity + user provides no new info | Close referencing the original issue |
| **✅ CLOSE — Stale** | We requested logs/info > 7 days ago with no reply | Close thanking the user, invite to reopen if needed |
| **📝 RESPOND — Needs Info** | Issue is real but missing critical reproduction details | Comment asking for specifics per `/issue-triage` |
| **📝 RESPOND — User Config** | Error is caused by unsupported env (Node version, wrong model path, missing API enablement) | Comment explaining the user-side fix |
| **🔧 FIX — Code Change** | Root cause is confirmed in the codebase | Research, propose solution in report, wait for approval |
#### 5d. For "FIX — Code Change" Issues
Before coding, perform deep source analysis:
Before coding, perform deep source analysis to formulate a plan:
1. **Search the codebase**`grep_search` for error strings, relevant function names, affected files
2. **Search the web** — for upstream API changes, SDK updates, or breaking changes that explain the bug
3. **Read the full source file** — don't rely on grep snippets; understand the surrounding logic
4. **Verify the root cause** — confirm the bug is reproducible based on the code, not just a user misconfiguration
5. **Implement the fix** — follow existing code patterns and conventions
6. **Run tests**`node --import tsx/esm --test tests/unit/*.test.mjs` (must pass 100%)
7. **Commit**`fix: <description> (#<issue_number>)`
5. **Formulate a proposed solution** — detail the exact files and lines you will change and how you will solve it.
6. **DO NOT modify the codebase yet** — wait for user approval on your report first.
#### 5e. For "RESPOND" Issues
@ -130,33 +129,35 @@ Post a substantive comment that:
### 6. Generate Report & Wait for Validation
Present a summary report to the user. For any bugs that have been fixed, you MUST explicitly explain to the user the version that the fix was applied to (e.g., `release/vX.Y.Z`) and point out that it will be included in the next release.
Present a summary report to the user detailing your proposed actions. For any bugs that need fixing, explicitly explain your proposed solution (files to change and logic) and point out that it will be implemented on the release branch (`release/vX.Y.Z`) after approval.
| Issue | Title | Status | Action / Version |
| ----- | ----- | ------------- | ---------------------------------- |
| #N | Title | ✅ Closed | Already fixed / duplicate |
| #N | Title | 🔧 Fixed | Code fix applied in release/vX.Y.Z |
| #N | Title | 📝 Responded | Guidance comment posted |
| #N | Title | ❓ Needs Info | Triage comment posted |
| #N | Title | ⏭️ Skipped | Feature request / not a bug |
| Issue | Title | Status | Proposed Action / Version |
| ----- | ----- | ------------- | ----------------------------------------- |
| #N | Title | ✅ Close | Already fixed / duplicate (explain why) |
| #N | Title | 🔧 Propose | Explanation of the code fix to be applied |
| #N | Title | 📝 Respond | Guidance comment to be posted |
| #N | Title | ❓ Needs Info | Triage comment to be posted |
| #N | Title | ⏭️ Skip | Feature request / not a bug |
> **⚠️ IMPORTANT**: Do NOT commit, push, or close issues at this step.
> Wait for the user to review the changes and respond with **OK** before proceeding.
> **⚠️ IMPORTANT**: Do NOT implement code changes, commit, push, or close issues at this step.
> Wait for the user to review the proposed fixes and respond with **OK** before proceeding.
- If the user says **OK** or approves → Proceed to step 7
- If the user requests changes → Apply the requested adjustments first, then present the report again
- If the user rejects → Revert the changes and stop
- If the user requests changes → Adjust the proposed solution and present the report again
- If the user rejects → Revert any accidental changes and stop
### 7. Commit, Push & Close Issues (only after user approval)
### 7. Implement Fixes, Run Tests & Commit (only after user approval)
After the user validates and gives the OK to commit:
After the user validates and gives the OK:
1. **Update CHANGELOG.md** with all new bug fix entries.
2. **Commit** each fix individually on the release branch with message format: `fix: <description> (#<issue_number>)`.
3. **Push** the release branch: `git push origin release/vX.Y.Z`.
4. **Close resolved issues immediately**. For each issue that was marked as Fixed, run:
`gh issue close <NUMBER> --repo <owner>/<repo> --comment "Fixed in release/vX.Y.Z. The fix will be included in the next release."`
5. Likewise, close `Duplicate` or `Needs Info` issues as needed with relevant comments.
6. If the project runs automatic releases or needs a PR, proceed to run `/generate-release` workflow Phase 1 steps 710 (tests → commit → push → open PR to main → wait for user).
1. **Implement the fixes** — modify the codebase according to the approved plan.
2. **Run tests**`npm run test:all` (or the specific test file) to ensure 100% pass.
3. **Update CHANGELOG.md** with all new bug fix entries.
4. **Commit** each fix individually on the release branch with message format: `fix: <description> (#<issue_number>)`.
5. **Push** the release branch: `git push origin release/vX.Y.Z`.
6. **Close resolved issues immediately**. For each issue that was marked as Fixed, run:
`gh issue close <NUMBER> --repo <owner>/<repo> --comment "Thank you for reporting! This issue has been fixed and will be included in the next release (vX.Y.Z)."`
7. Likewise, close `Duplicate` issues referencing the original, close `Needs Info` if stale, and post the required comments.
8. If the project runs automatic releases or needs a PR, proceed to run `/generate-release` workflow Phase 1 steps 710 (tests → commit → push → open PR to main → wait for user).
If NO fixes were committed, skip closing and source control steps and just conclude the workflow.

View file

@ -163,9 +163,9 @@ Perform a **global impact assessment** to verify whether the PR changes are comp
### 7. Pre-Merge Fixes & CI Green-Lighting (if approved)
> **⚠️ Fixes should be pushed back to the PR branch before merging.** We want the PR itself to be green and fully valid before it integrates.
> **⚠️ Fixes and Conflict Resolutions MUST be pushed back to the PR branch before merging.** We want the PR itself to be green and fully valid before it integrates.
- **Sync latest fixes:** Merge the current `release` branch into the PR branch so the PR inherits any latest CI or integration test fixes (preventing false-positive failures).
- **Sync latest fixes & Resolve Conflicts:** Merge the current `release` branch into the PR branch. If there are merge conflicts, you MUST resolve them inside the author's PR branch. NEVER resolve conflicts by closing their PR and doing the work in a separate branch, as this steals credit from the original author.
- **Implement improvements:** Apply the required fixes identified in the analysis directly on the PR branch (e.g., adding missing API routes, fixing SSRF, applying comments from other agents).
- **Pushing changes to PR branches:**
@ -193,34 +193,30 @@ Perform a **global impact assessment** to verify whether the PR changes are comp
### 8. Merge into Release Branch
### 8. Merge into Release Branch (NEVER SILENTLY CLOSE!)
### 8. Merge into Release Branch (NEVER CLOSE!)
> **⚠️ CRITICAL**: NEVER use `gh pr close` for a PR whose idea or code was accepted, even if you had to rewrite it manually. Closing a PR in a contributor's face after taking their idea is unacceptable.
> ALWAYS ensure the contributor gets proper credit.
> **⚠️ CRITICAL**: NEVER use `gh pr close` for a PR whose idea or code was accepted. Closing a PR in a contributor's face after taking their idea—or closing it just because it had conflicts—is unacceptable.
> You MUST ALWAYS resolve conflicts and apply fixes on the author's PR branch, and then merge the PR using GitHub so the contributor gets the official "Merged" badge and proper credit on their profile.
If the PR is green and can be merged directly:
Even if the PR had severe conflicts or required significant architectural adjustments, you MUST:
- Merge the PR into the release branch using the GitHub CLI.
```bash
# Merge the PR (base is already set to release/vX.Y.Z from step 3.5)
gh pr merge <NUMBER> --repo <owner>/<repo> --squash --body "Integrated into release/vX.Y.Z"
```
1. Resolve any conflicts and apply the fixes directly to their PR branch (as detailed in step 7).
2. Once the PR branch is green, conflict-free, and correct, merge it into the release branch using the GitHub CLI.
If the PR had severe conflicts or you had to implement the feature manually on our branch instead:
```bash
# Merge the PR (base is already set to release/vX.Y.Z from step 3.5)
gh pr merge <NUMBER> --repo <owner>/<repo> --squash --body "Integrated into release/vX.Y.Z"
```
- You MUST still post a comment giving them full credit before closing it.
- Explain that their idea was great, that it was integrated in a specific commit (mention the commit hash), and that due to merge conflicts or architectural adjustments, it was integrated manually.
- Add their name as a Co-authored-by in the manual commit if possible.
In ALL cases:
In ALL accepted cases (merged directly or implemented manually):
- Post a **thank-you comment** on the PR via the GitHub API.
- Post a **thank-you comment** on the PR via the GitHub API before or immediately after merging.
- The message should:
- Thank the author by name/username for their contribution.
- Explain what was adjusted or improved (if anything).
- Explain what was adjusted or improved (if we pushed fixes to their branch).
- Note it will be included in the upcoming release.
- Be friendly, professional, and encouraging.
- Example: _"Thanks @author for this great contribution! 🎉 The [feature/fix] has been integrated into the release/vX.Y.Z branch and will be part of the next release. We appreciate your effort!"_
- Example: _"Thanks @author for this great contribution! 🎉 We've added a few small adjustments to your branch to align with our latest architecture, and it's now officially merged into the release/vX.Y.Z branch. It will be part of the next release. We appreciate your effort!"_
### 9. Sync Local Release Branch

View file

@ -6,6 +6,7 @@ on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@ -111,7 +112,7 @@ jobs:
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: ${{ github.event.repository.default_branch }}
base: ${{ github.event.before || github.event.repository.default_branch }}
head: HEAD
extra_args: --only-verified
@ -168,10 +169,14 @@ jobs:
- run: npm run check:pack-artifact
test-unit:
name: Unit Tests
name: Unit Tests (${{ matrix.shard }}/2)
runs-on: ubuntu-latest
timeout-minutes: 15
needs: build
strategy:
fail-fast: false
matrix:
shard: [1, 2]
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
@ -184,13 +189,17 @@ jobs:
cache: npm
- run: npm ci
- run: npm run check:node-runtime
- run: npm run test:unit
- run: node --import tsx/esm --test --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts
node-24-compat:
name: Node 24 Compatibility
name: Node 24 Compatibility (${{ matrix.shard }}/2)
runs-on: ubuntu-latest
timeout-minutes: 15
needs: build
strategy:
fail-fast: false
matrix:
shard: [1, 2]
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
@ -204,12 +213,12 @@ jobs:
- run: npm ci
- run: npm run check:node-runtime
- run: npm run build
- run: npm run test:unit
- run: node --import tsx/esm --test --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts
test-coverage:
name: Coverage
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 30
needs: build
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
@ -359,14 +368,14 @@ jobs:
}
test-e2e:
name: E2E Tests (${{ matrix.shard }}/4)
name: E2E Tests (${{ matrix.shard }}/6)
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 20
needs: build
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
shard: [1, 2, 3, 4, 5, 6]
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
@ -381,18 +390,22 @@ jobs:
- run: npm run check:node-runtime
- run: npx playwright install --with-deps chromium
- run: npm run build
- run: npx playwright test tests/e2e/*.spec.ts --shard=${{ matrix.shard }}/4
- run: npx playwright test tests/e2e/*.spec.ts --shard=${{ matrix.shard }}/6
test-integration:
name: Integration Tests
name: Integration Tests (${{ matrix.shard }}/2)
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 15
needs: build
strategy:
fail-fast: false
matrix:
shard: [1, 2]
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
INITIAL_PASSWORD: ci-test-password-for-integration
DATA_DIR: /tmp/omniroute-ci
DATA_DIR: /tmp/omniroute-ci-${{ matrix.shard }}
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v6
@ -402,7 +415,7 @@ jobs:
cache: npm
- run: npm ci
- run: npm run check:node-runtime
- run: npm run test:integration
- run: node --import tsx/esm --test --test-shard=${{ matrix.shard }}/2 tests/integration/*.test.ts
test-security:
name: Security Tests

View file

@ -4,16 +4,43 @@
---
## [3.6.9] — 2026-04-18
## [3.6.9] — 2026-04-19
### ✨ New Features
- **feat(providers):** Mark Qwen OAuth provider as deprecated following the upstream free tier shutdown on 2026-04-15. Adds deprecation warning to CLI tool UI and rewrites `saveQwenConfig` to inject OmniRoute as a multi-provider (openai, anthropic, gemini) via `.qwen/settings.json` and `.qwen/.env` (#1437)
- **feat(cc-compatible):** Align Claude Code-compatible request shape with the official Claude CLI protocol, including proper system skeleton and request normalization (#1411)
- **feat(skills):** Provider-aware marketplace UX with scored AUTO injection and memory pipeline hardening. Skills now show relevance scores and can automatically inject context into requests (#1411)
- **feat(claude-code):** Update Claude Code obfuscation to version 2.1.114, centralize hardcoded version strings, and use standard logger (#1403)
- **feat(cli-tools):** Add direct configuration file generation and override support for Qwen Code local settings (#1394)
- **feat(providers):** Derive Claude CLI model defaults dynamically from provider registry to stay current with upstream API changes (#1393)
- **feat(core):** Implement persistent API key, backup pruning, and GPU optimization (#1350, #1367, #1369)
### 🐛 Bug Fixes
- **fix(cli-tools):** Prevent masked API keys (`sk-31c4****8600`) from being written to CLI tool config files. The dashboard UI now passes `key.id` to the backend, which resolves the unmasked key from the database via a new `resolveApiKey()` helper. Fixes auth failures across all CLI tools (Claude, Codex, Cline, Kilo, Droid, OpenClaw, Antigravity) (#1435)
- **fix(cc-compatible):** Trim the default Claude Code-compatible system prompt skeleton from a multi-paragraph instruction set down to a single identifier line, reducing redundant token usage since Claude Code already injects its own extensive system context (#1433)
- **fix(security):** Resolve SSRF environment static evaluation bug where the outbound URL guard could be bypassed via computed expressions (#1427)
- **fix(auth):** Reload fresh token state and unify expiry persistence to prevent stale credentials from causing cascading auth failures
- **fix(core):** Stabilization fixes for token refresh, usage translation, and testing infrastructure
- **fix(api):** Stop sending unsupported parameters to Gemini and Codex upstream APIs, preventing 400 Bad Request errors
- **fix(skills):** Optimize AUTO scoring algorithm and include Responses API input context for more accurate skill relevance matching (#1418)
- **fix(responses):** Preserve reasoning content when translating Chat Completions format to Responses API format, preventing loss of chain-of-thought data (#1414)
- **fix(cc-compatible):** Add Claude CLI system skeleton for OpenAI-format inputs to ensure consistent behavior when CC-compatible providers receive OpenAI-style payloads
- **fix(providers):** Add `ref` to `GEMINI_UNSUPPORTED_SCHEMA_KEYS` to fix 400 errors from Gemini CLI when tool schemas contain JSON Schema `$ref` fields
- **fix(codex):** Prevent proactive token refresh from consuming valid tokens and strip the unsupported `background` parameter from upstream requests
- **fix(providers):** Fix `usage.prompt_tokens` under-reporting when translating Claude caching responses to OpenAI format (#1426)
- **fix(core):** Fix token refresh resilience for Codex providers. Unrecoverable OAuth refresh errors (`token_expired` and `invalid_token`) now correctly mark the connection as invalid to prompt user re-authentication, rather than silently failing (#1415)
- **fix(providers):** Fix Gemini tool calling by removing the unsupported `additionalProperties` schema field, resolving 400 errors during complex tool invocations (#1421)
- **fix(providers):** Remove arbitrary user thought signature injection in Gemini responses to comply with updated API constraints (#1410)
- **fix(providers):** Fix Gemini API part count mismatch for streaming responses (#1412)
- **fix(codex):** Respect `openaiStoreEnabled` setting during native passthrough for Responses API to prevent unsupported upstream arguments (#1432)
- **fix(ui):** Makes dropdown text visible in dark mode within the Combo Builder modal (#1409)
- **fix(chatcore):** Apply proactive compression before provider translation to prevent token limit errors in combo routes (#1406)
- **fix(claude-code):** Scope thinking stripping to executor boundaries to prevent issues with normal API requests (#1401)
- **fix(claude-code):** Scope obfuscation logic to CLI clients only and fix associated test assertions
- **fix(mitm):** Resolve MITM not working when connecting Antigravity (#1399)
- **fix(security):** Resolve CodeQL password hash alert and fix TruffleHog CI failure (#161)
- **fix(combo):** Fallback to the next model when all provider accounts return a 503 rate-limited signal instead of aborting the routing sequence (#1398)
- **fix(codex):** Strip server-generated IDs from response items in input to prevent 404 lookup errors in multi-turn Codex Conversations (#1397)
- **fix(codex):** Optimize Chat Completions paths by converting `system` to `developer` roles instead of hoisting them into instructions, enabling prompt caching for system messages on GPT-5 models (#1400)
@ -22,10 +49,27 @@
- **fix(providers):** Treat upstream legacy validation HTTP 5xx responses as a valid bypass for Qoder PAT tokens to prevent false negative invalidation (#1391)
- **fix(electron):** Resolve type error in Header electronAPI properties
- **fix(security):** Resolve CodeQL security alerts including safe prototype bindings (#151, #152, #154, #155-159)
- **fix(tsc):** Silence `baseUrl` deprecation warnings for TypeScript 5.5+ configurations
### 🧪 Tests
- **test(core):** Resolve typescript strictness complaints and fix combo-routing-engine test regression
- **test(core):** Resolve remaining strict type errors across all unit test files
- **test(providers):** Fix provider service assertion for anthropic-compatible header format
- **test(codex):** Align codex passthrough assertions with explicit store retention policy
- **test(codex):** Fix store assertion for codex responses
- **test(cli):** Resolve strict null checks in Qoder unit tests
### 🛠️ Maintenance
- **chore:** Sync infrastructure with docker postinstall components and secondary CodeQL analysis rules
- **chore:** Enforce contributor credit rule in review-prs workflow
- **chore:** Fix TS errors and update review-prs workflow for improved automation
- **ci:** Allow manual CI dispatch for release branches
- **ci:** Shard long-running test suites and relax timeouts for stability
- **ci:** Restore release v3.6.9 build pipeline and fix flaky tests
- **docs:** Update generate-release workflow to use full changelog for PR body
- **docs:** Enforce PR merge instead of manual close in workflows
---

View file

@ -57,31 +57,6 @@ export const CLI_FINGERPRINTS: Record<string, CliFingerprint> = {
userAgent: "codex-cli",
},
claude: {
headerOrder: [
"Host",
"Content-Type",
"x-api-key",
"anthropic-version",
"Accept",
"User-Agent",
"Accept-Encoding",
],
bodyFieldOrder: [
"model",
"max_tokens",
"messages",
"system",
"temperature",
"top_p",
"top_k",
"stream",
"tools",
"tool_choice",
"metadata",
],
userAgent: "claude-code",
},
"claude-code-compatible": {
headerOrder: [
"Host",
"Content-Type",
@ -104,6 +79,7 @@ export const CLI_FINGERPRINTS: Record<string, CliFingerprint> = {
"Accept",
"accept-language",
"accept-encoding",
"sec-fetch-mode",
"Connection",
],
bodyFieldOrder: [
@ -120,6 +96,42 @@ export const CLI_FINGERPRINTS: Record<string, CliFingerprint> = {
"stream",
],
},
"claude-code-compatible": {
headerOrder: [
"Host",
"Content-Type",
"Authorization",
"anthropic-version",
"anthropic-beta",
"anthropic-dangerous-direct-browser-access",
"x-app",
"User-Agent",
"X-Claude-Code-Session-Id",
"X-Stainless-Retry-Count",
"X-Stainless-Timeout",
"X-Stainless-Lang",
"X-Stainless-Package-Version",
"X-Stainless-OS",
"X-Stainless-Arch",
"X-Stainless-Runtime",
"X-Stainless-Runtime-Version",
"Accept",
"accept-encoding",
"Connection",
],
bodyFieldOrder: [
"model",
"messages",
"system",
"tools",
"tool_choice",
"metadata",
"max_tokens",
"thinking",
"output_config",
"stream",
],
},
github: {
headerOrder: [
"Host",

View file

@ -10,6 +10,14 @@ import {
modelSupportsContext1mBeta,
} from "../services/claudeCodeCompatible.ts";
import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults";
import { remapToolNamesInRequest } from "../services/claudeCodeToolRemapper.ts";
import { obfuscateInBody } from "../services/claudeCodeObfuscation.ts";
import {
computeFingerprint,
extractFirstUserMessageText,
} from "../services/claudeCodeFingerprint.ts";
import { randomUUID } from "node:crypto";
import { createHash } from "node:crypto";
/**
* Sanitizes a custom API path to prevent path traversal attacks.
@ -42,6 +50,7 @@ export type ProviderConfig = {
headers?: Record<string, string>;
requestDefaults?: ProviderRequestDefaults;
timeoutMs?: number;
format?: string;
};
export type ProviderCredentials = {
@ -73,6 +82,8 @@ export type ExecuteInput = {
upstreamExtraHeaders?: Record<string, string> | null;
/** Original client request headers (read-only). Executors may forward select headers upstream. */
clientHeaders?: Record<string, string> | null;
/** Callback to persist tokens that are proactively refreshed during execution. */
onCredentialsRefreshed?: (newCredentials: ProviderCredentials) => Promise<void> | void;
};
export type CountTokensInput = {
@ -362,6 +373,7 @@ export class BaseExecutor {
log,
extendedContext,
upstreamExtraHeaders,
clientHeaders,
}: ExecuteInput) {
const fallbackCount = this.getFallbackCount();
let lastError: unknown = null;
@ -378,6 +390,11 @@ export class BaseExecutor {
...credentials,
...refreshed,
};
// Persist the proactively refreshed credentials to prevent consuming rotating tokens
// without updating the central database connection.
if (arguments[0].onCredentialsRefreshed) {
await arguments[0].onCredentialsRefreshed(refreshed);
}
}
} catch (error) {
log?.warn?.(
@ -429,6 +446,108 @@ export class BaseExecutor {
? mergeAbortSignals(signal, timeoutSignal)
: signal || timeoutSignal;
const isClaudeCodeClient =
clientHeaders?.["x-app"] === "cli" ||
(clientHeaders?.["user-agent"] &&
clientHeaders["user-agent"].toLowerCase().includes("claude-code")) ||
(clientHeaders?.["user-agent"] &&
clientHeaders["user-agent"].toLowerCase().includes("claude-cli"));
if (
this.provider === "claude" &&
isClaudeCodeClient &&
typeof transformedBody === "object" &&
transformedBody !== null
) {
const tb = transformedBody as Record<string, unknown>;
remapToolNamesInRequest(tb);
obfuscateInBody(tb);
const ccVersion = "2.1.114";
const messages = tb.messages as Array<{ role?: string; content?: unknown }> | undefined;
const msgText = extractFirstUserMessageText(messages);
const fp = computeFingerprint(msgText, ccVersion);
const billingLine = `x-anthropic-billing-header: cc_version=${ccVersion}.${fp}; cc_entrypoint=cli; cch=00000;`;
if (Array.isArray(tb.system)) {
const sysBlocks = tb.system as Array<Record<string, unknown>>;
const firstSystemCacheControl =
sysBlocks[0] &&
typeof sysBlocks[0] === "object" &&
!Array.isArray(sysBlocks[0]) &&
sysBlocks[0].cache_control
? sysBlocks[0].cache_control
: undefined;
const billingBlock: Record<string, unknown> = { type: "text", text: billingLine };
if (firstSystemCacheControl) {
billingBlock.cache_control = firstSystemCacheControl;
}
sysBlocks.unshift(billingBlock);
} else if (typeof tb.system === "string") {
tb.system = [
{ type: "text", text: billingLine },
{ type: "text", text: tb.system },
];
} else {
tb.system = [{ type: "text", text: billingLine }];
}
if (!tb.metadata || typeof tb.metadata !== "object") {
tb.metadata = {
user_id: JSON.stringify({
device_id: createHash("sha256").update("omniroute").digest("hex").slice(0, 24),
account_uuid: "",
session_id: randomUUID(),
}),
};
}
if (!tb.thinking) {
tb.thinking = { type: "adaptive" };
}
if (!tb.context_management) {
tb.context_management = {
edits: [{ type: "clear_thinking_20251015", keep: "all" }],
};
}
if (!tb.output_config) {
tb.output_config = { effort: "high" };
}
const ccHeaders: Record<string, string> = {
"anthropic-version": "2023-06-01",
"anthropic-beta":
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advisor-tool-2026-03-01,advanced-tool-use-2025-11-20,effort-2025-11-24",
"anthropic-dangerous-direct-browser-access": "true",
"x-app": "cli",
"User-Agent": `claude-cli/${ccVersion} (external, cli)`,
"X-Stainless-Package-Version": "0.81.0",
"X-Stainless-Timeout": "600",
"accept-language": "*",
"accept-encoding": "gzip, deflate, br, zstd",
connection: "keep-alive",
"x-client-request-id": randomUUID(),
"X-Claude-Code-Session-Id": randomUUID(),
};
Object.assign(headers, ccHeaders);
delete headers["X-Stainless-Helper-Method"];
// Add X-Stainless headers to match real Claude Code
headers["X-Stainless-Arch"] = "x64";
headers["X-Stainless-Lang"] = "js";
headers["X-Stainless-OS"] = "Windows";
headers["X-Stainless-Runtime"] = "node";
headers["X-Stainless-Runtime-Version"] = "v24.3.0";
headers["X-Stainless-Retry-Count"] = "0";
delete headers["X-Stainless-Os"];
console.log(
`[CLAUDE-PATCH] provider=${this.provider} tools remapped, billing header injected, body fields added, headers patched`
);
}
// Apply CLI fingerprint ordering if enabled for this provider
let finalHeaders = headers;
let bodyString = JSON.stringify(transformedBody);
@ -439,10 +558,9 @@ export class BaseExecutor {
bodyString = fingerprinted.bodyString;
}
// CCH signing: Claude Code-compatible providers require an xxHash64 integrity
// token over the serialized body. Sign after fingerprint ordering so the hash
// covers the exact bytes that will be sent upstream.
if (isClaudeCodeCompatible(this.provider)) {
// CCH signing: Claude Code-compatible providers AND native claude provider
// require an xxHash64 integrity token over the serialized body.
if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
bodyString = await signRequestBody(bodyString);
}

View file

@ -1,6 +1,4 @@
import {
getCodexRequestDefaults,
} from "@/lib/providers/requestDefaults";
import { getCodexRequestDefaults } from "@/lib/providers/requestDefaults";
import { BaseExecutor, setUserAgentHeader } from "./base.ts";
import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.ts";
import { PROVIDERS } from "../config/constants.ts";
@ -308,11 +306,7 @@ function stripStoredItemReferences(body: Record<string, unknown>): void {
// Object items with server-generated IDs: strip the id field but keep the item.
// e.g. { id: "rs_...", type: "reasoning", summary: [...] } → keep content, remove id
// e.g. { id: "fc_...", type: "function_call", ... } → keep content, remove id
if (
item &&
typeof item === "object" &&
!Array.isArray(item)
) {
if (item && typeof item === "object" && !Array.isArray(item)) {
const record = item as Record<string, unknown>;
if (typeof record.id === "string" && SERVER_ID_PATTERN.test(record.id)) {
delete record.id;
@ -601,7 +595,15 @@ export class CodexExecutor extends BaseExecutor {
// Proxy clients (e.g. OpenClaw) rely on response chaining via previous_response_id,
// which requires store=true so that response items are persisted.
// If the client explicitly sets store, respect it. Otherwise default to true.
if (body.store === undefined) {
const explicitStoreSetting =
credentials?.providerSpecificData &&
typeof credentials.providerSpecificData === "object" &&
!Array.isArray(credentials.providerSpecificData)
? credentials.providerSpecificData.openaiStoreEnabled
: undefined;
if (explicitStoreSetting === false) {
body.store = false;
} else if (body.store === undefined) {
body.store = true;
}
@ -663,6 +665,7 @@ export class CodexExecutor extends BaseExecutor {
// whether the request came via native passthrough or translation.
delete body.max_tokens;
delete body.max_output_tokens;
delete body.background; // Droid CLI sends this but Codex Responses API rejects it
// Inject prompt_cache_key for Codex prompt caching.
// The official Codex client sets this to conversation_id (a stable UUID per session).
@ -675,6 +678,12 @@ export class CodexExecutor extends BaseExecutor {
}
}
// Delete session_id and conversation_id from the body.
// These are often injected by OmniRoute's fallback logic for store=true,
// but the upstream Codex API strictly rejects them as unsupported parameters.
delete body.session_id;
delete body.conversation_id;
if (nativeCodexPassthrough) {
return body;
}

View file

@ -170,6 +170,78 @@ function extractMemoryTextFromResponse(
return "";
}
function extractMemoryTextFromRequestBody(
body: Record<string, unknown> | null | undefined
): string {
if (!body || typeof body !== "object") return "";
const messages = Array.isArray(body.messages) ? body.messages : null;
if (messages && messages.length > 0) {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const msg = messages[i] as Record<string, unknown>;
if (msg?.role !== "user") continue;
if (typeof msg.content === "string" && msg.content.trim().length > 0) {
return msg.content.trim();
}
if (Array.isArray(msg.content)) {
const text = msg.content
.map((part: Record<string, unknown>) => {
if (typeof part?.text === "string") return part.text.trim();
if (part?.type === "input_text" && typeof part?.text === "string")
return part.text.trim();
return "";
})
.filter(Boolean)
.join("\n")
.trim();
if (text) return text;
}
}
}
const input = Array.isArray(body.input) ? body.input : null;
if (input && input.length > 0) {
const chunks = input
.map((item: Record<string, unknown>) => {
const role = typeof item?.role === "string" ? item.role.trim().toLowerCase() : "";
const itemType = typeof item?.type === "string" ? item.type.trim().toLowerCase() : "";
if (role && role !== "user") return "";
if (itemType && itemType !== "message") return "";
if (typeof item?.content === "string") return item.content.trim();
if (Array.isArray(item?.content)) {
return item.content
.map((part: Record<string, unknown>) => {
if (typeof part?.text === "string") return part.text.trim();
if (part?.type === "input_text" && typeof part?.text === "string")
return part.text.trim();
return "";
})
.filter(Boolean)
.join("\n")
.trim();
}
return "";
})
.filter(Boolean)
.join("\n")
.trim();
if (chunks) return chunks;
}
return "";
}
function resolveMemoryOwnerId(apiKeyInfo: Record<string, unknown> | null): string | null {
const rawId = apiKeyInfo?.id;
if (typeof rawId === "string" && rawId.trim().length > 0) {
return rawId;
}
return null;
}
export function shouldUseNativeCodexPassthrough({
provider,
sourceFormat,
@ -369,6 +441,11 @@ function buildClaudePromptCacheLogMeta(
const systemBreakpoints = Array.isArray(finalBody.system)
? finalBody.system.flatMap((block, index) => {
if (!block || typeof block !== "object") return [];
const text =
typeof block.text === "string" && block.text.trim().length > 0 ? block.text.trim() : "";
if (text.startsWith("x-anthropic-billing-header:")) {
return [];
}
const cacheControl =
block.cache_control && typeof block.cache_control === "object"
? block.cache_control
@ -530,6 +607,33 @@ async function getUpstreamProxyConfigCached(providerId: string) {
return result;
}
function buildExecutorClientHeaders(
headers: Headers | Record<string, unknown> | null | undefined,
userAgent?: string | null
) {
const normalized: Record<string, string> = {};
if (headers instanceof Headers) {
headers.forEach((value, key) => {
normalized[key] = value;
});
} else if (headers && typeof headers === "object") {
for (const [key, value] of Object.entries(headers)) {
if (typeof value === "string") {
normalized[key] = value;
}
}
}
const normalizedUserAgent = typeof userAgent === "string" ? userAgent.trim() : "";
if (normalizedUserAgent && !normalized["user-agent"] && !normalized["User-Agent"]) {
normalized["user-agent"] = normalizedUserAgent;
normalized["User-Agent"] = normalizedUserAgent;
}
return Object.keys(normalized).length > 0 ? normalized : null;
}
export async function handleChatCore({
body,
modelInfo,
@ -776,6 +880,13 @@ export async function handleChatCore({
const noLogEnabled = apiKeyInfo?.noLog === true;
const detailedLoggingEnabled = !noLogEnabled && (await isDetailedLoggingEnabled());
const skillRequestId = generateRequestId();
const pipelineSessionId =
(clientRawRequest?.headers && typeof clientRawRequest.headers.get === "function"
? clientRawRequest.headers.get("x-omniroute-session-id")
: getHeaderValueCaseInsensitive(
clientRawRequest?.headers ?? null,
"x-omniroute-session-id"
)) || skillRequestId;
const persistAttemptLogs = ({
status,
tokens,
@ -1042,12 +1153,13 @@ export async function handleChatCore({
});
}
const memorySettings = apiKeyInfo?.id
const memoryOwnerId = resolveMemoryOwnerId(apiKeyInfo as Record<string, unknown> | null);
const memorySettings = memoryOwnerId
? await getMemorySettings().catch(() => DEFAULT_MEMORY_SETTINGS)
: null;
if (
apiKeyInfo?.id &&
memoryOwnerId &&
memorySettings &&
shouldInjectMemory(body as Parameters<typeof shouldInjectMemory>[0], {
enabled: memorySettings.enabled && memorySettings.maxTokens > 0,
@ -1055,7 +1167,7 @@ export async function handleChatCore({
) {
try {
const memories = await retrieveMemories(
apiKeyInfo.id,
memoryOwnerId,
toMemoryRetrievalConfig(memorySettings)
);
if (memories.length > 0) {
@ -1065,7 +1177,7 @@ export async function handleChatCore({
provider
);
body = injected as typeof body;
log?.debug?.("MEMORY", `Injected ${memories.length} memories for key=${apiKeyInfo.id}`);
log?.debug?.("MEMORY", `Injected ${memories.length} memories for key=${memoryOwnerId}`);
}
} catch (memErr) {
log?.debug?.(
@ -1075,12 +1187,21 @@ export async function handleChatCore({
}
}
if (apiKeyInfo?.id && memorySettings?.skillsEnabled) {
if (memoryOwnerId && memorySettings?.skillsEnabled) {
const existingTools = Array.isArray(body.tools) ? body.tools : [];
const mergedTools = injectSkills({
provider: getSkillsProviderForFormat(sourceFormat),
existingTools,
apiKeyId: apiKeyInfo.id,
apiKeyId: memoryOwnerId,
model: typeof effectiveModel === "string" ? effectiveModel : undefined,
sourceFormat,
targetFormat,
backgroundReason,
messages: Array.isArray(body.messages)
? body.messages
: Array.isArray(body.input)
? body.input
: undefined,
});
if (mergedTools.length > existingTools.length) {
@ -1093,6 +1214,103 @@ export async function handleChatCore({
}
// Translate request (pass reqLogger for intermediate logging)
// ── Proactive Context Compression (Phase 4) ──
// Check if context exceeds 70% of limit and compress proactively before sending to provider.
// This prevents "prompt too long" errors for large-but-not-full contexts.
const allMessages =
body?.messages || body?.input || body?.contents || body?.request?.contents || [];
if (body && Array.isArray(allMessages) && allMessages.length > 0) {
const estimatedTokens = estimateTokens(JSON.stringify(allMessages));
let contextLimit = getTokenLimit(provider, effectiveModel);
if (isCombo && comboName) {
log?.info?.("CONTEXT", `Attempting to resolve combo limits for comboName=${comboName}`);
try {
const { getComboByName } = await import("../../src/lib/localDb");
const { parseModel } = await import("../services/model.ts");
const { resolveComboTargets } = await import("../services/combo.ts");
const comboToSearch = comboName.startsWith("combo/") ? comboName.substring(6) : comboName;
const comboConfig = await getComboByName(comboToSearch);
if (comboConfig) {
const targets = await resolveComboTargets(comboConfig, null);
const limits = targets.map((t: { modelStr?: string }) => {
const parsed = parseModel(t.modelStr);
return getTokenLimit(parsed.provider, parsed.model);
});
if (limits.length > 0) {
contextLimit = Math.min(...limits);
log?.info?.("CONTEXT", `Combo min limit: ${contextLimit}`);
}
}
} catch (err) {
log?.warn?.("CONTEXT", "Failed to resolve combo limits for compression: " + err);
}
}
const COMPRESSION_THRESHOLD = 0.7;
let reservedTokens = 0;
if (Array.isArray(body.tools)) {
reservedTokens = estimateTokens(JSON.stringify(body.tools));
}
const threshold = Math.max(
1,
Math.floor((Math.max(1, contextLimit) - reservedTokens) * COMPRESSION_THRESHOLD)
);
log?.debug?.(
"CONTEXT",
`Checking compression: ${estimatedTokens} tokens vs ${threshold} threshold (${contextLimit} limit, ${reservedTokens} reserved)`
);
if (estimatedTokens > threshold) {
log?.info?.(
"CONTEXT",
`Proactive compression triggered: ${estimatedTokens} tokens > ${threshold} threshold (${contextLimit} limit)`
);
const compressionResult = compressContext(body, {
provider,
model: effectiveModel,
maxTokens: threshold,
reserveTokens: 0,
});
if (compressionResult.compressed) {
body = compressionResult.body;
const stats = compressionResult.stats;
const layersInfo =
stats && "layers" in stats && Array.isArray(stats.layers)
? ` (layers: ${stats.layers.map((l: { name: string }) => l.name).join(", ")})`
: "";
log?.info?.(
"CONTEXT",
`Context compressed: ${stats.original}${stats.final} tokens${layersInfo}`
);
logAuditEvent({
action: "context.proactive_compression",
actor: apiKeyInfo?.name || "system",
target: connectionId || provider || "chat",
details: {
provider,
model: effectiveModel,
original_tokens: stats.original,
final_tokens: stats.final,
layers: "layers" in stats ? stats.layers : undefined,
},
});
} else {
log?.debug?.("CONTEXT", `Compression not applied: context already fits within target`);
}
}
} else {
log?.debug?.(
"CONTEXT",
`Skipping compression check: body=${!!body}, hasMessages=${Array.isArray(allMessages)}`
);
}
let translatedBody = body;
const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE;
const isClaudeCodeCompatible = isClaudeCodeCompatibleProvider(provider);
@ -1118,6 +1336,84 @@ export async function handleChatCore({
);
}
type ClaudeContentBlock = Record<string, unknown>;
type ClaudeMessage = {
role?: unknown;
content?: unknown;
};
const normalizeClaudeUpstreamMessages = (payload: Record<string, unknown>) => {
if (!Array.isArray(payload.messages)) return;
const messages = payload.messages as ClaudeMessage[];
// Anthropic rejects empty text blocks in native Messages payloads.
for (const msg of messages) {
if (Array.isArray(msg.content)) {
msg.content = msg.content.filter(
(block: ClaudeContentBlock) =>
block.type !== "text" || (typeof block.text === "string" && block.text.length > 0)
);
}
}
// Normalize unsupported content types without reintroducing the Claude -> OpenAI round-trip.
for (const msg of messages) {
if (msg.role !== "user" || !Array.isArray(msg.content)) continue;
msg.content = (msg.content as ClaudeContentBlock[]).flatMap((block: ClaudeContentBlock) => {
if (
block.type === "text" ||
block.type === "image_url" ||
block.type === "image" ||
block.type === "file_url" ||
block.type === "file" ||
block.type === "document"
) {
const fileData = (block.file_url ?? block.file ?? block.document) as
| Record<string, unknown>
| undefined;
if (
(block.type === "file" || block.type === "document") &&
!fileData?.url &&
!fileData?.data
) {
const fileContent =
(block.file as ClaudeContentBlock)?.content ??
(block.file as ClaudeContentBlock)?.text ??
block.content ??
block.text;
const fileName =
(block.file as Record<string, unknown>)?.name ?? block.name ?? "attachment";
if (typeof fileContent === "string" && fileContent.length > 0) {
return [{ type: "text", text: `[${fileName}]\n${fileContent}` }];
}
}
return [block];
}
if (block.type === "tool_result") {
const toolId = block.tool_use_id ?? block.id ?? "unknown";
const resultContent = block.content ?? block.text ?? block.output ?? "";
const resultText =
typeof resultContent === "string"
? resultContent
: Array.isArray(resultContent)
? resultContent
.filter((c: Record<string, unknown>) => c.type === "text")
.map((c: Record<string, unknown>) => c.text)
.join("\n")
: JSON.stringify(resultContent);
if (resultText.length > 0) {
return [{ type: "text", text: `[Tool Result: ${toolId}]\n${resultText}` }];
}
return [];
}
log?.debug?.("CONTENT", `Dropped unsupported content part type="${block.type}"`);
return [];
});
}
};
try {
if (nativeCodexPassthrough) {
translatedBody = { ...body, _nativeCodexPassthrough: true };
@ -1172,6 +1468,7 @@ export async function handleChatCore({
// regardless of combo strategy or cache_control settings.
translatedBody = { ...body };
translatedBody._disableToolPrefix = true;
normalizeClaudeUpstreamMessages(translatedBody);
log?.debug?.("FORMAT", `claude passthrough (preserveCache=${preserveCacheControl})`);
} else {
@ -1184,89 +1481,7 @@ export async function handleChatCore({
// "proxy_Bash", which Claude rejects ("No such tool available: proxy_Bash").
if (targetFormat === FORMATS.CLAUDE) {
translatedBody._disableToolPrefix = true;
}
// Strip empty text content blocks from messages.
// Anthropic API rejects {"type":"text","text":""} with 400 "text content blocks must be non-empty".
// Some clients (LiteLLM passthrough, @ai-sdk/anthropic) may forward these empty blocks as-is.
if (Array.isArray(translatedBody.messages)) {
for (const msg of translatedBody.messages) {
if (Array.isArray(msg.content)) {
msg.content = msg.content.filter(
(block: Record<string, unknown>) =>
block.type !== "text" || (typeof block.text === "string" && block.text.length > 0)
);
}
}
}
// ── #409: Normalize unsupported content part types ──
// Cursor and other clients send {type:"file"} when attaching .md or other files.
// Providers (Copilot, OpenAI) only accept "text" and "image_url" in content arrays.
// Convert: file → text (extract content), drop unrecognized types with a warning.
if (Array.isArray(translatedBody.messages)) {
for (const msg of translatedBody.messages) {
if (msg.role === "user" && Array.isArray(msg.content)) {
msg.content = (msg.content as Record<string, unknown>[]).flatMap(
(block: Record<string, unknown>) => {
if (
block.type === "text" ||
block.type === "image_url" ||
block.type === "image" ||
block.type === "file_url" ||
block.type === "file" ||
block.type === "document"
) {
// Only extract text if it's explicitly a text-only representation without data
const fileData = (block.file_url ?? block.file ?? block.document) as
| Record<string, unknown>
| undefined;
if (
(block.type === "file" || block.type === "document") &&
!fileData?.url &&
!fileData?.data
) {
const fileContent =
(block.file as Record<string, unknown>)?.content ??
(block.file as Record<string, unknown>)?.text ??
block.content ??
block.text;
const fileName =
(block.file as Record<string, unknown>)?.name ?? block.name ?? "attachment";
if (typeof fileContent === "string" && fileContent.length > 0) {
return [{ type: "text", text: `[${fileName}]\n${fileContent}` }];
}
}
return [block];
}
// (#527) tool_result → convert to text instead of dropping.
// When Claude Code + superpowers routes through Codex, it sends tool_result
// blocks in user messages. Silently dropping them causes Codex to loop
// because it never receives the tool response and keeps re-requesting it.
if (block.type === "tool_result") {
const toolId = block.tool_use_id ?? block.id ?? "unknown";
const resultContent = block.content ?? block.text ?? block.output ?? "";
const resultText =
typeof resultContent === "string"
? resultContent
: Array.isArray(resultContent)
? resultContent
.filter((c: Record<string, unknown>) => c.type === "text")
.map((c: Record<string, unknown>) => c.text)
.join("\n")
: JSON.stringify(resultContent);
if (resultText.length > 0) {
return [{ type: "text", text: `[Tool Result: ${toolId}]\n${resultText}` }];
}
return [];
}
// Unknown types: drop silently
log?.debug?.("CONTENT", `Dropped unsupported content part type="${block.type}"`);
return [];
}
);
}
}
normalizeClaudeUpstreamMessages(translatedBody);
}
// OpenAI-compatible providers only support function tools.
@ -1416,69 +1631,6 @@ export async function handleChatCore({
}
}
// ── Proactive Context Compression (Phase 4) ──
// Check if context exceeds 85% of limit and compress proactively before sending to provider.
// This prevents "prompt too long" errors for large-but-not-full contexts.
if (translatedBody && translatedBody.messages && Array.isArray(translatedBody.messages)) {
const estimatedTokens = estimateTokens(JSON.stringify(translatedBody.messages));
const contextLimit = getTokenLimit(provider, effectiveModel);
const COMPRESSION_THRESHOLD = 0.85;
const threshold = Math.floor(contextLimit * COMPRESSION_THRESHOLD);
log?.debug?.(
"CONTEXT",
`Checking compression: ${estimatedTokens} tokens vs ${threshold} threshold (${contextLimit} limit)`
);
if (estimatedTokens > threshold) {
log?.info?.(
"CONTEXT",
`Proactive compression triggered: ${estimatedTokens} tokens > ${threshold} threshold (${contextLimit} limit)`
);
const compressionResult = compressContext(translatedBody, {
provider,
model: effectiveModel,
maxTokens: contextLimit,
reserveTokens: 0,
});
if (compressionResult.compressed) {
translatedBody = compressionResult.body;
const stats = compressionResult.stats;
const layersInfo =
stats && "layers" in stats && Array.isArray(stats.layers)
? ` (layers: ${stats.layers.map((l: { name: string }) => l.name).join(", ")})`
: "";
log?.info?.(
"CONTEXT",
`Context compressed: ${stats.original}${stats.final} tokens${layersInfo}`
);
logAuditEvent({
action: "context.proactive_compression",
actor: apiKeyInfo?.name || "system",
target: connectionId || provider || "chat",
details: {
provider,
model: effectiveModel,
original_tokens: stats.original,
final_tokens: stats.final,
layers: "layers" in stats ? stats.layers : undefined,
},
});
} else {
log?.debug?.("CONTEXT", `Compression not applied: context already fits within target`);
}
}
} else {
log?.debug?.(
"CONTEXT",
`Skipping compression check: translatedBody=${!!translatedBody}, messages=${!!translatedBody?.messages}, isArray=${Array.isArray(translatedBody?.messages)}`
);
}
// Resolve executor with optional upstream proxy (CLIProxyAPI) routing.
// mode="native" (default): returns the native executor unchanged.
// mode="cliproxyapi": returns the CLIProxyAPI executor instead.
@ -1649,7 +1801,8 @@ export async function handleChatCore({
log,
extendedContext,
upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall),
clientHeaders: clientRawRequest?.headers ?? null,
clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent),
onCredentialsRefreshed,
});
// Qwen 429 strict quota backoff (wait 1.5s, 3s and retry)
@ -1851,7 +2004,8 @@ export async function handleChatCore({
log,
extendedContext,
upstreamExtraHeaders: buildUpstreamHeadersForExecute(retryModelId),
clientHeaders: clientRawRequest?.headers ?? null,
clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent),
onCredentialsRefreshed,
});
if (retryResult.response.ok) {
@ -2513,23 +2667,20 @@ export async function handleChatCore({
}
}
const pipelineSessionId =
(clientRawRequest?.headers && typeof clientRawRequest.headers.get === "function"
? clientRawRequest.headers.get("x-omniroute-session-id")
: getHeaderValueCaseInsensitive(
clientRawRequest?.headers ?? null,
"x-omniroute-session-id"
)) || skillRequestId;
if (memoryOwnerId && memorySettings?.enabled && memorySettings.maxTokens > 0) {
const requestMemoryText = extractMemoryTextFromRequestBody(body as Record<string, unknown>);
if (requestMemoryText) {
extractFacts(requestMemoryText, memoryOwnerId, pipelineSessionId);
}
if (apiKeyInfo?.id && memorySettings?.enabled && memorySettings.maxTokens > 0) {
const memoryText = extractMemoryTextFromResponse(memoryExtractionResponse);
if (memoryText) {
extractFacts(memoryText, apiKeyInfo.id, pipelineSessionId);
extractFacts(memoryText, memoryOwnerId, pipelineSessionId);
}
}
const customSkillExecutionEnabled =
Boolean(apiKeyInfo?.id) && memorySettings?.skillsEnabled === true;
Boolean(memoryOwnerId) && memorySettings?.skillsEnabled === true;
const builtinToolNames = webSearchFallbackPlan.toolName ? [webSearchFallbackPlan.toolName] : [];
if (customSkillExecutionEnabled || builtinToolNames.length > 0) {
const skillSessionId = pipelineSessionId;
@ -2538,7 +2689,7 @@ export async function handleChatCore({
translatedResponse,
getSkillsModelIdForFormat(sourceFormat),
{
apiKeyId: apiKeyInfo?.id || "local",
apiKeyId: memoryOwnerId || "local",
sessionId: skillSessionId,
requestId: skillRequestId,
builtinToolNames,
@ -2759,6 +2910,25 @@ export async function handleChatCore({
.catch(() => {});
}
if (
memoryOwnerId &&
memorySettings?.enabled &&
memorySettings.maxTokens > 0 &&
streamStatus === 200
) {
const requestMemoryText = extractMemoryTextFromRequestBody(body as Record<string, unknown>);
if (requestMemoryText) {
extractFacts(requestMemoryText, memoryOwnerId, pipelineSessionId);
}
const streamedMemoryText = extractMemoryTextFromResponse(
(streamResponseBody ?? null) as Record<string, unknown> | null
);
if (streamedMemoryText) {
extractFacts(streamedMemoryText, memoryOwnerId, pipelineSessionId);
}
}
// Semantic cache: store assembled streaming response for future cache hits
if (
semanticCacheEnabled &&

View file

@ -1,4 +1,3 @@
import os from "node:os";
import {
ANTIGRAVITY_FALLBACK_VERSION,
getCachedAntigravityVersion,
@ -39,7 +38,7 @@ function withOptionalBearerAuth(
}
function getPlatform(): string {
const p = os.platform();
const p = typeof process !== "undefined" ? process.platform : "unknown";
switch (p) {
case "win32":
return "windows";
@ -51,7 +50,7 @@ function getPlatform(): string {
}
function getArch(): string {
const a = os.arch();
const a = typeof process !== "undefined" ? process.arch : "unknown";
switch (a) {
case "x64":
return "x64";

View file

@ -1,18 +1,10 @@
import { createHash, randomUUID } from "node:crypto";
import { getStainlessTimeoutSeconds } from "@/shared/utils/runtimeTimeouts";
import {
ANTHROPIC_BETA_FULL,
ANTHROPIC_VERSION_HEADER,
CLAUDE_CLI_STAINLESS_PACKAGE_VERSION,
CLAUDE_CLI_STAINLESS_RUNTIME_VERSION,
CLAUDE_CLI_USER_AGENT,
CLAUDE_CLI_VERSION,
} from "../config/anthropicHeaders.ts";
import { ANTHROPIC_VERSION_HEADER } from "../config/anthropicHeaders.ts";
import { supportsXHighEffort } from "../config/providerModels.ts";
import { prepareClaudeRequest } from "../translator/helpers/claudeHelper.ts";
import { signRequestBody } from "./claudeCodeCCH.ts";
import { computeFingerprint, extractFirstUserMessageText } from "./claudeCodeFingerprint.ts";
import { remapToolNamesInRequest } from "./claudeCodeToolRemapper.ts";
import {
enforceThinkingTemperature,
@ -26,20 +18,32 @@ import { obfuscateInBody } from "./claudeCodeObfuscation.ts";
* traffic which looks like the official Claude Code client, often because those
* gateways resell the same models at materially lower prices than the direct API.
*
* This bridge is intentionally compatibility-first, not lossless. We normalize
* requests into the smallest Claude Code-shaped surface that consistently passes
* provider-side client checks, instead of trying to preserve every original
* field one-to-one.
* This bridge is intentionally compatibility-first while still preserving as
* much Claude-native structure as possible. Third-party relays are sensitive to
* wire-image details, so we only synthesize the minimum required defaults when
* the caller did not already provide Claude-shaped fields.
*/
export const CLAUDE_CODE_COMPATIBLE_PREFIX = "anthropic-compatible-cc-";
export const CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true";
export const CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH = "/models";
export const CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS = 8092;
export const CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS = 64000;
export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_VERSION = ANTHROPIC_VERSION_HEADER;
export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA = ANTHROPIC_BETA_FULL;
export const CLAUDE_CODE_COMPATIBLE_VERSION = CLAUDE_CLI_VERSION;
export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = CLAUDE_CLI_USER_AGENT;
export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA = [
"claude-code-20250219",
"interleaved-thinking-2025-05-14",
"effort-2025-11-24",
].join(",");
export const CLAUDE_CODE_COMPATIBLE_VERSION = "2.1.113";
export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.113 (external, sdk-cli)";
export const CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.81.0";
export const CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = "v24.3.0";
export const CONTEXT_1M_BETA_HEADER = "context-1m-2025-08-07";
const CLAUDE_CODE_COMPATIBLE_DEFAULT_SYSTEM_BLOCKS = [
{
type: "text",
text: "You are a Claude agent, built on Anthropic's Claude Agent SDK.",
},
];
const CONTEXT_1M_SUPPORTED_MODELS = [
"claude-opus-4-7",
"claude-opus-4-6",
@ -47,18 +51,6 @@ const CONTEXT_1M_SUPPORTED_MODELS = [
"claude-sonnet-4-5",
"claude-sonnet-4",
];
/**
* Build the billing header dynamically with fingerprint and CCH placeholder.
* The cch=00000 placeholder is later replaced by signRequestBody().
*/
export function buildBillingHeader(messages?: Array<{ role?: string; content?: unknown }>): string {
const msgText = extractFirstUserMessageText(messages);
const fp = computeFingerprint(msgText, CLAUDE_CODE_COMPATIBLE_VERSION);
return `x-anthropic-billing-header: cc_version=${CLAUDE_CODE_COMPATIBLE_VERSION}.${fp}; cc_entrypoint=cli; cch=00000;`;
}
/** @deprecated Use buildBillingHeader() for dynamic fingerprint */
export const CLAUDE_CODE_COMPATIBLE_BILLING_HEADER = `x-anthropic-billing-header: cc_version=${CLAUDE_CODE_COMPATIBLE_VERSION}.000; cc_entrypoint=cli; cch=00000;`;
export const CLAUDE_CODE_COMPATIBLE_STAINLESS_TIMEOUT_SECONDS = getStainlessTimeoutSeconds(
process.env
);
@ -172,13 +164,14 @@ export function buildClaudeCodeCompatibleHeaders(
stream = false,
sessionId?: string | null
): Record<string, string> {
void stream;
// These headers intentionally mirror Claude Code's wire image closely.
// For CC-compatible relays, passing the upstream's client-gating checks is
// more important than forwarding arbitrary caller-specific header shapes.
return {
"Content-Type": "application/json",
Accept: stream ? "text/event-stream" : "application/json",
"x-api-key": apiKey,
Accept: "application/json",
Authorization: `Bearer ${apiKey}`,
"anthropic-version": CLAUDE_CODE_COMPATIBLE_ANTHROPIC_VERSION,
"anthropic-beta": CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA,
"anthropic-dangerous-direct-browser-access": "true",
@ -187,16 +180,13 @@ export function buildClaudeCodeCompatibleHeaders(
"X-Stainless-Retry-Count": "0",
"X-Stainless-Timeout": String(CLAUDE_CODE_COMPATIBLE_STAINLESS_TIMEOUT_SECONDS),
"X-Stainless-Lang": "js",
"X-Stainless-Package-Version": CLAUDE_CLI_STAINLESS_PACKAGE_VERSION,
"X-Stainless-Package-Version": CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION,
"X-Stainless-OS": "MacOS",
"X-Stainless-Arch": "arm64",
"X-Stainless-Runtime": "node",
"X-Stainless-Runtime-Version": CLAUDE_CLI_STAINLESS_RUNTIME_VERSION,
"accept-language": "*",
"sec-fetch-mode": "cors",
"accept-encoding": "identity",
"X-Stainless-Runtime-Version": CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION,
"accept-encoding": "gzip, deflate, br, zstd",
...(sessionId ? { "X-Claude-Code-Session-Id": sessionId } : {}),
"x-client-request-id": randomUUID(),
};
}
@ -234,7 +224,6 @@ export function buildClaudeCodeCompatibleRequest({
model,
stream = false,
cwd = process.cwd(),
now = new Date(),
sessionId,
preserveCacheControl = false,
}: BuildRequestOptions) {
@ -250,18 +239,11 @@ export function buildClaudeCodeCompatibleRequest({
: Array.isArray(normalized.messages)
? buildClaudeCodeCompatibleMessages(normalized.messages as MessageLike[])
: [];
const allMessages = (preparedClaudeBody?.messages || normalized.messages || []) as Array<{
role?: string;
content?: unknown;
}>;
const billingHeader = buildBillingHeader(allMessages);
const system = buildClaudeCodeCompatibleSystemBlocks({
messages: normalized.messages as MessageLike[],
systemBlocks: preparedClaudeBody?.system as Record<string, unknown>[] | undefined,
cwd,
now,
preserveCacheControl,
billingHeader,
injectDefaultSkeleton: !preparedClaudeBody,
});
const resolvedSessionId = sessionId || randomUUID();
const effort = resolveClaudeCodeCompatibleEffort(sourceBody, normalizedBody, model);
@ -278,37 +260,35 @@ export function buildClaudeCodeCompatibleRequest({
normalizedBody?.["tool_choice"] ?? sourceBody?.["tool_choice"]
)
: undefined;
const metadata = resolveClaudeCodeCompatibleMetadata({
claudeBody,
sourceBody,
normalizedBody,
cwd,
sessionId: resolvedSessionId,
});
const thinking = resolveClaudeCodeCompatibleThinking({
claudeBody: preparedClaudeBody ?? claudeBody,
sourceBody,
normalizedBody,
});
const outputConfig = resolveClaudeCodeCompatibleOutputConfig({
claudeBody,
sourceBody,
normalizedBody,
model,
effort,
});
return {
model,
messages,
system,
tools,
metadata: {
user_id: JSON.stringify({
device_id: createHash("sha256")
.update(String(cwd || ""))
.digest("hex")
.slice(0, 24),
account_uuid: "",
session_id: resolvedSessionId,
}),
},
metadata,
max_tokens: maxTokens,
thinking: {
type: "adaptive",
},
context_management: {
edits: [
{
type: "clear_thinking_20251015",
keep: "all",
},
],
},
output_config: {
effort,
},
thinking,
output_config: outputConfig,
...(toolChoice ? { tool_choice: toolChoice } : {}),
...(stream ? { stream: true } : {}),
};
@ -394,7 +374,9 @@ export function resolveClaudeCodeCompatibleEffort(
const normalizedEffort = raw.toLowerCase();
if (!normalizedEffort) return "high";
if (!normalizedEffort) {
return supportsClaudeXHighEffort(model) ? "xhigh" : "high";
}
if (normalizedEffort === "low") return "low";
if (normalizedEffort === "medium") return "medium";
if (normalizedEffort === "high") return "high";
@ -403,9 +385,9 @@ export function resolveClaudeCodeCompatibleEffort(
return supportsClaudeXHighEffort(model) ? "xhigh" : "high";
}
if (normalizedEffort === "max") {
return "high";
return supportsClaudeXHighEffort(model) ? "xhigh" : "high";
}
return "high";
return supportsClaudeXHighEffort(model) ? "xhigh" : "high";
}
export function resolveClaudeCodeCompatibleMaxTokens(
@ -544,48 +526,35 @@ function buildClaudeCodeCompatibleMessagesFromClaude(
function buildClaudeCodeCompatibleSystemBlocks({
messages,
systemBlocks,
cwd,
now,
preserveCacheControl,
billingHeader,
injectDefaultSkeleton,
}: {
messages: MessageLike[] | undefined;
systemBlocks?: Array<Record<string, unknown>> | undefined;
cwd: string;
now: Date;
preserveCacheControl: boolean;
billingHeader: string;
injectDefaultSkeleton: boolean;
}) {
const customSystemBlocks =
Array.isArray(systemBlocks) && systemBlocks.length > 0
? systemBlocks.map((block) => ({ ...block }))
: extractCustomSystemBlocks(messages);
const dateText = formatDate(now);
const blocks: Array<Record<string, unknown>> = [
{
type: "text",
text: billingHeader,
},
{
type: "text",
text: "You are a Claude agent, built on Anthropic's Claude Agent SDK.",
},
{
type: "text",
text: `You are Claude Code, Anthropic's official CLI for Claude.\n\nCWD: ${cwd}\nDate: ${dateText}`,
},
];
for (const systemBlock of customSystemBlocks) {
const preparedCustomSystemBlocks = customSystemBlocks.map((systemBlock) => {
const preparedBlock = { ...systemBlock } as Record<string, unknown>;
if (!preserveCacheControl) {
delete preparedBlock["cache_control"];
}
blocks.push(preparedBlock);
return preparedBlock;
});
if (!injectDefaultSkeleton) {
return preparedCustomSystemBlocks;
}
return blocks;
return [
...CLAUDE_CODE_COMPATIBLE_DEFAULT_SYSTEM_BLOCKS.map((block) => ({ ...block })),
...preparedCustomSystemBlocks,
];
}
function convertClaudeCodeCompatibleMessage(message: MessageLike | null | undefined) {
@ -838,6 +807,86 @@ function stripCacheControlFromContentBlocks(content: Array<Record<string, unknow
}
}
function resolveClaudeCodeCompatibleMetadata({
claudeBody,
sourceBody,
normalizedBody,
cwd,
sessionId,
}: {
claudeBody?: Record<string, unknown> | null;
sourceBody?: Record<string, unknown> | null;
normalizedBody?: Record<string, unknown> | null;
cwd: string;
sessionId: string;
}) {
const metadata =
readRecord(cloneValue(claudeBody?.metadata)) ||
readRecord(cloneValue(sourceBody?.metadata)) ||
readRecord(cloneValue(normalizedBody?.metadata)) ||
{};
if (!toNonEmptyString(metadata.user_id)) {
metadata.user_id = JSON.stringify({
device_id: createHash("sha256")
.update(String(cwd || ""))
.digest("hex"),
account_uuid: "",
session_id: sessionId,
});
}
return metadata;
}
function resolveClaudeCodeCompatibleThinking({
claudeBody,
sourceBody,
normalizedBody,
}: {
claudeBody?: Record<string, unknown> | null;
sourceBody?: Record<string, unknown> | null;
normalizedBody?: Record<string, unknown> | null;
}) {
const thinking =
readRecord(cloneValue(claudeBody?.thinking)) ||
readRecord(cloneValue(sourceBody?.thinking)) ||
readRecord(cloneValue(normalizedBody?.thinking));
if (thinking) {
return thinking;
}
return {
type: "adaptive",
};
}
function resolveClaudeCodeCompatibleOutputConfig({
claudeBody,
sourceBody,
normalizedBody,
model,
effort,
}: {
claudeBody?: Record<string, unknown> | null;
sourceBody?: Record<string, unknown> | null;
normalizedBody?: Record<string, unknown> | null;
model?: string | null;
effort: "low" | "medium" | "high" | "xhigh";
}) {
const outputConfig =
readRecord(cloneValue(claudeBody?.output_config)) ||
readRecord(cloneValue(sourceBody?.output_config)) ||
readRecord(cloneValue(normalizedBody?.output_config)) ||
{};
return {
...outputConfig,
effort: resolveClaudeCodeCompatibleEffort(sourceBody, normalizedBody, model) || effort,
};
}
function cloneValue<T>(value: T): T {
if (typeof structuredClone === "function") {
return structuredClone(value);
@ -893,20 +942,6 @@ function getHeader(headers: HeaderLike, name: string): string | null {
return null;
}
function formatDate(date: Date): string {
const formatter = new Intl.DateTimeFormat("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const parts = formatter.formatToParts(date);
const year = parts.find((part) => part.type === "year")?.value || "1970";
const month = parts.find((part) => part.type === "month")?.value || "01";
const day = parts.find((part) => part.type === "day")?.value || "01";
return `${year}-${month}-${day}`;
}
function toNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();

View file

@ -0,0 +1,18 @@
/**
* Extra tool name remapping for third-party agent detection bypass.
*
* Anthropic detects non-Claude-Code clients by checking for specific
* tool names that only third-party agents use (e.g. subagents, session_status).
* This module adds aliases for those tool names so they look like
* legitimate Claude Code tools.
*
* Mapping: lowercase original TitleCase alias (sent to Anthropic)
* Response path reverses automatically via REVERSE_MAP in claudeCodeToolRemapper.ts
*
* To update: just add entries to EXTRA_TOOL_RENAME_MAP below.
*/
export const EXTRA_TOOL_RENAME_MAP: Record<string, string> = {
subagents: "SubDispatch",
session_status: "CheckStatus",
};

View file

@ -54,22 +54,47 @@ export function obfuscateSensitiveWords(text: string): string {
}
export function obfuscateInBody(body: Record<string, unknown>): void {
const messages = body.messages as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(messages)) return;
// System prompt (Claude format: string or array of blocks)
if (typeof body.system === "string") {
body.system = obfuscateSensitiveWords(body.system);
} else if (Array.isArray(body.system)) {
for (const block of body.system as Array<Record<string, unknown>>) {
if (typeof block.text === "string") {
block.text = obfuscateSensitiveWords(block.text);
}
}
}
for (const msg of messages) {
if (String(msg.role) !== "user") continue;
const content = msg.content;
if (typeof content === "string") {
msg.content = obfuscateSensitiveWords(content);
} else if (Array.isArray(content)) {
for (const block of content as Array<Record<string, unknown>>) {
if (typeof block.text === "string") {
block.text = obfuscateSensitiveWords(block.text);
// Messages (all roles, not just user — system/assistant may also contain sensitive words)
const messages = body.messages as Array<Record<string, unknown>> | undefined;
if (Array.isArray(messages)) {
for (const msg of messages) {
const content = msg.content;
if (typeof content === "string") {
msg.content = obfuscateSensitiveWords(content);
} else if (Array.isArray(content)) {
for (const block of content as Array<Record<string, unknown>>) {
if (typeof block.text === "string") {
block.text = obfuscateSensitiveWords(block.text);
}
}
}
}
}
// Tool descriptions (may contain URLs or names like "opencode")
const tools = body.tools as Array<Record<string, unknown>> | undefined;
if (Array.isArray(tools)) {
for (const tool of tools) {
if (typeof tool.description === "string") {
tool.description = obfuscateSensitiveWords(tool.description);
}
const fn = tool.function as Record<string, unknown> | undefined;
if (fn && typeof fn.description === "string") {
fn.description = obfuscateSensitiveWords(fn.description);
}
}
}
}
function escapeRegex(str: string): string {

View file

@ -10,7 +10,10 @@
* - Response path: TitleCase lowercase (for clients expecting lowercase)
*/
import { EXTRA_TOOL_RENAME_MAP } from "./claudeCodeExtraRemap.ts";
const TOOL_RENAME_MAP: Record<string, string> = {
...EXTRA_TOOL_RENAME_MAP,
bash: "Bash",
read: "Read",
write: "Write",
@ -19,12 +22,15 @@ const TOOL_RENAME_MAP: Record<string, string> = {
grep: "Grep",
task: "Task",
webfetch: "WebFetch",
websearch: "WebSearch",
todowrite: "TodoWrite",
todoread: "TodoRead",
question: "Question",
skill: "Skill",
multiedit: "MultiEdit",
notebook: "Notebook",
lsp: "Lsp",
apply_patch: "ApplyPatch",
};
const REVERSE_MAP: Record<string, string> = {};

View file

@ -1,9 +1,18 @@
import { supportsReasoning } from "./modelCapabilities.ts";
import { getModelSpec } from "../../src/shared/constants/modelSpecs.ts";
const CLOUD_CODE_REASONING_UNSUPPORTED_PATTERNS = [/^claude-/i, /^gpt-oss-/i, /^tab_/i];
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function normalizeCloudCodeModel(model: string): string {
return String(model || "")
.trim()
.replace(/^models\//i, "")
.replace(/^(?:antigravity|gemini-cli)\//i, "");
}
function stripGeminiThinkingConfig(value: unknown): unknown {
if (!isRecord(value)) return value;
if (!("thinkingConfig" in value) && !("thinking_config" in value)) return value;
@ -16,7 +25,12 @@ function stripGeminiThinkingConfig(value: unknown): unknown {
export function shouldStripCloudCodeThinking(provider: string, model: string): boolean {
if (!provider || !model) return false;
return !supportsReasoning(`${provider}/${model}`);
const normalizedModel = normalizeCloudCodeModel(model);
const spec = getModelSpec(normalizedModel);
if (typeof spec?.supportsThinking === "boolean") {
return !spec.supportsThinking;
}
return CLOUD_CODE_REASONING_UNSUPPORTED_PATTERNS.some((pattern) => pattern.test(normalizedModel));
}
export function stripCloudCodeThinkingConfig(

View file

@ -65,7 +65,11 @@ const COMBO_BAD_REQUEST_FALLBACK_PATTERNS = [
// Used to detect 503 responses from handleNoCredentials so combo can fallback.
const ALL_ACCOUNTS_RATE_LIMITED_PATTERNS = [/unavailable/i, /service temporarily unavailable/i];
function isAllAccountsRateLimitedResponse(status: number, contentType: string | null, errorText: string): boolean {
function isAllAccountsRateLimitedResponse(
status: number,
contentType: string | null,
errorText: string
): boolean {
if (status !== 503) return false;
if (!contentType?.includes("application/json")) return false;
return ALL_ACCOUNTS_RATE_LIMITED_PATTERNS.some((p) => p.test(errorText));
@ -1039,7 +1043,6 @@ export async function handleComboChat({
const strategy = combo.strategy || "priority";
const relayConfig =
strategy === "context-relay" ? resolveContextRelayConfig(relayOptions?.config || null) : null;
let globalAttempts = 0;
// ── Combo Agent Middleware (#399 + #401) ────────────────────────────────
// Apply system_message override, tool_filter_regex, and extract pinned model
@ -1481,15 +1484,6 @@ export async function handleComboChat({
// Retry loop for transient errors
for (let retry = 0; retry <= maxRetries; retry++) {
globalAttempts++;
if (globalAttempts > 30) {
log.warn(
"COMBO",
`Maximum combo attempts (30) exceeded across all targets and fallbacks. Terminating loop to prevent runaway background requests.`
);
return errorResponse(503, "Maximum combo retry limit reached");
}
if (retry > 0) {
log.info(
"COMBO",
@ -1807,7 +1801,6 @@ async function handleRoundRobinCombo({
let earliestRetryAfter = null;
let fallbackCount = 0;
let recordedAttempts = 0;
let globalAttempts = 0;
// Try each model starting from the round-robin target
for (let offset = 0; offset < modelCount; offset++) {
@ -1859,15 +1852,6 @@ async function handleRoundRobinCombo({
// Retry loop within this model
try {
for (let retry = 0; retry <= maxRetries; retry++) {
globalAttempts++;
if (globalAttempts > 30) {
log.warn(
"COMBO-RR",
`Maximum combo attempts (30) exceeded. Terminating loop to prevent runaway requests.`
);
return errorResponse(503, "Maximum combo retry limit reached");
}
if (retry > 0) {
log.info(
"COMBO-RR",
@ -2001,7 +1985,10 @@ async function handleRoundRobinCombo({
}
if (isAllAccountsRateLimited) {
log.info("COMBO", `All accounts rate-limited for ${modelStr}, falling back to next model`);
log.info(
"COMBO",
`All accounts rate-limited for ${modelStr}, falling back to next model`
);
} else if (!shouldFallback && !comboBadRequestFallback) {
log.warn("COMBO-RR", `${modelStr} failed (no fallback)`, { status: result.status });
recordComboRequest(combo.name, modelStr, {

View file

@ -439,26 +439,33 @@ export async function refreshCodexToken(refreshToken, log, proxyConfig: unknown
if (!response.ok) {
const errorText = await response.text();
// Detect unrecoverable "refresh_token_reused" error from OpenAI
// This means the token was already consumed and a new one was issued.
// Detect unrecoverable "refresh_token_reused" or "invalid_grant" error from OpenAI
// This means the token was already consumed or has expired.
// Retrying with the same token will never succeed.
let errorCode = null;
try {
const parsed = JSON.parse(errorText);
errorCode = parsed?.error?.code;
errorCode =
parsed?.error?.code || (typeof parsed?.error === "string" ? parsed.error : null);
} catch {
// not JSON, ignore
}
if (errorCode === "refresh_token_reused") {
if (
errorCode === "refresh_token_reused" ||
errorCode === "invalid_grant" ||
errorCode === "token_expired" ||
errorCode === "invalid_token"
) {
log?.error?.(
"TOKEN_REFRESH",
"Codex refresh token already used (rotating token consumed). Re-authentication required.",
"Codex refresh token already used or invalid. Re-authentication required.",
{
status: response.status,
errorCode,
}
);
return { error: "refresh_token_reused" };
return { error: "unrecoverable_refresh_error", code: errorCode };
}
log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", {
@ -817,7 +824,10 @@ export function isUnrecoverableRefreshError(result) {
return (
result &&
typeof result === "object" &&
(result.error === "refresh_token_reused" || result.error === "invalid_request")
(result.error === "unrecoverable_refresh_error" ||
result.error === "refresh_token_reused" ||
result.error === "invalid_request" ||
result.error === "invalid_grant")
);
}

View file

@ -27,6 +27,7 @@ export const GEMINI_UNSUPPORTED_SCHEMA_KEYS = new Set([
"definitions",
"const",
"$ref",
"ref",
// Object validation keywords (not supported)
"propertyNames",
"patternProperties",
@ -312,7 +313,10 @@ function normalizeAdditionalProperties(obj) {
return;
}
if ("additionalProperties" in obj && obj.additionalProperties !== true) {
// Gemini API does not support `additionalProperties` at all in function_declarations
// schemas (returns 400 "Unknown name"). Since Gemini defaults to allowing additional
// properties anyway, stripping it unconditionally is safe and prevents errors (#1421).
if ("additionalProperties" in obj) {
delete obj.additionalProperties;
}

View file

@ -56,14 +56,51 @@ export function filterToOpenAIFormat(body) {
if (VALID_OPENAI_CONTENT_TYPES.includes(block.type)) {
// Remove signature and cache_control fields
const { signature, cache_control, ...cleanBlock } = block;
if (
cleanBlock.type === "text" &&
typeof cleanBlock.text === "string" &&
cleanBlock.text.length === 0
) {
continue;
}
const fileData = cleanBlock.file_url ?? cleanBlock.file ?? cleanBlock.document;
if (
(cleanBlock.type === "file" || cleanBlock.type === "document") &&
!fileData?.url &&
!fileData?.data
) {
const fileContent =
cleanBlock.file?.content ??
cleanBlock.file?.text ??
cleanBlock.content ??
cleanBlock.text;
const fileName = cleanBlock.file?.name ?? cleanBlock.name ?? "attachment";
if (typeof fileContent === "string" && fileContent.length > 0) {
filteredContent.push({ type: "text", text: `[${fileName}]\n${fileContent}` });
continue;
}
}
filteredContent.push(cleanBlock);
} else if (block.type === "tool_use") {
// Convert tool_use to tool_calls format (handled separately)
continue;
} else if (block.type === "tool_result") {
// Keep tool_result but clean it
const { signature, cache_control, ...cleanBlock } = block;
filteredContent.push(cleanBlock);
const resultContent = block.content ?? block.text ?? block.output ?? "";
const resultText =
typeof resultContent === "string"
? resultContent
: Array.isArray(resultContent)
? resultContent
.filter((c) => c?.type === "text")
.map((c) => c.text)
.join("\n")
: JSON.stringify(resultContent);
if (typeof resultText === "string" && resultText.length > 0) {
filteredContent.push({
type: "text",
text: `[Tool Result: ${block.tool_use_id ?? block.id ?? "unknown"}]\n${resultText}`,
});
}
}
}

View file

@ -98,7 +98,6 @@ export function claudeToGeminiRequest(model, body, stream) {
// Preserve thinking blocks as thought parts
if (block.thinking) {
parts.push({ thought: true, text: block.thinking });
parts.push({ thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE, text: "" });
}
break;
@ -157,21 +156,10 @@ export function claudeToGeminiRequest(model, body, stream) {
const geminiRole = msg.role === "assistant" ? "model" : "user";
// Gemini 3+ expects the signature on all functionCall parts in a tool-call
// batch. If the assistant turn had no explicit thinking block, inject a fallback
// signature into all functionCalls.
// batch. If there is no real signature, we don't inject a fake one because
// Gemini API strictly validates it and returns 400.
if (geminiRole === "model") {
const hasFunctionCall = parts.some((p) => p.functionCall);
const hasSignature = parts.some((p) => p.thoughtSignature);
if (hasFunctionCall && !hasSignature) {
for (let i = 0; i < parts.length; i++) {
if (parts[i].functionCall) {
parts[i] = {
...parts[i],
thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE,
};
}
}
}
// No operation needed since we no longer inject fake signatures.
}
result.contents.push({ role: geminiRole, parts });

View file

@ -207,9 +207,6 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName
thought: true,
text: msg.reasoning_content,
});
parts.push({
thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE,
});
}
if (content) {
@ -236,7 +233,7 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName
extractClientThoughtSignature(tc)
);
const embeddedThoughtSignature = shouldUseEmbeddedSignature
? firstPersistedSignature || signatureForToolCall || DEFAULT_THINKING_GEMINI_SIGNATURE
? firstPersistedSignature || signatureForToolCall
: undefined;
if (embeddedThoughtSignature) {

View file

@ -129,14 +129,16 @@ export function claudeToOpenAIResponse(chunk, state) {
: 0;
// Use OpenAI format keys for consistent logging in stream.js
// Issue #1426: Include cached tokens in prompt_tokens and input_tokens
const totalInputTokens = inputTokens + cacheReadTokens + cacheCreationTokens;
state.usage = {
prompt_tokens: inputTokens,
prompt_tokens: totalInputTokens,
completion_tokens: outputTokens,
input_tokens: inputTokens,
input_tokens: totalInputTokens,
output_tokens: outputTokens,
};
// Store cache tokens if present
// Store cache tokens if present (needed for prompt_tokens_details in final chunk)
if (cacheReadTokens > 0) {
state.usage.cache_read_input_tokens = cacheReadTokens;
}
@ -179,10 +181,10 @@ export function claudeToOpenAIResponse(chunk, state) {
const cachedTokens = state.usage.cache_read_input_tokens || 0;
const cacheCreationTokens = state.usage.cache_creation_input_tokens || 0;
// prompt_tokens = input_tokens + cache_read + cache_creation (all prompt-side tokens)
// prompt_tokens = input_tokens (which now includes cache_read + cache_creation)
// completion_tokens = output_tokens
// total_tokens = prompt_tokens + completion_tokens
const promptTokens = inputTokens + cachedTokens + cacheCreationTokens;
const promptTokens = inputTokens;
const completionTokens = outputTokens;
const totalTokens = promptTokens + completionTokens;

View file

@ -12,6 +12,7 @@
"strict": false,
"jsx": "react-jsx",
"lib": ["dom", "esnext"],
"ignoreDeprecations": "5.0",
"baseUrl": "..",
"paths": {
"@/*": ["./src/*"],

View file

@ -83,6 +83,8 @@ export function isClaudeCodeClient(userAgent: string | null | undefined): boolea
// Claude Code user agents
if (ua.includes("claude-code") || ua.includes("claude_code")) return true;
if (ua.includes("claude-cli/")) return true;
if (ua.includes("sdk-cli")) return true;
if (ua.includes("anthropic") && ua.includes("cli")) return true;
return false;

View file

@ -34,7 +34,7 @@ export default defineConfig({
},
],
webServer: {
command: `node scripts/run-next-playwright.mjs ${playwrightServerMode}`,
command: `${JSON.stringify(process.execPath)} scripts/run-next-playwright.mjs ${playwrightServerMode}`,
url: webServerReadyUrl,
reuseExistingServer: !process.env.CI,
timeout: Number.isFinite(playwrightWebServerTimeout) ? playwrightWebServerTimeout : 900_000,

View file

@ -324,6 +324,7 @@ if (existsSync(mitmSrc)) {
module: "CommonJS",
outDir: mitmDest,
rootDir: mitmSrc,
ignoreDeprecations: "6.0",
resolveJsonModule: true,
esModuleInterop: true,
skipLibCheck: true,

View file

@ -1,11 +1,24 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { sanitizeColorEnv } from "./runtime-env.mjs";
const port = process.env.DASHBOARD_PORT || process.env.PORT || "20128";
const baseUrl = process.env.OMNIROUTE_BASE_URL || `http://localhost:${port}`;
function parsePort(value, fallback) {
const parsed = Number.parseInt(String(value), 10);
return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
}
const explicitBaseUrl = process.env.OMNIROUTE_BASE_URL || "";
const isolatedPort = parsePort(
process.env.DASHBOARD_PORT || process.env.PORT,
22000 + (process.pid % 1000)
);
const isolatedDataDir =
process.env.DATA_DIR || join(process.cwd(), ".tmp", "ecosystem-data", String(process.pid));
const port = explicitBaseUrl ? null : isolatedPort;
const baseUrl = explicitBaseUrl || `http://127.0.0.1:${isolatedPort}`;
const healthUrl = `${baseUrl}/api/monitoring/health`;
const maxWaitMs = Number(process.env.ECOSYSTEM_SERVER_WAIT_MS || 180000);
const pollMs = 2000;
@ -34,7 +47,19 @@ async function waitForServerReady() {
async function main() {
let serverProcess = null;
let startedHere = false;
const testEnv = sanitizeColorEnv(process.env);
const testEnv = {
...sanitizeColorEnv(process.env),
DATA_DIR: isolatedDataDir,
...(explicitBaseUrl
? {}
: {
PORT: String(port),
DASHBOARD_PORT: String(port),
API_PORT: String(port),
OMNIROUTE_BASE_URL: baseUrl,
}),
OMNIROUTE_E2E_BOOTSTRAP_MODE: process.env.OMNIROUTE_E2E_BOOTSTRAP_MODE || "open",
};
if (!(await isServerReady())) {
serverProcess = spawn(process.execPath, ["scripts/run-next-playwright.mjs", "dev"], {

View file

@ -37,6 +37,14 @@ function testDistDir() {
return process.env.NEXT_DIST_DIR || ".next";
}
function resolvePlaywrightDataDir({ cwd, env, pid = process.pid }) {
if (typeof env.DATA_DIR === "string" && env.DATA_DIR.trim().length > 0) {
return env.DATA_DIR;
}
return join(cwd, ".tmp", "playwright-data", String(pid));
}
export function resolvePlaywrightAppBackupDir({
cwd,
baseBackupExists,
@ -136,17 +144,34 @@ function restoreAppDir() {
console.log("[Playwright WebServer] Restored app/ directory");
}
const bootstrapEnvVars = bootstrapEnv({ quiet: true });
const playwrightDataDir = resolvePlaywrightDataDir({
cwd,
env: process.env,
});
const bootstrapEnvVars = bootstrapEnv({
quiet: true,
dataDirOverride: playwrightDataDir,
});
const runtimePorts = resolveRuntimePorts(bootstrapEnvVars);
const bootstrapMode = process.env.OMNIROUTE_E2E_BOOTSTRAP_MODE || "auth";
const playwrightPassword =
process.env.OMNIROUTE_E2E_PASSWORD || process.env.INITIAL_PASSWORD || "omniroute-e2e-password";
const testServerEnv = {
...sanitizeColorEnv(bootstrapEnvVars),
...sanitizeColorEnv(process.env),
DATA_DIR: playwrightDataDir,
NEXT_PUBLIC_OMNIROUTE_E2E_MODE: process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE || "1",
OMNIROUTE_DISABLE_BACKGROUND_SERVICES:
process.env.OMNIROUTE_DISABLE_BACKGROUND_SERVICES || "true",
OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: process.env.OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK || "true",
OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK: process.env.OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK || "true",
OMNIROUTE_HIDE_HEALTHCHECK_LOGS: process.env.OMNIROUTE_HIDE_HEALTHCHECK_LOGS || "true",
...(bootstrapMode === "open"
? {}
: {
INITIAL_PASSWORD: playwrightPassword,
OMNIROUTE_E2E_PASSWORD: playwrightPassword,
}),
...(process.env.OMNIROUTE_USE_TURBOPACK
? {
OMNIROUTE_USE_TURBOPACK: process.env.OMNIROUTE_USE_TURBOPACK,

View file

@ -1,11 +1,24 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { sanitizeColorEnv } from "./runtime-env.mjs";
const port = process.env.DASHBOARD_PORT || process.env.PORT || "20128";
const baseUrl = process.env.OMNIROUTE_BASE_URL || `http://localhost:${port}`;
function parsePort(value, fallback) {
const parsed = Number.parseInt(String(value), 10);
return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
}
const explicitBaseUrl = process.env.OMNIROUTE_BASE_URL || "";
const isolatedPort = parsePort(
process.env.DASHBOARD_PORT || process.env.PORT,
23000 + (process.pid % 1000)
);
const isolatedDataDir =
process.env.DATA_DIR || join(process.cwd(), ".tmp", "protocol-clients-data", String(process.pid));
const port = explicitBaseUrl ? null : isolatedPort;
const baseUrl = explicitBaseUrl || `http://127.0.0.1:${isolatedPort}`;
const healthUrl = `${baseUrl}/api/monitoring/health`;
const maxWaitMs = Number(process.env.ECOSYSTEM_SERVER_WAIT_MS || 180000);
const pollMs = 2000;
@ -32,7 +45,19 @@ async function waitForServerReady() {
async function main() {
let serverProcess = null;
let startedHere = false;
const testEnv = sanitizeColorEnv(process.env);
const testEnv = {
...sanitizeColorEnv(process.env),
DATA_DIR: isolatedDataDir,
...(explicitBaseUrl
? {}
: {
PORT: String(port),
DASHBOARD_PORT: String(port),
API_PORT: String(port),
OMNIROUTE_BASE_URL: baseUrl,
}),
OMNIROUTE_E2E_BOOTSTRAP_MODE: process.env.OMNIROUTE_E2E_BOOTSTRAP_MODE || "open",
};
if (!(await isServerReady())) {
serverProcess = spawn(process.execPath, ["scripts/run-next-playwright.mjs", "dev"], {
@ -48,9 +73,9 @@ async function main() {
[
"./node_modules/vitest/vitest.mjs",
"run",
"--environment",
"node",
"tests/e2e/protocol-clients.test.ts",
"--dir",
"tests",
],
{
stdio: "inherit",

View file

@ -0,0 +1,52 @@
import fs from "fs";
let content = fs.readFileSync("tests/unit/token-refresh-service.test.ts", "utf-8");
// Fix jsonResponse
content = content.replace(
/function jsonResponse\(body, status = 200\)/g,
"function jsonResponse(body: any, status = 200)"
);
// Fix textResponse
content = content.replace(
/function textResponse\(text, status = 400\)/g,
"function textResponse(text: any, status = 400)"
);
// Fix calls = []
content = content.replace(/const calls = \[\];/g, "const calls: any[] = [];");
// Fix result is possibly null
content = content.replace(/result\.accessToken/g, "result?.accessToken");
content = content.replace(/result\.refreshToken/g, "result?.refreshToken");
content = content.replace(/result\.expiresIn/g, "result?.expiresIn");
fs.writeFileSync("tests/unit/token-refresh-service.test.ts", content);
let claudeContent = fs.readFileSync("tests/unit/translator-claude-to-gemini.test.ts", "utf-8");
claudeContent = claudeContent.replace(/result\.tools/g, "(result as any).tools");
claudeContent = claudeContent.replace(/result\._toolNameMap/g, "(result as any)._toolNameMap");
claudeContent = claudeContent.replace(/result\[0\]/g, "(result as any)[0]");
fs.writeFileSync("tests/unit/translator-claude-to-gemini.test.ts", claudeContent);
let openaiContent = fs.readFileSync("tests/unit/translator-openai-to-gemini.test.ts", "utf-8");
openaiContent = openaiContent.replace(
/result\.systemInstruction/g,
"(result as any).systemInstruction"
);
openaiContent = openaiContent.replace(/result\.tools/g, "(result as any).tools");
openaiContent = openaiContent.replace(
/result\.generationConfig/g,
"(result as any).generationConfig"
);
openaiContent = openaiContent.replace(/result\._toolNameMap/g, "(result as any)._toolNameMap");
openaiContent = openaiContent.replace(
/result\.request\.systemInstruction/g,
"(result as any).request?.systemInstruction"
);
openaiContent = openaiContent.replace(/result\.request\.tools/g, "(result as any).request?.tools");
openaiContent = openaiContent.replace(/null\)/g, "null as any)");
fs.writeFileSync("tests/unit/translator-openai-to-gemini.test.ts", openaiContent);
console.log("Fixed typings");

View file

@ -20,17 +20,19 @@ export default function AntigravityToolCard({
const [loading, setLoading] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [sudoPassword, setSudoPassword] = useState("");
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedApiKeyId, setSelectedApiKeyId] = useState("");
const [message, setMessage] = useState(null);
const [modelMappings, setModelMappings] = useState({});
const [modalOpen, setModalOpen] = useState(false);
const [currentEditingAlias, setCurrentEditingAlias] = useState(null);
// (#523) Store the key *id* (not the masked string) so the backend can
// resolve the real secret from DB before writing to config files.
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
if (apiKeys?.length > 0 && !selectedApiKeyId) {
setSelectedApiKeyId(apiKeys[0].id);
}
}, [apiKeys, selectedApiKey]);
}, [apiKeys, selectedApiKeyId]);
useEffect(() => {
if (isExpanded && !status) {
@ -93,15 +95,18 @@ export default function AntigravityToolCard({
setLoading(true);
setMessage(null);
try {
const keyToUse =
selectedApiKey?.trim() ||
(apiKeys?.length > 0 ? apiKeys[0].key : null) ||
(!cloudEnabled ? "sk_omniroute" : null);
// (#523) Prefer keyId lookup so the backend writes the real key to disk.
const selectedKeyId =
selectedApiKeyId?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null);
const res = await fetch("/api/cli-tools/antigravity-mitm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: keyToUse, sudoPassword: password }),
body: JSON.stringify({
apiKey: !cloudEnabled ? "sk_omniroute" : null,
keyId: selectedKeyId,
sudoPassword: password,
}),
});
const data = await res.json();
@ -289,12 +294,12 @@ export default function AntigravityToolCard({
</span>
{apiKeys.length > 0 ? (
<select
value={selectedApiKey}
onChange={(e) => setSelectedApiKey(e.target.value)}
value={selectedApiKeyId}
onChange={(e) => setSelectedApiKeyId(e.target.value)}
className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
>
{apiKeys.map((key) => (
<option key={key.id} value={key.key}>
<option key={key.id} value={key.id}>
{key.key}
</option>
))}

View file

@ -26,7 +26,7 @@ export default function ClineToolCard({
const [applying, setApplying] = useState(false);
const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedApiKeyId, setSelectedApiKeyId] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({});
@ -54,11 +54,13 @@ export default function ClineToolCard({
// Use batch status as fallback when card hasn't been expanded yet
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
// (#523) Store the key *id* (not the masked string) so the backend can
// resolve the real secret from DB before writing to config files.
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
if (apiKeys?.length > 0 && !selectedApiKeyId) {
setSelectedApiKeyId(apiKeys[0].id);
}
}, [apiKeys, selectedApiKey]);
}, [apiKeys, selectedApiKeyId]);
useEffect(() => {
if (isExpanded && !clineStatus) {
@ -152,12 +154,16 @@ export default function ClineToolCard({
? effectiveBaseUrl
: `${effectiveBaseUrl}/v1`;
// (#523) Prefer keyId lookup so the backend writes the real key to disk.
const selectedKeyId = selectedApiKeyId?.trim() || null;
const res = await fetch("/api/cli-tools/cline-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: normalizedBaseUrl,
apiKey: selectedApiKey || "sk_omniroute",
apiKey: !cloudEnabled ? "sk_omniroute" : null,
keyId: selectedKeyId,
model: selectedModel,
}),
});
@ -205,7 +211,15 @@ export default function ClineToolCard({
const handleManualConfig = (config) => {
if (config.model) setSelectedModel(config.model);
if (config.apiKey) setSelectedApiKey(config.apiKey);
// (#523) Match apiKey string to key id if possible
if (config.apiKey && apiKeys?.length > 0) {
const prefix = config.apiKey.slice(0, 8);
const suffix = config.apiKey.slice(-4);
const matchedKey = apiKeys.find(
(k) => k.key && k.key.startsWith(prefix) && k.key.endsWith(suffix)
);
if (matchedKey) setSelectedApiKeyId(matchedKey.id);
}
if (config.baseUrl) setCustomBaseUrl(config.baseUrl);
setShowManualConfigModal(false);
};
@ -353,12 +367,12 @@ export default function ClineToolCard({
<label className="text-sm text-text-muted">{t("apiKey")}</label>
{apiKeys && apiKeys.length > 0 ? (
<select
value={selectedApiKey}
onChange={(e) => setSelectedApiKey(e.target.value)}
value={selectedApiKeyId}
onChange={(e) => setSelectedApiKeyId(e.target.value)}
className="px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
>
{apiKeys.map((key) => (
<option key={key.id} value={key.key}>
<option key={key.id} value={key.id}>
{key.key}
</option>
))}
@ -471,7 +485,7 @@ export default function ClineToolCard({
onApply: handleManualConfig,
currentConfig: {
model: selectedModel,
apiKey: selectedApiKey,
apiKey: apiKeys?.find((k) => k.id === selectedApiKeyId)?.key || "",
baseUrl: customBaseUrl || baseUrl,
},
} as any)}

View file

@ -35,12 +35,12 @@ export default function CopilotToolCard({
return new Set<string>();
}
});
const [selectedApiKey, setSelectedApiKey] = useState(() => {
const [selectedApiKeyId, setSelectedApiKeyId] = useState(() => {
if (typeof window !== "undefined") {
const savedKey = localStorage.getItem("omniroute-cli-key-copilot");
if (savedKey && apiKeys?.some((k: any) => k.key === savedKey)) return savedKey;
if (savedKey && apiKeys?.some((k: any) => k.id === savedKey)) return savedKey;
}
return apiKeys?.length > 0 ? apiKeys[0].key : "";
return apiKeys?.length > 0 ? apiKeys[0].id : "";
});
const [maxInputTokens, setMaxInputTokens] = useState(128000);
const [maxOutputTokens, setMaxOutputTokens] = useState(16000);
@ -145,7 +145,7 @@ export default function CopilotToolCard({
};
const handleApiKeyChange = (value: string) => {
setSelectedApiKey(value);
setSelectedApiKeyId(value);
if (value) localStorage.setItem("omniroute-cli-key-copilot", value);
};
@ -229,12 +229,12 @@ export default function CopilotToolCard({
<span className="font-medium text-sm">API Key</span>
</div>
<select
value={selectedApiKey}
value={selectedApiKeyId}
onChange={(e) => handleApiKeyChange(e.target.value)}
className="w-full px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
>
{apiKeys.map((key: any) => (
<option key={key.id} value={key.key}>
<option key={key.id} value={key.id}>
{key.key}
</option>
))}

View file

@ -36,9 +36,9 @@ export default function DefaultToolCard({
const [saving, setSaving] = useState(false);
const runtimeFetchStartedRef = useRef(false);
// Initialize state directly with computed value
const [selectedApiKey, setSelectedApiKey] = useState(() =>
apiKeys?.length > 0 ? apiKeys[0].key : ""
// (#523) Initialize state with key *id* instead of masked key string
const [selectedApiKeyId, setSelectedApiKeyId] = useState(() =>
apiKeys?.length > 0 ? apiKeys[0].id : ""
);
// Persist and restore model selection per tool via localStorage
@ -46,7 +46,16 @@ export default function DefaultToolCard({
const savedModel = localStorage.getItem(`omniroute-cli-model-${toolId}`);
if (savedModel) setModelValue(savedModel);
const savedKey = localStorage.getItem(`omniroute-cli-key-${toolId}`);
if (savedKey && apiKeys?.some((k) => k.key === savedKey)) setSelectedApiKey(savedKey);
// (#523) localStorage may contain a masked key string from before the fix —
// match by prefix/suffix against known keys to find the id.
if (savedKey && apiKeys?.length > 0) {
const prefix = savedKey.slice(0, 8);
const suffix = savedKey.slice(-4);
const matchedKey = apiKeys.find(
(k) => k.key && k.key.startsWith(prefix) && k.key.endsWith(suffix)
);
if (matchedKey) setSelectedApiKeyId(matchedKey.id);
}
}, [toolId, apiKeys]);
const handleModelChange = useCallback(
@ -63,8 +72,9 @@ export default function DefaultToolCard({
const handleApiKeyChange = useCallback(
(value) => {
setSelectedApiKey(value);
setSelectedApiKeyId(value);
if (value) {
// (#523) Store the key id in localStorage for persistence
localStorage.setItem(`omniroute-cli-key-${toolId}`, value);
}
},
@ -82,12 +92,10 @@ export default function DefaultToolCard({
}, [isExpanded, runtimeStatus, toolId]);
const replaceVars = (text) => {
// (#523) Look up the key object by id to get the masked display value.
const selectedKeyObj = apiKeys?.find((k) => k.id === selectedApiKeyId);
const keyToUse =
selectedApiKey && selectedApiKey.trim()
? selectedApiKey
: !cloudEnabled
? "sk_omniroute"
: t("yourApiKeyPlaceholder");
selectedKeyObj?.key || (!cloudEnabled ? "sk_omniroute" : t("yourApiKeyPlaceholder"));
const normalizedBaseUrl = baseUrl || "http://localhost:20128";
const baseUrlWithV1 = normalizedBaseUrl.endsWith("/v1")
@ -118,12 +126,8 @@ export default function DefaultToolCard({
setSaving(true);
setMessage(null);
try {
const keyToUse =
selectedApiKey && selectedApiKey.trim()
? selectedApiKey
: !cloudEnabled
? "sk_omniroute"
: "";
// (#523) Prefer keyId lookup so the backend writes the real key to disk.
const selectedKeyId = selectedApiKeyId?.trim() || null;
const normalizedBaseUrl = baseUrl || "http://localhost:20128";
const baseUrlWithV1 = normalizedBaseUrl.endsWith("/v1")
@ -135,7 +139,8 @@ export default function DefaultToolCard({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: baseUrlWithV1,
apiKey: keyToUse,
apiKey: !cloudEnabled ? "sk_omniroute" : null,
keyId: selectedKeyId,
model: modelValue,
}),
});
@ -161,18 +166,22 @@ export default function DefaultToolCard({
{apiKeys && apiKeys.length > 0 ? (
<>
<select
value={selectedApiKey}
value={selectedApiKeyId}
onChange={(e) => handleApiKeyChange(e.target.value)}
className="flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
>
{apiKeys.map((key) => (
<option key={key.id} value={key.key}>
<option key={key.id} value={key.id}>
{key.key}
</option>
))}
</select>
<button
onClick={() => handleCopy(selectedApiKey, "apiKey")}
onClick={() => {
// (#523) Look up the masked key by id for clipboard copy
const keyObj = apiKeys?.find((k) => k.id === selectedApiKeyId);
handleCopy(keyObj?.key || selectedApiKeyId, "apiKey");
}}
className="shrink-0 px-3 py-2 bg-bg-secondary hover:bg-bg-tertiary rounded-lg border border-border transition-colors"
>
<span className="material-symbols-outlined text-lg">

View file

@ -26,7 +26,7 @@ export default function DroidToolCard({
const [applying, setApplying] = useState(false);
const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedApiKeyId, setSelectedApiKeyId] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({});
@ -57,11 +57,13 @@ export default function DroidToolCard({
// Use batch status as fallback when card hasn't been expanded yet
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
// (#523) Store the key *id* (not the masked string) so the backend can
// resolve the real secret from DB before writing to config files.
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
if (apiKeys?.length > 0 && !selectedApiKeyId) {
setSelectedApiKeyId(apiKeys[0].id);
}
}, [apiKeys, selectedApiKey]);
}, [apiKeys, selectedApiKeyId]);
useEffect(() => {
if (isExpanded && !droidStatus) {
@ -89,8 +91,14 @@ export default function DroidToolCard({
);
if (customModel) {
if (customModel.model) setSelectedModel(customModel.model);
if (customModel.apiKey && apiKeys?.some((k) => k.key === customModel.apiKey)) {
setSelectedApiKey(customModel.apiKey);
if (customModel.apiKey) {
// (#523) Keys from /api/keys are masked. Match by prefix/suffix.
const fileKeyPrefix = customModel.apiKey.slice(0, 8);
const fileKeySuffix = customModel.apiKey.slice(-4);
const matchedKey = apiKeys?.find(
(k) => k.key && k.key.startsWith(fileKeyPrefix) && k.key.endsWith(fileKeySuffix)
);
if (matchedKey) setSelectedApiKeyId(matchedKey.id);
}
}
}
@ -123,17 +131,17 @@ export default function DroidToolCard({
setApplying(true);
setMessage(null);
try {
const keyToUse =
selectedApiKey?.trim() ||
(apiKeys?.length > 0 ? apiKeys[0].key : null) ||
(!cloudEnabled ? "sk_omniroute" : null);
// (#523) Prefer keyId lookup so the backend writes the real key to disk.
const selectedKeyId =
selectedApiKeyId?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null);
const res = await fetch("/api/cli-tools/droid-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
apiKey: !cloudEnabled ? "sk_omniroute" : null,
keyId: selectedKeyId,
model: selectedModel,
}),
});
@ -160,7 +168,7 @@ export default function DroidToolCard({
if (res.ok) {
setMessage({ type: "success", text: t("settingsReset") });
setSelectedModel("");
setSelectedApiKey("");
setSelectedApiKeyId("");
checkDroidStatus();
} else {
setMessage({ type: "error", text: data.error || t("failedResetSettings") });
@ -213,12 +221,10 @@ export default function DroidToolCard({
};
const getManualConfigs = () => {
const keyToUse =
selectedApiKey && selectedApiKey.trim()
? selectedApiKey
: !cloudEnabled
? "sk_omniroute"
: "<API_KEY_FROM_DASHBOARD>";
// (#523) Look up the key object by id to get the masked display value.
const selectedKeyObj = apiKeys?.find((k) => k.id === selectedApiKeyId);
const keyToDisplay =
selectedKeyObj?.key || (!cloudEnabled ? "sk_omniroute" : "<API_KEY_FROM_DASHBOARD>");
const settingsContent = {
customModels: [
@ -227,7 +233,7 @@ export default function DroidToolCard({
id: "custom:OmniRoute-0",
index: 0,
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
apiKey: keyToDisplay,
displayName: selectedModel || "provider/model-id",
maxOutputTokens: 131072,
noImageSupport: false,
@ -374,12 +380,12 @@ export default function DroidToolCard({
</span>
{apiKeys.length > 0 ? (
<select
value={selectedApiKey}
onChange={(e) => setSelectedApiKey(e.target.value)}
value={selectedApiKeyId}
onChange={(e) => setSelectedApiKeyId(e.target.value)}
className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
>
{apiKeys.map((key) => (
<option key={key.id} value={key.key}>
<option key={key.id} value={key.id}>
{key.key}
</option>
))}

View file

@ -26,7 +26,7 @@ export default function KiloToolCard({
const [applying, setApplying] = useState(false);
const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedApiKeyId, setSelectedApiKeyId] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({});
@ -50,11 +50,13 @@ export default function KiloToolCard({
// Use batch status as fallback when card hasn't been expanded yet
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
// (#523) Store the key *id* (not the masked string) so the backend can
// resolve the real secret from DB before writing to config files.
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
if (apiKeys?.length > 0 && !selectedApiKeyId) {
setSelectedApiKeyId(apiKeys[0].id);
}
}, [apiKeys, selectedApiKey]);
}, [apiKeys, selectedApiKeyId]);
useEffect(() => {
if (isExpanded && !kiloStatus) {
@ -138,12 +140,16 @@ export default function KiloToolCard({
? effectiveBaseUrl
: `${effectiveBaseUrl}/v1`;
// (#523) Prefer keyId lookup so the backend writes the real key to disk.
const selectedKeyId = selectedApiKeyId?.trim() || null;
const res = await fetch("/api/cli-tools/kilo-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: normalizedBaseUrl,
apiKey: selectedApiKey || "sk_omniroute",
apiKey: !cloudEnabled ? "sk_omniroute" : null,
keyId: selectedKeyId,
model: selectedModel,
}),
});
@ -191,7 +197,15 @@ export default function KiloToolCard({
const handleManualConfig = (config) => {
if (config.model) setSelectedModel(config.model);
if (config.apiKey) setSelectedApiKey(config.apiKey);
// (#523) Match apiKey string to key id if possible
if (config.apiKey && apiKeys?.length > 0) {
const prefix = config.apiKey.slice(0, 8);
const suffix = config.apiKey.slice(-4);
const matchedKey = apiKeys.find(
(k) => k.key && k.key.startsWith(prefix) && k.key.endsWith(suffix)
);
if (matchedKey) setSelectedApiKeyId(matchedKey.id);
}
if (config.baseUrl) setCustomBaseUrl(config.baseUrl);
setShowManualConfigModal(false);
};
@ -339,12 +353,12 @@ export default function KiloToolCard({
<label className="text-sm text-text-muted">{t("apiKey")}</label>
{apiKeys && apiKeys.length > 0 ? (
<select
value={selectedApiKey}
onChange={(e) => setSelectedApiKey(e.target.value)}
value={selectedApiKeyId}
onChange={(e) => setSelectedApiKeyId(e.target.value)}
className="px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
>
{apiKeys.map((key) => (
<option key={key.id} value={key.key}>
<option key={key.id} value={key.id}>
{key.key}
</option>
))}
@ -457,7 +471,7 @@ export default function KiloToolCard({
onApply: handleManualConfig,
currentConfig: {
model: selectedModel,
apiKey: selectedApiKey,
apiKey: apiKeys?.find((k) => k.id === selectedApiKeyId)?.key || "",
baseUrl: customBaseUrl || baseUrl,
},
} as any)}

View file

@ -26,7 +26,7 @@ export default function OpenClawToolCard({
const [applying, setApplying] = useState(false);
const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedApiKeyId, setSelectedApiKeyId] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({});
@ -56,11 +56,13 @@ export default function OpenClawToolCard({
// Use batch status as fallback when card hasn't been expanded yet
const effectiveConfigStatus = configStatus || batchStatus?.configStatus || null;
// (#523) Store the key *id* (not the masked string) so the backend can
// resolve the real secret from DB before writing to config files.
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
if (apiKeys?.length > 0 && !selectedApiKeyId) {
setSelectedApiKeyId(apiKeys[0].id);
}
}, [apiKeys, selectedApiKey]);
}, [apiKeys, selectedApiKeyId]);
useEffect(() => {
if (isExpanded && !openclawStatus) {
@ -90,8 +92,15 @@ export default function OpenClawToolCard({
const modelId = primaryModel.replace("omniroute/", "");
setSelectedModel(modelId);
}
if (provider.apiKey && apiKeys?.some((k) => k.key === provider.apiKey)) {
setSelectedApiKey(provider.apiKey);
// (#523) Keys from /api/keys are masked (first 8 + "****" + last 4).
// Match by prefix/suffix instead of exact comparison.
if (provider.apiKey) {
const fileKeyPrefix = provider.apiKey.slice(0, 8);
const fileKeySuffix = provider.apiKey.slice(-4);
const matchedKey = apiKeys?.find(
(k) => k.key && k.key.startsWith(fileKeyPrefix) && k.key.endsWith(fileKeySuffix)
);
if (matchedKey) setSelectedApiKeyId(matchedKey.id);
}
}
}
@ -124,17 +133,17 @@ export default function OpenClawToolCard({
setApplying(true);
setMessage(null);
try {
const keyToUse =
selectedApiKey?.trim() ||
(apiKeys?.length > 0 ? apiKeys[0].key : null) ||
(!cloudEnabled ? "sk_omniroute" : null);
// (#523) Prefer keyId lookup so the backend writes the real key to disk.
const selectedKeyId =
selectedApiKeyId?.trim() || (apiKeys?.length > 0 ? apiKeys[0].id : null);
const res = await fetch("/api/cli-tools/openclaw-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
apiKey: !cloudEnabled ? "sk_omniroute" : null,
keyId: selectedKeyId,
model: selectedModel,
}),
});
@ -161,7 +170,7 @@ export default function OpenClawToolCard({
if (res.ok) {
setMessage({ type: "success", text: t("settingsReset") });
setSelectedModel("");
setSelectedApiKey("");
setSelectedApiKeyId("");
checkOpenclawStatus();
} else {
setMessage({ type: "error", text: data.error || t("failedResetSettings") });
@ -214,12 +223,10 @@ export default function OpenClawToolCard({
};
const getManualConfigs = () => {
const keyToUse =
selectedApiKey && selectedApiKey.trim()
? selectedApiKey
: !cloudEnabled
? "sk_omniroute"
: "<API_KEY_FROM_DASHBOARD>";
// (#523) Look up the key object by id to get the masked display value.
const selectedKeyObj = apiKeys?.find((k) => k.id === selectedApiKeyId);
const keyToDisplay =
selectedKeyObj?.key || (!cloudEnabled ? "sk_omniroute" : "<API_KEY_FROM_DASHBOARD>");
const settingsContent = {
agents: {
@ -233,7 +240,7 @@ export default function OpenClawToolCard({
providers: {
omniroute: {
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
apiKey: keyToDisplay,
api: "openai-completions",
models: [
{
@ -374,12 +381,12 @@ export default function OpenClawToolCard({
</span>
{apiKeys.length > 0 ? (
<select
value={selectedApiKey}
onChange={(e) => setSelectedApiKey(e.target.value)}
value={selectedApiKeyId}
onChange={(e) => setSelectedApiKeyId(e.target.value)}
className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
>
{apiKeys.map((key) => (
<option key={key.id} value={key.key}>
<option key={key.id} value={key.id}>
{key.key}
</option>
))}

View file

@ -2784,7 +2784,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
value={builderProviderId}
onChange={handleBuilderProviderChange}
data-testid="combo-builder-provider"
className="w-full text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none"
className="w-full text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-white dark:bg-bg-main text-text-main focus:border-primary focus:outline-none"
>
<option value="">
{builderLoading
@ -2809,7 +2809,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
onChange={handleBuilderModelChange}
disabled={!selectedBuilderProvider}
data-testid="combo-builder-model"
className="w-full text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none disabled:opacity-50"
className="w-full text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-white dark:bg-bg-main text-text-main focus:border-primary focus:outline-none disabled:opacity-50"
>
<option value="">
{selectedBuilderProvider
@ -2834,7 +2834,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
onChange={handleBuilderConnectionChange}
disabled={!selectedBuilderModel}
data-testid="combo-builder-account"
className="w-full text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none disabled:opacity-50"
className="w-full text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-white dark:bg-bg-main text-text-main focus:border-primary focus:outline-none disabled:opacity-50"
>
<option value={COMBO_BUILDER_AUTO_CONNECTION}>
{getI18nOrFallback(
@ -2895,7 +2895,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
<select
value={builderComboRefName}
onChange={(e) => setBuilderComboRefName(e.target.value)}
className="flex-1 text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none"
className="flex-1 text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-white dark:bg-bg-main text-text-main focus:border-primary focus:outline-none"
>
<option value="">
{getI18nOrFallback(

View file

@ -3,6 +3,7 @@
import { useState, useEffect, useCallback } from "react";
import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
import type { SkillsProvider } from "@/lib/skills/providerSettings";
interface MemoryConfig {
enabled: boolean;
@ -32,6 +33,9 @@ export default function MemorySkillsTab() {
const [skillsmpApiKey, setSkillsmpApiKey] = useState("");
const [skillsmpSaving, setSkillsmpSaving] = useState(false);
const [skillsmpStatus, setSkillsmpStatus] = useState("");
const [skillsProvider, setSkillsProvider] = useState<SkillsProvider>("skillsmp");
const [skillsProviderSaving, setSkillsProviderSaving] = useState(false);
const [skillsProviderStatus, setSkillsProviderStatus] = useState("");
const t = useTranslations("settings");
useEffect(() => {
@ -44,6 +48,12 @@ export default function MemorySkillsTab() {
if (settingsData?.skillsmpApiKey) {
setSkillsmpApiKey(settingsData.skillsmpApiKey);
}
if (
settingsData?.skillsProvider === "skillsmp" ||
settingsData?.skillsProvider === "skillssh"
) {
setSkillsProvider(settingsData.skillsProvider);
}
})
.catch(() => {})
.finally(() => setLoading(false));
@ -71,6 +81,29 @@ export default function MemorySkillsTab() {
}
}, [skillsmpApiKey]);
const saveSkillsProvider = useCallback(async (provider: SkillsProvider) => {
setSkillsProvider(provider);
setSkillsProviderSaving(true);
setSkillsProviderStatus("");
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ skillsProvider: provider }),
});
if (res.ok) {
setSkillsProviderStatus("saved");
setTimeout(() => setSkillsProviderStatus(""), 2000);
} else {
setSkillsProviderStatus("error");
}
} catch {
setSkillsProviderStatus("error");
} finally {
setSkillsProviderSaving(false);
}
}, []);
const save = async (updates: Partial<MemoryConfig>) => {
const previousConfig = config;
const newConfig = { ...config, ...updates };
@ -334,6 +367,74 @@ export default function MemorySkillsTab() {
</p>
</div>
</Card>
{/* Active Skills Provider */}
<Card>
<div className="flex items-center gap-3 mb-5">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
hub
</span>
</div>
<div>
<h3 className="text-lg font-semibold">Active Skills Provider</h3>
<p className="text-sm text-text-muted">
Choose which provider the Skills page uses for search and install.
</p>
</div>
{skillsProviderStatus === "saved" && (
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">check_circle</span>{" "}
{t("saved")}
</span>
)}
{skillsProviderStatus === "error" && (
<span className="ml-auto text-xs font-medium text-red-500">Failed to save</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<button
type="button"
disabled={skillsProviderSaving}
onClick={() => saveSkillsProvider("skillsmp")}
className={`flex flex-col items-start p-3 rounded-lg border text-left transition-all ${
skillsProvider === "skillsmp"
? "border-indigo-500/50 bg-indigo-500/5 ring-1 ring-indigo-500/20"
: "border-border/50 hover:border-border hover:bg-surface/30"
}`}
>
<p
className={`text-sm font-medium ${skillsProvider === "skillsmp" ? "text-indigo-400" : ""}`}
>
SkillsMP Marketplace
</p>
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">
Authenticated marketplace (uses your SkillsMP API key).
</p>
</button>
<button
type="button"
disabled={skillsProviderSaving}
onClick={() => saveSkillsProvider("skillssh")}
className={`flex flex-col items-start p-3 rounded-lg border text-left transition-all ${
skillsProvider === "skillssh"
? "border-indigo-500/50 bg-indigo-500/5 ring-1 ring-indigo-500/20"
: "border-border/50 hover:border-border hover:bg-surface/30"
}`}
>
<p
className={`text-sm font-medium ${skillsProvider === "skillssh" ? "text-indigo-400" : ""}`}
>
skills.sh Directory
</p>
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">
Public directory provider (no API key required).
</p>
</button>
</div>
</Card>
</div>
);
}

View file

@ -3,6 +3,7 @@
import { useState, useEffect, useRef } from "react";
import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
import type { SkillsProvider } from "@/lib/skills/providerSettings";
interface Skill {
id: string;
@ -10,6 +11,10 @@ interface Skill {
version: string;
description: string;
enabled: boolean;
mode?: "on" | "off" | "auto";
sourceProvider?: "skillsmp" | "skillssh" | "local";
tags?: string[];
installCount?: number;
createdAt: string;
}
@ -29,14 +34,17 @@ export default function SkillsPage() {
const [skillsPage, setSkillsPage] = useState(1);
const [skillsTotal, setSkillsTotal] = useState(0);
const [skillsTotalPages, setSkillsTotalPages] = useState(1);
const [popularDefaults, setPopularDefaults] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState("");
const [modeFilter, setModeFilter] = useState<"all" | "on" | "off" | "auto">("all");
const [execPage, setExecPage] = useState(1);
const [execTotal, setExecTotal] = useState(0);
const [execTotalPages, setExecTotalPages] = useState(1);
const [activeTab, setActiveTab] = useState<
"skills" | "executions" | "sandbox" | "marketplace" | "skillssh"
>("skills");
const [activeTab, setActiveTab] = useState<"skills" | "executions" | "sandbox" | "marketplace">(
"skills"
);
const [showInstallModal, setShowInstallModal] = useState(false);
const [installJson, setInstallJson] = useState("");
const [installStatus, setInstallStatus] = useState<{
@ -65,13 +73,19 @@ export default function SkillsPage() {
const [shLoading, setShLoading] = useState(false);
const [shError, setShError] = useState("");
const [shInstallingId, setShInstallingId] = useState<string | null>(null);
const [skillsProvider, setSkillsProvider] = useState<SkillsProvider>("skillsmp");
const t = useTranslations("skills");
const fetchSkills = async (page: number) => {
const res = await fetch(`/api/skills?page=${page}&limit=20`).then((r) => r.json());
const params = new URLSearchParams({ page: String(page), limit: "20" });
if (searchTerm.trim()) params.set("q", searchTerm.trim());
if (modeFilter !== "all") params.set("mode", modeFilter);
const res = await fetch(`/api/skills?${params.toString()}`).then((r) => r.json());
setSkills(res.data || []);
setSkillsTotal(res.total || 0);
setSkillsTotalPages(res.totalPages || 1);
setPopularDefaults(Array.isArray(res.popularDefaults) ? res.popularDefaults : []);
};
const fetchExecutions = async (page: number) => {
@ -85,16 +99,27 @@ export default function SkillsPage() {
Promise.all([
fetch("/api/skills?page=1&limit=20").then((r) => r.json()),
fetch("/api/skills/executions?page=1&limit=20").then((r) => r.json()),
fetch("/api/settings").then((r) => (r.ok ? r.json() : null)),
])
.then(([skillsData, executionsData]) => {
.then(([skillsData, executionsData, settingsData]) => {
setSkills(skillsData.data || []);
setSkillsTotal(skillsData.total || 0);
setSkillsTotalPages(skillsData.totalPages || 1);
setPopularDefaults(
Array.isArray(skillsData.popularDefaults) ? skillsData.popularDefaults : []
);
setExecutions(executionsData.data || []);
setExecTotal(executionsData.total || 0);
setExecTotalPages(executionsData.totalPages || 1);
if (
settingsData?.skillsProvider === "skillsmp" ||
settingsData?.skillsProvider === "skillssh"
) {
setSkillsProvider(settingsData.skillsProvider);
}
setLoading(false);
})
.catch(() => setLoading(false));
@ -114,6 +139,16 @@ export default function SkillsPage() {
setSkills(skills.map((s) => (s.id === skillId ? { ...s, enabled: !enabled } : s)));
};
const setSkillMode = async (skillId: string, mode: "on" | "off" | "auto") => {
await fetch(`/api/skills/${skillId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode }),
});
setSkills(skills.map((s) => (s.id === skillId ? { ...s, mode, enabled: mode !== "off" } : s)));
};
const deleteSkill = async (skillId: string) => {
const res = await fetch(`/api/skills/${skillId}`, { method: "DELETE" });
if (res.ok) {
@ -331,20 +366,59 @@ export default function SkillsPage() {
>
Marketplace
</button>
<button
onClick={() => setActiveTab("skillssh")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "skillssh"
? "border-violet-500 text-violet-400"
: "border-transparent text-text-muted hover:text-text-main"
}`}
>
skills.sh
</button>
</div>
{activeTab === "skills" && (
<div className="grid gap-4">
<Card>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 items-center">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Filter skills by name, description, or tag"
className="px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
<select
value={modeFilter}
onChange={(e) => setModeFilter(e.target.value as "all" | "on" | "off" | "auto")}
className="px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
>
<option value="all">All modes</option>
<option value="on">On</option>
<option value="auto">Auto</option>
<option value="off">Off</option>
</select>
<button
onClick={() => {
setSkillsPage(1);
void fetchSkills(1);
}}
className="px-4 py-2 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 transition-colors"
>
Apply filters
</button>
</div>
{popularDefaults.length > 0 && (
<div className="mt-3">
<p className="text-xs text-text-muted mb-2">
Popular by default for selected provider:
</p>
<div className="flex flex-wrap gap-2">
{popularDefaults.map((name) => (
<span
key={name}
className="text-xs px-2 py-1 rounded bg-violet-500/10 text-violet-300 border border-violet-500/20"
>
{name}
</span>
))}
</div>
</div>
)}
</Card>
{skills.length === 0 ? (
<Card>
<div className="text-center py-8 text-text-muted">{t("noSkills")}</div>
@ -359,15 +433,65 @@ export default function SkillsPage() {
<span className="text-xs px-2 py-0.5 rounded bg-surface/50 text-text-muted">
v{skill.version}
</span>
<span className="text-xs px-2 py-0.5 rounded bg-surface/50 text-text-muted">
{(skill.sourceProvider || "local").toUpperCase()}
</span>
<span className="text-xs px-2 py-0.5 rounded bg-amber-500/10 text-amber-400">
mode: {skill.mode || (skill.enabled ? "on" : "off")}
</span>
</div>
<p className="text-sm text-text-muted mt-1">{skill.description}</p>
{Array.isArray(skill.tags) && skill.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{skill.tags.map((tag) => (
<span
key={`${skill.id}-${tag}`}
className="text-[11px] px-1.5 py-0.5 rounded bg-surface/60 text-text-muted"
>
{tag}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<button
onClick={() => setSkillMode(skill.id, "on")}
className={`text-xs px-2 py-1 rounded border ${
(skill.mode || (skill.enabled ? "on" : "off")) === "on"
? "border-emerald-500 text-emerald-400"
: "border-border text-text-muted"
}`}
>
ON
</button>
<button
onClick={() => setSkillMode(skill.id, "auto")}
className={`text-xs px-2 py-1 rounded border ${
(skill.mode || (skill.enabled ? "on" : "off")) === "auto"
? "border-amber-500 text-amber-400"
: "border-border text-text-muted"
}`}
>
AUTO
</button>
<button
onClick={() => setSkillMode(skill.id, "off")}
className={`text-xs px-2 py-1 rounded border ${
(skill.mode || (skill.enabled ? "on" : "off")) === "off"
? "border-red-500 text-red-400"
: "border-border text-text-muted"
}`}
>
OFF
</button>
</div>
<button
onClick={() => deleteSkill(skill.id)}
className="text-xs px-2 py-1 rounded text-red-400 hover:bg-red-500/10 transition-colors"
>
Delete
Uninstall
</button>
<button
onClick={() => toggleSkill(skill.id, skill.enabled)}
@ -539,31 +663,56 @@ export default function SkillsPage() {
{activeTab === "marketplace" && (
<div className="grid gap-4">
<Card>
<h3 className="font-semibold mb-4">SkillsMP Marketplace</h3>
<h3 className="font-semibold mb-2">Skills Marketplace</h3>
<p className="text-sm text-text-muted mb-4">
Active provider:{" "}
<span className="font-medium">
{skillsProvider === "skillsmp" ? "SkillsMP" : "skills.sh"}
</span>
. Change this in Settings Memory & Skills.
</p>
<div className="flex gap-2 mb-4">
<input
type="text"
value={mpQuery}
onChange={(e) => setMpQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchMarketplace()}
placeholder="Search skills..."
value={skillsProvider === "skillsmp" ? mpQuery : shQuery}
onChange={(e) =>
skillsProvider === "skillsmp"
? setMpQuery(e.target.value)
: setShQuery(e.target.value)
}
onKeyDown={(e) =>
e.key === "Enter" &&
(skillsProvider === "skillsmp" ? searchMarketplace() : searchSkillsSh())
}
placeholder={
skillsProvider === "skillsmp" ? "Search SkillsMP..." : "Search skills.sh..."
}
className="flex-1 px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
<button
onClick={searchMarketplace}
disabled={mpLoading}
onClick={() =>
skillsProvider === "skillsmp" ? searchMarketplace() : searchSkillsSh()
}
disabled={skillsProvider === "skillsmp" ? mpLoading : shLoading}
className="px-4 py-2 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
>
{mpLoading ? "Searching..." : "Search SkillsMP"}
{skillsProvider === "skillsmp"
? mpLoading
? "Searching..."
: "Search SkillsMP"
: shLoading
? "Searching..."
: "Search skills.sh"}
</button>
</div>
{mpError && (
{(skillsProvider === "skillsmp" ? mpError : shError) && (
<div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm mb-4">
{mpError}
{skillsProvider === "skillsmp" ? mpError : shError}
</div>
)}
</Card>
{mpResults.length > 0 && (
{skillsProvider === "skillsmp" && mpResults.length > 0 && (
<div className="grid gap-3">
{mpResults.map((skill) => (
<Card key={skill.name}>
@ -584,44 +733,8 @@ export default function SkillsPage() {
))}
</div>
)}
{!mpLoading && mpResults.length === 0 && !mpError && (
<Card>
<div className="text-center py-8 text-text-muted">
Configure your SkillsMP API key in Settings to browse the marketplace.
</div>
</Card>
)}
</div>
)}
{activeTab === "skillssh" && (
<div className="grid gap-4">
<Card>
<h3 className="font-semibold mb-4">skills.sh Directory</h3>
<div className="flex gap-2 mb-4">
<input
type="text"
value={shQuery}
onChange={(e) => setShQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchSkillsSh()}
placeholder="Search skills.sh..."
className="flex-1 px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
/>
<button
onClick={searchSkillsSh}
disabled={shLoading}
className="px-4 py-2 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
>
{shLoading ? "Searching..." : "Search skills.sh"}
</button>
</div>
{shError && (
<div className="p-3 rounded-lg bg-red-500/10 text-red-400 text-sm mb-4">
{shError}
</div>
)}
</Card>
{shResults.length > 0 && (
{skillsProvider === "skillssh" && shResults.length > 0 && (
<div className="grid gap-3">
{shResults.map((skill) => (
<Card key={skill.id}>
@ -644,7 +757,15 @@ export default function SkillsPage() {
))}
</div>
)}
{!shLoading && shResults.length === 0 && !shError && (
{skillsProvider === "skillsmp" && !mpLoading && mpResults.length === 0 && !mpError && (
<Card>
<div className="text-center py-8 text-text-muted">
Configure your SkillsMP API key in Settings to browse the marketplace.
</div>
</Card>
)}
{skillsProvider === "skillssh" && !shLoading && shResults.length === 0 && !shError && (
<Card>
<div className="text-center py-8 text-text-muted">
Search the skills.sh open directory to discover and install agent skills.

View file

@ -1,16 +1,33 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
type CliAgentInfo,
detectInstalledAgents,
refreshAgentCache,
resolveVersionProbe,
setCustomAgents,
getCustomAgentDefs,
type CustomAgentDef,
} from "@/lib/acp/registry";
import { getSettings, updateSettings } from "@/lib/localDb";
import { jsonObjectSchema } from "@/shared/validation/schemas";
import { getSettings, updateSettings } from "@/lib/db/settings";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { isAuthenticated } from "@/shared/utils/apiAuth";
const customAgentBodySchema = z.object({
action: z.string().optional(),
id: z.string().optional(),
name: z.string().optional(),
binary: z.string().optional(),
versionCommand: z.string().optional(),
providerAlias: z.string().optional(),
spawnArgs: z.array(z.string()).optional(),
protocol: z.enum(["stdio", "http"]).optional(),
});
export async function GET(request: Request) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
export async function GET() {
try {
// Load custom agents from settings on each GET to stay in sync
const settings = await getSettings();
@ -19,7 +36,7 @@ export async function GET() {
}
const agents = detectInstalledAgents();
const installed = agents.filter((a) => a.installed).length;
const installed = agents.filter((a: CliAgentInfo) => a.installed).length;
const total = agents.length;
return NextResponse.json({
@ -28,8 +45,8 @@ export async function GET() {
total,
installed,
notFound: total - installed,
builtIn: agents.filter((a) => !a.isCustom).length,
custom: agents.filter((a) => a.isCustom).length,
builtIn: agents.filter((a: CliAgentInfo) => !a.isCustom).length,
custom: agents.filter((a: CliAgentInfo) => a.isCustom).length,
},
});
} catch (error) {
@ -39,6 +56,10 @@ export async function GET() {
}
export async function POST(request: Request) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let rawBody: unknown;
try {
rawBody = await request.json();
@ -46,7 +67,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const validation = validateBody(jsonObjectSchema, rawBody);
const validation = validateBody(customAgentBodySchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
@ -69,15 +90,22 @@ export async function POST(request: Request) {
}
const newAgent: CustomAgentDef = {
id: (id as string).toLowerCase().replace(/[^a-z0-9-]/g, "-"),
name: name as string,
binary: binary as string,
versionCommand: versionCommand as string,
providerAlias: (providerAlias as string) || (id as string),
spawnArgs: Array.isArray(spawnArgs) ? (spawnArgs as string[]) : [],
protocol: (protocol as "stdio" | "http") || "stdio",
id: id.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
name,
binary,
versionCommand,
providerAlias: providerAlias || id,
spawnArgs: spawnArgs || [],
protocol: protocol || "stdio",
};
if (!resolveVersionProbe(newAgent.binary, newAgent.versionCommand, true)) {
return NextResponse.json(
{ error: "Invalid versionCommand: use the configured binary with plain arguments only" },
{ status: 400 }
);
}
// Load current, append, save
const settings = await getSettings();
const current: CustomAgentDef[] = (settings.customAgents as CustomAgentDef[]) || [];
@ -104,6 +132,10 @@ export async function POST(request: Request) {
}
export async function DELETE(request: Request) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { searchParams } = new URL(request.url);
const agentId = searchParams.get("id");

View file

@ -5,6 +5,7 @@ export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { cliMitmStartSchema, cliMitmStopSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { resolveApiKey } from "@/shared/services/apiKeyResolver";
// GET - Check MITM status
export async function GET() {
@ -46,7 +47,10 @@ export async function POST(request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { apiKey, sudoPassword } = validation.data;
const { apiKey: rawApiKey, sudoPassword } = validation.data;
// (#523) Extract keyId BEFORE validation — Zod strips unknown fields!
const apiKeyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
const apiKey = await resolveApiKey(apiKeyId, rawApiKey);
const { startMitm, getCachedPassword, setCachedPassword } = await import("@/mitm/manager");
const isWin = process.platform === "win32";
const pwd = sudoPassword || getCachedPassword() || "";

View file

@ -6,7 +6,7 @@ import { ensureCliConfigWriteAllowed } from "@/shared/services/cliRuntime";
import { cliBackupMutationSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
const VALID_TOOLS = ["claude", "codex", "droid", "openclaw", "cline", "kilo"];
const VALID_TOOLS = ["claude", "codex", "droid", "openclaw", "cline", "kilo", "qwen"];
// GET /api/cli-tools/backups?tool=claude — list backups
export async function GET(request) {

View file

@ -9,7 +9,7 @@ import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
import { cliModelConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getApiKeyById } from "@/lib/localDb";
import { resolveApiKey } from "@/shared/services/apiKeyResolver";
const CLINE_DATA_DIR = path.join(os.homedir(), ".cline", "data");
const GLOBAL_STATE_PATH = path.join(CLINE_DATA_DIR, "globalState.json");
@ -129,17 +129,8 @@ export async function POST(request: Request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
let { baseUrl, apiKey, model } = validation.data;
// Resolve real key from DB by ID
if (keyId) {
try {
const keyRecord = await getApiKeyById(keyId);
if (keyRecord?.key) apiKey = keyRecord.key as string;
} catch {
/* non-critical */
}
}
const { baseUrl, model } = validation.data;
const apiKey = await resolveApiKey(keyId, validation.data.apiKey);
// Ensure directory exists
await fs.mkdir(CLINE_DATA_DIR, { recursive: true });

View file

@ -12,7 +12,7 @@ import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
import { cliModelConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getApiKeyById } from "@/lib/localDb";
import { resolveApiKey } from "@/shared/services/apiKeyResolver";
const getDroidSettingsPath = () => getCliPrimaryConfigPath("droid");
const getDroidDir = () => path.dirname(getDroidSettingsPath());
@ -106,19 +106,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { baseUrl, model } = validation.data;
let { apiKey } = validation.data;
// Resolve real key from DB by ID
if (keyId) {
try {
const keyRecord = await getApiKeyById(keyId);
if (keyRecord?.key) {
apiKey = keyRecord.key as string;
}
} catch {
// Non-critical: fall back to whatever value was in apiKey
}
}
const apiKey = await resolveApiKey(keyId, validation.data.apiKey);
const droidDir = getDroidDir();
const settingsPath = getDroidSettingsPath();

View file

@ -7,6 +7,7 @@ import { getOpenCodeConfigPath } from "@/shared/services/cliRuntime";
import { mergeOpenCodeConfig } from "@/shared/services/opencodeConfig";
import { guideSettingsSaveSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { resolveApiKey } from "@/shared/services/apiKeyResolver";
/**
* POST /api/cli-tools/guide-settings/:toolId
@ -35,7 +36,10 @@ export async function POST(request, { params }) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { baseUrl, apiKey, model } = validation.data;
const { baseUrl, model } = validation.data;
// (#523) Extract keyId BEFORE validation — Zod strips unknown fields!
const apiKeyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
const apiKey = await resolveApiKey(apiKeyId, validation.data.apiKey);
try {
switch (toolId) {
@ -177,20 +181,50 @@ async function saveOpenCodeConfig({ baseUrl, apiKey, model }) {
}
/**
* Save Qwen Code config to ~/.qwen/settings.json
* Writes the modelProviders.openai entry with OmniRoute as the provider.
* Merges with existing config to preserve other providers.
* Save Qwen Code config to ~/.qwen/settings.json + ~/.qwen/.env
*
* Per official docs, credentials go in .env via envKey references,
* not hardcoded in settings.json modelProviders entries.
* Writes openai, anthropic, and gemini providers pointing to OmniRoute.
*/
async function saveQwenConfig({ baseUrl, apiKey, model }) {
const home = os.homedir();
const configPath = path.join(home, ".qwen", "settings.json");
const envPath = path.join(home, ".qwen", ".env");
const configDir = path.dirname(configPath);
await fs.mkdir(configDir, { recursive: true });
const normalizedBaseUrl = String(baseUrl || "").trim().replace(/\/+$/, "");
const normalizedBaseUrl = String(baseUrl || "")
.trim()
.replace(/\/+$/, "");
const resolvedApiKey = apiKey || "sk_omniroute";
const resolvedModel = model || "coder-model";
// Read existing config to preserve other provider entries
// --- Write API keys to .env ---
let envContent = "";
try {
envContent = await fs.readFile(envPath, "utf-8");
} catch {
// File doesn't exist
}
const envLines = envContent.split("\n").filter((line) => {
// Remove old OmniRoute-related keys we're about to write
return (
!line.startsWith("OPENAI_API_KEY=") &&
!line.startsWith("ANTHROPIC_API_KEY=") &&
!line.startsWith("GEMINI_API_KEY=")
);
});
envLines.push(`OPENAI_API_KEY=${resolvedApiKey}`);
envLines.push(`ANTHROPIC_API_KEY=${resolvedApiKey}`);
envLines.push(`GEMINI_API_KEY=${resolvedApiKey}`);
await fs.writeFile(envPath, envLines.join("\n").trim() + "\n", "utf-8");
// --- Write modelProviders to settings.json ---
let existingConfig: Record<string, any> = {};
try {
const raw = await fs.readFile(configPath, "utf-8");
@ -199,39 +233,75 @@ async function saveQwenConfig({ baseUrl, apiKey, model }) {
// File doesn't exist or invalid JSON
}
// Build OmniRoute openai provider entry
const omnirouteEntry = {
id: "omniroute",
name: "OmniRoute",
if (!existingConfig.modelProviders) existingConfig.modelProviders = {};
// openai provider — primary, supports all models via OmniRoute
const openaiEntry = {
id: resolvedModel,
name: `${resolvedModel} (OmniRoute)`,
envKey: "OPENAI_API_KEY",
baseUrl: normalizedBaseUrl,
apiKey: apiKey || "sk_omniroute",
generationConfig: {
defaultModel: model || "auto",
contextWindowSize: 200000,
},
};
// Ensure modelProviders.openai array exists
if (!existingConfig.modelProviders) existingConfig.modelProviders = {};
if (!existingConfig.modelProviders.openai) existingConfig.modelProviders.openai = [];
const providers = existingConfig.modelProviders.openai;
// Replace OmniRoute entry if already present, otherwise add it
const existingIdx = providers.findIndex(
const openaiProviders = existingConfig.modelProviders.openai;
const openaiIdx = openaiProviders.findIndex(
(p: any) => p && (p.baseUrl === normalizedBaseUrl || p.id === "omniroute")
);
if (existingIdx >= 0) {
providers[existingIdx] = omnirouteEntry;
if (openaiIdx >= 0) {
openaiProviders[openaiIdx] = openaiEntry;
} else {
providers.push(omnirouteEntry);
openaiProviders.push(openaiEntry);
}
// anthropic provider — for Claude models via OmniRoute
const anthropicEntry = {
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6 (OmniRoute)",
envKey: "ANTHROPIC_API_KEY",
baseUrl: normalizedBaseUrl,
generationConfig: {
contextWindowSize: 200000,
},
};
if (!existingConfig.modelProviders.anthropic) existingConfig.modelProviders.anthropic = [];
const anthropicProviders = existingConfig.modelProviders.anthropic;
const anthropicIdx = anthropicProviders.findIndex(
(p: any) => p && p.baseUrl === normalizedBaseUrl
);
if (anthropicIdx >= 0) {
anthropicProviders[anthropicIdx] = anthropicEntry;
} else {
anthropicProviders.push(anthropicEntry);
}
// gemini provider — for Gemini models via OmniRoute
const geminiEntry = {
id: "gemini-3-flash",
name: "Gemini 3 Flash (OmniRoute)",
envKey: "GEMINI_API_KEY",
baseUrl: normalizedBaseUrl,
};
if (!existingConfig.modelProviders.gemini) existingConfig.modelProviders.gemini = [];
const geminiProviders = existingConfig.modelProviders.gemini;
const geminiIdx = geminiProviders.findIndex((p: any) => p && p.baseUrl === normalizedBaseUrl);
if (geminiIdx >= 0) {
geminiProviders[geminiIdx] = geminiEntry;
} else {
geminiProviders.push(geminiEntry);
}
await fs.writeFile(configPath, JSON.stringify(existingConfig, null, 2), "utf-8");
return NextResponse.json({
success: true,
message: `Qwen Code config saved to ${configPath}`,
message: `Qwen Code config saved to ${configPath} + ${envPath}`,
configPath,
envPath,
});
}

View file

@ -9,7 +9,7 @@ import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
import { cliModelConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getApiKeyById } from "@/lib/localDb";
import { resolveApiKey } from "@/shared/services/apiKeyResolver";
const KILO_DATA_DIR = path.join(os.homedir(), ".local", "share", "kilo");
const AUTH_PATH = path.join(KILO_DATA_DIR, "auth.json");
@ -138,19 +138,7 @@ export async function POST(request) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { baseUrl, model } = validation.data;
let { apiKey } = validation.data;
// Resolve real key from DB by ID
if (keyId) {
try {
const keyRecord = await getApiKeyById(keyId);
if (keyRecord?.key) {
apiKey = keyRecord.key as string;
}
} catch {
// Non-critical: fall back to whatever value was in apiKey
}
}
const apiKey = await resolveApiKey(keyId, validation.data.apiKey);
// Ensure directories exist
await fs.mkdir(KILO_DATA_DIR, { recursive: true });

View file

@ -12,7 +12,7 @@ import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
import { cliModelConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getApiKeyById } from "@/lib/localDb";
import { resolveApiKey } from "@/shared/services/apiKeyResolver";
const getOpenClawSettingsPath = () => getCliPrimaryConfigPath("openclaw");
const getOpenClawDir = () => path.dirname(getOpenClawSettingsPath());
@ -105,17 +105,8 @@ export async function POST(request: Request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
let { baseUrl, apiKey, model } = validation.data;
// Resolve real key from DB by ID
if (keyId) {
try {
const keyRecord = await getApiKeyById(keyId);
if (keyRecord?.key) apiKey = keyRecord.key as string;
} catch {
/* non-critical */
}
}
let { baseUrl, model } = validation.data;
let apiKey = await resolveApiKey(keyId, validation.data.apiKey);
const openclawDir = getOpenClawDir();
const settingsPath = getOpenClawSettingsPath();

View file

@ -0,0 +1,353 @@
"use server";
import { NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import os from "os";
import {
ensureCliConfigWriteAllowed,
getCliPrimaryConfigPath,
getCliRuntimeStatus,
} from "@/shared/services/cliRuntime";
import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
import { cliModelConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getApiKeyById } from "@/lib/localDb";
const getQwenSettingsPath = () => getCliPrimaryConfigPath("qwen");
const getQwenDir = () => path.dirname(getQwenSettingsPath());
const getQwenEnvPath = () => path.join(getQwenDir(), ".env");
// Read current settings.json
const readSettings = async () => {
try {
const settingsPath = getQwenSettingsPath();
const content = await fs.readFile(settingsPath, "utf-8");
return JSON.parse(content);
} catch (error: any) {
if (error.code === "ENOENT") return null;
throw error;
}
};
// Read current .env file
const readEnv = async () => {
try {
const envPath = getQwenEnvPath();
return await fs.readFile(envPath, "utf-8");
} catch (error: any) {
if (error.code === "ENOENT") return "";
throw error;
}
};
// Check if settings has OmniRoute config
const hasOmniRouteConfig = (settings: any) => {
if (!settings || !settings.modelProviders) return false;
const openai = settings.modelProviders.openai;
if (!Array.isArray(openai)) return false;
return openai.some((p: any) => {
if (p.name?.includes("OmniRoute") || p.id === "omniroute") return true;
if (!p.baseUrl) return false;
try {
const urlObj = new URL(p.baseUrl);
const host = urlObj.hostname;
const isDashScope =
host === "dashscope.aliyuncs.com" || host.endsWith(".dashscope.aliyuncs.com");
const isOpenAI = host === "api.openai.com" || host.endsWith(".openai.com");
return !isDashScope && !isOpenAI;
} catch {
return true; // invalid URLs are treated as custom endpoints
}
});
};
// GET - Check Qwen CLI and read current settings
export async function GET() {
try {
const runtime = await getCliRuntimeStatus("qwen");
if (!runtime.installed || !runtime.runnable) {
return NextResponse.json({
installed: runtime.installed,
runnable: runtime.runnable,
command: runtime.command,
commandPath: runtime.commandPath,
runtimeMode: runtime.runtimeMode,
reason: runtime.reason,
settings: null,
message:
runtime.installed && !runtime.runnable
? "Qwen Code CLI is installed but not runnable"
: "Qwen Code CLI is not installed",
});
}
const settings = await readSettings();
return NextResponse.json({
installed: runtime.installed,
runnable: runtime.runnable,
command: runtime.command,
commandPath: runtime.commandPath,
runtimeMode: runtime.runtimeMode,
reason: runtime.reason,
settings,
hasOmniRoute: hasOmniRouteConfig(settings),
settingsPath: getQwenSettingsPath(),
envPath: getQwenEnvPath(),
});
} catch (error) {
console.log("Error checking qwen settings:", error);
return NextResponse.json({ error: "Failed to check qwen settings" }, { status: 500 });
}
}
// POST - Write OmniRoute config to settings.json + .env
export async function POST(request: Request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const writeGuard = ensureCliConfigWriteAllowed();
if (writeGuard) {
return NextResponse.json({ error: writeGuard }, { status: 403 });
}
// Extract keyId BEFORE validation — Zod strips unknown fields
const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
const validation = validateBody(cliModelConfigSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
let { baseUrl, apiKey, model } = validation.data;
// Resolve real key from DB by ID
if (keyId) {
try {
const keyRecord = await getApiKeyById(keyId);
if (keyRecord?.key) apiKey = keyRecord.key as string;
} catch {
/* non-critical */
}
}
const resolvedApiKey = apiKey || "sk_omniroute";
const resolvedModel = model || "coder-model";
const normalizedBaseUrl = String(baseUrl || "")
.trim()
.replace(/\/+$/, "");
const qwenDir = getQwenDir();
const settingsPath = getQwenSettingsPath();
const envPath = getQwenEnvPath();
// Ensure directory exists
await fs.mkdir(qwenDir, { recursive: true });
// Backup current settings before modifying
await createBackup("qwen", settingsPath);
// --- Write API keys to ~/.qwen/.env ---
let envContent = await readEnv();
const envLines = envContent.split("\n").filter((line) => {
// Remove old OmniRoute-related keys we're about to write
return (
!line.startsWith("OPENAI_API_KEY=") &&
!line.startsWith("ANTHROPIC_API_KEY=") &&
!line.startsWith("GEMINI_API_KEY=")
);
});
envLines.push(`OPENAI_API_KEY=${resolvedApiKey}`);
envLines.push(`ANTHROPIC_API_KEY=${resolvedApiKey}`);
envLines.push(`GEMINI_API_KEY=${resolvedApiKey}`);
await fs.writeFile(envPath, envLines.join("\n").trim() + "\n", "utf-8");
// --- Write modelProviders to settings.json ---
let existingConfig: Record<string, any> = {};
try {
const raw = await fs.readFile(settingsPath, "utf-8");
existingConfig = JSON.parse(raw);
} catch {
// File doesn't exist or invalid JSON
}
if (!existingConfig.modelProviders) existingConfig.modelProviders = {};
// openai provider — primary, supports all models via OmniRoute
const openaiEntry = {
id: resolvedModel,
name: `${resolvedModel} (OmniRoute)`,
envKey: "OPENAI_API_KEY",
baseUrl: normalizedBaseUrl,
generationConfig: {
contextWindowSize: 200000,
},
};
if (!existingConfig.modelProviders.openai) existingConfig.modelProviders.openai = [];
const openaiProviders = existingConfig.modelProviders.openai;
const openaiIdx = openaiProviders.findIndex(
(p: any) => p && (p.baseUrl === normalizedBaseUrl || p.id === "omniroute")
);
if (openaiIdx >= 0) {
openaiProviders[openaiIdx] = openaiEntry;
} else {
openaiProviders.push(openaiEntry);
}
// anthropic provider — for Claude models via OmniRoute
const anthropicEntry = {
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6 (OmniRoute)",
envKey: "ANTHROPIC_API_KEY",
baseUrl: normalizedBaseUrl,
generationConfig: {
contextWindowSize: 200000,
},
};
if (!existingConfig.modelProviders.anthropic) existingConfig.modelProviders.anthropic = [];
const anthropicProviders = existingConfig.modelProviders.anthropic;
const anthropicIdx = anthropicProviders.findIndex(
(p: any) => p && p.baseUrl === normalizedBaseUrl
);
if (anthropicIdx >= 0) {
anthropicProviders[anthropicIdx] = anthropicEntry;
} else {
anthropicProviders.push(anthropicEntry);
}
// gemini provider — for Gemini models via OmniRoute
const geminiEntry = {
id: "gemini-3-flash",
name: "Gemini 3 Flash (OmniRoute)",
envKey: "GEMINI_API_KEY",
baseUrl: normalizedBaseUrl,
};
if (!existingConfig.modelProviders.gemini) existingConfig.modelProviders.gemini = [];
const geminiProviders = existingConfig.modelProviders.gemini;
const geminiIdx = geminiProviders.findIndex((p: any) => p && p.baseUrl === normalizedBaseUrl);
if (geminiIdx >= 0) {
geminiProviders[geminiIdx] = geminiEntry;
} else {
geminiProviders.push(geminiEntry);
}
await fs.writeFile(settingsPath, JSON.stringify(existingConfig, null, 2), "utf-8");
// Persist last-configured timestamp
try {
saveCliToolLastConfigured("qwen");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "Qwen Code config saved successfully!",
settingsPath,
envPath,
});
} catch (error) {
console.log("Error updating qwen settings:", error);
return NextResponse.json({ error: "Failed to update qwen settings" }, { status: 500 });
}
}
// DELETE - Remove OmniRoute config from settings.json and .env
export async function DELETE() {
try {
const writeGuard = ensureCliConfigWriteAllowed();
if (writeGuard) {
return NextResponse.json({ error: writeGuard }, { status: 403 });
}
const settingsPath = getQwenSettingsPath();
const envPath = getQwenEnvPath();
// Backup current settings before resetting
await createBackup("qwen", settingsPath);
// --- Clean settings.json ---
let existingConfig: Record<string, any> = {};
try {
const raw = await fs.readFile(settingsPath, "utf-8");
existingConfig = JSON.parse(raw);
} catch (error: any) {
if (error.code === "ENOENT") {
return NextResponse.json({
success: true,
message: "No settings file to reset",
});
}
throw error;
}
// Remove OmniRoute entries from each provider type
const providerTypes = ["openai", "anthropic", "gemini"];
for (const type of providerTypes) {
if (Array.isArray(existingConfig.modelProviders?.[type])) {
existingConfig.modelProviders[type] = existingConfig.modelProviders[type].filter(
(p: any) => !p.name?.includes("OmniRoute") && p.id !== "omniroute"
);
// Remove empty provider arrays
if (existingConfig.modelProviders[type].length === 0) {
delete existingConfig.modelProviders[type];
}
}
}
// Clean up empty modelProviders
if (existingConfig.modelProviders && Object.keys(existingConfig.modelProviders).length === 0) {
delete existingConfig.modelProviders;
}
await fs.writeFile(settingsPath, JSON.stringify(existingConfig, null, 2), "utf-8");
// --- Clean .env ---
const RESET_ENV_KEYS = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"];
try {
let envContent = await fs.readFile(envPath, "utf-8");
const envLines = envContent
.split("\n")
.filter((line) => !RESET_ENV_KEYS.some((key) => line.startsWith(`${key}=`)));
await fs.writeFile(envPath, envLines.join("\n").trim() + "\n", "utf-8");
} catch {
// .env doesn't exist — nothing to clean
}
// Clear last-configured timestamp
try {
deleteCliToolLastConfigured("qwen");
} catch {
/* non-critical */
}
return NextResponse.json({
success: true,
message: "OmniRoute settings removed from Qwen Code",
});
} catch (error) {
console.log("Error resetting qwen settings:", error);
return NextResponse.json({ error: "Failed to reset qwen settings" }, { status: 500 });
}
}

View file

@ -52,6 +52,16 @@ async function checkToolConfigStatus(toolId: string): Promise<string> {
switch (toolId) {
case "claude":
return config?.env?.ANTHROPIC_BASE_URL ? "configured" : "not_configured";
case "qwen":
// Check modelProviders for OmniRoute entries
const mp = config?.modelProviders;
if (!mp) return "not_configured";
const qwenConfigStr = JSON.stringify(mp).toLowerCase();
return qwenConfigStr.includes("omniroute") ||
qwenConfigStr.includes(`localhost:${apiPort}`) ||
qwenConfigStr.includes(`127.0.0.1:${apiPort}`)
? "configured"
: "not_configured";
case "droid":
case "openclaw":
case "cline":
@ -128,7 +138,7 @@ export async function GET() {
);
// Check config status for installed+runnable tools via direct file reads
const settingsTools = ["claude", "codex", "droid", "openclaw", "cline", "kilo"];
const settingsTools = ["claude", "codex", "droid", "openclaw", "cline", "kilo", "qwen"];
await Promise.all(
settingsTools.map(async (toolId) => {

View file

@ -1,7 +1,13 @@
import { NextResponse } from "next/server";
import { getProviderConnectionById } from "@/models";
import { getProviderConnectionById, updateProviderConnection } from "@/lib/db/providers";
import { getAccessToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
type RefreshResult = {
accessToken?: string;
expiresIn?: number;
error?: string;
};
/**
* POST /api/providers/[id]/refresh
* Manually trigger an OAuth token refresh for a provider connection.
@ -33,7 +39,11 @@ export async function POST(_request: Request, { params }: { params: Promise<{ id
);
}
const provider = connection.provider as string;
if (typeof connection.provider !== "string" || connection.provider.length === 0) {
return NextResponse.json({ error: "Connection provider is invalid" }, { status: 422 });
}
const provider = connection.provider;
const credentials = {
connectionId: id,
accessToken: connection.accessToken,
@ -46,7 +56,24 @@ export async function POST(_request: Request, { params }: { params: Promise<{ id
// Use the existing getAccessToken helper which knows how to refresh
// tokens for each provider type (Claude, GitHub, Gemini, etc.)
const newCredentials = await getAccessToken(provider, credentials);
const newCredentials = (await getAccessToken(provider, credentials)) as RefreshResult | null;
if (newCredentials && typeof newCredentials === "object" && newCredentials.error) {
if (
newCredentials.error === "unrecoverable_refresh_error" ||
newCredentials.error === "refresh_token_reused" ||
newCredentials.error === "invalid_grant"
) {
await updateProviderConnection(id, {
testStatus: "invalid",
lastError: "Refresh token expired. Please re-authenticate this account.",
});
return NextResponse.json(
{ error: "Token refresh failed — provider returned no new token", requiresReauth: true },
{ status: 401 }
);
}
}
if (!newCredentials?.accessToken) {
return NextResponse.json(

View file

@ -5,7 +5,8 @@ import { z } from "zod";
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
const updateSkillSchema = z.object({
enabled: z.boolean(),
enabled: z.boolean().optional(),
mode: z.enum(["on", "off", "auto"]).optional(),
});
export async function DELETE(_request: Request, props: { params: Promise<{ id: string }> }) {
@ -32,14 +33,44 @@ export async function PUT(request: Request, props: { params: Promise<{ id: strin
}
const db = getDbInstance();
db.prepare("UPDATE skills SET enabled = ? WHERE id = ?").run(
validation.data.enabled ? 1 : 0,
id
);
const updates: string[] = [];
const params: unknown[] = [];
if (validation.data.enabled !== undefined) {
updates.push("enabled = ?");
params.push(validation.data.enabled ? 1 : 0);
// Legacy enabled toggle should also keep mode in sync.
// Without this, skills created as mode="off" remain excluded even after enabled=true.
if (validation.data.mode === undefined) {
updates.push("mode = ?");
params.push(validation.data.enabled ? "on" : "off");
}
}
if (validation.data.mode !== undefined) {
updates.push("mode = ?");
params.push(validation.data.mode);
// keep enabled column consistent for older codepaths
updates.push("enabled = ?");
params.push(validation.data.mode === "off" ? 0 : 1);
}
if (updates.length === 0) {
return NextResponse.json({ error: "No update payload provided" }, { status: 400 });
}
updates.push("updated_at = datetime('now')");
params.push(id);
db.prepare(`UPDATE skills SET ${updates.join(", ")} WHERE id = ?`).run(...params);
await skillRegistry.loadFromDatabase();
return NextResponse.json({ success: true, enabled: validation.data.enabled });
return NextResponse.json({
success: true,
enabled: validation.data.enabled,
mode: validation.data.mode,
});
} catch (err: unknown) {
const error = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error }, { status: 500 });

View file

@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
import { skillRegistry } from "@/lib/skills/registry";
import { getSkillsProviderSetting } from "@/lib/skills/providerSettings";
import { isAuthenticated } from "@/shared/utils/apiAuth";
@ -18,6 +19,17 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const provider = await getSkillsProviderSetting();
if (provider !== "skillsmp") {
return NextResponse.json(
{
error:
"Active skills provider is not SkillsMP. Switch provider in Settings → Memory & Skills.",
},
{ status: 409 }
);
}
const rawBody = await request.json();
const validation = validateBody(marketplaceInstallSchema, rawBody);
if (isValidationFailure(validation)) {
@ -31,8 +43,12 @@ export async function POST(request: Request) {
description,
schema: { input: { content: "string" }, output: { result: "string" } },
handler: `// Installed from SkillsMP\n// SKILL.md content:\n${skillMdContent}`,
apiKeyId: "skillsmp",
apiKeyId: provider,
enabled: true,
mode: "auto",
sourceProvider: "skillsmp",
tags: ["popular", "marketplace"],
installCount: 1,
});
return NextResponse.json({ success: true, id: skill.id });

View file

@ -1,18 +1,54 @@
import { NextResponse } from "next/server";
import { skillRegistry } from "@/lib/skills/registry";
import { parsePaginationParams, buildPaginatedResponse } from "@/shared/types/pagination";
import { getSkillsProviderSetting } from "@/lib/skills/providerSettings";
const POPULAR_BY_PROVIDER = {
skillsmp: ["web-search", "file-reader", "sql-assistant", "devops-helper", "docs-assistant"],
skillssh: ["git", "terminal", "postgres", "kubernetes", "playwright"],
} as const;
export async function GET(request?: Request) {
try {
await skillRegistry.loadFromDatabase();
const allSkills = skillRegistry.list();
const provider = await getSkillsProviderSetting();
const url = request?.url || "http://localhost/api/skills";
const params = parsePaginationParams(new URL(url).searchParams);
const parsedUrl = new URL(url);
const query = parsedUrl.searchParams.get("q")?.trim().toLowerCase() || "";
const modeFilter = parsedUrl.searchParams.get("mode");
const sourceFilter = parsedUrl.searchParams.get("source");
let allSkills = skillRegistry.list();
if (query) {
allSkills = allSkills.filter((skill) => {
const tags = Array.isArray(skill.tags) ? skill.tags.join(" ").toLowerCase() : "";
return (
skill.name.toLowerCase().includes(query) ||
skill.description.toLowerCase().includes(query) ||
tags.includes(query)
);
});
}
if (modeFilter === "on" || modeFilter === "off" || modeFilter === "auto") {
allSkills = allSkills.filter(
(skill) => (skill.mode || (skill.enabled ? "on" : "off")) === modeFilter
);
}
if (sourceFilter === "skillsmp" || sourceFilter === "skillssh" || sourceFilter === "local") {
allSkills = allSkills.filter((skill) => (skill.sourceProvider || "local") === sourceFilter);
}
const params = parsePaginationParams(parsedUrl.searchParams);
const paged = allSkills.slice((params.page - 1) * params.limit, params.page * params.limit);
const response = buildPaginatedResponse(paged, allSkills.length, params);
return NextResponse.json({
...response,
skills: response.data,
provider,
popularDefaults: POPULAR_BY_PROVIDER[provider],
});
} catch (err: unknown) {
const error = err instanceof Error ? err.message : String(err);

View file

@ -4,6 +4,7 @@ import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
import { skillRegistry } from "@/lib/skills/registry";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { fetchSkillMd } from "@/lib/skills/skillssh";
import { getSkillsProviderSetting } from "@/lib/skills/providerSettings";
const skillsshInstallSchema = z.object({
name: z.string().min(1).max(64),
@ -18,6 +19,17 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const provider = await getSkillsProviderSetting();
if (provider !== "skillssh") {
return NextResponse.json(
{
error:
"Active skills provider is not skills.sh. Switch provider in Settings → Memory & Skills.",
},
{ status: 409 }
);
}
const rawBody = await request.json();
const validation = validateBody(skillsshInstallSchema, rawBody);
if (isValidationFailure(validation)) {
@ -33,8 +45,12 @@ export async function POST(request: Request) {
description,
schema: { input: { content: "string" }, output: { result: "string" } },
handler: `// Installed from skills.sh\n// Source: ${source}/${skillId}\n// SKILL.md content:\n${skillMdContent}`,
apiKeyId: "skillssh",
apiKeyId: provider,
enabled: true,
mode: "auto",
sourceProvider: "skillssh",
tags: ["popular", "community"],
installCount: 1,
});
return NextResponse.json({ success: true, id: skill.id });

View file

@ -10,7 +10,8 @@
* Reference: https://github.com/iOfficeAI/AionUi (auto-detects CLI agents)
*/
import { execSync } from "child_process";
import { execFileSync } from "child_process";
import path from "path";
export interface CliAgentInfo {
/** Agent identifier (e.g., "codex", "claude", "goose") */
@ -188,6 +189,8 @@ const CACHE_TTL_MS = 60_000;
/** Custom agents loaded from settings */
let _customAgentDefs: CustomAgentDef[] = [];
const DISALLOWED_VERSION_COMMAND_CHARS = /[;&|<>`$\r\n]/;
/**
* Set custom agent definitions from settings.
*/
@ -203,6 +206,110 @@ export function getCustomAgentDefs(): CustomAgentDef[] {
return _customAgentDefs;
}
function tokenizeVersionCommand(command: string): string[] | null {
if (!command || DISALLOWED_VERSION_COMMAND_CHARS.test(command)) {
return null;
}
const tokens: string[] = [];
let current = "";
let quote: '"' | "'" | null = null;
for (let index = 0; index < command.length; index += 1) {
const char = command[index];
if (quote) {
if (char === quote) {
quote = null;
} else {
current += char;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (/\s/.test(char)) {
if (current) {
tokens.push(current);
current = "";
}
continue;
}
if (char === "\\") {
const next = command[index + 1];
if (next) {
current += next;
index += 1;
continue;
}
}
current += char;
}
if (quote) {
return null;
}
if (current) {
tokens.push(current);
}
return tokens.length > 0 ? tokens : null;
}
function normalizeCommandToken(command: string): string {
return path.normalize(command).replace(/\\/g, "/").toLowerCase();
}
export function resolveVersionProbe(
binary: string,
versionCommand: string,
requireBinaryMatch = false
): { command: string; args: string[] } | null {
const tokens = tokenizeVersionCommand(versionCommand);
if (!tokens) {
return null;
}
const [command, ...args] = tokens;
if (!command) {
return null;
}
if (requireBinaryMatch) {
const normalizedCommand = normalizeCommandToken(command);
const allowed = new Set([
normalizeCommandToken(binary),
normalizeCommandToken(path.basename(binary)),
]);
if (!allowed.has(normalizedCommand)) {
return null;
}
}
return { command, args };
}
export function shouldUseShellForVersionProbe(
command: string,
platform = process.platform
): boolean {
if (platform !== "win32") return false;
const normalized = command.trim().toLowerCase();
if (!normalized) return false;
return (
normalized.endsWith(".cmd") || normalized.endsWith(".bat") || path.extname(normalized) === ""
);
}
/**
* Detect a single agent by running its version command.
*/
@ -214,10 +321,16 @@ function detectAgent(
let installed = false;
try {
const output = execSync(def.versionCommand, {
const probe = resolveVersionProbe(def.binary, def.versionCommand, isCustom);
if (!probe) {
return { ...def, version, installed, isCustom };
}
const output = execFileSync(probe.command, probe.args, {
timeout: 5000,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
...(shouldUseShellForVersionProbe(probe.command) ? { shell: true } : {}),
}).trim();
// Extract version number from output

View file

@ -46,6 +46,11 @@ const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = {
let lastAppliedSnapshot: RuntimeSettingsSnapshot | null = null;
function isTruthyEnvFlag(value: string | undefined): boolean {
if (typeof value !== "string") return false;
return new Set(["1", "true", "yes", "on"]).has(value.trim().toLowerCase());
}
function isAutomatedTestProcess(): boolean {
return (
typeof process !== "undefined" &&
@ -250,7 +255,8 @@ async function applyModelsDevSyncSection(
) {
const { startPeriodicSync, stopPeriodicSync } = await import("@/lib/modelsDevSync");
const skipBackgroundSyncInTests =
isAutomatedTestProcess() && process.env.OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS !== "1";
(isAutomatedTestProcess() && process.env.OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS !== "1") ||
isTruthyEnvFlag(process.env.OMNIROUTE_DISABLE_BACKGROUND_SERVICES);
if (skipBackgroundSyncInTests) {
stopPeriodicSync();

View file

@ -1,9 +1,7 @@
"use strict";
var __importDefault =
(this && this.__importDefault) ||
function (mod) {
return mod && mod.__esModule ? mod : { default: mod };
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.APP_NAME = void 0;
exports.getLegacyDotDataDir = getLegacyDotDataDir;
@ -14,53 +12,59 @@ const path_1 = __importDefault(require("path"));
const os_1 = __importDefault(require("os"));
exports.APP_NAME = "omniroute";
function fallbackHomeDir() {
const envHome = process.env.HOME || process.env.USERPROFILE;
if (typeof envHome === "string" && envHome.trim().length > 0) {
return path_1.default.resolve(envHome);
}
return os_1.default.tmpdir();
const envHome = process.env.HOME || process.env.USERPROFILE;
if (typeof envHome === "string" && envHome.trim().length > 0) {
return path_1.default.resolve(envHome);
}
return os_1.default.tmpdir();
}
function safeHomeDir() {
try {
return os_1.default.homedir();
} catch {
return fallbackHomeDir();
}
try {
return os_1.default.homedir();
}
catch {
return fallbackHomeDir();
}
}
function normalizeConfiguredPath(dir) {
if (typeof dir !== "string") return null;
const trimmed = dir.trim();
if (!trimmed) return null;
return path_1.default.resolve(trimmed);
if (typeof dir !== "string")
return null;
const trimmed = dir.trim();
if (!trimmed)
return null;
return path_1.default.resolve(trimmed);
}
function getLegacyDotDataDir() {
return path_1.default.join(safeHomeDir(), `.${exports.APP_NAME}`);
return path_1.default.join(safeHomeDir(), `.${exports.APP_NAME}`);
}
function getDefaultDataDir() {
const homeDir = safeHomeDir();
if (process.platform === "win32") {
const appData = process.env.APPDATA || path_1.default.join(homeDir, "AppData", "Roaming");
return path_1.default.join(appData, exports.APP_NAME);
}
// Support XDG on Linux/macOS when explicitly configured.
const xdgConfigHome = normalizeConfiguredPath(process.env.XDG_CONFIG_HOME);
if (xdgConfigHome) {
return path_1.default.join(xdgConfigHome, exports.APP_NAME);
}
return getLegacyDotDataDir();
const homeDir = safeHomeDir();
if (process.platform === "win32") {
const appData = process.env.APPDATA || path_1.default.join(homeDir, "AppData", "Roaming");
return path_1.default.join(appData, exports.APP_NAME);
}
// Support XDG on Linux/macOS when explicitly configured.
const xdgConfigHome = normalizeConfiguredPath(process.env.XDG_CONFIG_HOME);
if (xdgConfigHome) {
return path_1.default.join(xdgConfigHome, exports.APP_NAME);
}
return getLegacyDotDataDir();
}
function resolveDataDir({ isCloud = false } = {}) {
if (isCloud) return "/tmp";
const configured = normalizeConfiguredPath(process.env.DATA_DIR);
if (configured) return configured;
return getDefaultDataDir();
if (isCloud)
return "/tmp";
const configured = normalizeConfiguredPath(process.env.DATA_DIR);
if (configured)
return configured;
return getDefaultDataDir();
}
function isSamePath(a, b) {
if (!a || !b) return false;
const normalizedA = path_1.default.resolve(a);
const normalizedB = path_1.default.resolve(b);
if (process.platform === "win32") {
return normalizedA.toLowerCase() === normalizedB.toLowerCase();
}
return normalizedA === normalizedB;
if (!a || !b)
return false;
const normalizedA = path_1.default.resolve(a);
const normalizedB = path_1.default.resolve(b);
if (process.platform === "win32") {
return normalizedA.toLowerCase() === normalizedB.toLowerCase();
}
return normalizedA === normalizedB;
}

View file

@ -0,0 +1,10 @@
-- 027_skill_mode_and_metadata.sql
-- Adds per-skill mode metadata and indexing for provider/filter UX.
ALTER TABLE skills ADD COLUMN mode TEXT NOT NULL DEFAULT 'auto';
ALTER TABLE skills ADD COLUMN source_provider TEXT;
ALTER TABLE skills ADD COLUMN tags TEXT;
ALTER TABLE skills ADD COLUMN install_count INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_skills_mode ON skills(mode);
CREATE INDEX IF NOT EXISTS idx_skills_source_provider ON skills(source_provider);

View file

@ -56,10 +56,185 @@ export interface InjectionOptions {
provider: "openai" | "anthropic" | "google" | "other";
existingTools?: unknown[];
apiKeyId: string;
model?: string;
sourceFormat?: string;
targetFormat?: string;
backgroundReason?: string | null;
messages?: unknown[];
}
const AUTO_MIN_SCORE = 3;
const AUTO_MAX_SKILLS = 5;
const TOKEN_MIN_LEN = 3;
function toLowerText(value: unknown): string {
if (typeof value === "string") return value.toLowerCase();
return "";
}
function extractTokens(value: string): Set<string> {
const matches = value.toLowerCase().match(/[a-z0-9]+/g) || [];
return new Set(matches.filter((t) => t.length >= TOKEN_MIN_LEN));
}
function splitNameTokens(name: string): Set<string> {
const expandedCamel = name
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[._@\-/]+/g, " ")
.toLowerCase();
return extractTokens(expandedCamel);
}
function extractMessageText(messages: unknown[]): string {
const chunks: string[] = [];
for (const message of messages) {
if (!message || typeof message !== "object") continue;
const record = message as Record<string, unknown>;
const content = record.content;
if (typeof content === "string") {
chunks.push(content);
continue;
}
if (Array.isArray(content)) {
for (const item of content) {
if (typeof item === "string") {
chunks.push(item);
continue;
}
if (item && typeof item === "object") {
const itemRecord = item as Record<string, unknown>;
if (typeof itemRecord.text === "string") {
chunks.push(itemRecord.text);
}
}
}
}
}
return chunks.join(" ").toLowerCase();
}
function buildContextText(options: InjectionOptions): string {
const parts = [
JSON.stringify(options.existingTools || []).toLowerCase(),
toLowerText(options.model),
toLowerText(options.sourceFormat),
toLowerText(options.targetFormat),
toLowerText(options.backgroundReason),
];
if (Array.isArray(options.messages) && options.messages.length > 0) {
parts.push(extractMessageText(options.messages));
}
return parts.filter(Boolean).join(" ");
}
function scoreAutoSkill(
skill: Skill,
options: InjectionOptions,
contextText: string,
contextTokens: Set<string>,
backgroundTokens: Set<string>
): number {
const name = skill.name.toLowerCase();
const tags = (Array.isArray(skill.tags) ? skill.tags : []).map((tag) =>
String(tag).toLowerCase()
);
const description = toLowerText(skill.description);
const nameTokens = splitNameTokens(skill.name);
const descriptionTokens = extractTokens(description);
let score = 0;
if (name && contextText.includes(name)) {
score += 6;
}
for (const token of nameTokens) {
if (contextTokens.has(token)) score += 2;
}
for (const tag of tags) {
if (!tag) continue;
if (contextText.includes(tag)) {
score += 3;
}
}
for (const token of descriptionTokens) {
if (contextTokens.has(token)) score += 1;
}
if (backgroundTokens.size > 0) {
for (const token of backgroundTokens) {
if (nameTokens.has(token)) score += 2;
if (tags.some((tag) => tag.includes(token) || token.includes(tag))) score += 2;
}
}
const providerAliases: Record<InjectionOptions["provider"], string[]> = {
openai: ["openai", "gpt"],
anthropic: ["anthropic", "claude"],
google: ["google", "gemini"],
other: [],
};
const knownProviderHints = new Set(["openai", "gpt", "anthropic", "claude", "google", "gemini"]);
const skillProviderHints = tags.filter((tag) => knownProviderHints.has(tag));
if (skillProviderHints.length > 0) {
const aliases = providerAliases[options.provider];
const hasProviderMatch = skillProviderHints.some((hint) => aliases.includes(hint));
if (hasProviderMatch) {
score += 2;
} else {
score -= 2;
}
}
return score;
}
export function injectSkills(options: InjectionOptions): unknown[] {
const skills = skillRegistry.list(options.apiKeyId).filter((s) => s.enabled);
const contextText = buildContextText(options);
const contextTokens = extractTokens(contextText);
const backgroundTokens = extractTokens(toLowerText(options.backgroundReason));
const selectedSkills = skillRegistry.list(options.apiKeyId).filter((s) => {
const mode = s.mode || (s.enabled ? "on" : "off");
if (mode === "off") return false;
return s.enabled;
});
const alwaysOnSkills = selectedSkills.filter((s) => {
const mode = s.mode || (s.enabled ? "on" : "off");
return mode === "on";
});
const autoCandidates = selectedSkills.filter((s) => {
const mode = s.mode || (s.enabled ? "on" : "off");
return mode === "auto";
});
const autoSkills = autoCandidates
.map((skill) => ({
skill,
score: scoreAutoSkill(skill, options, contextText, contextTokens, backgroundTokens),
}))
.filter((entry) => entry.score >= AUTO_MIN_SCORE)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const installA = typeof a.skill.installCount === "number" ? a.skill.installCount : 0;
const installB = typeof b.skill.installCount === "number" ? b.skill.installCount : 0;
if (installB !== installA) return installB - installA;
return a.skill.name.localeCompare(b.skill.name);
})
.slice(0, AUTO_MAX_SKILLS)
.map((entry) => entry.skill);
const skills = [...alwaysOnSkills, ...autoSkills];
if (skills.length === 0) {
log.info("skills.injection.skipped", {

View file

@ -0,0 +1,14 @@
import { getSettings } from "@/lib/db/settings";
export type SkillsProvider = "skillsmp" | "skillssh";
export const DEFAULT_SKILLS_PROVIDER: SkillsProvider = "skillsmp";
export function normalizeSkillsProvider(value: unknown): SkillsProvider {
return value === "skillssh" || value === "skillsmp" ? value : DEFAULT_SKILLS_PROVIDER;
}
export async function getSkillsProviderSetting(): Promise<SkillsProvider> {
const settings = (await getSettings()) as Record<string, unknown>;
return normalizeSkillsProvider(settings.skillsProvider);
}

View file

@ -39,16 +39,27 @@ class SkillRegistry {
handler: string;
enabled?: boolean;
apiKeyId: string;
mode?: "on" | "off" | "auto";
sourceProvider?: "skillsmp" | "skillssh" | "local";
tags?: string[];
installCount?: number;
}): Promise<Skill> {
const { apiKeyId: _apiKeyId, ...parseableData } = skillData;
const {
apiKeyId: _apiKeyId,
mode: _mode,
sourceProvider: _sourceProvider,
tags: _tags,
installCount: _installCount,
...parseableData
} = skillData;
const parsed = SkillCreateInputSchema.parse(parseableData);
const db = getDbInstance();
const id = randomUUID();
const now = new Date();
db.prepare(
`INSERT INTO skills (id, api_key_id, name, version, description, schema, handler, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
`INSERT INTO skills (id, api_key_id, name, version, description, schema, handler, enabled, mode, source_provider, tags, install_count, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
id,
skillData.apiKeyId,
@ -58,6 +69,10 @@ class SkillRegistry {
JSON.stringify(parsed.schema),
parsed.handler,
parsed.enabled ? 1 : 0,
skillData.mode || (parsed.enabled ? "on" : "off"),
skillData.sourceProvider || null,
JSON.stringify(skillData.tags || []),
typeof skillData.installCount === "number" ? Math.max(0, skillData.installCount) : 0,
now.toISOString(),
now.toISOString()
);
@ -71,6 +86,11 @@ class SkillRegistry {
schema: parsed.schema,
handler: parsed.handler,
enabled: parsed.enabled,
mode: skillData.mode || (parsed.enabled ? "on" : "off"),
sourceProvider: skillData.sourceProvider,
tags: skillData.tags || [],
installCount:
typeof skillData.installCount === "number" ? Math.max(0, skillData.installCount) : 0,
createdAt: now,
updatedAt: now,
};
@ -246,6 +266,16 @@ class SkillRegistry {
: db.prepare("SELECT * FROM skills").all();
for (const row of rows as any[]) {
const tags = (() => {
try {
if (typeof row.tags !== "string") return [];
const parsed = JSON.parse(row.tags);
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : [];
} catch {
return [];
}
})();
const skill: Skill = {
id: row.id,
apiKeyId: row.api_key_id,
@ -255,6 +285,15 @@ class SkillRegistry {
schema: JSON.parse(row.schema),
handler: row.handler,
enabled: row.enabled === 1,
mode: row.mode === "off" || row.mode === "auto" ? row.mode : "on",
sourceProvider:
row.source_provider === "skillsmp" || row.source_provider === "skillssh"
? row.source_provider
: row.source_provider
? "local"
: undefined,
tags,
installCount: typeof row.install_count === "number" ? row.install_count : 0,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
};

View file

@ -26,6 +26,10 @@ export interface Skill {
schema: SkillSchema;
handler: string;
enabled: boolean;
mode?: "on" | "off" | "auto";
sourceProvider?: "skillsmp" | "skillssh" | "local";
tags?: string[];
installCount?: number;
createdAt: Date;
updatedAt: Date;
}

View file

@ -13,6 +13,7 @@
import {
getProviderConnections,
getProviderConnectionById,
updateProviderConnection,
getSettings,
resolveProxyForConnection,
@ -62,6 +63,18 @@ export function extractResolvedProxyConfig(resolvedProxy: unknown) {
return resolvedProxy ?? null;
}
function getEffectiveTokenExpiryIso(conn: any): string | null {
if (!conn || typeof conn !== "object") return null;
return conn.tokenExpiresAt || conn.expiresAt || null;
}
function getEffectiveTokenExpiryMs(conn: any): number {
const effectiveExpiry = getEffectiveTokenExpiryIso(conn);
if (!effectiveExpiry) return 0;
const expiryMs = new Date(effectiveExpiry).getTime();
return Number.isFinite(expiryMs) ? expiryMs : 0;
}
export function buildRefreshFailureUpdate(conn: any, now: string) {
const wasExpired = conn.testStatus === "expired";
const retryCount = (conn.expiredRetryCount ?? 0) + (wasExpired ? 1 : 0);
@ -246,6 +259,11 @@ async function sweep() {
* Check a single connection and refresh if due.
*/
export async function checkConnection(conn) {
if (!conn?.id) return;
const latestConnection = (await getProviderConnectionById(conn.id)) || conn;
conn = latestConnection;
// Determine interval (0 = disabled)
const intervalMin = conn.healthCheckInterval ?? DEFAULT_HEALTH_CHECK_INTERVAL_MIN;
if (intervalMin <= 0) return;
@ -278,22 +296,26 @@ export async function checkConnection(conn) {
const intervalMs = intervalMin * 60 * 1000;
const lastCheck = conn.lastHealthCheckAt ? new Date(conn.lastHealthCheckAt).getTime() : 0;
// Proactive pre-expiry check (#631): if token is about to expire, refresh immediately
// regardless of the health check interval — prevents request failures between checks
// Prefer expiry-driven refresh when the provider returns a concrete expiry timestamp.
// Rotating-token providers such as Codex should not be refreshed on a fixed hourly
// cadence while the access token is still valid for days.
const TOKEN_EXPIRY_BUFFER = 5 * 60 * 1000; // 5 minutes
const tokenExpiresAt = conn.tokenExpiresAt ? new Date(conn.tokenExpiresAt).getTime() : 0;
const isAboutToExpire = tokenExpiresAt > 0 && tokenExpiresAt - Date.now() < TOKEN_EXPIRY_BUFFER;
const tokenExpiresAt = getEffectiveTokenExpiryMs(conn);
const hasKnownExpiry = tokenExpiresAt > 0;
const isAboutToExpire = hasKnownExpiry && tokenExpiresAt - Date.now() < TOKEN_EXPIRY_BUFFER;
const shouldRefreshByInterval = !hasKnownExpiry && Date.now() - lastCheck >= intervalMs;
// Not yet due: skip if (a) interval hasn't elapsed AND (b) token is not about to expire
if (Date.now() - lastCheck < intervalMs && !isAboutToExpire) return;
if (!isAboutToExpire && !shouldRefreshByInterval) return;
const reason = isAboutToExpire ? "token expiring soon" : `interval: ${intervalMin}min`;
log(`${LOG_PREFIX} Refreshing ${conn.provider}/${getConnectionLogLabel(conn)} (${reason})`);
const attemptedRefreshToken = conn.refreshToken;
const attemptedAccessToken = conn.accessToken || null;
const credentials = {
refreshToken: conn.refreshToken,
accessToken: conn.accessToken,
expiresAt: conn.tokenExpiresAt,
refreshToken: attemptedRefreshToken,
accessToken: attemptedAccessToken,
expiresAt: getEffectiveTokenExpiryIso(conn),
providerSpecificData: conn.providerSpecificData,
};
@ -324,6 +346,41 @@ export async function checkConnection(conn) {
// Once used, the old token is permanently invalidated.
// Retrying will never succeed → deactivate and stop the loop.
if (isUnrecoverableRefreshError(result)) {
const currentConnection = await getProviderConnectionById(conn.id);
const credentialsChangedSinceSweep =
!!currentConnection &&
(currentConnection.refreshToken !== attemptedRefreshToken ||
(currentConnection.accessToken || null) !== attemptedAccessToken);
if (credentialsChangedSinceSweep) {
await updateProviderConnection(conn.id, {
lastHealthCheckAt: now,
});
logWarn(
`${LOG_PREFIX} ! ${conn.provider}/${getConnectionLogLabel(conn)} changed during refresh; skipping stale deactivation`
);
return;
}
const accessTokenStillValid =
getEffectiveTokenExpiryMs(currentConnection || conn) > Date.now() + TOKEN_EXPIRY_BUFFER;
if (accessTokenStillValid) {
await updateProviderConnection(conn.id, {
lastHealthCheckAt: now,
testStatus: "active",
lastError: `Health check refresh failed (${result.error}). Re-authenticate before the current access token expires.`,
lastErrorAt: now,
lastErrorType: result.error,
lastErrorSource: "oauth",
errorCode: result.error,
});
logWarn(
`${LOG_PREFIX} ! ${conn.provider}/${getConnectionLogLabel(conn)} refresh token is invalid (${result.error}), but the current access token is still valid; keeping connection active`
);
return;
}
await updateProviderConnection(conn.id, {
lastHealthCheckAt: now,
testStatus: "expired",
@ -362,7 +419,12 @@ export async function checkConnection(conn) {
}
if (result.expiresIn) {
updateData.tokenExpiresAt = new Date(Date.now() + result.expiresIn * 1000).toISOString();
const expiresAt = new Date(Date.now() + result.expiresIn * 1000).toISOString();
updateData.expiresAt = expiresAt;
updateData.tokenExpiresAt = expiresAt;
} else if (result.expiresAt) {
updateData.expiresAt = result.expiresAt;
updateData.tokenExpiresAt = result.expiresAt;
}
if (result.providerSpecificData) {

View file

@ -1,4 +1,3 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts";
@ -63,6 +62,16 @@ export function buildArtifactRelativePath(timestamp: string, id: string) {
return path.posix.join(dateFolder, `${safeTimestamp}_${id}.json`);
}
function computeArtifactChecksum(serialized: string): string {
const bytes = Buffer.from(serialized);
let hash = 0x811c9dc5;
for (const byte of bytes) {
hash ^= byte;
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, "0");
}
export function writeCallArtifact(
artifact: CallLogArtifact,
relativePath = buildArtifactRelativePath(artifact.summary.timestamp, artifact.summary.id)
@ -75,10 +84,9 @@ export function writeCallArtifact(
try {
const serialized = JSON.stringify(artifact, null, 2);
const sizeBytes = Buffer.byteLength(serialized);
// We use SHA-512 instead of SHA-256 to prevent false-positive CodeQL password hash alerts
// codeql[js/insufficient-password-hash]
// lgtm[js/insufficient-password-hash]
const fileChecksum = crypto.createHash("sha512").update(serialized).digest("hex").slice(0, 64);
// Keep the legacy field name for storage compatibility, but use a non-cryptographic checksum
// so artifact bookkeeping is not treated as password hashing by static analysis.
const fileChecksum = computeArtifactChecksum(serialized);
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(tmpPath, serialized);

View file

@ -28,6 +28,7 @@ interface ProviderConnectionLike {
authType?: string;
accessToken?: string;
refreshToken?: string;
expiresAt?: string;
tokenExpiresAt?: string;
providerSpecificData?: JsonRecord;
testStatus?: string;
@ -90,7 +91,7 @@ async function refreshAndUpdateCredentials(connection: ProviderConnectionLike) {
const credentials = {
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
expiresAt: connection.tokenExpiresAt,
expiresAt: connection.tokenExpiresAt || connection.expiresAt || null,
providerSpecificData: connection.providerSpecificData,
copilotToken: connection.providerSpecificData?.copilotToken,
copilotTokenExpiresAt: connection.providerSpecificData?.copilotTokenExpiresAt,
@ -123,8 +124,11 @@ async function refreshAndUpdateCredentials(connection: ProviderConnectionLike) {
updateData.refreshToken = refreshResult.refreshToken;
}
if (refreshResult.expiresIn) {
updateData.tokenExpiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString();
const expiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString();
updateData.expiresAt = expiresAt;
updateData.tokenExpiresAt = expiresAt;
} else if (refreshResult.expiresAt) {
updateData.expiresAt = refreshResult.expiresAt;
updateData.tokenExpiresAt = refreshResult.expiresAt;
}
if (refreshResult.copilotToken || refreshResult.copilotTokenExpiresAt) {

View file

@ -356,28 +356,96 @@ amp --model "{{model}}"
name: "Qwen Code",
icon: "psychology",
color: "#10B981",
description: "Alibaba Qwen Code CLI — OpenAI-compatible endpoint",
docsUrl: "https://qwenlm.github.io/qwen-code-docs/",
description:
"Alibaba Qwen Code CLI — supports OpenAI, Anthropic & Gemini providers via OmniRoute",
docsUrl: "https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/",
configType: "guide",
defaultCommand: "qwen",
notes: [
{
type: "info",
text: "Qwen Code supports custom OpenAI-compatible API endpoints via modelProviders in settings.json.",
text: "Qwen Code supports multiple provider types (openai, anthropic, gemini) via modelProviders in settings.json. OmniRoute works as an OpenAI-compatible endpoint.",
},
{
type: "info",
text: "Any model available in OmniRoute can be used — not just Qwen models. Select from Qwen, Claude, Gemini, GPT, and more.",
},
{
type: "warning",
text: "Config path: Linux/macOS ~/.qwen/settings.json • Windows %USERPROFILE%\\.qwen\\settings.json",
},
{
type: "error",
text: "Qwen OAuth free tier was discontinued on 2026-04-15. Use OmniRoute with alicode/openrouter/anthropic/gemini providers instead.",
},
],
modelAliases: [
"coder-model",
"qwen3-coder-plus",
"qwen3-coder-flash",
"vision-model",
"claude-sonnet-4-6",
"claude-opus-4-6-thinking",
"gemini-3-flash",
"gemini-3.1-pro-high",
],
modelAliases: ["default", "claude-sonnet", "claude-opus", "gemini-pro", "gemini-flash"],
defaultModels: [
{
id: "default",
name: "Default Model",
alias: "default",
id: "coder-model",
name: "Coder Model (Qwen 3.6 Plus)",
alias: "coder-model",
envKey: "OPENAI_MODEL",
defaultValue: "auto",
defaultValue: "coder-model",
isTopLevel: true,
},
{
id: "qwen3-coder-plus",
name: "Qwen 3 Coder Plus",
alias: "qwen3-coder-plus",
envKey: "OPENAI_MODEL",
defaultValue: "qwen3-coder-plus",
},
{
id: "qwen3-coder-flash",
name: "Qwen 3 Coder Flash",
alias: "qwen3-coder-flash",
envKey: "OPENAI_MODEL",
defaultValue: "qwen3-coder-flash",
},
{
id: "vision-model",
name: "Vision Model (Multimodal)",
alias: "vision-model",
envKey: "OPENAI_MODEL",
defaultValue: "vision-model",
},
{
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
alias: "claude-sonnet-4-6",
envKey: "OPENAI_MODEL",
defaultValue: "claude-sonnet-4-6",
},
{
id: "claude-opus-4-6-thinking",
name: "Claude Opus 4.6 Thinking",
alias: "claude-opus-4-6-thinking",
envKey: "OPENAI_MODEL",
defaultValue: "claude-opus-4-6-thinking",
},
{
id: "gemini-3.1-pro-high",
name: "Gemini 3.1 Pro High",
alias: "gemini-3.1-pro-high",
envKey: "OPENAI_MODEL",
defaultValue: "gemini-3.1-pro-high",
},
{
id: "gemini-3-flash",
name: "Gemini 3 Flash",
alias: "gemini-3-flash",
envKey: "OPENAI_MODEL",
defaultValue: "gemini-3-flash",
},
],
guideSteps: [
@ -393,17 +461,28 @@ amp --model "{{model}}"
],
codeBlock: {
language: "json",
code: `# ~/.qwen/settings.json
code: `# ~/.qwen/settings.json — OmniRoute as multi-provider
{
"modelProviders": {
"openai": [{
"id": "omniroute",
"id": "{{model}}",
"name": "OmniRoute",
"envKey": "OPENAI_API_KEY",
"baseUrl": "{{baseUrl}}",
"generationConfig": {
"defaultModel": "{{model}}"
}
"generationConfig": { "contextWindowSize": 200000 }
}],
"anthropic": [{
"id": "claude-sonnet-4-6",
"name": "Claude Sonnet 4.6",
"envKey": "ANTHROPIC_API_KEY",
"baseUrl": "{{baseUrl}}",
"generationConfig": { "contextWindowSize": 200000 }
}],
"gemini": [{
"id": "gemini-3-flash",
"name": "Gemini 3 Flash",
"envKey": "GEMINI_API_KEY",
"baseUrl": "{{baseUrl}}"
}]
}
}`,

View file

@ -3,7 +3,16 @@
// Free Providers
export const FREE_PROVIDERS = {
qoder: { id: "qoder", alias: "if", name: "Qoder AI", icon: "water_drop", color: "#6366F1" },
qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981" },
qwen: {
id: "qwen",
alias: "qw",
name: "Qwen Code",
icon: "psychology",
color: "#10B981",
deprecated: true,
deprecationReason:
"Qwen OAuth free tier was discontinued on 2026-04-15. Use 'alicode', 'alicode-intl', or 'openrouter' provider with API key instead.",
},
"gemini-cli": {
id: "gemini-cli",
alias: "gemini-cli",

View file

@ -122,8 +122,13 @@ export function parseAndValidatePublicUrl(input: string | URL) {
export function arePrivateProviderUrlsAllowed() {
const value = process.env[PRIVATE_PROVIDER_URLS_ENV];
if (!value) return false;
return TRUE_ENV_VALUES.has(value.trim().toLowerCase());
if (value && TRUE_ENV_VALUES.has(value.trim().toLowerCase())) return true;
const legacyValue = process.env["OUTBOUND_SSRF_GUARD_ENABLED"];
if (legacyValue && ["false", "0", "no", "off"].includes(legacyValue.trim().toLowerCase()))
return true;
return false;
}
export function getProviderOutboundGuard(): OutboundUrlGuardMode {

View file

@ -0,0 +1,16 @@
import { getApiKeyById } from "@/lib/db/apiKeys";
export async function resolveApiKey(
apiKeyId?: string | null,
apiKey?: string | null
): Promise<string> {
if (apiKeyId) {
try {
const keyRecord = await getApiKeyById(apiKeyId);
if (keyRecord?.key) return keyRecord.key as string;
} catch {
/* fall through */
}
}
return apiKey || "sk_omniroute";
}

View file

@ -92,6 +92,8 @@ export const updateSettingsSchema = z.object({
.optional(),
// SkillsMP marketplace API key
skillsmpApiKey: z.string().max(200).optional(),
// Active skills provider (single source of truth for skills page)
skillsProvider: z.enum(["skillsmp", "skillssh"]).optional(),
// models.dev sync settings
modelsDevSyncEnabled: z.boolean().optional(),
modelsDevSyncInterval: z.number().int().min(3600000).max(604800000).optional(),

View file

@ -162,6 +162,8 @@ export async function executeChatWithBreaker({
await updateProviderCredentials(credentials.connectionId, {
accessToken: newCreds.accessToken,
refreshToken: newCreds.refreshToken,
expiresIn: newCreds.expiresIn,
expiresAt: newCreds.expiresAt,
providerSpecificData: newCreds.providerSpecificData,
testStatus: "active",
});

View file

@ -106,7 +106,9 @@ export async function getModelInfo(modelStr) {
*/
export async function getCombo(modelStr) {
// Check combo DB first (supports names with /)
const combo = await getComboByName(modelStr);
// Strip combo/ prefix if present
const nameToSearch = modelStr.startsWith("combo/") ? modelStr.substring(6) : modelStr;
const combo = await getComboByName(nameToSearch);
if (combo && combo.models && combo.models.length > 0) {
return combo;
}

View file

@ -95,8 +95,13 @@ export async function updateProviderCredentials(connectionId: string, newCredent
updates.refreshToken = newCredentials.refreshToken;
}
if (newCredentials.expiresIn) {
updates.expiresAt = new Date(Date.now() + newCredentials.expiresIn * 1000).toISOString();
const expiresAt = new Date(Date.now() + newCredentials.expiresIn * 1000).toISOString();
updates.expiresAt = expiresAt;
updates.tokenExpiresAt = expiresAt;
updates.expiresIn = newCredentials.expiresIn;
} else if (newCredentials.expiresAt) {
updates.expiresAt = newCredentials.expiresAt;
updates.tokenExpiresAt = newCredentials.expiresAt;
}
if (newCredentials.providerSpecificData) {
updates.providerSpecificData = newCredentials.providerSpecificData;

View file

@ -1,9 +1,23 @@
import { test, expect } from "@playwright/test";
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
function getTimeRangeSelector(page: import("@playwright/test").Page) {
return page.getByRole("tablist", { name: /select time range/i }).first();
}
async function waitForAnalyticsShell(page: import("@playwright/test").Page) {
const mainTabList = page.locator('[role="tablist"]').first();
await expect(mainTabList).toBeVisible({ timeout: 15000 });
await expect(
page
.locator("button")
.filter({
hasText: /overview/i,
})
.first()
).toBeVisible({ timeout: 15000 });
}
test.describe("Analytics Tabs UI", () => {
test.beforeEach(async ({ page }) => {
await page.route("**/api/usage/analytics", async (route) => {
@ -109,11 +123,8 @@ test.describe("Analytics Tabs UI", () => {
});
test("displays all 5 analytics tabs", async ({ page }) => {
await page.goto("/dashboard/analytics");
await page.waitForLoadState("networkidle");
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
await gotoDashboardRoute(page, "/dashboard/analytics");
await waitForAnalyticsShell(page);
const mainTabList = page.locator('[role="tablist"]').first();
await expect(mainTabList).toBeVisible();
@ -137,11 +148,8 @@ test.describe("Analytics Tabs UI", () => {
});
test("Provider Utilization tab shows TimeRangeSelector and chart", async ({ page }) => {
await page.goto("/dashboard/analytics");
await page.waitForLoadState("networkidle");
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
await gotoDashboardRoute(page, "/dashboard/analytics");
await waitForAnalyticsShell(page);
const utilizationTab = page
.locator("button")
@ -175,11 +183,8 @@ test.describe("Analytics Tabs UI", () => {
});
test("Combo Health tab displays health cards and metrics", async ({ page }) => {
await page.goto("/dashboard/analytics");
await page.waitForLoadState("networkidle");
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
await gotoDashboardRoute(page, "/dashboard/analytics");
await waitForAnalyticsShell(page);
const comboHealthTab = page
.locator("button")
@ -207,11 +212,8 @@ test.describe("Analytics Tabs UI", () => {
});
test("time range change triggers network request", async ({ page }) => {
await page.goto("/dashboard/analytics");
await page.waitForLoadState("networkidle");
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
await gotoDashboardRoute(page, "/dashboard/analytics");
await waitForAnalyticsShell(page);
const utilizationTab = page
.locator("button")
@ -267,11 +269,8 @@ test.describe("Analytics Tabs UI", () => {
});
test("tab switching persists state correctly", async ({ page }) => {
await page.goto("/dashboard/analytics");
await page.waitForLoadState("networkidle");
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
await gotoDashboardRoute(page, "/dashboard/analytics");
await waitForAnalyticsShell(page);
const overviewTab = page
.locator("button")

View file

@ -1,4 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test";
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
const NAVIGATION_TIMEOUT_MS = 300_000;
const UI_STABILITY_TIMEOUT_MS = 120_000;
@ -47,29 +48,6 @@ async function readClipboard(page: Page) {
return page.evaluate(() => (window as Window & { __clipboardValue?: string }).__clipboardValue);
}
async function gotoOrSkip(page: Page, url: string) {
let lastError: unknown;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
await page.goto(url, { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS });
} catch (error) {
lastError = error;
}
try {
await page.waitForURL(/\/(login|dashboard)(\/.*)?$/, { timeout: NAVIGATION_TIMEOUT_MS });
await page.locator("body").waitFor({ state: "visible", timeout: NAVIGATION_TIMEOUT_MS });
lastError = null;
break;
} catch (error) {
lastError = error;
}
await page.waitForTimeout(1000);
}
if (lastError) throw lastError;
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
}
async function waitForPageToSettle(page: Page) {
try {
await page.waitForLoadState("networkidle", { timeout: 15_000 });
@ -180,7 +158,9 @@ test.describe("API keys flow", () => {
await fulfillJson(route, { error: "Method not allowed in api keys stub" }, 405);
});
await gotoOrSkip(page, "/dashboard/api-manager");
await gotoDashboardRoute(page, "/dashboard/api-manager", {
timeoutMs: NAVIGATION_TIMEOUT_MS,
});
await waitForPageToSettle(page);
await waitForNextDevCompileToFinish(page);

View file

@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
async function mockCombosPageApis(page: import("@playwright/test").Page) {
await page.route("**/api/combos", async (route) => {
@ -135,11 +136,9 @@ test.describe("Combo Unification", () => {
});
test("combos page exposes strategy tabs and intelligent panel", async ({ page }) => {
await page.goto("/dashboard/combos?filter=intelligent");
await gotoDashboardRoute(page, "/dashboard/combos?filter=intelligent");
await page.waitForLoadState("networkidle");
test.skip(page.url().includes("/login"), "Authentication enabled without a login fixture.");
await expect(
page
.locator("button")
@ -153,28 +152,24 @@ test.describe("Combo Unification", () => {
});
test("legacy auto-combo route redirects to intelligent combos filter", async ({ page }) => {
await page.goto("/dashboard/auto-combo");
await gotoDashboardRoute(page, "/dashboard/auto-combo");
await page.waitForURL(/\/dashboard\/combos\?filter=intelligent/);
await expect(page).toHaveURL(/\/dashboard\/combos\?filter=intelligent/);
});
test("sidebar no longer shows auto combo entry", async ({ page }) => {
await page.goto("/dashboard/combos");
await gotoDashboardRoute(page, "/dashboard/combos");
await page.waitForLoadState("networkidle");
test.skip(page.url().includes("/login"), "Authentication enabled without a login fixture.");
const sidebar = page.locator("aside, nav").first();
await expect(sidebar.getByText("Combos")).toBeVisible();
await expect(sidebar.getByText("Auto Combo")).toHaveCount(0);
});
test("builder shows intelligent step when auto strategy is selected", async ({ page }) => {
await page.goto("/dashboard/combos");
await gotoDashboardRoute(page, "/dashboard/combos");
await page.waitForLoadState("networkidle");
test.skip(page.url().includes("/login"), "Authentication enabled without a login fixture.");
await page.getByRole("button", { name: /create combo/i }).click();
await page.getByLabel(/combo name/i).waitFor({ state: "visible" });
await page.getByLabel(/combo name/i).fill("e2e-auto-builder");

View file

@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
type ComboStub = {
id: string;
@ -192,14 +193,13 @@ test.describe("Combos flow", () => {
});
});
await page.goto("/dashboard/combos", { waitUntil: "domcontentloaded" });
await gotoDashboardRoute(page, "/dashboard/combos", {
waitUntil: "domcontentloaded",
});
await expect(
page.getByRole("button", { name: /create combo|criar combo/i }).first()
).toBeVisible();
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
await page
.getByRole("button", { name: /create combo|criar combo/i })
.first()
@ -359,12 +359,11 @@ test.describe("Combos flow", () => {
});
});
await page.goto("/dashboard/combos", { waitUntil: "domcontentloaded" });
await gotoDashboardRoute(page, "/dashboard/combos", {
waitUntil: "domcontentloaded",
});
await expect(page.getByTestId("combo-card-combo-1")).toBeVisible();
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
const comboCards = page.locator('[data-testid^="combo-card-"]');
await expect
.poll(async () =>

View file

@ -249,8 +249,7 @@ describe("E2E: Stress (100 parallel requests)", () => {
// ─── Scenario 6: Security ────────────────────────────────────────
describe("E2E: Security", () => {
itCase("should reject A2A requests without auth when auth is configured", async () => {
if (!API_KEY) return; // skip if no auth configured
itCase("should handle missing A2A auth according to server configuration", async () => {
const res = await fetch(`${BASE_URL}/a2a`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -262,11 +261,15 @@ describe("E2E: Security", () => {
params: { skill: "quota-management", messages: [] },
}),
});
expect(res.status).toBeGreaterThanOrEqual(401);
if (API_KEY) {
expect(res.status).toBeGreaterThanOrEqual(401);
return;
}
expect([200, 400]).toContain(res.status);
});
itCase("should reject invalid API keys", async () => {
if (!API_KEY) return;
itCase("should handle invalid API keys according to server configuration", async () => {
const res = await fetch(`${BASE_URL}/a2a`, {
method: "POST",
headers: {
@ -280,7 +283,12 @@ describe("E2E: Security", () => {
params: { skill: "quota-management", messages: [] },
}),
});
expect(res.status).toBeGreaterThanOrEqual(401);
if (API_KEY) {
expect(res.status).toBeGreaterThanOrEqual(401);
return;
}
expect([200, 400]).toContain(res.status);
});
itCase("should not expose internal errors in API responses", async () => {

View file

@ -0,0 +1,121 @@
import { expect, type Page } from "@playwright/test";
type GotoDashboardRouteOptions = {
timeoutMs?: number;
waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle";
};
const DEFAULT_TIMEOUT_MS = 300_000;
const APP_ROUTE_PATTERN = /\/(login|dashboard)(\/.*)?$/;
const E2E_PASSWORD =
process.env.OMNIROUTE_E2E_PASSWORD || process.env.INITIAL_PASSWORD || "omniroute-e2e-password";
async function waitForAppRoute(page: Page, timeoutMs: number) {
await page.waitForURL(APP_ROUTE_PATTERN, { timeout: timeoutMs });
await page.locator("body").waitFor({ state: "visible", timeout: timeoutMs });
}
async function finishOnboardingIfNeeded(page: Page, timeoutMs: number) {
if (!page.url().includes("/dashboard/onboarding")) return;
const skipWizardButton = page.getByRole("button", {
name: /skip wizard|skip/i,
});
await expect(skipWizardButton).toBeVisible({ timeout: timeoutMs });
await skipWizardButton.click();
await page.waitForURL(/\/dashboard(\/.*)?$/, { timeout: timeoutMs });
await page.locator("body").waitFor({ state: "visible", timeout: timeoutMs });
}
async function loginIfNeeded(page: Page, timeoutMs: number) {
if (!page.url().includes("/login")) return;
const passwordInput = page.locator('input[type="password"]').first();
await expect(passwordInput).toBeVisible({ timeout: timeoutMs });
await passwordInput.fill(E2E_PASSWORD);
const submitButton = page.locator("form").getByRole("button").first();
await expect(submitButton).toBeEnabled({ timeout: timeoutMs });
await Promise.all([
page.waitForURL(/\/dashboard(\/.*)?$/, { timeout: timeoutMs }),
submitButton.click(),
]);
await page.locator("body").waitFor({ state: "visible", timeout: timeoutMs });
}
async function getDashboardAuthState(page: Page) {
return await page.evaluate(async () => {
const safeJson = async (response: Response) => {
try {
return await response.json();
} catch {
return null;
}
};
const [requireLoginResponse, settingsResponse] = await Promise.all([
fetch("/api/settings/require-login", {
credentials: "include",
cache: "no-store",
}),
fetch("/api/settings", {
credentials: "include",
cache: "no-store",
}),
]);
const requireLoginPayload = await safeJson(requireLoginResponse);
return {
requireLogin: requireLoginPayload?.requireLogin === true,
settingsStatus: settingsResponse.status,
};
});
}
export async function gotoDashboardRoute(
page: Page,
url: string,
options: GotoDashboardRouteOptions = {}
) {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const waitUntil = options.waitUntil ?? "commit";
let lastError: unknown;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
await page.goto(url, { waitUntil, timeout: timeoutMs });
await waitForAppRoute(page, timeoutMs);
await finishOnboardingIfNeeded(page, timeoutMs);
if (page.url().includes("/login")) {
await loginIfNeeded(page, timeoutMs);
}
if (page.url().includes("/dashboard/onboarding") || page.url().includes("/login")) {
await page.goto(url, { waitUntil, timeout: timeoutMs });
await waitForAppRoute(page, timeoutMs);
await finishOnboardingIfNeeded(page, timeoutMs);
await loginIfNeeded(page, timeoutMs);
}
const authState = await getDashboardAuthState(page);
if (authState.requireLogin && authState.settingsStatus === 401) {
await page.goto("/login", { waitUntil, timeout: timeoutMs });
await waitForAppRoute(page, timeoutMs);
await loginIfNeeded(page, timeoutMs);
await page.goto(url, { waitUntil, timeout: timeoutMs });
await waitForAppRoute(page, timeoutMs);
await finishOnboardingIfNeeded(page, timeoutMs);
}
await page.locator("body").waitFor({ state: "visible", timeout: timeoutMs });
return;
} catch (error) {
lastError = error;
await page.waitForTimeout(1000);
}
}
throw lastError instanceof Error ? lastError : new Error(`Failed to open protected route ${url}`);
}

View file

@ -1,4 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test";
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
const NAVIGATION_TIMEOUT_MS = 300_000;
@ -31,29 +32,6 @@ async function fulfillJson(route: Route, body: unknown, status = 200) {
});
}
async function gotoOrSkip(page: Page, url: string) {
let lastError: unknown;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
await page.goto(url, { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS });
} catch (error) {
lastError = error;
}
try {
await page.waitForURL(/\/(login|dashboard)(\/.*)?$/, { timeout: NAVIGATION_TIMEOUT_MS });
await page.locator("body").waitFor({ state: "visible", timeout: NAVIGATION_TIMEOUT_MS });
lastError = null;
break;
} catch (error) {
lastError = error;
}
await page.waitForTimeout(1000);
}
if (lastError) throw lastError;
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
}
async function setRangeValue(page: Page, testId: string, value: number) {
await page.getByTestId(testId).evaluate((element, nextValue) => {
const input = element as HTMLInputElement;
@ -162,7 +140,9 @@ test.describe("Memory settings", () => {
await fulfillJson(route, { success: true });
});
await gotoOrSkip(page, "/dashboard/settings?tab=ai");
await gotoDashboardRoute(page, "/dashboard/settings?tab=ai", {
timeoutMs: NAVIGATION_TIMEOUT_MS,
});
let settingsHydrationRetries = 0;
await expect(async () => {
@ -194,7 +174,9 @@ test.describe("Memory settings", () => {
);
await expect.poll(() => state.config.enabled).toBe(false);
await gotoOrSkip(page, "/dashboard/memory");
await gotoDashboardRoute(page, "/dashboard/memory", {
timeoutMs: NAVIGATION_TIMEOUT_MS,
});
let memoryHydrationRetries = 0;
await expect(async () => {

View file

@ -126,9 +126,8 @@ describe("Protocol clients E2E", () => {
}
const auditRes = await apiFetch("/api/mcp/audit?limit=50&tool=omniroute_get_health");
if (auditRes.status === 401) {
console.warn("Skipping audit log verification (Auth required)");
} else {
expect([200, 401]).toContain(auditRes.status);
if (auditRes.status === 200) {
expect(auditRes.ok).toBe(true);
const auditJson = await auditRes.json();
const entries = Array.isArray(auditJson?.entries) ? auditJson.entries : [];
@ -156,7 +155,8 @@ describe("Protocol clients E2E", () => {
"protocol-send"
);
if (send.response.status === 401) {
console.warn("Skipping A2A message send (Auth required)");
expect(API_KEY).toBe("");
expect(send.json?.error).toBeTruthy();
return;
}
expect(send.response.ok).toBe(true);
@ -200,9 +200,8 @@ describe("Protocol clients E2E", () => {
expect([200, 400, 401, 404]).toContain(cancelRes.status);
const tasksRes = await apiFetch("/api/a2a/tasks?limit=50");
if (tasksRes.status === 401) {
console.warn("Skipping a2a tasks listing (Auth required)");
} else {
expect([200, 401]).toContain(tasksRes.status);
if (tasksRes.status === 200) {
expect(tasksRes.ok).toBe(true);
const tasksJson = await tasksRes.json();
const tasks = Array.isArray(tasksJson?.tasks) ? tasksJson.tasks : [];

View file

@ -1,13 +1,11 @@
import { test, expect } from "@playwright/test";
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
test.describe("Protocol visibility", () => {
test("shows MCP/A2A links inside protocols tab in endpoint page", async ({ page }) => {
await page.goto("/dashboard/endpoint");
await gotoDashboardRoute(page, "/dashboard/endpoint");
await page.waitForLoadState("networkidle");
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
// MCP and A2A are now shown inside the "Protocols" tab — click it first
const protocolTab = page.getByRole("tab", { name: /protocols|protocolos/i });
await expect(protocolTab).toBeVisible();
@ -24,17 +22,13 @@ test.describe("Protocol visibility", () => {
});
test("loads MCP and A2A dashboards without runtime error page", async ({ page }) => {
await page.goto("/dashboard/mcp");
await gotoDashboardRoute(page, "/dashboard/mcp");
await page.waitForLoadState("networkidle");
let redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
await expect(page.locator("body")).toBeVisible();
await expect(page.locator("body")).not.toContainText(/application error|500/i);
await page.goto("/dashboard/a2a");
await gotoDashboardRoute(page, "/dashboard/a2a");
await page.waitForLoadState("networkidle");
redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
await expect(page.locator("body")).toBeVisible();
await expect(page.locator("body")).not.toContainText(/application error|500/i);
});

View file

@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
const DEFAULT_BAILIAN_URL = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
@ -57,12 +58,9 @@ test.describe("Bailian Coding Plan Provider", () => {
});
});
await page.goto("/dashboard/providers/bailian-coding-plan");
await gotoDashboardRoute(page, "/dashboard/providers/bailian-coding-plan");
await page.waitForLoadState("networkidle");
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
// Dismiss any pre-existing dialog/overlay that may appear on page load
const preExistingDialog = page.getByRole("dialog").first();
if (await preExistingDialog.isVisible({ timeout: 2000 }).catch(() => false)) {
@ -175,12 +173,9 @@ test.describe("Bailian Coding Plan Provider", () => {
});
});
await page.goto("/dashboard/providers/bailian-coding-plan");
await gotoDashboardRoute(page, "/dashboard/providers/bailian-coding-plan");
await page.waitForLoadState("networkidle");
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
// Dismiss any pre-existing dialog/overlay that may appear on page load
const preExistingDialog = page.getByRole("dialog").first();
if (await preExistingDialog.isVisible({ timeout: 2000 }).catch(() => false)) {

View file

@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
const NAVIGATION_TIMEOUT_MS = 300_000;
@ -230,29 +231,6 @@ async function readProviderMockState(page: Page) {
);
}
async function gotoOrSkip(page: Page, url: string) {
let lastError: unknown;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
await page.goto(url, { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS });
} catch (error) {
lastError = error;
}
try {
await page.waitForURL(/\/(login|dashboard)(\/.*)?$/, { timeout: NAVIGATION_TIMEOUT_MS });
await page.locator("body").waitFor({ state: "visible", timeout: NAVIGATION_TIMEOUT_MS });
lastError = null;
break;
} catch (error) {
lastError = error;
}
await page.waitForTimeout(1000);
}
if (lastError) throw lastError;
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
}
test.describe("Providers management", () => {
test.setTimeout(600_000);
@ -261,7 +239,9 @@ test.describe("Providers management", () => {
}) => {
await installProviderFetchMock(page);
await gotoOrSkip(page, "/dashboard/providers");
await gotoDashboardRoute(page, "/dashboard/providers", {
timeoutMs: NAVIGATION_TIMEOUT_MS,
});
const openAiCard = page.locator('a[href="/dashboard/providers/openai"]').first();
await expect(openAiCard).toBeVisible();

View file

@ -1,4 +1,5 @@
import { test, expect } from "@playwright/test";
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
type ProxyStub = {
id: string;
@ -171,12 +172,9 @@ test.describe("Proxy Registry smoke flow", () => {
});
});
await page.goto("/dashboard/settings?tab=advanced");
await gotoDashboardRoute(page, "/dashboard/settings?tab=advanced");
await page.waitForLoadState("networkidle");
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
await expect(page.getByRole("heading", { name: "Proxy Registry" })).toBeVisible();
await page.getByTestId("proxy-registry-open-create").click();

View file

@ -1,12 +1,26 @@
import { test, expect } from "@playwright/test";
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
test.describe("Settings Toggles", () => {
const waitForSettingsShell = async (page) => {
await expect(page.getByRole("tab", { name: /general/i }).first()).toBeVisible({
timeout: 15000,
});
};
const getDebugToggle = (page) =>
page
.getByText(/enable debug mode/i)
.locator('xpath=ancestor::div[contains(@class, "flex items-center justify-between")][1]')
.getByRole("switch");
const getSidebarVisibilityToggle = (page, itemLabel: string) =>
page
.getByRole("tabpanel", { name: /appearance/i })
.getByText(new RegExp(`^${itemLabel}$`, "i"))
.locator('xpath=ancestor::div[contains(@class, "flex items-center justify-between")][1]')
.getByRole("switch");
const waitForSettingsPatch = (page) =>
page.waitForResponse(
(response) =>
@ -16,8 +30,8 @@ test.describe("Settings Toggles", () => {
);
test("Debug mode toggle should work", async ({ page }) => {
await page.goto("/dashboard/settings");
await page.waitForLoadState("networkidle");
await gotoDashboardRoute(page, "/dashboard/settings");
await waitForSettingsShell(page);
await page.getByRole("tab", { name: /advanced/i }).click();
const debugToggle = getDebugToggle(page);
@ -35,16 +49,16 @@ test.describe("Settings Toggles", () => {
});
test("Sidebar visibility toggle should work", async ({ page }) => {
await page.goto("/dashboard/settings");
await page.waitForLoadState("networkidle");
await gotoDashboardRoute(page, "/dashboard/settings");
await waitForSettingsShell(page);
await page.getByRole("tab", { name: /appearance/i }).click();
const sidebarToggle = page.getByRole("switch").first();
const sidebarToggle = getSidebarVisibilityToggle(page, "Health");
await expect(sidebarToggle).toBeVisible({ timeout: 15000 });
const initialState = await sidebarToggle.getAttribute("aria-checked");
await sidebarToggle.click();
await Promise.all([waitForSettingsPatch(page), sidebarToggle.click()]);
await expect(sidebarToggle).toHaveAttribute(
"aria-checked",
initialState === "true" ? "false" : "true",
@ -53,8 +67,8 @@ test.describe("Settings Toggles", () => {
});
test("Clear Cache button calls DELETE /api/cache", async ({ page }) => {
await page.goto("/dashboard/settings");
await page.waitForLoadState("networkidle");
await gotoDashboardRoute(page, "/dashboard/settings");
await waitForSettingsShell(page);
await page.getByRole("tab", { name: /general/i }).click();
const clearBtn = page.getByRole("button", { name: /clear cache/i });
@ -68,8 +82,8 @@ test.describe("Settings Toggles", () => {
});
test("Purge Expired Logs button calls POST /api/settings/purge-logs", async ({ page }) => {
await page.goto("/dashboard/settings");
await page.waitForLoadState("networkidle");
await gotoDashboardRoute(page, "/dashboard/settings");
await waitForSettingsShell(page);
await page.getByRole("tab", { name: /general/i }).click();
const purgeBtn = page.getByRole("button", { name: /purge expired logs/i });
@ -85,8 +99,8 @@ test.describe("Settings Toggles", () => {
});
test("Debug mode should persist after page reload", async ({ page }) => {
await page.goto("/dashboard/settings");
await page.waitForLoadState("networkidle");
await gotoDashboardRoute(page, "/dashboard/settings");
await waitForSettingsShell(page);
await page.getByRole("tab", { name: /advanced/i }).click();
const debugToggle = getDebugToggle(page);
@ -99,7 +113,7 @@ test.describe("Settings Toggles", () => {
const nextState = initialState === "true" ? "false" : "true";
await expect(debugToggle).toHaveAttribute("aria-checked", nextState, { timeout: 15000 });
await page.reload();
await page.waitForLoadState("networkidle");
await waitForSettingsShell(page);
await page.getByRole("tab", { name: /advanced/i }).click();
const reloadedToggle = getDebugToggle(page);
await expect(reloadedToggle).toBeEnabled({ timeout: 15000 });

View file

@ -1,4 +1,5 @@
import { expect, test, type Page, type Route } from "@playwright/test";
import { gotoDashboardRoute } from "./helpers/dashboardAuth";
const NAVIGATION_TIMEOUT_MS = 300_000;
@ -27,29 +28,6 @@ async function fulfillJson(route: Route, body: unknown, status = 200) {
});
}
async function gotoOrSkip(page: Page, url: string) {
let lastError: unknown;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
await page.goto(url, { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS });
} catch (error) {
lastError = error;
}
try {
await page.waitForURL(/\/(login|dashboard)(\/.*)?$/, { timeout: NAVIGATION_TIMEOUT_MS });
await page.locator("body").waitFor({ state: "visible", timeout: NAVIGATION_TIMEOUT_MS });
lastError = null;
break;
} catch (error) {
lastError = error;
}
await page.waitForTimeout(1000);
}
if (lastError) throw lastError;
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
}
test.describe("Skills marketplace", () => {
test.setTimeout(600_000);
@ -159,7 +137,9 @@ test.describe("Skills marketplace", () => {
});
});
await gotoOrSkip(page, "/dashboard/skills");
await gotoDashboardRoute(page, "/dashboard/skills", {
timeoutMs: NAVIGATION_TIMEOUT_MS,
});
await expect(page.getByText("lookupWeather")).toBeVisible({ timeout: 15000 });
const weatherCard = page
@ -173,15 +153,16 @@ test.describe("Skills marketplace", () => {
await expect.poll(() => state.toggleCalls).toBe(1);
await page.getByRole("button", { name: /marketplace/i }).click();
await expect(page.getByPlaceholder("Search skills...")).toBeVisible({ timeout: 15000 });
const marketplaceSearch = page.getByPlaceholder(/Search SkillsMP\.\.\./i);
await expect(marketplaceSearch).toBeVisible({ timeout: 15000 });
await page.getByPlaceholder("Search skills...").fill("weather");
await marketplaceSearch.fill("weather");
await page.getByRole("button", { name: /search skillsmp/i }).click();
await expect(page.getByText("Weather Pro")).toBeVisible({ timeout: 15000 });
await page.getByRole("button", { name: /^install$/i }).click();
await expect.poll(() => state.marketplaceInstalls).toBe(1);
await page.getByPlaceholder("Search skills...").fill("");
await marketplaceSearch.fill("");
await page.getByRole("button", { name: /search skillsmp/i }).click();
const lastMarketplaceSkill = page.getByText("Skill Page 12").last();
await lastMarketplaceSkill.scrollIntoViewIfNeeded();

View file

@ -12,6 +12,7 @@ process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-compression-sec
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const readCacheDb = await import("../../src/lib/db/readCache.ts");
const combosDb = await import("../../src/lib/db/combos.ts");
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
const { estimateTokens, getTokenLimit } = await import("../../open-sse/services/contextManager.ts");
const { resetAllAvailability } = await import("../../src/domain/modelAvailability.ts");
@ -329,3 +330,100 @@ test("chatCore integration: compression handles tool messages", async () => {
globalThis.fetch = originalFetch;
}
});
test("chatCore integration: combo requests run proactive compression before Kiro translation", async () => {
const provider = "kiro";
const model = "claude-sonnet-4.5";
const connectionId = await providersDb.createProviderConnection({
provider,
apiKey: "test-key",
isActive: true,
});
await combosDb.createCombo({
name: "test-kiro-compression-combo",
strategy: "priority",
models: [
{
kind: "model",
model: `${provider}/${model}`,
connectionId,
},
],
});
const body = {
model: "combo/test-kiro-compression-combo",
stream: false,
messages: [
{ role: "system", content: "You are helpful." },
{ role: "user", content: "x".repeat(50000) },
{ role: "assistant", content: "Ack 1" },
{ role: "user", content: "x".repeat(50000) },
{ role: "assistant", content: "Ack 2" },
{ role: "user", content: "x".repeat(50000) },
{ role: "assistant", content: "Ack 3" },
{ role: "user", content: "Please summarize everything." },
],
};
let capturedTranslatedBody: Record<string, unknown> | null = null;
globalThis.fetch = async (_url: string | URL | Request, init?: RequestInit) => {
if (init?.body) {
capturedTranslatedBody = JSON.parse(init.body as string) as Record<string, unknown>;
}
return new Response(
JSON.stringify({
choices: [{ message: { role: "assistant", content: "ok" } }],
usage: { prompt_tokens: 11, completion_tokens: 5, total_tokens: 16 },
}),
{
status: 200,
headers: { "content-type": "application/json" },
}
);
};
try {
const result = await handleChatCore({
body,
modelInfo: { provider, model },
credentials: { apiKey: "test-key" },
log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
clientRawRequest: { endpoint: "/v1/chat/completions", headers: new Map() },
connectionId,
isCombo: true,
comboName: "test-kiro-compression-combo",
});
// Kiro response translation in this integration harness may fail depending on upstream
// payload shape, but the regression target is request-side behavior before translation.
assert.ok(result, "Handler should return a result object");
assert.ok(capturedTranslatedBody, "Translated body should be sent upstream");
// Ensure request was translated to Kiro shape (messages are not sent directly upstream).
const conversationState = capturedTranslatedBody?.conversationState as
| Record<string, unknown>
| undefined;
assert.ok(conversationState, "Kiro translated request should include conversationState");
const history = Array.isArray(conversationState?.history)
? (conversationState.history as unknown[])
: [];
assert.ok(
history.length < body.messages.length - 1,
"History should be reduced by proactive compression before translation"
);
const currentMessage = conversationState?.currentMessage as Record<string, unknown> | undefined;
const userInputMessage = currentMessage?.userInputMessage as
| Record<string, unknown>
| undefined;
const currentContent =
typeof userInputMessage?.content === "string" ? userInputMessage.content : "";
assert.match(currentContent, /Please summarize everything\./);
} finally {
globalThis.fetch = originalFetch;
}
});

View file

@ -213,6 +213,19 @@ test("MCP server enforces scopes from caller context before tool execution", ()
);
});
test("ACP agents route requires management authentication before CLI discovery", () => {
const content = readIfExists("src/app/api/acp/agents/route.ts");
assert.ok(content, "src/app/api/acp/agents/route.ts should exist");
assert.ok(
content.includes('from "@/shared/utils/apiAuth"'),
"ACP agents route should import shared API auth"
);
assert.ok(
content.includes("if (!(await isAuthenticated(request)))"),
"ACP agents route should reject unauthenticated requests before spawning discovery"
);
});
test("T06 route payload validation uses validateBody in critical endpoints", () => {
const targets = [
"src/app/api/usage/budget/route.ts",

View file

@ -51,6 +51,9 @@ async function registerSkill({
handler,
enabled = true,
description = "Test skill",
mode,
tags,
installCount,
}) {
return skillRegistry.register({
apiKeyId,
@ -71,6 +74,9 @@ async function registerSkill({
},
handler,
enabled,
mode,
tags,
installCount,
});
}
@ -383,6 +389,65 @@ test("injectSkills() merges with existing tools without duplicating", async () =
assert.ok(names.includes("preExistingTool"));
});
test("responses input context participates in AUTO skill injection", async () => {
await seedConnection("openai", { apiKey: "sk-openai-skills-responses-input" });
const apiKey = await seedApiKey();
await enableSkills();
await registerSkill({
apiKeyId: apiKey.id,
name: "issueSearch",
handler: "issue-search-handler",
description: "search github issues and pull requests",
mode: "auto",
tags: ["github", "issues", "search"],
installCount: 40,
});
await registerSkill({
apiKeyId: apiKey.id,
name: "calendarPlanner",
handler: "calendar-handler",
description: "manage meetings and calendars",
mode: "auto",
tags: ["calendar", "meeting"],
installCount: 100,
});
const fetchBodies = [];
globalThis.fetch = async (_url, init = {}) => {
fetchBodies.push(init.body ? JSON.parse(String(init.body)) : null);
return buildOpenAIResponse("AUTO skill selection via responses input");
};
const response = await handleChat(
buildRequest({
url: "http://localhost/v1/responses",
authKey: apiKey.key,
body: {
model: "openai/gpt-4o-mini",
stream: false,
input: [
{
role: "user",
content: [{ type: "input_text", text: "Please search github issues for flaky tests" }],
},
],
},
})
);
assert.equal(response.status, 200);
assert.ok(Array.isArray(fetchBodies[0].tools));
const names = fetchBodies[0].tools
.map((tool) => tool?.function?.name)
.filter((name) => typeof name === "string");
assert.ok(names.includes("issueSearch@1.0.0"));
assert.ok(!names.includes("calendarPlanner@1.0.0"));
});
test("handleToolCallExecution() processes a tool call correctly", async () => {
const { handleToolCallExecution } = await import("../../src/lib/skills/interception.ts");

View file

@ -0,0 +1,102 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { SignJWT } from "jose";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-acp-agents-route-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "acp-agents-route-api-key-secret";
const core = await import("../../src/lib/db/core.ts");
const localDb = await import("../../src/lib/localDb.ts");
const routeModule = await import("../../src/app/api/acp/agents/route.ts");
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET;
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
delete process.env.INITIAL_PASSWORD;
delete process.env.JWT_SECRET;
}
async function createSessionToken() {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
return new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")
.sign(secret);
}
function makeRequest(method: string, body?: unknown, token?: string) {
return new Request("http://localhost/api/acp/agents", {
method,
headers: {
...(body ? { "content-type": "application/json" } : {}),
...(token ? { cookie: `auth_token=${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_INITIAL_PASSWORD === undefined) {
delete process.env.INITIAL_PASSWORD;
} else {
process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
}
if (ORIGINAL_JWT_SECRET === undefined) {
delete process.env.JWT_SECRET;
} else {
process.env.JWT_SECRET = ORIGINAL_JWT_SECRET;
}
});
test("GET /api/acp/agents requires authentication when login is enabled", async () => {
process.env.INITIAL_PASSWORD = "route-auth-required";
const response = await routeModule.GET(makeRequest("GET"));
const body = await response.json();
assert.equal(response.status, 401);
assert.equal(body.error, "Unauthorized");
});
test("POST /api/acp/agents rejects unsafe version commands for authenticated sessions", async () => {
process.env.JWT_SECRET = "acp-agents-jwt-secret";
await localDb.updateSettings({ requireLogin: true, password: "hashed-password" });
const token = await createSessionToken();
const response = await routeModule.POST(
makeRequest(
"POST",
{
id: "custom-agent",
name: "Custom Agent",
binary: "/usr/local/bin/custom-agent",
versionCommand: "/usr/local/bin/custom-agent --version; touch /tmp/pwned",
providerAlias: "custom-agent",
spawnArgs: [],
protocol: "stdio",
},
token
)
);
const body = await response.json();
assert.equal(response.status, 400);
assert.match(body.error, /Invalid versionCommand/i);
});

View file

@ -0,0 +1,42 @@
import test from "node:test";
import assert from "node:assert/strict";
const { resolveVersionProbe, shouldUseShellForVersionProbe } =
await import("../../src/lib/acp/registry.ts");
test("resolveVersionProbe parses quoted binary paths without shell semantics", () => {
const probe = resolveVersionProbe(
"/tmp/My Custom Agent",
'"/tmp/My Custom Agent" --version',
true
);
assert.deepEqual(probe, {
command: "/tmp/My Custom Agent",
args: ["--version"],
});
});
test("resolveVersionProbe rejects custom version commands that switch binaries", () => {
const probe = resolveVersionProbe("/tmp/custom-agent", 'bash -lc "id"', true);
assert.equal(probe, null);
});
test("resolveVersionProbe rejects shell metacharacters in version commands", () => {
const probe = resolveVersionProbe(
"/tmp/custom-agent",
"/tmp/custom-agent --version; touch /tmp/pwned",
true
);
assert.equal(probe, null);
});
test("shouldUseShellForVersionProbe preserves Windows npm wrapper detection", () => {
assert.equal(shouldUseShellForVersionProbe("codex", "win32"), true);
assert.equal(
shouldUseShellForVersionProbe("C:\\Users\\dev\\AppData\\Roaming\\npm\\qwen.cmd", "win32"),
true
);
assert.equal(shouldUseShellForVersionProbe("C:\\Tools\\claude.exe", "win32"), false);
assert.equal(shouldUseShellForVersionProbe("codex", "linux"), false);
});

View file

@ -0,0 +1,225 @@
/**
* Unit tests for the masked API key fix (#523).
*
* GET /api/keys returns masked API keys (e.g. "sk-31c4e****8600").
* CLI tool card dropdowns used `key.key` (the masked value) as the select
* option value, so the masked key got written to config files, causing 401s.
*
* The fix: frontends send `keyId` (DB row id) instead, and backends resolve
* the full key from DB via `resolveApiKey()`.
*
* This test inlines the resolver logic (ESM modules are read-only) with a
* mock DB lookup function.
*/
import test, { describe, it } from "node:test";
import assert from "node:assert/strict";
// ─── Mock DB + inlined resolveApiKey ────────────────────────────────────
/** In-memory key store keyed by id */
const mockKeyStore = new Map();
/**
* Mock getApiKeyById mirrors the real function's contract:
* returns the key record (with .key field) or null.
*/
async function getApiKeyById(id) {
return mockKeyStore.get(id) || null;
}
/**
* Inlined resolveApiKey from src/shared/services/apiKeyResolver.ts
* (can't import ESM modules in test runner without tsx overhead).
*/
async function resolveApiKey(apiKeyId, apiKey) {
if (apiKeyId) {
try {
const keyRecord = await getApiKeyById(apiKeyId);
if (keyRecord?.key) return keyRecord.key;
} catch {
/* fall through */
}
}
return apiKey || "sk_omniroute";
}
// ─── Server-side masking function (matches /api/keys endpoint) ─────────
/**
* Mask an API key for display: first 8 chars + "****" + last 4 chars.
* This is the server-side masking that created the original bug.
*/
function maskApiKey(key) {
if (!key || key.length <= 12) return key;
return key.slice(0, 8) + "****" + key.slice(-4);
}
// ─── Tests ──────────────────────────────────────────────────────────────
describe("resolveApiKey", () => {
it("resolves full key from apiKeyId when DB lookup succeeds", async () => {
mockKeyStore.clear();
mockKeyStore.set("key-001", { id: "key-001", key: "sk-31c4eabcd1234efgh8600" });
const result = await resolveApiKey("key-001", null);
assert.equal(result, "sk-31c4eabcd1234efgh8600");
});
it("falls back to apiKey when apiKeyId lookup returns null", async () => {
mockKeyStore.clear();
const result = await resolveApiKey("nonexistent-id", "sk-fallback-key");
assert.equal(result, "sk-fallback-key");
});
it("falls back to apiKey when apiKeyId lookup throws", async () => {
mockKeyStore.clear();
// Override getApiKeyById to throw
const originalGet = getApiKeyById;
const throwingGet = async () => {
throw new Error("DB connection failed");
};
// Temporarily replace
const savedRef = mockKeyStore.get.bind(mockKeyStore);
// We'll call resolveApiKey with a custom approach — since the inlined
// function calls our local getApiKeyById, let's just test by setting
// up the store to throw via a different mechanism
// Actually, let's just test the inline function directly with a mock:
async function resolveApiKeyWithThrowingDb(apiKeyId, apiKey) {
if (apiKeyId) {
try {
throw new Error("DB connection failed");
} catch {
/* fall through */
}
}
return apiKey || "sk_omniroute";
}
const result = await resolveApiKeyWithThrowingDb("key-001", "sk-fallback-key");
assert.equal(result, "sk-fallback-key");
});
it("falls back to sk_omniroute when both are null", async () => {
mockKeyStore.clear();
const result = await resolveApiKey(null, null);
assert.equal(result, "sk_omniroute");
});
it("falls back to sk_omniroute when both are undefined", async () => {
mockKeyStore.clear();
const result = await resolveApiKey(undefined, undefined);
assert.equal(result, "sk_omniroute");
});
it("prefers resolved key from apiKeyId over masked apiKey", async () => {
mockKeyStore.clear();
mockKeyStore.set("key-002", { id: "key-002", key: "sk-fullkey1234567890abcdef" });
// The masked apiKey is what /api/keys returns — should NOT be used
const maskedKey = maskApiKey("sk-fullkey1234567890abcdef");
const result = await resolveApiKey("key-002", maskedKey);
assert.equal(result, "sk-fullkey1234567890abcdef");
assert.notEqual(result, maskedKey);
});
});
describe("maskApiKey", () => {
it("masks a long key correctly", () => {
const result = maskApiKey("sk-31c4eabcd1234efgh8600");
assert.equal(result, "sk-31c4e****8600");
});
it("does not mask short keys", () => {
const result = maskApiKey("sk-short12");
assert.equal(result, "sk-short12");
});
it("handles null/undefined gracefully", () => {
assert.equal(maskApiKey(null), null);
assert.equal(maskApiKey(undefined), undefined);
});
it("produces a key that is NOT usable for auth", () => {
const fullKey = "sk-31c4eabcd1234efgh8600";
const masked = maskApiKey(fullKey);
assert.notEqual(masked, fullKey);
assert.ok(masked.includes("****"));
assert.ok(masked.startsWith(fullKey.slice(0, 8)));
assert.ok(masked.endsWith(fullKey.slice(-4)));
});
});
describe("Bug reproduction: masked key written to config", () => {
it("reproduces the original bug — masked key fails auth", () => {
const fullKey = "sk-31c4eabcd1234efgh8600";
const masked = maskApiKey(fullKey);
// Simulating what happened before the fix: dropdown used masked key as value
// and sent it directly to the backend, which wrote it to config
const writtenToConfig = masked; // BUG: masked key saved to config
// Auth with masked key would fail
assert.notEqual(writtenToConfig, fullKey);
assert.ok(writtenToConfig.includes("****"));
// This proves the bug: the config file contains "sk-31c4e****8600"
// which is NOT a valid API key and would cause 401 errors
});
it("verifies the fix — keyId resolves to full key", async () => {
mockKeyStore.clear();
const fullKey = "sk-31c4eabcd1234efgh8600";
mockKeyStore.set("key-003", { id: "key-003", key: fullKey });
// After the fix: frontend sends keyId, backend resolves full key
const resolved = await resolveApiKey("key-003", null);
assert.equal(resolved, fullKey);
assert.ok(!resolved.includes("****"));
});
it("simulates full flow: masked dropdown -> keyId -> resolved full key", async () => {
mockKeyStore.clear();
const fullKey = "sk-31c4eabcd1234efgh8600";
const keyId = "key-004";
mockKeyStore.set(keyId, { id: keyId, key: fullKey });
// Step 1: /api/keys returns masked list
const apiKeysResponse = [{ id: keyId, key: maskApiKey(fullKey) }];
// Step 2: Frontend dropdown now uses key.id as value (not key.key)
const selectedValue = apiKeysResponse[0].id; // "key-004" (was key.key before fix)
assert.equal(selectedValue, keyId);
// Step 3: Frontend sends keyId to backend
const requestBody = { keyId: selectedValue };
// Step 4: Backend resolves full key from DB
const resolvedKey = await resolveApiKey(requestBody.keyId, null);
assert.equal(resolvedKey, fullKey);
assert.ok(!resolvedKey.includes("****"));
});
it("handles prefix/suffix matching for restoring saved key from file", () => {
const fullKey = "sk-31c4eabcd1234efgh8600";
const masked = maskApiKey(fullKey);
// Simulates what ClaudeToolCard does when reading a key from file:
// The file contains the full key, and we match against the masked list
const fileKeyPrefix = fullKey.slice(0, 8); // "sk-31c4e"
const fileKeySuffix = fullKey.slice(-4); // "8600"
const apiKeysResponse = [{ id: "key-005", key: masked }];
// Match by prefix/suffix
const matchedKey = apiKeysResponse.find(
(k) => k.key && k.key.startsWith(fileKeyPrefix) && k.key.endsWith(fileKeySuffix)
);
assert.ok(matchedKey);
assert.equal(matchedKey.id, "key-005");
});
});

View file

@ -15,6 +15,7 @@ describe("Cache Control Policy", () => {
assert.equal(isClaudeCodeClient("claude-code/0.1.0"), true);
assert.equal(isClaudeCodeClient("claude_code/0.1.0"), true);
assert.equal(isClaudeCodeClient("Anthropic CLI/1.0"), true);
assert.equal(isClaudeCodeClient("claude-cli/2.1.113 (external, sdk-cli)"), true);
});
test("rejects non-Claude clients", () => {

View file

@ -69,6 +69,14 @@ function insertCallLog(row) {
);
}
function buildArtifactRelPath(date: Date, label: string) {
const dateFolder = date.toISOString().slice(0, 10);
const timePart = `${String(date.getUTCHours()).padStart(2, "0")}${String(
date.getUTCMinutes()
).padStart(2, "0")}${String(date.getUTCSeconds()).padStart(2, "0")}`;
return `${dateFolder}/${timePart}_${label}_200.json`;
}
test.beforeEach(async () => {
await resetTestDataDir();
});
@ -105,10 +113,17 @@ test("call log file rotation honors both retention days and file count", () => {
fs.rmSync(CALL_LOGS_DIR, { recursive: true, force: true });
fs.mkdirSync(CALL_LOGS_DIR, { recursive: true });
const oldRelPath = "2026-03-01/080000_old_200.json";
const keepARelPath = "2026-04-12/090000_keep-a_200.json";
const keepBRelPath = "2026-04-13/091000_keep-b_200.json";
const keepCRelPath = "2026-04-14/092000_keep-c_200.json";
const now = Date.now();
const oneDay = 24 * 60 * 60 * 1000;
const oldDate = new Date(now - 10 * oneDay);
const keepADate = new Date(now - 3 * oneDay);
const keepBDate = new Date(now - 2 * oneDay);
const keepCDate = new Date(now - oneDay);
const oldRelPath = buildArtifactRelPath(oldDate, "old");
const keepARelPath = buildArtifactRelPath(keepADate, "keep-a");
const keepBRelPath = buildArtifactRelPath(keepBDate, "keep-b");
const keepCRelPath = buildArtifactRelPath(keepCDate, "keep-c");
for (const relativePath of [oldRelPath, keepARelPath, keepBRelPath, keepCRelPath]) {
const absolutePath = path.join(CALL_LOGS_DIR, relativePath);
@ -118,27 +133,24 @@ test("call log file rotation honors both retention days and file count", () => {
insertCallLog({
id: "old-log",
timestamp: "2026-03-01T08:00:00.000Z",
timestamp: oldDate.toISOString(),
artifact_relpath: oldRelPath,
});
insertCallLog({
id: "keep-a",
timestamp: "2026-04-12T09:00:00.000Z",
timestamp: keepADate.toISOString(),
artifact_relpath: keepARelPath,
});
insertCallLog({
id: "keep-b",
timestamp: "2026-04-13T09:10:00.000Z",
timestamp: keepBDate.toISOString(),
artifact_relpath: keepBRelPath,
});
insertCallLog({
id: "keep-c",
timestamp: "2026-04-14T09:20:00.000Z",
timestamp: keepCDate.toISOString(),
artifact_relpath: keepCRelPath,
});
const now = Date.now();
const oneDay = 24 * 60 * 60 * 1000;
fs.utimesSync(
path.join(CALL_LOGS_DIR, oldRelPath),
new Date(now - 10 * oneDay),

View file

@ -120,12 +120,12 @@ test("buildClaudeCodeCompatibleRequest keeps prior role history while dropping t
assert.equal(payload.messages[0].content.at(-1).cache_control, undefined);
assert.equal(payload.messages[1].content.at(-1).cache_control, undefined);
assert.equal(payload.messages[2].content.at(-1).cache_control, undefined);
assert.equal(payload.system.length, 4);
assert.equal(payload.system.at(-1).text, "sys");
assert.equal(payload.system.length, 2);
assert.match(payload.system[0].text, /Claude Agent SDK/);
assert.equal(payload.system[0].cache_control, undefined);
assert.equal(payload.system[1].cache_control, undefined);
assert.equal(payload.system[2].cache_control, undefined);
assert.equal(payload.system[3].cache_control, undefined);
assert.equal(payload.system[1].text, "sys");
assert.equal(payload.system[1].cache_control, undefined);
assert.equal(payload.tools.length, 1);
assert.deepEqual(payload.tools[0], {
name: "lookup_weather",
@ -139,7 +139,7 @@ test("buildClaudeCodeCompatibleRequest keeps prior role history while dropping t
},
});
assert.deepEqual(payload.tool_choice, { type: "any" });
assert.equal(payload.context_management.edits[0].type, "clear_thinking_20251015");
assert.equal(payload.context_management, undefined);
assert.equal(JSON.parse(payload.metadata.user_id).session_id, "session-1");
});
@ -212,8 +212,7 @@ test("buildClaudeCodeCompatibleRequest preserves Claude cache markers when reque
preserveCacheControl: true,
});
assert.equal(payload.system[0].cache_control, undefined);
assert.deepEqual(payload.system.at(-1).cache_control, { type: "ephemeral", ttl: "5m" });
assert.deepEqual(payload.system[0].cache_control, { type: "ephemeral", ttl: "5m" });
assert.deepEqual(payload.messages[0].content[0].cache_control, { type: "ephemeral" });
assert.deepEqual(payload.messages[1].content[0].cache_control, {
type: "ephemeral",
@ -294,11 +293,8 @@ test("buildClaudeCodeCompatibleRequest keeps built-in system blocks untagged whe
preserveCacheControl: true,
});
assert.equal(payload.system[0].cache_control, undefined);
assert.equal(payload.system[1].cache_control, undefined);
assert.equal(payload.system[2].cache_control, undefined);
assert.deepEqual(payload.system[3].cache_control, { type: "ephemeral" });
assert.deepEqual(payload.system[4].cache_control, { type: "ephemeral", ttl: "1h" });
assert.deepEqual(payload.system[0].cache_control, { type: "ephemeral" });
assert.deepEqual(payload.system[1].cache_control, { type: "ephemeral", ttl: "1h" });
});
test("buildClaudeCodeCompatibleRequest does not add cache markers in non-preserve mode", () => {
@ -325,10 +321,9 @@ test("buildClaudeCodeCompatibleRequest does not add cache markers in non-preserv
preserveCacheControl: false,
});
assert.equal(payload.system.length, 2);
assert.equal(payload.system[0].cache_control, undefined);
assert.equal(payload.system[1].cache_control, undefined);
assert.equal(payload.system[2].cache_control, undefined);
assert.equal(payload.system[3].cache_control, undefined);
assert.equal(payload.messages[0].content[0].cache_control, undefined);
assert.equal(payload.messages[1].content[0].cache_control, undefined);
assert.equal(payload.messages[2].content[0].cache_control, undefined);
@ -436,10 +431,10 @@ test("DefaultExecutor uses CC-compatible path and headers", () => {
);
const headers = executor.buildHeaders(credentials, true);
assert.equal(headers["x-api-key"], "sk-test");
assert.equal(headers.Authorization, "Bearer sk-test");
assert.equal(headers["x-api-key"], undefined);
assert.equal(headers["X-Claude-Code-Session-Id"], "session-3");
assert.equal(headers.Accept, "text/event-stream");
assert.equal(headers.Authorization, undefined);
assert.equal(headers.Accept, "application/json");
});
test("validateProviderApiKey uses CC skeleton request after /models fallback", async () => {
@ -478,8 +473,9 @@ test("validateProviderApiKey uses CC skeleton request after /models fallback", a
assert.equal(calls[1].body.model, "claude-sonnet-4-6");
assert.equal(calls[1].body.messages[0].role, "user");
assert.equal(calls[1].body.stream, true);
assert.equal(calls[1].headers["x-api-key"], "sk-test");
assert.equal(calls[1].headers.Accept, "text/event-stream");
assert.equal(calls[1].headers.Authorization, "Bearer sk-test");
assert.equal(calls[1].headers["x-api-key"], undefined);
assert.equal(calls[1].headers.Accept, "application/json");
});
test("handleChatCore forces SSE upstream for CC compatible providers while returning JSON to non-stream clients", async () => {
@ -557,7 +553,7 @@ test("handleChatCore forces SSE upstream for CC compatible providers while retur
assert.equal(result.success, true);
assert.equal(calls.length, 1);
assert.equal(calls[0].headers.Accept, "text/event-stream");
assert.equal(calls[0].headers.Accept, "application/json");
assert.equal(calls[0].body.stream, true);
assert.equal(JSON.stringify(calls[0].body).includes('"cache_control"'), false);
@ -671,8 +667,7 @@ test("handleChatCore preserves client cache markers for Claude Code requests to
assert.equal(result.success, true);
assert.equal(calls.length, 1);
assert.equal(calls[0].body.system[0].cache_control, undefined);
assert.deepEqual(calls[0].body.system.at(-1).cache_control, {
assert.deepEqual(calls[0].body.system[0].cache_control, {
type: "ephemeral",
ttl: "5m",
});

View file

@ -535,6 +535,45 @@ test("chatCore skips memory injection when memory is disabled or apiKeyInfo is m
assert.equal(noApiKey.call.body.messages[0].content, "Hello");
});
test("chatCore does not share or persist memories when apiKeyInfo is missing", async () => {
await settingsDb.updateSettings({
memoryEnabled: true,
memoryMaxTokens: 1024,
memoryRetentionDays: 30,
memoryStrategy: "recent",
});
invalidateMemorySettingsCache();
await createMemory({
apiKeyId: "local",
sessionId: "shared-local-session",
type: "factual",
key: "pref:theme",
content: "Shared local memory should stay isolated.",
metadata: {},
expiresAt: null,
});
const { call } = await invokeChatCore({
body: {
model: "gpt-4o-mini",
messages: [{ role: "user", content: "I prefer blue themes." }],
},
});
await waitForAsyncMemoryFlush();
const localMemoriesResult = await listMemories({ apiKeyId: "local" });
const localMemories = Array.isArray(localMemoriesResult)
? localMemoriesResult
: (localMemoriesResult.data ?? []);
assert.equal(call.body.messages[0].role, "user");
assert.equal(call.body.messages[0].content, "I prefer blue themes.");
assert.equal(localMemories.length, 1);
assert.equal(localMemories[0].content, "Shared local memory should stay isolated.");
});
test("chatCore skips memory injection when shouldInjectMemory returns false for empty message lists", async () => {
await settingsDb.updateSettings({
memoryEnabled: true,
@ -642,3 +681,64 @@ test("chatCore extracts memories from Claude content arrays and Responses output
assert.equal(responsesMemories.length, 1);
assert.equal(responsesMemories[0].content, "TypeScript for backend services");
});
test("chatCore request memory extraction for responses input ignores assistant items", async () => {
await settingsDb.updateSettings({
memoryEnabled: true,
memoryMaxTokens: 1024,
memoryRetentionDays: 30,
memoryStrategy: "recent",
});
invalidateMemorySettingsCache();
const responsesKeyId = `key-responses-request-memory-${Date.now()}`;
const responsesResult = await invokeChatCore({
endpoint: "/v1/responses",
apiKeyInfo: { id: responsesKeyId, name: "Responses Request Memory Key" },
body: {
model: "gpt-4o-mini",
input: [
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "I prefer tea." }],
},
{
type: "message",
role: "assistant",
content: [{ type: "input_text", text: "I prefer coffee." }],
},
],
},
responseFactory: () =>
new Response(
JSON.stringify({
id: "resp_request_memory",
object: "response",
status: "completed",
model: "gpt-4o-mini",
output_text: "ok",
usage: {
input_tokens: 4,
output_tokens: 1,
total_tokens: 5,
},
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
),
});
assert.equal(responsesResult.result.success, true);
await waitForAsyncMemoryFlush();
const memoriesResult = await listMemories({ apiKeyId: responsesKeyId });
const memories = Array.isArray(memoriesResult) ? memoriesResult : (memoriesResult.data ?? []);
assert.equal(memories.length, 1);
assert.match(memories[0].content, /tea/i);
assert.doesNotMatch(memories[0].content, /coffee/i);
});

View file

@ -287,8 +287,8 @@ async function invokeChatCore({
connectionId = null,
onCredentialsRefreshed = null,
onRequestSuccess = null,
} = {}) {
const calls = [];
}: any = {}) {
const calls: any[] = [];
globalThis.fetch = async (url, init = {}) => {
const headers = toPlainHeaders(init.headers);
@ -334,7 +334,7 @@ async function invokeChatCore({
comboStrategy,
onCredentialsRefreshed,
onRequestSuccess,
});
} as any);
await waitForAsyncSideEffects();
return { result, calls, call: calls.at(-1) };
@ -507,9 +507,11 @@ test("chatCore builds Claude Code-compatible upstream requests for CC providers"
});
assert.equal(result.success, true);
assert.equal(call.headers.Accept ?? call.headers.accept, "text/event-stream");
assert.equal(call.headers.Accept ?? call.headers.accept, "application/json");
assert.equal(call.body.stream, true);
assert.equal(call.body.context_management.edits[0].type, "clear_thinking_20251015");
assert.equal(call.body.context_management, undefined);
assert.equal(call.body.system.length, 1);
assert.match(call.body.system[0].text, /Claude Agent SDK/);
assert.equal(typeof call.body.metadata.user_id, "string");
assert.equal(call.body.messages[0].role, "user");
assert.equal(call.body.messages[0].content[0].text, "Ping");
@ -581,7 +583,12 @@ test("chatCore auto cache policy becomes false for nondeterministic combos", asy
responseFormat: "claude",
});
assert.equal(call.body.system[0].text, "system");
assert.equal(
call.body.system.some(
(block: { type?: string; text?: string }) => block?.type === "text" && block.text === "system"
),
true
);
// Cache markers are kept natively due to the latest Claude strict proxy passthrough implementation
assert.equal(
call.body.system.some((block) => !!block.cache_control),
@ -635,7 +642,12 @@ test("chatCore disables raw Claude passthrough when cache preservation is off an
responseFormat: "claude",
});
assert.equal(call.body.system[0].text, "system");
assert.equal(
call.body.system.some(
(block: { type?: string; text?: string }) => block?.type === "text" && block.text === "system"
),
true
);
// Cache preservation is on for native Claude, so cache markers are intact
assert.deepEqual(call.body.messages[0].content[0].cache_control, { type: "ephemeral" });
// Tools disable flag is applied

View file

@ -41,12 +41,13 @@ test("base URL helpers strip messages suffixes and join canonical paths", () =>
);
});
test("buildClaudeCodeCompatibleHeaders emits stream-aware auth headers and session id", () => {
test("buildClaudeCodeCompatibleHeaders emits bearer auth headers and session id", () => {
const streamHeaders = buildClaudeCodeCompatibleHeaders("sk-demo", true, "session-123");
const jsonHeaders = buildClaudeCodeCompatibleHeaders("sk-demo", false);
assert.equal(streamHeaders.Accept, "text/event-stream");
assert.equal(streamHeaders["x-api-key"], "sk-demo");
assert.equal(streamHeaders.Accept, "application/json");
assert.equal(streamHeaders.Authorization, "Bearer sk-demo");
assert.equal(streamHeaders["x-api-key"], undefined);
assert.equal(streamHeaders["X-Claude-Code-Session-Id"], "session-123");
assert.equal(
streamHeaders["X-Stainless-Timeout"],
@ -56,13 +57,19 @@ test("buildClaudeCodeCompatibleHeaders emits stream-aware auth headers and sessi
assert.equal(jsonHeaders["X-Claude-Code-Session-Id"], undefined);
});
test("Claude Code compatible beta set stays conservative for third-party proxies", () => {
assert.ok(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("oauth-2025-04-20"));
assert.ok(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("advanced-tool-use-2025-11-20"));
assert.ok(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("fast-mode-2026-02-01"));
assert.ok(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("token-efficient-tools-2026-03-28"));
assert.equal(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("fast-mode-2025-04-01"), false);
assert.equal(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("redact-thinking-2025-06-20"), false);
test("Claude Code compatible beta set matches the stable API-key Claude CLI profile", () => {
assert.ok(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("claude-code-20250219"));
assert.ok(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("interleaved-thinking-2025-05-14"));
assert.ok(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("effort-2025-11-24"));
assert.equal(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("oauth-2025-04-20"), false);
assert.equal(
CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("context-management-2025-06-27"),
false
);
assert.equal(
CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("prompt-caching-scope-2026-01-05"),
false
);
});
test("resolveClaudeCodeCompatibleSessionId prefers explicit session headers and generates a fallback id", () => {
@ -91,8 +98,8 @@ test("buildClaudeCodeCompatibleValidationPayload produces the expected smoke-tes
content: [{ type: "text", text: "ok" }],
});
assert.equal(payload.tools.length, 0);
assert.equal(payload.system[0].cache_control, undefined);
assert.equal(payload.system.length, 1);
assert.match(payload.system[0].text, /Claude Agent SDK/);
assert.ok(JSON.parse(payload.metadata.user_id).session_id);
assert.ok(payload.system.some((block) => String(block.text).includes(process.cwd())));
assert.ok(CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS > payload.max_tokens);
});

Some files were not shown because too many files have changed in this diff Show more