diff --git a/.agents/workflows/generate-release.md b/.agents/workflows/generate-release.md index b60481ae..3d6633bf 100644 --- a/.agents/workflows/generate-release.md +++ b/.agents/workflows/generate-release.md @@ -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 diff --git a/.agents/workflows/resolve-issues.md b/.agents/workflows/resolve-issues.md index 9cd5c94f..f0130fb5 100644 --- a/.agents/workflows/resolve-issues.md +++ b/.agents/workflows/resolve-issues.md @@ -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 1–5. @@ -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: (#)` +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: (#)`. -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 --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 7–10 (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: (#)`. +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 --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 7–10 (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. diff --git a/.agents/workflows/review-prs.md b/.agents/workflows/review-prs.md index 43f6ab03..6cb91e08 100644 --- a/.agents/workflows/review-prs.md +++ b/.agents/workflows/review-prs.md @@ -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 --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 --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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8b3ff80..078717e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index e051871d..9e7189ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 --- diff --git a/open-sse/config/cliFingerprints.ts b/open-sse/config/cliFingerprints.ts index c3dcbd25..a4d47425 100644 --- a/open-sse/config/cliFingerprints.ts +++ b/open-sse/config/cliFingerprints.ts @@ -57,31 +57,6 @@ export const CLI_FINGERPRINTS: Record = { 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 = { "Accept", "accept-language", "accept-encoding", + "sec-fetch-mode", "Connection", ], bodyFieldOrder: [ @@ -120,6 +96,42 @@ export const CLI_FINGERPRINTS: Record = { "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", diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 71e12d2f..2857a087 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -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; requestDefaults?: ProviderRequestDefaults; timeoutMs?: number; + format?: string; }; export type ProviderCredentials = { @@ -73,6 +82,8 @@ export type ExecuteInput = { upstreamExtraHeaders?: Record | null; /** Original client request headers (read-only). Executors may forward select headers upstream. */ clientHeaders?: Record | null; + /** Callback to persist tokens that are proactively refreshed during execution. */ + onCredentialsRefreshed?: (newCredentials: ProviderCredentials) => Promise | 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; + 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>; + const firstSystemCacheControl = + sysBlocks[0] && + typeof sysBlocks[0] === "object" && + !Array.isArray(sysBlocks[0]) && + sysBlocks[0].cache_control + ? sysBlocks[0].cache_control + : undefined; + const billingBlock: Record = { 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 = { + "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); } diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index da670e9d..899c4eb0 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -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): 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; 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; } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index dd38270a..49aefdbc 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -170,6 +170,78 @@ function extractMemoryTextFromResponse( return ""; } +function extractMemoryTextFromRequestBody( + body: Record | 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; + 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) => { + 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) => { + 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) => { + 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 | 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 | null | undefined, + userAgent?: string | null +) { + const normalized: Record = {}; + + 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 | null); + const memorySettings = memoryOwnerId ? await getMemorySettings().catch(() => DEFAULT_MEMORY_SETTINGS) : null; if ( - apiKeyInfo?.id && + memoryOwnerId && memorySettings && shouldInjectMemory(body as Parameters[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; + type ClaudeMessage = { + role?: unknown; + content?: unknown; + }; + + const normalizeClaudeUpstreamMessages = (payload: Record) => { + 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 + | 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)?.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) => c.type === "text") + .map((c: Record) => 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) => - 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[]).flatMap( - (block: Record) => { - 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 - | undefined; - if ( - (block.type === "file" || block.type === "document") && - !fileData?.url && - !fileData?.data - ) { - const fileContent = - (block.file as Record)?.content ?? - (block.file as Record)?.text ?? - block.content ?? - block.text; - const fileName = - (block.file as Record)?.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) => c.type === "text") - .map((c: Record) => 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); + 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); + if (requestMemoryText) { + extractFacts(requestMemoryText, memoryOwnerId, pipelineSessionId); + } + + const streamedMemoryText = extractMemoryTextFromResponse( + (streamResponseBody ?? null) as Record | null + ); + if (streamedMemoryText) { + extractFacts(streamedMemoryText, memoryOwnerId, pipelineSessionId); + } + } + // Semantic cache: store assembled streaming response for future cache hits if ( semanticCacheEnabled && diff --git a/open-sse/services/antigravityHeaders.ts b/open-sse/services/antigravityHeaders.ts index 39d61f4e..8c8d3d32 100644 --- a/open-sse/services/antigravityHeaders.ts +++ b/open-sse/services/antigravityHeaders.ts @@ -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"; diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index 517dbc57..4e114f08 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -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 { + 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[] | 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> | 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> = [ - { - 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; 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 | null; + sourceBody?: Record | null; + normalizedBody?: Record | 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 | null; + sourceBody?: Record | null; + normalizedBody?: Record | 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 | null; + sourceBody?: Record | null; + normalizedBody?: Record | 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(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(); diff --git a/open-sse/services/claudeCodeExtraRemap.ts b/open-sse/services/claudeCodeExtraRemap.ts new file mode 100644 index 00000000..2bfb31b8 --- /dev/null +++ b/open-sse/services/claudeCodeExtraRemap.ts @@ -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 = { + subagents: "SubDispatch", + session_status: "CheckStatus", +}; diff --git a/open-sse/services/claudeCodeObfuscation.ts b/open-sse/services/claudeCodeObfuscation.ts index 38e6e1e9..864c2e07 100644 --- a/open-sse/services/claudeCodeObfuscation.ts +++ b/open-sse/services/claudeCodeObfuscation.ts @@ -54,22 +54,47 @@ export function obfuscateSensitiveWords(text: string): string { } export function obfuscateInBody(body: Record): void { - const messages = body.messages as Array> | 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>) { + 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>) { - 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> | 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>) { + 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> | 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 | undefined; + if (fn && typeof fn.description === "string") { + fn.description = obfuscateSensitiveWords(fn.description); + } + } + } } function escapeRegex(str: string): string { diff --git a/open-sse/services/claudeCodeToolRemapper.ts b/open-sse/services/claudeCodeToolRemapper.ts index c17a3f61..bc6c4724 100644 --- a/open-sse/services/claudeCodeToolRemapper.ts +++ b/open-sse/services/claudeCodeToolRemapper.ts @@ -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 = { + ...EXTRA_TOOL_RENAME_MAP, bash: "Bash", read: "Read", write: "Write", @@ -19,12 +22,15 @@ const TOOL_RENAME_MAP: Record = { 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 = {}; diff --git a/open-sse/services/cloudCodeThinking.ts b/open-sse/services/cloudCodeThinking.ts index 8a44a897..769c414d 100644 --- a/open-sse/services/cloudCodeThinking.ts +++ b/open-sse/services/cloudCodeThinking.ts @@ -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 { 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( diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 02d6abfb..dcbd63a4 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -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, { diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 63798e0f..e8681929 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -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") ); } diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index 1e7e7424..a41ac9a9 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -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; } diff --git a/open-sse/translator/helpers/openaiHelper.ts b/open-sse/translator/helpers/openaiHelper.ts index bbc4995c..f05ca0a8 100644 --- a/open-sse/translator/helpers/openaiHelper.ts +++ b/open-sse/translator/helpers/openaiHelper.ts @@ -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}`, + }); + } } } diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index 367f12e5..40d0c5a9 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -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 }); diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 149685f4..69b3075c 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -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) { diff --git a/open-sse/translator/response/claude-to-openai.ts b/open-sse/translator/response/claude-to-openai.ts index 7101ae68..79f2a447 100644 --- a/open-sse/translator/response/claude-to-openai.ts +++ b/open-sse/translator/response/claude-to-openai.ts @@ -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; diff --git a/open-sse/tsconfig.json b/open-sse/tsconfig.json index e619d74e..6a35a3d3 100644 --- a/open-sse/tsconfig.json +++ b/open-sse/tsconfig.json @@ -12,6 +12,7 @@ "strict": false, "jsx": "react-jsx", "lib": ["dom", "esnext"], + "ignoreDeprecations": "5.0", "baseUrl": "..", "paths": { "@/*": ["./src/*"], diff --git a/open-sse/utils/cacheControlPolicy.ts b/open-sse/utils/cacheControlPolicy.ts index 0810674b..1e511cbd 100644 --- a/open-sse/utils/cacheControlPolicy.ts +++ b/open-sse/utils/cacheControlPolicy.ts @@ -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; diff --git a/playwright.config.ts b/playwright.config.ts index 6340683b..624fc5a6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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, diff --git a/scripts/prepublish.ts b/scripts/prepublish.ts index b278a1a8..1e278ccb 100644 --- a/scripts/prepublish.ts +++ b/scripts/prepublish.ts @@ -324,6 +324,7 @@ if (existsSync(mitmSrc)) { module: "CommonJS", outDir: mitmDest, rootDir: mitmSrc, + ignoreDeprecations: "6.0", resolveJsonModule: true, esModuleInterop: true, skipLibCheck: true, diff --git a/scripts/run-ecosystem-tests.mjs b/scripts/run-ecosystem-tests.mjs index 32f7f958..247729fb 100644 --- a/scripts/run-ecosystem-tests.mjs +++ b/scripts/run-ecosystem-tests.mjs @@ -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"], { diff --git a/scripts/run-next-playwright.mjs b/scripts/run-next-playwright.mjs index 595ae880..420140c2 100644 --- a/scripts/run-next-playwright.mjs +++ b/scripts/run-next-playwright.mjs @@ -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, diff --git a/scripts/run-protocol-clients-tests.mjs b/scripts/run-protocol-clients-tests.mjs index 1c9620eb..f0945eb9 100644 --- a/scripts/run-protocol-clients-tests.mjs +++ b/scripts/run-protocol-clients-tests.mjs @@ -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", diff --git a/scripts/scratch/fix-types.mjs b/scripts/scratch/fix-types.mjs new file mode 100644 index 00000000..2001c2b8 --- /dev/null +++ b/scripts/scratch/fix-types.mjs @@ -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"); diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.tsx index 03a21cb2..153613e4 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.tsx @@ -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({ {apiKeys.length > 0 ? ( 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) => ( - ))} @@ -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)} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.tsx index 24eb9778..a79ed8cf 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.tsx @@ -35,12 +35,12 @@ export default function CopilotToolCard({ return new Set(); } }); - 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({ API Key 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) => ( - ))} + + + + ); } diff --git a/src/app/(dashboard)/dashboard/skills/page.tsx b/src/app/(dashboard)/dashboard/skills/page.tsx index 7acada5a..3781adfc 100644 --- a/src/app/(dashboard)/dashboard/skills/page.tsx +++ b/src/app/(dashboard)/dashboard/skills/page.tsx @@ -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([]); + 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(null); + const [skillsProvider, setSkillsProvider] = useState("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 - {activeTab === "skills" && (
+ +
+ 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" + /> + + +
+ + {popularDefaults.length > 0 && ( +
+

+ Popular by default for selected provider: +

+
+ {popularDefaults.map((name) => ( + + {name} + + ))} +
+
+ )} +
+ {skills.length === 0 ? (
{t("noSkills")}
@@ -359,15 +433,65 @@ export default function SkillsPage() { v{skill.version} + + {(skill.sourceProvider || "local").toUpperCase()} + + + mode: {skill.mode || (skill.enabled ? "on" : "off")} +

{skill.description}

+ {Array.isArray(skill.tags) && skill.tags.length > 0 && ( +
+ {skill.tags.map((tag) => ( + + {tag} + + ))} +
+ )}
+
+ + + +
- {mpError && ( + {(skillsProvider === "skillsmp" ? mpError : shError) && (
- {mpError} + {skillsProvider === "skillsmp" ? mpError : shError}
)} - {mpResults.length > 0 && ( + + {skillsProvider === "skillsmp" && mpResults.length > 0 && (
{mpResults.map((skill) => ( @@ -584,44 +733,8 @@ export default function SkillsPage() { ))}
)} - {!mpLoading && mpResults.length === 0 && !mpError && ( - -
- Configure your SkillsMP API key in Settings to browse the marketplace. -
-
- )} - - )} - {activeTab === "skillssh" && ( -
- -

skills.sh Directory

-
- 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" - /> - -
- {shError && ( -
- {shError} -
- )} -
- {shResults.length > 0 && ( + {skillsProvider === "skillssh" && shResults.length > 0 && (
{shResults.map((skill) => ( @@ -644,7 +757,15 @@ export default function SkillsPage() { ))}
)} - {!shLoading && shResults.length === 0 && !shError && ( + + {skillsProvider === "skillsmp" && !mpLoading && mpResults.length === 0 && !mpError && ( + +
+ Configure your SkillsMP API key in Settings to browse the marketplace. +
+
+ )} + {skillsProvider === "skillssh" && !shLoading && shResults.length === 0 && !shError && (
Search the skills.sh open directory to discover and install agent skills. diff --git a/src/app/api/acp/agents/route.ts b/src/app/api/acp/agents/route.ts index 765c397c..9f8fc6bf 100644 --- a/src/app/api/acp/agents/route.ts +++ b/src/app/api/acp/agents/route.ts @@ -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"); diff --git a/src/app/api/cli-tools/antigravity-mitm/route.ts b/src/app/api/cli-tools/antigravity-mitm/route.ts index c2b67867..48a523ff 100644 --- a/src/app/api/cli-tools/antigravity-mitm/route.ts +++ b/src/app/api/cli-tools/antigravity-mitm/route.ts @@ -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() || ""; diff --git a/src/app/api/cli-tools/backups/route.ts b/src/app/api/cli-tools/backups/route.ts index 968297fa..beb3a7d6 100644 --- a/src/app/api/cli-tools/backups/route.ts +++ b/src/app/api/cli-tools/backups/route.ts @@ -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) { diff --git a/src/app/api/cli-tools/cline-settings/route.ts b/src/app/api/cli-tools/cline-settings/route.ts index b032babb..dbe83280 100644 --- a/src/app/api/cli-tools/cline-settings/route.ts +++ b/src/app/api/cli-tools/cline-settings/route.ts @@ -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 }); diff --git a/src/app/api/cli-tools/droid-settings/route.ts b/src/app/api/cli-tools/droid-settings/route.ts index 25cd6d52..46a7d642 100644 --- a/src/app/api/cli-tools/droid-settings/route.ts +++ b/src/app/api/cli-tools/droid-settings/route.ts @@ -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(); diff --git a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts index 49f1adaa..1dd970bb 100644 --- a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts +++ b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts @@ -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 = {}; 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, }); } diff --git a/src/app/api/cli-tools/kilo-settings/route.ts b/src/app/api/cli-tools/kilo-settings/route.ts index 95248e31..d14f4b17 100644 --- a/src/app/api/cli-tools/kilo-settings/route.ts +++ b/src/app/api/cli-tools/kilo-settings/route.ts @@ -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 }); diff --git a/src/app/api/cli-tools/openclaw-settings/route.ts b/src/app/api/cli-tools/openclaw-settings/route.ts index 88958bb5..e007951b 100644 --- a/src/app/api/cli-tools/openclaw-settings/route.ts +++ b/src/app/api/cli-tools/openclaw-settings/route.ts @@ -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(); diff --git a/src/app/api/cli-tools/qwen-settings/route.ts b/src/app/api/cli-tools/qwen-settings/route.ts new file mode 100644 index 00000000..1e079755 --- /dev/null +++ b/src/app/api/cli-tools/qwen-settings/route.ts @@ -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 = {}; + 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 = {}; + 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 }); + } +} diff --git a/src/app/api/cli-tools/status/route.ts b/src/app/api/cli-tools/status/route.ts index ebcb7067..cc7c5aff 100644 --- a/src/app/api/cli-tools/status/route.ts +++ b/src/app/api/cli-tools/status/route.ts @@ -52,6 +52,16 @@ async function checkToolConfigStatus(toolId: string): Promise { 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) => { diff --git a/src/app/api/providers/[id]/refresh/route.ts b/src/app/api/providers/[id]/refresh/route.ts index 6d902228..ddc2c3cb 100644 --- a/src/app/api/providers/[id]/refresh/route.ts +++ b/src/app/api/providers/[id]/refresh/route.ts @@ -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( diff --git a/src/app/api/skills/[id]/route.ts b/src/app/api/skills/[id]/route.ts index 159eae0b..80d5fc9b 100644 --- a/src/app/api/skills/[id]/route.ts +++ b/src/app/api/skills/[id]/route.ts @@ -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 }); diff --git a/src/app/api/skills/marketplace/install/route.ts b/src/app/api/skills/marketplace/install/route.ts index 4bce9825..f05ec580 100644 --- a/src/app/api/skills/marketplace/install/route.ts +++ b/src/app/api/skills/marketplace/install/route.ts @@ -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 }); diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts index 3d62075e..7e4c10fa 100644 --- a/src/app/api/skills/route.ts +++ b/src/app/api/skills/route.ts @@ -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); diff --git a/src/app/api/skills/skillssh/install/route.ts b/src/app/api/skills/skillssh/install/route.ts index 3e6d24cf..cb3288e2 100644 --- a/src/app/api/skills/skillssh/install/route.ts +++ b/src/app/api/skills/skillssh/install/route.ts @@ -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 }); diff --git a/src/lib/acp/registry.ts b/src/lib/acp/registry.ts index 9721f3d0..a752f394 100644 --- a/src/lib/acp/registry.ts +++ b/src/lib/acp/registry.ts @@ -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 diff --git a/src/lib/config/runtimeSettings.ts b/src/lib/config/runtimeSettings.ts index ef5e2ccc..7239790b 100644 --- a/src/lib/config/runtimeSettings.ts +++ b/src/lib/config/runtimeSettings.ts @@ -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(); diff --git a/src/lib/dataPaths.js b/src/lib/dataPaths.js index 5078bbe9..35954299 100644 --- a/src/lib/dataPaths.js +++ b/src/lib/dataPaths.js @@ -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; } diff --git a/src/lib/db/migrations/027_skill_mode_and_metadata.sql b/src/lib/db/migrations/027_skill_mode_and_metadata.sql new file mode 100644 index 00000000..523acdea --- /dev/null +++ b/src/lib/db/migrations/027_skill_mode_and_metadata.sql @@ -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); diff --git a/src/lib/skills/injection.ts b/src/lib/skills/injection.ts index 809c65f0..c5a63e20 100644 --- a/src/lib/skills/injection.ts +++ b/src/lib/skills/injection.ts @@ -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 { + 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 { + 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; + 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; + 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, + backgroundTokens: Set +): 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 = { + 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", { diff --git a/src/lib/skills/providerSettings.ts b/src/lib/skills/providerSettings.ts new file mode 100644 index 00000000..0fa978ca --- /dev/null +++ b/src/lib/skills/providerSettings.ts @@ -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 { + const settings = (await getSettings()) as Record; + return normalizeSkillsProvider(settings.skillsProvider); +} diff --git a/src/lib/skills/registry.ts b/src/lib/skills/registry.ts index 17f0b4b9..2ced908a 100644 --- a/src/lib/skills/registry.ts +++ b/src/lib/skills/registry.ts @@ -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 { - 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), }; diff --git a/src/lib/skills/types.ts b/src/lib/skills/types.ts index 9bc6e622..45977b05 100644 --- a/src/lib/skills/types.ts +++ b/src/lib/skills/types.ts @@ -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; } diff --git a/src/lib/tokenHealthCheck.ts b/src/lib/tokenHealthCheck.ts index dbf3c104..49b05d73 100644 --- a/src/lib/tokenHealthCheck.ts +++ b/src/lib/tokenHealthCheck.ts @@ -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) { diff --git a/src/lib/usage/callLogArtifacts.ts b/src/lib/usage/callLogArtifacts.ts index 10b5a0f9..6d8e1b94 100644 --- a/src/lib/usage/callLogArtifacts.ts +++ b/src/lib/usage/callLogArtifacts.ts @@ -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); diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 8cc239e7..461303c3 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -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) { diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index 032b1b27..b20daf31 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -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}}" }] } }`, diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 82f480a5..535477be 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -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", diff --git a/src/shared/network/outboundUrlGuard.ts b/src/shared/network/outboundUrlGuard.ts index 0d115e27..09385caa 100644 --- a/src/shared/network/outboundUrlGuard.ts +++ b/src/shared/network/outboundUrlGuard.ts @@ -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 { diff --git a/src/shared/services/apiKeyResolver.ts b/src/shared/services/apiKeyResolver.ts new file mode 100644 index 00000000..72124fad --- /dev/null +++ b/src/shared/services/apiKeyResolver.ts @@ -0,0 +1,16 @@ +import { getApiKeyById } from "@/lib/db/apiKeys"; + +export async function resolveApiKey( + apiKeyId?: string | null, + apiKey?: string | null +): Promise { + if (apiKeyId) { + try { + const keyRecord = await getApiKeyById(apiKeyId); + if (keyRecord?.key) return keyRecord.key as string; + } catch { + /* fall through */ + } + } + return apiKey || "sk_omniroute"; +} diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index f1ac8caa..1672ad45 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -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(), diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index e5ec2a56..f0912205 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -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", }); diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index 4cd074b5..d50ee138 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -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; } diff --git a/src/sse/services/tokenRefresh.ts b/src/sse/services/tokenRefresh.ts index 8f44c19c..adee5a3e 100755 --- a/src/sse/services/tokenRefresh.ts +++ b/src/sse/services/tokenRefresh.ts @@ -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; diff --git a/tests/e2e/analytics-tabs.spec.ts b/tests/e2e/analytics-tabs.spec.ts index 4d9d6a8f..cdbeca76 100644 --- a/tests/e2e/analytics-tabs.spec.ts +++ b/tests/e2e/analytics-tabs.spec.ts @@ -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") diff --git a/tests/e2e/api-keys-flow.spec.ts b/tests/e2e/api-keys-flow.spec.ts index 3d8dde8d..bdf5b5a8 100644 --- a/tests/e2e/api-keys-flow.spec.ts +++ b/tests/e2e/api-keys-flow.spec.ts @@ -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); diff --git a/tests/e2e/combo-unification.spec.ts b/tests/e2e/combo-unification.spec.ts index cd12c7b1..1a767a83 100644 --- a/tests/e2e/combo-unification.spec.ts +++ b/tests/e2e/combo-unification.spec.ts @@ -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"); diff --git a/tests/e2e/combos-flow.spec.ts b/tests/e2e/combos-flow.spec.ts index fc63cce2..e4431582 100644 --- a/tests/e2e/combos-flow.spec.ts +++ b/tests/e2e/combos-flow.spec.ts @@ -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 () => diff --git a/tests/e2e/ecosystem.test.ts b/tests/e2e/ecosystem.test.ts index f8428045..8f7c8e09 100644 --- a/tests/e2e/ecosystem.test.ts +++ b/tests/e2e/ecosystem.test.ts @@ -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 () => { diff --git a/tests/e2e/helpers/dashboardAuth.ts b/tests/e2e/helpers/dashboardAuth.ts new file mode 100644 index 00000000..280d499f --- /dev/null +++ b/tests/e2e/helpers/dashboardAuth.ts @@ -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}`); +} diff --git a/tests/e2e/memory-settings.spec.ts b/tests/e2e/memory-settings.spec.ts index ea368313..0444b6bf 100644 --- a/tests/e2e/memory-settings.spec.ts +++ b/tests/e2e/memory-settings.spec.ts @@ -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 () => { diff --git a/tests/e2e/protocol-clients.test.ts b/tests/e2e/protocol-clients.test.ts index 6d5ee846..7d31b80d 100644 --- a/tests/e2e/protocol-clients.test.ts +++ b/tests/e2e/protocol-clients.test.ts @@ -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 : []; diff --git a/tests/e2e/protocol-visibility.spec.ts b/tests/e2e/protocol-visibility.spec.ts index ca1e3cd2..3a4dea2b 100644 --- a/tests/e2e/protocol-visibility.spec.ts +++ b/tests/e2e/protocol-visibility.spec.ts @@ -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); }); diff --git a/tests/e2e/providers-bailian-coding-plan.spec.ts b/tests/e2e/providers-bailian-coding-plan.spec.ts index 18ce90f3..682c78fb 100644 --- a/tests/e2e/providers-bailian-coding-plan.spec.ts +++ b/tests/e2e/providers-bailian-coding-plan.spec.ts @@ -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)) { diff --git a/tests/e2e/providers-management.spec.ts b/tests/e2e/providers-management.spec.ts index 27109313..73bae06b 100644 --- a/tests/e2e/providers-management.spec.ts +++ b/tests/e2e/providers-management.spec.ts @@ -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(); diff --git a/tests/e2e/proxy-registry.smoke.spec.ts b/tests/e2e/proxy-registry.smoke.spec.ts index 9e2cec36..9ef48115 100644 --- a/tests/e2e/proxy-registry.smoke.spec.ts +++ b/tests/e2e/proxy-registry.smoke.spec.ts @@ -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(); diff --git a/tests/e2e/settings-toggles.spec.ts b/tests/e2e/settings-toggles.spec.ts index f48fbe0c..8f05a14c 100644 --- a/tests/e2e/settings-toggles.spec.ts +++ b/tests/e2e/settings-toggles.spec.ts @@ -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 }); diff --git a/tests/e2e/skills-marketplace.spec.ts b/tests/e2e/skills-marketplace.spec.ts index 9b732f2f..f37e992c 100644 --- a/tests/e2e/skills-marketplace.spec.ts +++ b/tests/e2e/skills-marketplace.spec.ts @@ -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(); diff --git a/tests/integration/chatcore-compression-integration.test.ts b/tests/integration/chatcore-compression-integration.test.ts index 88391f89..13adcd92 100644 --- a/tests/integration/chatcore-compression-integration.test.ts +++ b/tests/integration/chatcore-compression-integration.test.ts @@ -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 | null = null; + globalThis.fetch = async (_url: string | URL | Request, init?: RequestInit) => { + if (init?.body) { + capturedTranslatedBody = JSON.parse(init.body as string) as Record; + } + 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 + | 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 | undefined; + const userInputMessage = currentMessage?.userInputMessage as + | Record + | undefined; + const currentContent = + typeof userInputMessage?.content === "string" ? userInputMessage.content : ""; + assert.match(currentContent, /Please summarize everything\./); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/integration/security-hardening.test.ts b/tests/integration/security-hardening.test.ts index c34681ea..df01b34c 100644 --- a/tests/integration/security-hardening.test.ts +++ b/tests/integration/security-hardening.test.ts @@ -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", diff --git a/tests/integration/skills-pipeline.test.ts b/tests/integration/skills-pipeline.test.ts index 71180a84..136aa30c 100644 --- a/tests/integration/skills-pipeline.test.ts +++ b/tests/integration/skills-pipeline.test.ts @@ -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"); diff --git a/tests/unit/acp-agents-route.test.ts b/tests/unit/acp-agents-route.test.ts new file mode 100644 index 00000000..58f98816 --- /dev/null +++ b/tests/unit/acp-agents-route.test.ts @@ -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); +}); diff --git a/tests/unit/acp-registry.test.ts b/tests/unit/acp-registry.test.ts new file mode 100644 index 00000000..6e5d2f66 --- /dev/null +++ b/tests/unit/acp-registry.test.ts @@ -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); +}); diff --git a/tests/unit/api-key-mask-fix.test.mjs b/tests/unit/api-key-mask-fix.test.mjs new file mode 100644 index 00000000..d7cf4ec6 --- /dev/null +++ b/tests/unit/api-key-mask-fix.test.mjs @@ -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"); + }); +}); diff --git a/tests/unit/cache-control-policy.test.ts b/tests/unit/cache-control-policy.test.ts index abf6456f..c731a3b1 100644 --- a/tests/unit/cache-control-policy.test.ts +++ b/tests/unit/cache-control-policy.test.ts @@ -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", () => { diff --git a/tests/unit/call-log-file-rotation.test.ts b/tests/unit/call-log-file-rotation.test.ts index f2b050a2..8530f595 100644 --- a/tests/unit/call-log-file-rotation.test.ts +++ b/tests/unit/call-log-file-rotation.test.ts @@ -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), diff --git a/tests/unit/cc-compatible-provider.test.ts b/tests/unit/cc-compatible-provider.test.ts index 6f3ced17..c9be1396 100644 --- a/tests/unit/cc-compatible-provider.test.ts +++ b/tests/unit/cc-compatible-provider.test.ts @@ -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", }); diff --git a/tests/unit/chatcore-sanitization.test.ts b/tests/unit/chatcore-sanitization.test.ts index e01cd573..5c448359 100644 --- a/tests/unit/chatcore-sanitization.test.ts +++ b/tests/unit/chatcore-sanitization.test.ts @@ -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); +}); diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index e765850b..dc173d0f 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -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 diff --git a/tests/unit/claude-code-compatible-helpers.test.ts b/tests/unit/claude-code-compatible-helpers.test.ts index 4f822eab..724ad1a8 100644 --- a/tests/unit/claude-code-compatible-helpers.test.ts +++ b/tests/unit/claude-code-compatible-helpers.test.ts @@ -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); }); diff --git a/tests/unit/claude-code-compatible-request.test.ts b/tests/unit/claude-code-compatible-request.test.ts index 441002d9..19c94c30 100644 --- a/tests/unit/claude-code-compatible-request.test.ts +++ b/tests/unit/claude-code-compatible-request.test.ts @@ -114,8 +114,10 @@ test("buildClaudeCodeCompatibleRequest covers normalized OpenAI-style messages, content: [{ type: "text", text: "draft answer\nalternate answer" }], }, ]); - assert.equal(payload.system[3].text, "system note"); - assert.equal(payload.system[4].text, "developer note"); + assert.equal(payload.system.length, 3); + assert.match(payload.system[0].text, /Claude Agent SDK/); + assert.equal(payload.system[1].text, "system note"); + assert.equal(payload.system[2].text, "developer note"); assert.equal(payload.tools.length, 1); assert.deepEqual(payload.tools[0], { name: "lookup_account", @@ -157,6 +159,8 @@ test("buildClaudeCodeCompatibleRequest covers Claude-native bodies and cache-con { name: "toolA", input_schema: { type: "object" }, cache_control: { type: "ephemeral" } }, ], thinking: { type: "enabled", budget_tokens: 12 }, + output_config: { format: "compact" }, + metadata: { foo: "bar" }, }, model: "claude-sonnet-4-6", preserveCacheControl: false, @@ -194,11 +198,12 @@ test("buildClaudeCodeCompatibleRequest covers Claude-native bodies and cache-con assert.equal(stripped.messages.at(-1).role, "user"); assert.equal(stripped.system[0].cache_control, undefined); assert.equal(stripped.messages[0].content[0].cache_control, undefined); - assert.equal(stripped.system.at(-1).cache_control, undefined); assert.equal(stripped.tools[0].cache_control, undefined); - assert.equal(preserved.system[0].cache_control, undefined); + assert.deepEqual(stripped.thinking, { type: "enabled", budget_tokens: 12 }); + assert.deepEqual(stripped.output_config, { effort: "high", format: "compact" }); + assert.equal(stripped.metadata.foo, "bar"); + assert.deepEqual(preserved.system[0].cache_control, { type: "ephemeral" }); assert.equal(preserved.messages[0].content[0].cache_control.type, "ephemeral"); - assert.equal(preserved.system.at(-1).cache_control.type, "ephemeral"); assert.equal(preserved.tools[0].cache_control.type, "ephemeral"); }); @@ -217,6 +222,8 @@ test("buildClaudeCodeCompatibleRequest omits tool choice when there are no tools assert.equal(payload.tools.length, 0); assert.equal("tool_choice" in payload, false); assert.equal(payload.output_config.effort, "high"); + assert.equal(payload.system.length, 1); + assert.match(payload.system[0].text, /Claude Agent SDK/); }); test("buildClaudeCodeCompatibleRequest covers string system input, non-array Claude fields and tool choice variants", () => { diff --git a/tests/unit/combo-routing-engine.test.ts b/tests/unit/combo-routing-engine.test.ts index 7e6bfdd1..c3e26727 100644 --- a/tests/unit/combo-routing-engine.test.ts +++ b/tests/unit/combo-routing-engine.test.ts @@ -213,7 +213,7 @@ test("validateComboDAG enforces maximum nesting depth", () => { }); test("handleComboChat priority strategy defaults to first model and records success metrics", async () => { - const calls = []; + const calls: any[] = []; const combo = { name: "priority-default", models: ["openai/gpt-4o-mini", "claude/sonnet"], @@ -229,6 +229,7 @@ test("handleComboChat priority strategy defaults to first model and records succ isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -250,7 +251,7 @@ test("handleComboChat priority strategy defaults to first model and records succ }); test("handleComboChat priority strategy honors composite tier order before fallback", async () => { - const calls = []; + const calls: any[] = []; const combo = { name: "priority-composite-tiers", strategy: "priority", @@ -304,6 +305,7 @@ test("handleComboChat priority strategy honors composite tier order before fallb isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -314,7 +316,7 @@ test("handleComboChat priority strategy honors composite tier order before fallb test("handleComboChat weighted strategy selects by weight and falls back in descending weight order", async () => { const originalRandom = Math.random; - const calls = []; + const calls: any[] = []; Math.random = () => 0.95; @@ -338,6 +340,7 @@ test("handleComboChat weighted strategy selects by weight and falls back in desc isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -351,7 +354,7 @@ test("handleComboChat weighted strategy selects by weight and falls back in desc test("handleComboChat weighted strategy falls back to uniform random when all weights are zero", async () => { const originalRandom = Math.random; - const calls = []; + const calls: any[] = []; Math.random = () => 0.75; try { @@ -373,6 +376,7 @@ test("handleComboChat weighted strategy falls back to uniform random when all we isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -386,7 +390,7 @@ test("handleComboChat weighted strategy falls back to uniform random when all we test("handleComboChat random strategy uses shuffled model order", async () => { const originalRandom = Math.random; - const calls = []; + const calls: any[] = []; const sequence = [0.99, 0.0]; let idx = 0; Math.random = () => sequence[idx++] ?? 0; @@ -406,6 +410,7 @@ test("handleComboChat random strategy uses shuffled model order", async () => { isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -434,7 +439,7 @@ test("handleComboChat least-used strategy prefers the model with fewer recorded strategy: "least-used", }); - const calls = []; + const calls: any[] = []; await handleComboChat({ body: {}, @@ -450,6 +455,7 @@ test("handleComboChat least-used strategy prefers the model with fewer recorded isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -458,7 +464,7 @@ test("handleComboChat least-used strategy prefers the model with fewer recorded }); test("handleComboChat skips unavailable models and falls through to the next active target", async () => { - const calls = []; + const calls: any[] = []; const result = await handleComboChat({ body: {}, combo: { @@ -473,6 +479,7 @@ test("handleComboChat skips unavailable models and falls through to the next act isModelAvailable: async (modelStr) => modelStr !== "model-a", log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -482,7 +489,7 @@ test("handleComboChat skips unavailable models and falls through to the next act }); test("handleComboChat falls through empty successful responses and records failure metrics before succeeding", async () => { - const calls = []; + const calls: any[] = []; const result = await handleComboChat({ body: {}, combo: { @@ -501,6 +508,7 @@ test("handleComboChat falls through empty successful responses and records failu isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -517,7 +525,7 @@ test("handleComboChat falls through empty successful responses and records failu }); test("handleComboChat records per-target metrics separately when the same model repeats with different accounts", async () => { - const calls = []; + const calls: any[] = []; const combo = { name: "per-target-repeat", strategy: "priority", @@ -553,6 +561,7 @@ test("handleComboChat records per-target metrics separately when the same model isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -591,6 +600,7 @@ test("handleComboChat preserves the first failure status but surfaces the last e isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -602,7 +612,7 @@ test("handleComboChat preserves the first failure status but surfaces the last e }); test("handleComboChat round-robin rotates sequentially across requests", async () => { - const calls = []; + const calls: any[] = []; const combo = { name: "rr-sequence", strategy: "round-robin", @@ -621,6 +631,7 @@ test("handleComboChat round-robin rotates sequentially across requests", async ( isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -632,7 +643,7 @@ test("handleComboChat round-robin rotates sequentially across requests", async ( }); test("handleComboChat round-robin starts from composite tier default ordering", async () => { - const calls = []; + const calls: any[] = []; const combo = { name: "rr-composite-order", strategy: "round-robin", @@ -680,6 +691,7 @@ test("handleComboChat round-robin starts from composite tier default ordering", isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -747,6 +759,7 @@ test("handleComboChat accepts binary and Responses-style 200 bodies but falls th isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -769,13 +782,14 @@ test("handleComboChat accepts binary and Responses-style 200 bodies but falls th isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); assert.equal(responsesResult.ok, true); - const calls = []; + const calls: any[] = []; const malformedResult = await handleComboChat({ body: {}, combo: { @@ -800,6 +814,7 @@ test("handleComboChat accepts binary and Responses-style 200 bodies but falls th isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -825,6 +840,7 @@ test("handleComboChat accepts text-mode SSE payloads as valid non-streaming pass isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -834,7 +850,7 @@ test("handleComboChat accepts text-mode SSE payloads as valid non-streaming pass }); test("handleComboChat falls through invalid JSON and embedded 200 error bodies before succeeding", async () => { - const calls = []; + const calls: any[] = []; const result = await handleComboChat({ body: {}, combo: { @@ -862,6 +878,7 @@ test("handleComboChat falls through invalid JSON and embedded 200 error bodies b isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -897,6 +914,7 @@ test("handleComboChat returns the earliest retry-after when all priority targets settings: { comboDefaults: { maxRetries: 0, retryDelayMs: 1 }, }, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -927,6 +945,7 @@ test("handleComboChat returns 404 model_not_found when a combo has no executable retryDelayMs: 1, }, }, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -959,6 +978,7 @@ test("handleComboChat round-robin returns 404 when no models are configured", as retryDelayMs: 1, }, }, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -978,7 +998,7 @@ test("handleComboChat round-robin falls through semaphore timeouts and malformed timeoutMs: 100, } ); - const calls = []; + const calls: any[] = []; try { const result = await handleComboChat({ @@ -1005,6 +1025,7 @@ test("handleComboChat round-robin falls through semaphore timeouts and malformed retryDelayMs: 1, }, }, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1048,6 +1069,7 @@ test("handleComboChat round-robin surfaces retry-after metadata after exhausting retryDelayMs: 1, }, }, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1060,7 +1082,7 @@ test("handleComboChat round-robin surfaces retry-after metadata after exhausting }); test("handleComboChat round-robin keeps generic 400 errors terminal", async () => { - const calls = []; + const calls: any[] = []; const result = await handleComboChat({ body: {}, @@ -1089,6 +1111,7 @@ test("handleComboChat round-robin keeps generic 400 errors terminal", async () = retryDelayMs: 1, }, }, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1099,7 +1122,7 @@ test("handleComboChat round-robin keeps generic 400 errors terminal", async () = }); test("handleComboChat round-robin falls through provider-scoped 400s and returns the final error payload when no target recovers", async () => { - const calls = []; + const calls: any[] = []; const result = await handleComboChat({ body: {}, @@ -1131,6 +1154,7 @@ test("handleComboChat round-robin falls through provider-scoped 400s and returns retryDelayMs: 1, }, }, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1142,7 +1166,7 @@ test("handleComboChat round-robin falls through provider-scoped 400s and returns }); test("handleComboChat strict-random uses the shared deck without repeating within a cycle", async () => { - const calls = []; + const calls: any[] = []; const combo = { name: "strict-random-deck", strategy: "strict-random", @@ -1160,6 +1184,7 @@ test("handleComboChat strict-random uses the shared deck without repeating withi isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1179,7 +1204,7 @@ test("handleComboChat cost-optimized orders models by the cheapest configured in }, }); - const calls = []; + const calls: any[] = []; const result = await handleComboChat({ body: {}, combo: { @@ -1194,6 +1219,7 @@ test("handleComboChat cost-optimized orders models by the cheapest configured in isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1204,7 +1230,7 @@ test("handleComboChat cost-optimized orders models by the cheapest configured in test("handleComboChat weighted strategy resolves nested combos before falling back to the next weighted target", async () => { const originalRandom = Math.random; - const calls = []; + const calls: any[] = []; Math.random = () => 0.01; try { @@ -1227,6 +1253,7 @@ test("handleComboChat weighted strategy resolves nested combos before falling ba isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: [ { name: "weighted-nested-selection", @@ -1255,7 +1282,7 @@ test("handleComboChat context-optimized orders models by the largest synced cont }, }); - const calls = []; + const calls: any[] = []; const result = await handleComboChat({ body: {}, combo: { @@ -1270,6 +1297,7 @@ test("handleComboChat context-optimized orders models by the largest synced cont isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1292,6 +1320,7 @@ test("handleComboChat returns a 503 when every model is unavailable before execu isModelAvailable: async () => false, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1326,6 +1355,7 @@ test("handleComboChat returns the circuit-breaker unavailable response when all isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1337,7 +1367,7 @@ test("handleComboChat returns the circuit-breaker unavailable response when all test("handleComboChat auto strategy honors LKGP after filtering to tool-capable models", async () => { await settingsDb.setLKGP("auto-lkgp", "auto-lkgp", "claude"); - const calls = []; + const calls: any[] = []; const result = await handleComboChat({ body: { messages: [{ role: "user", content: "Write code using a tool" }], @@ -1357,6 +1387,7 @@ test("handleComboChat auto strategy honors LKGP after filtering to tool-capable isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1368,7 +1399,7 @@ test("handleComboChat auto strategy honors LKGP after filtering to tool-capable test("handleComboChat standalone lkgp strategy prioritizes the last known good provider", async () => { await settingsDb.setLKGP("standalone-lkgp", "standalone-lkgp", "anthropic"); - const calls = []; + const calls: any[] = []; const result = await handleComboChat({ body: {}, combo: { @@ -1384,6 +1415,7 @@ test("handleComboChat standalone lkgp strategy prioritizes the last known good p isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1393,7 +1425,7 @@ test("handleComboChat standalone lkgp strategy prioritizes the last known good p }); test("handleComboChat standalone lkgp strategy falls back to original order when no state exists", async () => { - const calls = []; + const calls: any[] = []; const result = await handleComboChat({ body: {}, combo: { @@ -1409,6 +1441,7 @@ test("handleComboChat standalone lkgp strategy falls back to original order when isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1430,6 +1463,7 @@ test("handleComboChat standalone lkgp strategy updates LKGP after a successful c isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1453,7 +1487,7 @@ test("handleComboChat auto strategy falls back to the full pool when tool filter }, }); - const calls = []; + const calls: any[] = []; const result = await handleComboChat({ body: { input: [{ role: "user", text: "Summarize this request" }], @@ -1475,6 +1509,7 @@ test("handleComboChat auto strategy falls back to the full pool when tool filter intentSimpleMaxWords: 5, intentExtraSimpleKeywords: "summarize, brief", }, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1493,7 +1528,7 @@ test("handleComboChat auto strategy falls back to rules when a custom router str }); const log = createLog(); - const calls = []; + const calls: any[] = []; const result = await handleComboChat({ body: { prompt: "Hello there" }, combo: { @@ -1509,6 +1544,7 @@ test("handleComboChat auto strategy falls back to rules when a custom router str isModelAvailable: async () => true, log, settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1523,7 +1559,7 @@ test("handleComboChat auto strategy falls back to rules when a custom router str }); test("handleComboChat auto strategy reads strategyName from combo.config.auto and can prefer latency", async () => { - const calls = []; + const calls: any[] = []; const result = await handleComboChat({ body: { prompt: "Just answer briefly" }, combo: { @@ -1543,6 +1579,7 @@ test("handleComboChat auto strategy reads strategyName from combo.config.auto an isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1552,7 +1589,7 @@ test("handleComboChat auto strategy reads strategyName from combo.config.auto an }); test("handleComboChat context cache protection pins the model and tags tool-call responses", async () => { - const calls = []; + const calls: any[] = []; const result = await handleComboChat({ body: { model: "openai/gpt-4o-mini", @@ -1591,6 +1628,7 @@ test("handleComboChat context cache protection pins the model and tags tool-call isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1622,6 +1660,7 @@ test("handleComboChat context cache protection sanitizes streamed text tags from isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1651,6 +1690,7 @@ test("handleComboChat context cache protection injects a hidden tag for tool-cal isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1674,6 +1714,7 @@ test("handleComboChat context cache protection flushes cleanly when a stream end isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1700,6 +1741,7 @@ test("handleComboChat round-robin resolves nested combos and returns inactive wh isModelAvailable: async () => false, log: createLog(), settings: null, + relayOptions: null as any, allCombos: [ { name: "rr-nested-inactive", models: ["nested-combo"] }, { name: "nested-combo", models: ["openai/model-a"] }, @@ -1737,6 +1779,7 @@ test("handleComboChat round-robin returns circuit-breaker unavailable when every isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1746,7 +1789,7 @@ test("handleComboChat round-robin returns circuit-breaker unavailable when every }); test("handleComboChat round-robin retries a transient failure on the same model before succeeding", async () => { - const calls = []; + const calls: any[] = []; let attempts = 0; const result = await handleComboChat({ @@ -1766,6 +1809,7 @@ test("handleComboChat round-robin retries a transient failure on the same model isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1775,7 +1819,7 @@ test("handleComboChat round-robin retries a transient failure on the same model }); test("handleComboChat round-robin recovers from provider-scoped 400s when a later model succeeds", async () => { - const calls = []; + const calls: any[] = []; const result = await handleComboChat({ body: {}, @@ -1801,6 +1845,7 @@ test("handleComboChat round-robin recovers from provider-scoped 400s when a late isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1810,7 +1855,7 @@ test("handleComboChat round-robin recovers from provider-scoped 400s when a late }); test("handleComboChat falls back to next model when first model returns all-accounts-rate-limited 503", async () => { - const calls = []; + const calls: any[] = []; const result = await handleComboChat({ body: {}, @@ -1838,6 +1883,7 @@ test("handleComboChat falls back to next model when first model returns all-acco isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1851,7 +1897,7 @@ test("handleComboChat falls back to next model when first model returns all-acco }); test("handleComboChat round-robin falls back when all-accounts-rate-limited 503 is returned", async () => { - const calls = []; + const calls: any[] = []; const result = await handleComboChat({ body: {}, @@ -1878,6 +1924,7 @@ test("handleComboChat round-robin falls back when all-accounts-rate-limited 503 isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); @@ -1889,7 +1936,7 @@ test("handleComboChat round-robin falls back when all-accounts-rate-limited 503 }); test("handleComboChat aborts combo when 503 response does NOT contain the unavailable signal", async () => { - const calls = []; + const calls: any[] = []; const result = await handleComboChat({ body: {}, @@ -1911,6 +1958,7 @@ test("handleComboChat aborts combo when 503 response does NOT contain the unavai isModelAvailable: async () => true, log: createLog(), settings: null, + relayOptions: null as any, allCombos: null, relayOptions: null, }); diff --git a/tests/unit/db-core-init.test.ts b/tests/unit/db-core-init.test.ts index f953a635..fa9ce838 100644 --- a/tests/unit/db-core-init.test.ts +++ b/tests/unit/db-core-init.test.ts @@ -705,6 +705,9 @@ test( assert.ok( db.prepare("SELECT version FROM _omniroute_migrations WHERE version = ?").get("026") ); + assert.ok( + db.prepare("SELECT version FROM _omniroute_migrations WHERE version = ?").get("027") + ); assert.equal( db .prepare("SELECT version FROM _omniroute_migrations WHERE version = ? AND name = ?") diff --git a/tests/unit/executor-default-base.test.ts b/tests/unit/executor-default-base.test.ts index b9eeb9fa..a76ddfba 100644 --- a/tests/unit/executor-default-base.test.ts +++ b/tests/unit/executor-default-base.test.ts @@ -316,10 +316,11 @@ test("DefaultExecutor.buildHeaders rotates extra API keys and builds Claude Code assert.equal(first.Authorization, "Bearer primary"); assert.equal(second.Authorization, "Bearer extra-1"); - assert.equal(ccHeaders["x-api-key"], "cc-key"); + assert.equal(ccHeaders.Authorization, "Bearer cc-key"); + assert.equal(ccHeaders["x-api-key"], undefined); assert.equal(ccHeaders["anthropic-version"], CLAUDE_CODE_COMPATIBLE_ANTHROPIC_VERSION); assert.equal(ccHeaders["X-Claude-Code-Session-Id"], "session-1"); - assert.equal(ccHeaders.Accept, "text/event-stream"); + assert.equal(ccHeaders.Accept, "application/json"); assert.equal(ccJsonHeaders.Accept, "application/json"); }); diff --git a/tests/unit/guide-settings-route.test.ts b/tests/unit/guide-settings-route.test.ts index dad4e98d..cb44150d 100644 --- a/tests/unit/guide-settings-route.test.ts +++ b/tests/unit/guide-settings-route.test.ts @@ -3,13 +3,21 @@ import assert from "node:assert/strict"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { NextResponse } from "next/server"; - const guideSettingsRoute = await import("../../src/app/api/cli-tools/guide-settings/[toolId]/route.ts"); const DUMMY_HOME = path.join(os.tmpdir(), "omniroute-qwen-test-" + Date.now()); const QWEN_CONFIG_PATH = path.join(DUMMY_HOME, ".qwen", "settings.json"); +const QWEN_ENV_PATH = path.join(DUMMY_HOME, ".qwen", ".env"); + +type QwenProviderEntry = { + id?: string; + baseUrl?: string; + envKey?: string; + generationConfig?: { + contextWindowSize?: number; + }; +}; function buildRequest(body: any) { return new Request("http://localhost/api/cli-tools/guide-settings/qwen", { @@ -40,11 +48,19 @@ test("guide-settings POST creates new qwen settings.json if it doesn't exist", a const content = JSON.parse(await fs.readFile(QWEN_CONFIG_PATH, "utf-8")); assert.ok(content.modelProviders.openai); - const omniProvider = content.modelProviders.openai.find((p: any) => p.id === "omniroute"); + const omniProvider = content.modelProviders.openai.find( + (p: QwenProviderEntry) => p.baseUrl === "http://my-omni" + ); assert.ok(omniProvider); + assert.equal(omniProvider.id, "qwen-max"); assert.equal(omniProvider.baseUrl, "http://my-omni"); - assert.equal(omniProvider.apiKey, "sk-123"); - assert.equal(omniProvider.generationConfig.defaultModel, "qwen-max"); + assert.equal(omniProvider.envKey, "OPENAI_API_KEY"); + assert.equal(omniProvider.generationConfig?.contextWindowSize, 200000); + + const envContent = await fs.readFile(QWEN_ENV_PATH, "utf-8"); + assert.match(envContent, /^OPENAI_API_KEY=sk-123$/m); + assert.match(envContent, /^ANTHROPIC_API_KEY=sk-123$/m); + assert.match(envContent, /^GEMINI_API_KEY=sk-123$/m); }); test("guide-settings POST merges into existing qwen settings.json", async () => { @@ -66,11 +82,22 @@ test("guide-settings POST merges into existing qwen settings.json", async () => const content = JSON.parse(await fs.readFile(QWEN_CONFIG_PATH, "utf-8")); assert.equal(content.modelProviders.openai.length, 2); - const otherProvider = content.modelProviders.openai.find((p: any) => p.id === "other"); + const otherProvider = content.modelProviders.openai.find( + (p: QwenProviderEntry) => p.id === "other" + ); assert.ok(otherProvider); assert.equal(otherProvider.baseUrl, "https://other"); - const omniProvider = content.modelProviders.openai.find((p: any) => p.id === "omniroute"); + const omniProvider = content.modelProviders.openai.find( + (p: QwenProviderEntry) => p.baseUrl === "http://my-omni" + ); assert.ok(omniProvider); - assert.equal(omniProvider.generationConfig.defaultModel, "auto"); // default fallback + assert.equal(omniProvider.id, "auto"); + assert.equal(omniProvider.envKey, "OPENAI_API_KEY"); + assert.equal(omniProvider.generationConfig?.contextWindowSize, 200000); + + const envContent = await fs.readFile(QWEN_ENV_PATH, "utf-8"); + assert.match(envContent, /^OPENAI_API_KEY=sk-123$/m); + assert.match(envContent, /^ANTHROPIC_API_KEY=sk-123$/m); + assert.match(envContent, /^GEMINI_API_KEY=sk-123$/m); }); diff --git a/tests/unit/memory-store.test.ts b/tests/unit/memory-store.test.ts index 7a3eef18..79d65fcf 100644 --- a/tests/unit/memory-store.test.ts +++ b/tests/unit/memory-store.test.ts @@ -248,15 +248,7 @@ test("listMemories applies query filtering before pagination and type stats", as assert.deepEqual(filtered.byType, { factual: 1, semantic: 1 }); }); -// --------------------------------------------------------------------------- -// Pagination via page parameter (page-based, complementing the offset tests above) -// SKIPPED: These tests require insertMemoryRow() which triggers a pre-existing -// SQLITE_MISMATCH error in the test environment (same issue that affects 7 of -// the 9 original tests above). The pagination logic itself is covered by the -// pure-function tests in tests/unit/pagination.test.ts. -// --------------------------------------------------------------------------- - -test.skip("listMemories supports page-based pagination (page 1)", async () => { +test("listMemories supports page-based pagination (page 1)", async () => { insertMemoryRow({ id: "pg-1", content: "first", @@ -284,7 +276,7 @@ test.skip("listMemories supports page-based pagination (page 1)", async () => { assert.equal(page1.total, 3); }); -test.skip("listMemories supports page-based pagination (page 2 returns remainder)", async () => { +test("listMemories supports page-based pagination (page 2 returns remainder)", async () => { insertMemoryRow({ id: "pg-1", content: "first", @@ -312,7 +304,7 @@ test.skip("listMemories supports page-based pagination (page 2 returns remainder assert.equal(page2.total, 3); }); -test.skip("listMemories returns empty data for a page beyond the result set", async () => { +test("listMemories returns empty data for a page beyond the result set", async () => { insertMemoryRow({ id: "pg-1", content: "only entry", @@ -325,7 +317,7 @@ test.skip("listMemories returns empty data for a page beyond the result set", as assert.equal(beyondPage.total, 1); }); -test.skip("listMemories page parameter defaults to page 1 when omitted with limit", async () => { +test("listMemories page parameter defaults to page 1 when omitted with limit", async () => { insertMemoryRow({ id: "pg-1", content: "first", diff --git a/tests/unit/provider-service.test.ts b/tests/unit/provider-service.test.ts index 83b9f0f8..bfb075ed 100644 --- a/tests/unit/provider-service.test.ts +++ b/tests/unit/provider-service.test.ts @@ -58,7 +58,7 @@ test("Anthropic-compatible Claude Code providers use the Claude Code URL and hea assert.equal(isClaudeCodeCompatible("anthropic-compatible-cc-demo"), true); assert.equal(url, "https://proxy.example.com/v1/messages?beta=true"); - assert.equal(headers["x-api-key"], "anthropic-token"); + assert.equal(headers["Authorization"], "Bearer anthropic-token"); assert.equal(headers.Accept, "application/json"); assert.equal(headers["X-Claude-Code-Session-Id"], "session-123"); assert.equal(headers["anthropic-version"], "2023-06-01"); diff --git a/tests/unit/skills-injection.test.ts b/tests/unit/skills-injection.test.ts index e9e3b985..51eca71a 100644 --- a/tests/unit/skills-injection.test.ts +++ b/tests/unit/skills-injection.test.ts @@ -139,3 +139,134 @@ test("detectProvider maps known model families and falls back to other", () => { assert.equal(detectProvider("gemini-2.5-pro"), "google"); assert.equal(detectProvider("custom-router-model"), "other"); }); + +test("injectSkills auto mode matches message/context semantics and applies score threshold", async () => { + await skillRegistry.register({ + name: "issueSearch", + version: "1.0.0", + description: "search github issues and pull requests", + schema: { input: { query: "string" }, output: { results: [] } }, + handler: "search-handler", + enabled: true, + mode: "auto", + tags: ["github", "issues", "search"], + installCount: 42, + apiKeyId: "key-auto", + }); + + await skillRegistry.register({ + name: "calendarPlanner", + version: "1.0.0", + description: "manage calendar scheduling", + schema: { input: {}, output: {} }, + handler: "calendar-handler", + enabled: true, + mode: "auto", + tags: ["calendar", "meeting"], + installCount: 99, + apiKeyId: "key-auto", + }); + + const tools = injectSkills({ + provider: "openai", + apiKeyId: "key-auto", + messages: [{ role: "user", content: "Please search github issues for flaky tests" }], + existingTools: [], + }); + + assert.equal(Array.isArray(tools), true); + assert.equal(tools.length, 1); + assert.deepEqual(tools[0], { + type: "function", + function: { + name: "issueSearch@1.0.0", + description: "search github issues and pull requests", + parameters: { query: "string" }, + }, + }); +}); + +test("injectSkills auto mode prefers provider-matching tagged skills", async () => { + await skillRegistry.register({ + name: "openaiDocTool", + version: "1.0.0", + description: "openai docs lookup", + schema: { input: {}, output: {} }, + handler: "openai-docs", + enabled: true, + mode: "auto", + tags: ["openai", "docs", "lookup"], + apiKeyId: "key-provider", + }); + + await skillRegistry.register({ + name: "claudeDocTool", + version: "1.0.0", + description: "anthropic docs lookup", + schema: { input: {}, output: {} }, + handler: "claude-docs", + enabled: true, + mode: "auto", + tags: ["anthropic", "docs", "lookup"], + apiKeyId: "key-provider", + }); + + const tools = injectSkills({ + provider: "openai", + apiKeyId: "key-provider", + existingTools: [{ type: "function", function: { name: "docs_lookup" } }], + messages: [{ role: "user", content: "lookup docs" }], + }); + + // Provider match is a ranking signal, not a hard exclusion rule. + // Ensure openai-tagged skill is prioritized first. + assert.equal(tools.length, 3); + const injectedNames = tools + .filter( + (tool): tool is { function: { name: string } } => + !!tool && typeof tool === "object" && "function" in tool + ) + .map((tool) => tool.function.name); + assert.equal(injectedNames[0], "openaiDocTool@1.0.0"); + assert.equal(injectedNames.includes("claudeDocTool@1.0.0"), true); +}); + +test("injectSkills auto mode limits selected auto skills and keeps on-mode skills", async () => { + await skillRegistry.register({ + name: "alwaysOnUtility", + version: "1.0.0", + description: "always available utility", + schema: { input: {}, output: {} }, + handler: "always-on", + enabled: true, + mode: "on", + apiKeyId: "key-limit", + }); + + for (let i = 0; i < 7; i++) { + await skillRegistry.register({ + name: `searchSkill${i}`, + version: "1.0.0", + description: "search docs and files", + schema: { input: {}, output: {} }, + handler: `search-${i}`, + enabled: true, + mode: "auto", + tags: ["search", "docs"], + installCount: i, + apiKeyId: "key-limit", + }); + } + + const tools = injectSkills({ + provider: "openai", + apiKeyId: "key-limit", + messages: [{ role: "user", content: "search docs and files quickly" }], + }); + + // 1 always-on + max 5 auto + assert.equal(tools.length, 6); + const names = tools.map((tool) => (tool as { function: { name: string } }).function.name); + assert.equal(names.includes("alwaysOnUtility@1.0.0"), true); + assert.equal(names.filter((name) => name.startsWith("searchSkill")).length, 5); +}); diff --git a/tests/unit/skills-provider-setting.test.ts b/tests/unit/skills-provider-setting.test.ts new file mode 100644 index 00000000..182882ee --- /dev/null +++ b/tests/unit/skills-provider-setting.test.ts @@ -0,0 +1,18 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { normalizeSkillsProvider, DEFAULT_SKILLS_PROVIDER } = + await import("../../src/lib/skills/providerSettings.ts"); + +test("normalizeSkillsProvider keeps valid values", () => { + assert.equal(normalizeSkillsProvider("skillsmp"), "skillsmp"); + assert.equal(normalizeSkillsProvider("skillssh"), "skillssh"); +}); + +test("normalizeSkillsProvider falls back for invalid values", () => { + assert.equal(DEFAULT_SKILLS_PROVIDER, "skillsmp"); + assert.equal(normalizeSkillsProvider(undefined), "skillsmp"); + assert.equal(normalizeSkillsProvider(null), "skillsmp"); + assert.equal(normalizeSkillsProvider(""), "skillsmp"); + assert.equal(normalizeSkillsProvider("invalid"), "skillsmp"); +}); diff --git a/tests/unit/skills-skillssh.test.ts b/tests/unit/skills-skillssh.test.ts index 84a9ded6..45536523 100644 --- a/tests/unit/skills-skillssh.test.ts +++ b/tests/unit/skills-skillssh.test.ts @@ -9,6 +9,7 @@ const originalDataDir = process.env.DATA_DIR; process.env.DATA_DIR = tmpDir; const core = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); const { skillRegistry } = await import("../../src/lib/skills/registry.ts"); const { searchSkillsSh, fetchSkillMd, SkillsShSearchResponseSchema, SkillsShSkillSchema } = await import("../../src/lib/skills/skillssh.ts"); @@ -30,8 +31,9 @@ function resetStorage() { const originalFetch = globalThis.fetch; -test.beforeEach(() => { +test.beforeEach(async () => { resetStorage(); + await settingsDb.updateSettings({ skillsProvider: "skillssh", requireLogin: false }); globalThis.fetch = originalFetch; }); diff --git a/tests/unit/t43-gemini-tool-call-no-thought-signature.test.ts b/tests/unit/t43-gemini-tool-call-no-thought-signature.test.ts index af517ad0..192020bf 100644 --- a/tests/unit/t43-gemini-tool-call-no-thought-signature.test.ts +++ b/tests/unit/t43-gemini-tool-call-no-thought-signature.test.ts @@ -1,12 +1,18 @@ /** - * T43: Gemini tool call parts must preserve thoughtSignature correctly. + * T43: Gemini tool call parts must NOT inject fake thoughtSignature. * - * Regression test for HTTP 400 "invalid argument" when OmniRoute translates - * OpenAI tool_calls to Gemini format. Gemini 3 requires the signature to live on - * the first functionCall part for a tool-call batch, and replays fail if the - * signature is stripped or emitted as a separate sibling part. + * After the fix for issue #1410, OmniRoute no longer injects a hardcoded + * DEFAULT_THINKING_GEMINI_SIGNATURE into tool call parts. The Gemini 3+ API + * strictly validates thought signatures cryptographically, and injecting a + * stale/fake one causes 400 errors. * - * Reproduces: https://github.com/diegosouzapw/OmniRoute/issues/725 + * Signatures are now only included when: + * 1. The client explicitly provides them via tool_call.thoughtSignature + * 2. They are resolved from the geminiThoughtSignatureStore (persisted from + * a prior upstream response) + * + * Reproduces: https://github.com/diegosouzapw/OmniRoute/issues/1410 + * Supersedes: https://github.com/diegosouzapw/OmniRoute/issues/725 */ import test from "node:test"; @@ -24,7 +30,7 @@ function translateToGemini(messages, tools) { }); } -test("T43: first functionCall part keeps thoughtSignature", () => { +test("T43: functionCall parts do NOT get a fake thoughtSignature injected", () => { const messages = [ { role: "user", content: "What is the weather in Tokyo?" }, { @@ -62,7 +68,6 @@ test("T43: first functionCall part keeps thoughtSignature", () => { const result = translateToGemini(messages, tools); - // Find the model turn that contains the functionCall const modelTurn = result.contents.find( (c) => c.role === "model" && c.parts?.some((p) => p.functionCall) ); @@ -73,16 +78,23 @@ test("T43: first functionCall part keeps thoughtSignature", () => { assert.equal(functionCallParts.length, 1, "Expected exactly 1 functionCall part"); assert.equal(functionCallParts[0].functionCall.name, "get_weather"); assert.deepEqual(functionCallParts[0].functionCall.args, { location: "Tokyo" }); - assert.ok( - typeof functionCallParts[0].thoughtSignature === "string" && - functionCallParts[0].thoughtSignature.length > 0, - `first functionCall part must carry thoughtSignature. Got: ${JSON.stringify(functionCallParts[0])}` + + // No fake signature should be injected when the client didn't provide one + assert.equal( + functionCallParts[0].thoughtSignature, + undefined, + "functionCall parts must NOT have a fake thoughtSignature injected" ); }); -test("T43: multiple tool calls only tag the first functionCall part", () => { +test("T43: client-provided thoughtSignature is ignored in default enabled cache mode", () => { + // In "enabled" mode (default), the signature cache ignores client-provided + // signatures and only uses persisted ones from upstream responses. + // This is the correct behavior to prevent stale/fake signatures from being + // forwarded to the Gemini API. + const clientSignature = "REAL_CLIENT_SIGNATURE_abc123"; const messages = [ - { role: "user", content: "Get weather for Tokyo and London" }, + { role: "user", content: "Get weather for Tokyo" }, { role: "assistant", content: null, @@ -91,11 +103,7 @@ test("T43: multiple tool calls only tag the first functionCall part", () => { id: "call_001", type: "function", function: { name: "get_weather", arguments: '{"location":"Tokyo"}' }, - }, - { - id: "call_002", - type: "function", - function: { name: "get_weather", arguments: '{"location":"London"}' }, + thoughtSignature: clientSignature, }, ], }, @@ -104,11 +112,6 @@ test("T43: multiple tool calls only tag the first functionCall part", () => { tool_call_id: "call_001", content: '{"temp":"15Β°C"}', }, - { - role: "tool", - tool_call_id: "call_002", - content: '{"temp":"10Β°C"}', - }, ]; const result = translateToGemini(messages, []); @@ -119,22 +122,19 @@ test("T43: multiple tool calls only tag the first functionCall part", () => { assert.ok(modelTurn, "Expected a model turn with functionCall parts"); const functionCallParts = modelTurn.parts.filter((p) => p.functionCall); - assert.equal(functionCallParts.length, 2, "Expected 2 functionCall parts"); + assert.equal(functionCallParts.length, 1, "Expected 1 functionCall part"); - assert.ok( - typeof functionCallParts[0].thoughtSignature === "string" && - functionCallParts[0].thoughtSignature.length > 0, - `first functionCall part must carry thoughtSignature. Got: ${JSON.stringify(functionCallParts[0])}` - ); - assert.ok( - !("thoughtSignature" in functionCallParts[1]), - `parallel follow-up functionCall parts must stay unsigned. Got: ${JSON.stringify(functionCallParts[1])}` + // In enabled cache mode, client-provided signatures are NOT forwarded + assert.equal( + functionCallParts[0].thoughtSignature, + undefined, + "Client-provided thoughtSignature should be ignored in enabled cache mode" ); }); -test("T43: thinking parts still include thoughtSignature (regression guard)", () => { +test("T43: thinking parts still emit thought=true (regression guard)", () => { // Ensure we did not accidentally break the thinking parts that legitimately - // need thoughtSignature (present when msg.reasoning_content is set). + // need thought: true (present when msg.reasoning_content is set). const messages = [ { role: "user", content: "Think about the weather" }, { @@ -154,10 +154,13 @@ test("T43: thinking parts still include thoughtSignature (regression guard)", () assert.ok(thinkingPart, "Expected a thinking part when reasoning_content is set"); assert.equal(thinkingPart.text, "The user wants weather data."); - const signaturePart = modelTurn.parts.find((p) => "thoughtSignature" in p); - assert.ok(signaturePart, "Expected a thoughtSignature part after thinking part"); - assert.ok( - !signaturePart.functionCall, - "thoughtSignature part must not also be a functionCall part" + // After #1410 fix, no fake thoughtSignature parts are injected + const signaturePart = modelTurn.parts.find( + (p) => "thoughtSignature" in p && !p.functionCall && !p.thought + ); + assert.equal( + signaturePart, + undefined, + "No standalone fake thoughtSignature parts should be injected" ); }); diff --git a/tests/unit/token-health-check.test.ts b/tests/unit/token-health-check.test.ts index 42402034..8cc9bea5 100644 --- a/tests/unit/token-health-check.test.ts +++ b/tests/unit/token-health-check.test.ts @@ -271,9 +271,130 @@ test("checkConnection uses the resolved proxy payload when refreshing tokens", a assert.equal(updated?.testStatus, "active"); assert.equal(updated?.lastError ?? null, null); assert.ok(updated?.tokenExpiresAt); + assert.ok(updated?.expiresAt); + assert.equal(updated?.expiresAt, updated?.tokenExpiresAt); } ); }); } ); }); + +test("checkConnection uses the latest stored refresh token instead of a stale sweep snapshot", async () => { + await resetStorage(); + + const providerId = "custom-oauth-stale-snapshot"; + const refreshRequests: string[] = []; + + await withHttpServer( + (req, res) => { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + refreshRequests.push(body); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + access_token: "snapshot-access-next", + refresh_token: "snapshot-refresh-next", + expires_in: 3600, + }) + ); + }); + }, + async (tokenServer) => { + await withPatchedProvider( + providerId, + { + tokenUrl: `${tokenServer.url}/token`, + clientId: "snapshot-client-id", + clientSecret: "snapshot-client-secret", + }, + async () => { + const connection = await providersDb.createProviderConnection({ + provider: providerId, + authType: "oauth", + name: "Snapshot Account", + email: "snapshot@example.com", + accessToken: "snapshot-access-old", + refreshToken: "snapshot-refresh-old", + isActive: true, + }); + + const staleCheckTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + await providersDb.updateProviderConnection(connection.id, { + refreshToken: "snapshot-refresh-current", + lastHealthCheckAt: staleCheckTime, + }); + + await tokenHealthCheck.checkConnection(connection); + + const updated = await providersDb.getProviderConnectionById(connection.id); + assert.equal(refreshRequests.length, 1); + assert.match(refreshRequests[0], /refresh_token=snapshot-refresh-current/); + assert.equal(updated?.refreshToken, "snapshot-refresh-next"); + assert.equal(updated?.accessToken, "snapshot-access-next"); + } + ); + } + ); +}); + +test("checkConnection skips interval refresh when token expiry is known and still far away", async () => { + await resetStorage(); + + const providerId = "custom-oauth-known-expiry"; + let refreshCount = 0; + + await withHttpServer( + (_req, res) => { + refreshCount += 1; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + access_token: "should-not-refresh", + refresh_token: "should-not-refresh", + expires_in: 3600, + }) + ); + }, + async (tokenServer) => { + await withPatchedProvider( + providerId, + { + tokenUrl: `${tokenServer.url}/token`, + clientId: "known-expiry-client-id", + clientSecret: "known-expiry-client-secret", + }, + async () => { + const connection = await providersDb.createProviderConnection({ + provider: providerId, + authType: "oauth", + name: "Known Expiry Account", + email: "known-expiry@example.com", + accessToken: "known-expiry-access", + refreshToken: "known-expiry-refresh", + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + isActive: true, + }); + + const staleCheckTime = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + await providersDb.updateProviderConnection(connection.id, { + lastHealthCheckAt: staleCheckTime, + }); + + await tokenHealthCheck.checkConnection(connection); + + const updated = await providersDb.getProviderConnectionById(connection.id); + assert.equal(refreshCount, 0); + assert.equal(updated?.accessToken, "known-expiry-access"); + assert.equal(updated?.refreshToken, "known-expiry-refresh"); + assert.equal(updated?.lastHealthCheckAt, staleCheckTime); + } + ); + } + ); +}); diff --git a/tests/unit/token-refresh-route-service.test.ts b/tests/unit/token-refresh-route-service.test.ts index 561771c9..7780b318 100644 --- a/tests/unit/token-refresh-route-service.test.ts +++ b/tests/unit/token-refresh-route-service.test.ts @@ -200,6 +200,8 @@ test("updateProviderCredentials persists rotated tokens and returns false for mi assert.equal(stored.refreshToken, "refresh-new"); assert.equal(stored.expiresIn, 600); assert.equal(typeof stored.expiresAt, "string"); + assert.equal(typeof stored.tokenExpiresAt, "string"); + assert.equal(stored.expiresAt, stored.tokenExpiresAt); assert.deepEqual(stored.providerSpecificData, { tenant: "team-a" }); assert.equal(missing, false); }); diff --git a/tests/unit/token-refresh-service.test.ts b/tests/unit/token-refresh-service.test.ts index ca83878e..2489b655 100644 --- a/tests/unit/token-refresh-service.test.ts +++ b/tests/unit/token-refresh-service.test.ts @@ -27,9 +27,29 @@ const { refreshWithRetry, } = tokenRefresh; -function createLog() { - const entries = []; - const push = (level, args) => { +type LogLevel = "debug" | "info" | "warn" | "error"; +type LogEntry = { + level: LogLevel; + scope: unknown; + message: unknown; + meta: unknown; +}; +type MockLogger = { + entries: LogEntry[]; + debug: (...args: [unknown?, unknown?, unknown?]) => void; + info: (...args: [unknown?, unknown?, unknown?]) => void; + warn: (...args: [unknown?, unknown?, unknown?]) => void; + error: (...args: [unknown?, unknown?, unknown?]) => void; +}; + +type TestFetch = typeof fetch; +type FastSetTimeout = typeof globalThis.setTimeout & { + __promisify__?: typeof globalThis.setTimeout.__promisify__; +}; + +function createLog(): MockLogger { + const entries: LogEntry[] = []; + const push = (level: LogLevel, args: [unknown?, unknown?, unknown?]) => { const [scope, message, meta] = args; entries.push({ level, scope, message, meta }); }; @@ -43,27 +63,27 @@ function createLog() { }; } -function jsonResponse(body, status = 200) { +function jsonResponse(body: any, status = 200) { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" }, }); } -function textResponse(text, status = 400) { +function textResponse(text: any, status = 400) { return new Response(text, { status, headers: { "content-type": "text/plain" }, }); } -function bodyToString(body) { +function bodyToString(body: BodyInit | null | undefined) { if (typeof body === "string") return body; if (body instanceof URLSearchParams) return body.toString(); return String(body ?? ""); } -async function withMockedFetch(fetchImpl, fn) { +async function withMockedFetch(fetchImpl: TestFetch, fn: () => Promise) { const originalFetch = globalThis.fetch; globalThis.fetch = fetchImpl; try { @@ -73,7 +93,7 @@ async function withMockedFetch(fetchImpl, fn) { } } -async function withMockedNow(now, fn) { +async function withMockedNow(now: number, fn: () => Promise) { const originalNow = Date.now; Date.now = () => now; try { @@ -83,11 +103,19 @@ async function withMockedNow(now, fn) { } } -async function withPatchedProperties(target, patch, fn) { - const previous = new Map(); +async function withPatchedProperties( + target: object, + patch: Record, + fn: () => Promise +) { + const previous = new Map(); + const targetRecord = target as Record; for (const [key, value] of Object.entries(patch)) { - previous.set(key, Object.prototype.hasOwnProperty.call(target, key) ? target[key] : undefined); - target[key] = value; + previous.set( + key, + Object.prototype.hasOwnProperty.call(targetRecord, key) ? targetRecord[key] : undefined + ); + targetRecord[key] = value; } try { @@ -96,21 +124,22 @@ async function withPatchedProperties(target, patch, fn) { for (const [key] of Object.entries(patch)) { const prior = previous.get(key); if (prior === undefined) { - delete target[key]; + delete targetRecord[key]; } else { - target[key] = prior; + targetRecord[key] = prior; } } } } -async function withFastRetryTimers(fn) { - const originalSetTimeout = globalThis.setTimeout as any; - (globalThis as any).setTimeout = Object.assign( - ((callback: any, delay = 0, ...args: any[]) => - originalSetTimeout(callback, delay === 30_000 ? delay : 0, ...args)) as any, +async function withFastRetryTimers(fn: () => Promise) { + const originalSetTimeout = globalThis.setTimeout as FastSetTimeout; + const fastSetTimeout: FastSetTimeout = Object.assign( + ((callback: TimerHandler, delay = 0, ...args: unknown[]) => + originalSetTimeout(callback, delay === 30_000 ? delay : 0, ...args)) as typeof setTimeout, { __promisify__: originalSetTimeout.__promisify__ } ); + globalThis.setTimeout = fastSetTimeout; try { return await fn(); } finally { @@ -149,7 +178,7 @@ test("refreshAccessToken returns null when refresh token is missing", async () = test("refreshAccessToken posts form data and returns rotated tokens", async () => { const log = createLog(); - const calls = []; + const calls: any[] = []; await withPatchedProperties( PROVIDERS, @@ -217,7 +246,7 @@ test("refreshAccessToken returns null on upstream refresh failure", async () => test("refreshClineToken handles nested payloads and computes expiresIn", async () => { const log = createLog(); - const calls = []; + const calls: any[] = []; await withMockedNow(1_700_000_000_000, async () => { await withMockedFetch( @@ -233,9 +262,9 @@ test("refreshClineToken handles nested payloads and computes expiresIn", async ( }, async () => { const result = await refreshClineToken("refresh-cline", log); - assert.equal(result.accessToken, "cline-access"); - assert.equal(result.refreshToken, "cline-refresh"); - assert.equal(result.expiresIn, 95); + assert.equal(result?.accessToken, "cline-access"); + assert.equal(result?.refreshToken, "cline-refresh"); + assert.equal(result?.expiresIn, 95); } ); }); @@ -250,7 +279,7 @@ test("refreshClineToken handles nested payloads and computes expiresIn", async ( test("refreshKimiCodingToken adds provider-specific headers and fields", async () => { const log = createLog(); - const calls = []; + const calls: any[] = []; await withMockedFetch( async (url, options = {}) => { @@ -284,7 +313,7 @@ test("refreshKimiCodingToken adds provider-specific headers and fields", async ( test("refreshClaudeOAuthToken posts the anthropic oauth refresh contract", async () => { const log = createLog(); - const calls = []; + const calls: any[] = []; await withMockedFetch( async (url, options = {}) => { @@ -313,7 +342,7 @@ test("refreshClaudeOAuthToken posts the anthropic oauth refresh contract", async test("refreshGoogleToken exchanges refresh tokens against the shared google endpoint", async () => { const log = createLog(); - const calls = []; + const calls: any[] = []; await withMockedFetch( async (url, options = {}) => { @@ -385,14 +414,17 @@ test("refreshCodexToken recognizes refresh_token_reused responses", async () => async () => textResponse(JSON.stringify({ error: { code: "refresh_token_reused" } }), 400), async () => { const result = await refreshCodexToken("codex-refresh", log); - assert.deepEqual(result, { error: "refresh_token_reused" }); + assert.deepEqual(result, { + error: "unrecoverable_refresh_error", + code: "refresh_token_reused", + }); } ); }); test("refreshKiroToken uses the AWS OIDC flow when client credentials are present", async () => { const log = createLog(); - const calls = []; + const calls: any[] = []; await withMockedFetch( async (url, options = {}) => { @@ -434,7 +466,7 @@ test("refreshKiroToken uses the AWS OIDC flow when client credentials are presen test("refreshKiroToken falls back to the social-auth refresh endpoint", async () => { const log = createLog(); - const calls = []; + const calls: any[] = []; await withMockedFetch( async (url, options = {}) => { @@ -463,7 +495,7 @@ test("refreshKiroToken falls back to the social-auth refresh endpoint", async () test("refreshQoderToken uses basic auth once qoder oauth settings are configured", async () => { const log = createLog(); - const calls = []; + const calls: any[] = []; await withPatchedProperties( PROVIDERS.qoder, @@ -507,7 +539,7 @@ test("refreshQoderToken uses basic auth once qoder oauth settings are configured test("refreshGitHubToken exchanges the refresh token with github oauth", async () => { const log = createLog(); - const calls = []; + const calls: any[] = []; await withPatchedProperties( PROVIDERS.github, @@ -543,7 +575,7 @@ test("refreshGitHubToken exchanges the refresh token with github oauth", async ( test("refreshCopilotToken returns the short-lived copilot token", async () => { const log = createLog(); - const calls = []; + const calls: any[] = []; await withMockedFetch( async (url, options = {}) => { @@ -666,7 +698,7 @@ test("getAccessToken cleans the in-flight cache after resolve and separates diff }, async () => { await withMockedFetch( - async (_url, options: any = {}) => { + async (_url, options: RequestInit = {}) => { fetchCount += 1; const refreshToken = new URLSearchParams(bodyToString(options.body)).get("refresh_token"); return jsonResponse({ @@ -719,7 +751,7 @@ test("getAllAccessTokens refreshes only active connections with providers", asyn }, async () => { await withMockedFetch( - async (_url, options: any = {}) => { + async (_url, options: RequestInit = {}) => { fetchCount += 1; const refreshToken = new URLSearchParams(bodyToString(options.body)).get("refresh_token"); return jsonResponse({ @@ -826,7 +858,7 @@ test("isProviderBlocked clears expired circuit-breaker entries once cooldown pas await refreshWithRetry(async () => null, 1, log, provider); } - const blockedUntil = Date.parse(getCircuitBreakerStatus()[provider].blockedUntil); + const blockedUntil = Date.parse(getCircuitBreakerStatus()[provider].blockedUntil as string); await withMockedNow(blockedUntil + 1, async () => { assert.equal(isProviderBlocked(provider), false); diff --git a/tests/unit/translator-claude-to-gemini.test.ts b/tests/unit/translator-claude-to-gemini.test.ts index 736760b1..65e80c1d 100644 --- a/tests/unit/translator-claude-to-gemini.test.ts +++ b/tests/unit/translator-claude-to-gemini.test.ts @@ -5,8 +5,33 @@ const { claudeToGeminiRequest } = await import("../../open-sse/translator/request/claude-to-gemini.ts"); const { DEFAULT_SAFETY_SETTINGS } = await import("../../open-sse/translator/helpers/geminiHelper.ts"); -const { DEFAULT_THINKING_GEMINI_SIGNATURE } = - await import("../../open-sse/config/defaultThinkingSignature.ts"); + +type UnknownRecord = Record; + +function getFunctionDeclarationParameters(parameters: unknown) { + assert.ok( + parameters && typeof parameters === "object", + "expected function declaration parameters" + ); + return parameters as UnknownRecord & { + properties?: Record; + examples?: unknown; + }; +} + +function getFunctionCall(part: unknown) { + assert.ok(part && typeof part === "object", "expected Gemini part"); + const functionCall = (part as UnknownRecord).functionCall; + assert.ok(functionCall && typeof functionCall === "object", "expected functionCall"); + return functionCall as { name: string }; +} + +function getFunctionResponse(part: unknown) { + assert.ok(part && typeof part === "object", "expected Gemini part"); + const functionResponse = (part as UnknownRecord).functionResponse; + assert.ok(functionResponse && typeof functionResponse === "object", "expected functionResponse"); + return functionResponse as { name: string }; +} test("Claude -> Gemini maps system, thinking, tool use, tool result and tools", () => { const result = claudeToGeminiRequest( @@ -55,15 +80,11 @@ test("Claude -> Gemini maps system, thinking, tool use, tool result and tools", parts: [{ text: "Rules" }], }); assert.equal(result.contents[0].role, "model"); - assert.deepEqual(result.contents[0].parts[0], { thought: true, text: "need tool" }); - assert.deepEqual(result.contents[0].parts[1], { - thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE, - text: "", - }); - assert.deepEqual(result.contents[0].parts[2], { + assert.deepEqual(result.contents[0].parts[0] as any, { thought: true, text: "need tool" }); + assert.deepEqual(result.contents[0].parts[1] as any, { functionCall: { id: "tu_1", name: "weather", args: { city: "Tokyo" } }, }); - assert.deepEqual(result.contents[1].parts[0], { + assert.deepEqual(result.contents[1].parts[0] as any, { functionResponse: { id: "tu_1", name: "weather", @@ -78,7 +99,7 @@ test("Claude -> Gemini maps system, thinking, tool use, tool result and tools", includeThoughts: true, }); assert.deepEqual(result.safetySettings, DEFAULT_SAFETY_SETTINGS); - assert.deepEqual(result.tools[0].functionDeclarations[0].parameters, { + assert.deepEqual((result as any).tools[0].functionDeclarations[0].parameters, { type: "object", properties: { city: { type: "string" } }, }); @@ -128,8 +149,8 @@ test("Claude -> Gemini injects a fallback thoughtSignature on tool-call batches assert.equal(result.contents.length, 1); assert.equal(result.contents[0].role, "model"); - assert.equal(result.contents[0].parts[0].functionCall.name, "read_file"); - assert.equal(result.contents[0].parts[0].thoughtSignature, DEFAULT_THINKING_GEMINI_SIGNATURE); + assert.equal((result.contents[0].parts[0] as any).functionCall.name, "read_file"); + assert.equal((result.contents[0].parts[0] as any).thoughtSignature, undefined); }); test("Claude -> Gemini sanitizes long tool names and exposes a restore map", () => { @@ -167,17 +188,17 @@ test("Claude -> Gemini sanitizes long tool names and exposes a restore map", () false ); - const sanitizedToolName = result.tools[0].functionDeclarations[0].name; + const sanitizedToolName = (result as any).tools[0].functionDeclarations[0].name as string; + const parameters = getFunctionDeclarationParameters( + (result as any).tools[0].functionDeclarations[0].parameters + ); assert.ok(longToolName.length > 64); assert.equal(sanitizedToolName.length, 64); - assert.equal(result._toolNameMap.get(sanitizedToolName), longToolName); - assert.equal(result.contents[0].parts[0].functionCall.name, sanitizedToolName); - assert.equal(result.contents[1].parts[0].functionResponse.name, sanitizedToolName); - assert.equal(result.tools[0].functionDeclarations[0].parameters.examples, undefined); - assert.equal( - result.tools[0].functionDeclarations[0].parameters.properties.path["x-ui"], - undefined - ); + assert.equal((result as any)._toolNameMap.get(sanitizedToolName), longToolName); + assert.equal(getFunctionCall(result.contents[0].parts[0] as any).name, sanitizedToolName); + assert.equal(getFunctionResponse(result.contents[1].parts[0] as any).name, sanitizedToolName); + assert.equal(parameters.examples, undefined); + assert.equal(parameters.properties?.path?.["x-ui"], undefined); }); test("Claude -> Gemini handles empty bodies without producing invalid content", () => { diff --git a/tests/unit/translator-helper-branches.test.ts b/tests/unit/translator-helper-branches.test.ts index 433297dc..e31c3933 100644 --- a/tests/unit/translator-helper-branches.test.ts +++ b/tests/unit/translator-helper-branches.test.ts @@ -186,14 +186,13 @@ test("openaiHelper filters content, normalizes tools and removes OpenAI-incompat const result = openaiHelper.filterToOpenAIFormat(body); - assert.equal(result.messages.length, 4); + assert.equal(result.messages.length, 3); assert.equal(result.messages[2].reasoning_content, "plan first"); assert.deepEqual(result.messages[2].content, [ { type: "text", text: "visible text" }, { type: "image_url", image_url: { url: "https://example.com/a.png" } }, - { type: "tool_result", tool_use_id: "call_1", text: "done" }, + { type: "text", text: "[Tool Result: call_1]\ndone" }, ]); - assert.deepEqual(result.messages[3].content, [{ type: "tool_result", tool_use_id: "call_2" }]); assert.equal(result.tools.length, 3); assert.equal(result.tools[0].function.name, "claude-tool"); assert.equal(result.tools[1].function.name, "gemini-tool"); diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index 88e419ee..f9c3eaba 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -13,6 +13,37 @@ const { } = await import("../../open-sse/translator/helpers/geminiHelper.ts"); const { ANTIGRAVITY_DEFAULT_SYSTEM } = await import("../../open-sse/config/constants.ts"); +type UnknownRecord = Record; + +function getFunctionCall(part: unknown) { + assert.ok(part && typeof part === "object", "expected Gemini functionCall part"); + const functionCall = (part as UnknownRecord).functionCall; + assert.ok(functionCall && typeof functionCall === "object", "expected functionCall payload"); + return functionCall as { id?: string; name: string; args?: unknown }; +} + +function getFunctionResponse(part: unknown) { + assert.ok(part && typeof part === "object", "expected Gemini functionResponse part"); + const functionResponse = (part as UnknownRecord).functionResponse; + assert.ok( + functionResponse && typeof functionResponse === "object", + "expected functionResponse payload" + ); + return functionResponse as { id?: string; name: string; response?: unknown }; +} + +function getFunctionDeclarationParameters(parameters: unknown) { + assert.ok( + parameters && typeof parameters === "object", + "expected function declaration parameters" + ); + return parameters as UnknownRecord & { + properties?: Record; + examples?: unknown; + $schema?: unknown; + }; +} + test("OpenAI -> Gemini helper converts text, images and files into Gemini parts", () => { const parts = convertOpenAIContentToParts([ { type: "text", text: "Hello" }, @@ -121,7 +152,7 @@ test("OpenAI -> Gemini helper inlines local refs and preserves only additionalPr assert.equal(cleaned.properties.shipping.properties.street.minLength, undefined); assert.deepEqual(cleaned.properties.shipping.required, ["street"]); assert.equal(cleaned.properties.shipping.additionalProperties, undefined); - assert.equal(cleaned.properties.metadata.additionalProperties, true); + assert.equal(cleaned.properties.metadata.additionalProperties, undefined); assert.equal(cleaned.properties.options.additionalProperties, undefined); }); @@ -193,8 +224,11 @@ test("OpenAI -> Gemini request maps messages, merged system instructions, tools false ); - assert.equal(result.systemInstruction.role, "user"); - assert.deepEqual(result.systemInstruction.parts, [{ text: "Rule A" }, { text: "Rule B" }]); + assert.equal((result as any).systemInstruction.role, "user"); + assert.deepEqual((result as any).systemInstruction.parts, [ + { text: "Rule A" }, + { text: "Rule B" }, + ]); assert.equal(result.contents[0].role, "user"); assert.deepEqual(result.contents[0].parts, [ { text: "What is the weather?" }, @@ -205,31 +239,38 @@ test("OpenAI -> Gemini request maps messages, merged system instructions, tools (content) => content.role === "model" && content.parts.some((part) => part.functionCall) ); assert.ok(modelTurn, "expected a model turn with functionCall"); + const modelTurnThought = modelTurn.parts[0] as { thought?: boolean; text?: string }; + const modelTurnFunctionCall = getFunctionCall(modelTurn.parts[2]); assert.equal(modelTurn.parts[0].thought, true); - assert.equal(modelTurn.parts[0].text, "Need live data"); - assert.equal(modelTurn.parts[1].thoughtSignature !== undefined, true); - assert.equal(modelTurn.parts[2].text, "Calling a tool"); - assert.equal(modelTurn.parts[3].functionCall.name, "weather"); - assert.deepEqual(modelTurn.parts[3].functionCall.args, { city: "Tokyo" }); + assert.equal(modelTurnThought.text, "Need live data"); + assert.equal(modelTurn.parts[1].text, "Calling a tool"); + assert.equal(modelTurnFunctionCall.name, "weather"); + assert.deepEqual(modelTurnFunctionCall.args, { city: "Tokyo" }); const toolResponseTurn = result.contents.find( (content) => content.role === "user" && content.parts.some((part) => part.functionResponse) ); assert.ok(toolResponseTurn, "expected a tool response turn"); - assert.deepEqual(toolResponseTurn.parts[0].functionResponse, { + assert.deepEqual(getFunctionResponse(toolResponseTurn.parts[0]), { id: "call_1", name: "weather", response: { result: { temp: 20 } }, }); - assert.equal(result.generationConfig.maxOutputTokens, 2222); - assert.equal(result.generationConfig.temperature, 0.3); - assert.equal(result.generationConfig.topP, 0.9); - assert.deepEqual(result.generationConfig.stopSequences, ["DONE"]); - assert.equal(result.generationConfig.responseMimeType, "application/json"); - assert.equal(result.generationConfig.responseSchema.properties.answer.type, "string"); - assert.deepEqual(result.generationConfig.responseSchema.properties.answer.enum, ["ok"]); - assert.deepEqual(result.tools[0].functionDeclarations[0].parameters, { + assert.equal((result as any).generationConfig.maxOutputTokens, 2222); + assert.equal((result as any).generationConfig.temperature, 0.3); + assert.equal((result as any).generationConfig.topP, 0.9); + assert.deepEqual((result as any).generationConfig.stopSequences, ["DONE"]); + assert.equal((result as any).generationConfig.responseMimeType, "application/json"); + const responseSchema = (result as any).generationConfig.responseSchema as { + properties: { answer: { type: string; enum?: string[] } }; + }; + assert.equal(responseSchema.properties.answer.type, "string"); + assert.deepEqual(responseSchema.properties.answer.enum, ["ok"]); + const parameters = getFunctionDeclarationParameters( + (result as any).tools[0].functionDeclarations[0].parameters + ); + assert.deepEqual(parameters, { type: "object", properties: { city: { type: "string" }, @@ -252,7 +293,7 @@ test("OpenAI -> Gemini request preserves custom safety settings and handles syst ); assert.deepEqual(result.safetySettings, customSafety); - assert.equal(result.systemInstruction, undefined); + assert.equal((result as any).systemInstruction, undefined); assert.equal(result.contents.length, 1); assert.equal(result.contents[0].role, "user"); assert.deepEqual(result.contents[0].parts, [{ text: "Only rules" }]); @@ -294,18 +335,20 @@ test("OpenAI -> Gemini CLI adds thinking config and normalizes namespaced tool n false ); - assert.equal(result.generationConfig.thinkingConfig.includeThoughts, true); - assert.ok(result.generationConfig.thinkingConfig.thinkingBudget > 0); - assert.equal(result.tools[0].functionDeclarations[0].name, "weather"); - assert.equal(result._toolNameMap.get("weather"), "ns:weather"); + assert.equal((result as any).generationConfig.thinkingConfig.includeThoughts, true); + assert.ok((result as any).generationConfig.thinkingConfig.thinkingBudget > 0); + assert.equal((result as any).tools[0].functionDeclarations[0].name, "weather"); + assert.equal((result as any)._toolNameMap.get("weather"), "ns:weather"); const modelTurn = result.contents.find((content) => content.role === "model"); - assert.equal(modelTurn.parts[0].functionCall.name, "weather"); + assert.ok(modelTurn, "expected a model turn"); + assert.equal(getFunctionCall(modelTurn.parts[0]).name, "weather"); const responseTurn = result.contents.find( (content) => content.role === "user" && content.parts.some((part) => part.functionResponse) ); - assert.equal(responseTurn.parts[0].functionResponse.name, "weather"); + assert.ok(responseTurn, "expected a function response turn"); + assert.equal(getFunctionResponse(responseTurn.parts[0]).name, "weather"); }); test("OpenAI -> Gemini request sanitizes long MCP tool names and strips unsupported schema fields", () => { @@ -355,25 +398,33 @@ test("OpenAI -> Gemini request sanitizes long MCP tool names and strips unsuppor false ); - const sanitizedToolName = result.tools[0].functionDeclarations[0].name; + const sanitizedToolName = (result as any).tools[0].functionDeclarations[0].name; assert.ok(longToolName.length > 64); assert.equal(sanitizedToolName.length, 64); assert.match(sanitizedToolName, /_[a-f0-9]{8}$/); - assert.equal(result._toolNameMap.get(sanitizedToolName), longToolName); + assert.equal((result as any)._toolNameMap.get(sanitizedToolName), longToolName); const modelTurn = result.contents.find((content) => content.role === "model"); - assert.equal(modelTurn.parts[0].functionCall.name, sanitizedToolName); + assert.ok(modelTurn, "expected a model turn"); + assert.equal(getFunctionCall(modelTurn.parts[0]).name, sanitizedToolName); const toolTurn = result.contents.find( (content) => content.role === "user" && content.parts.some((part) => part.functionResponse) ); - assert.equal(toolTurn.parts[0].functionResponse.name, sanitizedToolName); - assert.equal(result.tools[0].functionDeclarations[0].parameters.$schema, undefined); - assert.equal(result.tools[0].functionDeclarations[0].parameters.examples, undefined); - assert.equal( - result.tools[0].functionDeclarations[0].parameters.properties.paths.items["x-ui"], - undefined - ); + assert.ok(toolTurn, "expected a tool response turn"); + assert.equal(getFunctionResponse(toolTurn.parts[0]).name, sanitizedToolName); + const longToolParameters = getFunctionDeclarationParameters( + (result as any).tools[0].functionDeclarations[0].parameters + ) as UnknownRecord & { + properties?: { + paths?: { + items?: UnknownRecord; + }; + }; + }; + assert.equal(longToolParameters.$schema, undefined); + assert.equal(longToolParameters.examples, undefined); + assert.equal(longToolParameters.properties?.paths?.items?.["x-ui"], undefined); }); test("OpenAI -> Gemini request gives googleSearch precedence over function tools", () => { @@ -396,7 +447,7 @@ test("OpenAI -> Gemini request gives googleSearch precedence over function tools false ); - assert.deepEqual(result.tools, [{ googleSearch: {} }]); + assert.deepEqual((result as any).tools, [{ googleSearch: {} }]); }); test("OpenAI -> Antigravity keeps googleSearch without function calling config", () => { @@ -416,10 +467,10 @@ test("OpenAI -> Antigravity keeps googleSearch without function calling config", ], }, false, - { projectId: "proj-search" } + { projectId: "proj-search" } as any ); - assert.deepEqual(result.request.tools, [{ googleSearch: {} }]); + assert.deepEqual((result as any).request?.tools, [{ googleSearch: {} }]); assert.equal(result.request.toolConfig, undefined); }); @@ -427,7 +478,7 @@ test("OpenAI -> Gemini helper IDs and JSON parsing stay in the expected format", assert.match(generateRequestId(), /^agent-/); assert.match(generateSessionId(), /^-\d+$/); assert.deepEqual(tryParseJSON('{"ok":true}'), { ok: true }); - assert.equal(tryParseJSON("not-json"), null); + assert.equal(tryParseJSON("not-json"), null as any); }); test("OpenAI -> Antigravity wraps Gemini requests in a Cloud Code envelope", () => { @@ -447,7 +498,7 @@ test("OpenAI -> Antigravity wraps Gemini requests in a Cloud Code envelope", () reasoning_effort: "medium", }, false, - { projectId: "proj-1" } + { projectId: "proj-1" } as any ); assert.equal(result.project, "proj-1"); @@ -455,7 +506,10 @@ test("OpenAI -> Antigravity wraps Gemini requests in a Cloud Code envelope", () assert.equal(result.requestType, "agent"); assert.match(result.requestId, /^agent-/); assert.match(result.request.sessionId, /^-\d+$/); - assert.equal(result.request.systemInstruction.parts[0].text, ANTIGRAVITY_DEFAULT_SYSTEM); + assert.equal( + (result as any).request?.systemInstruction.parts[0].text, + ANTIGRAVITY_DEFAULT_SYSTEM + ); assert.deepEqual(result.request.toolConfig, { functionCallingConfig: { mode: "VALIDATED" }, }); @@ -499,27 +553,31 @@ test("OpenAI -> Antigravity uses the Claude bridge for Claude-family models", () ], }, false, - { projectId: "proj-claude" } + { projectId: "proj-claude" } as any ); assert.equal(result.project, "proj-claude"); assert.equal(result.userAgent, "antigravity"); - assert.equal(result.request.systemInstruction.parts[0].text, ANTIGRAVITY_DEFAULT_SYSTEM); - assert.equal(result.request.systemInstruction.parts[1].text, "Project rules"); + assert.equal( + (result as any).request?.systemInstruction.parts[0].text, + ANTIGRAVITY_DEFAULT_SYSTEM + ); + assert.equal((result as any).request?.systemInstruction.parts[1].text, "Project rules"); const modelTurn = result.request.contents.find( (content) => content.role === "model" && content.parts.some((part) => part.functionCall) ); assert.ok(modelTurn, "expected a Claude-bridged model turn"); - assert.equal(modelTurn.parts[0].functionCall.name, "read_file"); - assert.deepEqual(modelTurn.parts[0].functionCall.args, { path: "/tmp/demo" }); + const bridgeFunctionCall = getFunctionCall(modelTurn.parts[0]); + assert.equal(bridgeFunctionCall.name, "read_file"); + assert.deepEqual(bridgeFunctionCall.args, { path: "/tmp/demo" }); const toolTurn = result.request.contents.find( (content) => content.role === "user" && content.parts.some((part) => part.functionResponse) ); assert.ok(toolTurn, "expected a Claude-bridged tool response turn"); - assert.equal(toolTurn.parts[0].functionResponse.id, "call_1"); - assert.equal(result.request.tools[0].functionDeclarations[0].name, "read_file"); + assert.equal(getFunctionResponse(toolTurn.parts[0]).id, "call_1"); + assert.equal((result as any).request?.tools[0].functionDeclarations[0].name, "read_file"); }); test("OpenAI -> Antigravity Claude bridge sanitizes long names and preserves restore map", () => { @@ -561,20 +619,22 @@ test("OpenAI -> Antigravity Claude bridge sanitizes long names and preserves res ], }, false, - { projectId: "proj-claude-map" } + { projectId: "proj-claude-map" } as any ); - const sanitizedToolName = result.request.tools[0].functionDeclarations[0].name; + const sanitizedToolName = (result as any).request?.tools[0].functionDeclarations[0].name; assert.equal(sanitizedToolName.length, 64); - assert.equal(result._toolNameMap.get(sanitizedToolName), longToolName); + assert.equal((result as any)._toolNameMap.get(sanitizedToolName), longToolName); const modelTurn = result.request.contents.find( (content) => content.role === "model" && content.parts.some((part) => part.functionCall) ); - assert.equal(modelTurn.parts[0].functionCall.name, sanitizedToolName); + assert.ok(modelTurn, "expected a model turn"); + assert.equal(getFunctionCall(modelTurn.parts[0]).name, sanitizedToolName); const toolTurn = result.request.contents.find( (content) => content.role === "user" && content.parts.some((part) => part.functionResponse) ); - assert.equal(toolTurn.parts[0].functionResponse.name, sanitizedToolName); + assert.ok(toolTurn, "expected a tool response turn"); + assert.equal(getFunctionResponse(toolTurn.parts[0]).name, sanitizedToolName); }); diff --git a/vitest.config.ts b/vitest.config.ts index 7d8e261c..2f003449 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ "open-sse/**/__tests__/**/*.test.ts", "open-sse/services/**/__tests__/**/*.test.ts", "tests/e2e/ecosystem.test.ts", + "tests/e2e/protocol-clients.test.ts", ], exclude: [ "**/node_modules/**",