diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..86b7318 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.DS_Store +.env +.env.* +node_modules +dist +packages/*/dist +release +release-local +logs +tmp +test-results +playwright-report +blob-report +docs/node_modules +docs/dist +docs/.astro diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e2f2965..00463e0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -29,8 +29,8 @@ jobs: - name: Build and upload docs uses: withastro/action@v6 env: - ASTRO_SITE: https://musistudio.github.io - ASTRO_BASE: /claude-code-router + ASTRO_SITE: https://ccrdesk.top + ASTRO_BASE: / with: path: docs node-version: 24 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f2cbbd..409612d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,8 +14,24 @@ concurrency: jobs: macos: - name: macOS - runs-on: macos-14 + name: macOS (${{ matrix.display_name }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - arch: arm64 + arch_flag: --arm64 + runner: macos-14 + display_name: Apple Silicon + release_label: Apple-Silicon + artifact_name: macos-apple-silicon-arm64 + - arch: x64 + arch_flag: --x64 + runner: macos-15-intel + display_name: Intel + release_label: Intel + artifact_name: macos-intel-x64 steps: - name: Check out repository uses: actions/checkout@v4 @@ -39,16 +55,92 @@ jobs: exit 1 fi - - name: Build and publish ad-hoc macOS artifacts + - name: Build ad-hoc macOS artifacts + run: | + npm run build:assets + npx electron-builder \ + --config build/electron-builder.local.cjs \ + --mac ${{ matrix.arch_flag }} \ + --publish never \ + -c.mac.artifactName='Claude-Code-Router_${version}-mac-${{ matrix.release_label }}-${arch}.${ext}' + + - name: Upload macOS release files + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }} + path: | + release-local/Claude-Code-Router_*-mac-${{ matrix.release_label }}-${{ matrix.arch }}.* + release-local/latest-mac.yml + if-no-files-found: error + + publish-macos: + name: Publish macOS + needs: macos + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install metadata merge dependencies + run: npm ci --ignore-scripts + + - name: Download macOS release files + uses: actions/download-artifact@v4 + with: + pattern: macos-* + path: macos-release-artifacts + + - name: Merge macOS update metadata + run: node build/merge-macos-update-metadata.mjs latest-mac.yml macos-release-artifacts/*/latest-mac.yml + + - name: Create GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npm run build:assets - npx electron-builder --config build/electron-builder.local.cjs --mac --publish always + gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || \ + gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --generate-notes + + - name: Upload macOS release assets + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + shopt -s nullglob + assets=(macos-release-artifacts/*/Claude-Code-Router_*-mac-*.*) + if (( ${#assets[@]} == 0 )); then + echo "No macOS release assets were found." + exit 1 + fi + + upload_args=() + for asset in "${assets[@]}"; do + name="$(basename "$asset")" + case "$name" in + *Apple-Silicon-arm64.dmg) label="macOS Apple Silicon DMG (arm64)" ;; + *Apple-Silicon-arm64.zip) label="macOS Apple Silicon ZIP (arm64)" ;; + *Apple-Silicon-arm64.dmg.blockmap) label="macOS Apple Silicon DMG blockmap (arm64)" ;; + *Apple-Silicon-arm64.zip.blockmap) label="macOS Apple Silicon ZIP blockmap (arm64)" ;; + *Intel-x64.dmg) label="macOS Intel DMG (x64)" ;; + *Intel-x64.zip) label="macOS Intel ZIP (x64)" ;; + *Intel-x64.dmg.blockmap) label="macOS Intel DMG blockmap (x64)" ;; + *Intel-x64.zip.blockmap) label="macOS Intel ZIP blockmap (x64)" ;; + *) label="$name" ;; + esac + upload_args+=("${asset}#${label}") + done + upload_args+=("latest-mac.yml#macOS update metadata") + + gh release upload "$GITHUB_REF_NAME" "${upload_args[@]}" --clobber windows: name: Windows - needs: macos + needs: publish-macos runs-on: windows-latest steps: - name: Check out repository @@ -82,7 +174,7 @@ jobs: linux: name: Linux - needs: macos + needs: publish-macos runs-on: ubuntu-latest steps: - name: Check out repository diff --git a/.gitignore b/.gitignore index 291dafe..6efc5ae 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,8 @@ release tmp release-local logs -.opencat \ No newline at end of file +.opencat +test-results +playwright-report +blob-report +.tmp \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4c0c868 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,75 @@ +ARG NODE_IMAGE=node:22-bookworm +ARG RUNTIME_NODE_IMAGE=node:22-bookworm-slim + +FROM ${NODE_IMAGE} AS build +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY packages/cli/package.json packages/cli/package.json +COPY packages/core/package.json packages/core/package.json +COPY packages/electron/package.json packages/electron/package.json +COPY packages/ui/package.json packages/ui/package.json +RUN npm ci + +COPY . . +RUN npm run build:docker + +FROM ${NODE_IMAGE} AS production-deps +WORKDIR /app +ENV NODE_ENV=production + +COPY package.json package-lock.json ./ +COPY packages/core/package.json packages/core/package.json +RUN npm ci --omit=dev --workspace=@claude-code-router/core --include-workspace-root=false \ + && npm cache clean --force + +FROM ${RUNTIME_NODE_IMAGE} AS runtime +ENV NODE_ENV=production \ + CCR_DATA_DIR=/data \ + CCR_WEB_HOST=127.0.0.1 \ + CCR_WEB_PORT=3459 \ + CCR_NGINX_PORT=8080 \ + CCR_GATEWAY_HOST=127.0.0.1 \ + CCR_GATEWAY_PORT=3456 \ + CCR_GATEWAY_CORE_PORT=3457 \ + CCR_PUBLIC_HOST=127.0.0.1 \ + CCR_PUBLIC_PORT=3458 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates libstdc++6 nginx \ + && rm -rf /var/lib/apt/lists/* \ + && rm -f /etc/nginx/sites-enabled/default /etc/nginx/conf.d/default.conf \ + && rm -rf \ + /opt/yarn-* \ + /usr/local/bin/corepack \ + /usr/local/bin/npm \ + /usr/local/bin/npx \ + /usr/local/bin/yarn \ + /usr/local/bin/yarnpkg \ + /usr/local/include/node \ + /usr/local/lib/node_modules/corepack \ + /usr/local/lib/node_modules/npm \ + /usr/local/share/doc \ + /usr/local/share/man + +COPY package.json package-lock.json ./ +COPY packages/core/package.json packages/core/package.json +COPY --from=production-deps /app/node_modules node_modules + +COPY --from=build /app/packages/core/dist packages/core/dist +COPY --from=build /app/packages/ui/dist/renderer /usr/share/nginx/html +COPY docker/entrypoint.sh /usr/local/bin/ccr-docker-entrypoint +COPY docker/pm2.config.cjs docker/pm2.config.cjs + +RUN chmod +x /usr/local/bin/ccr-docker-entrypoint \ + && mkdir -p /data /run/nginx /var/lib/nginx /var/log/nginx + +VOLUME ["/data"] +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:' + (process.env.CCR_NGINX_PORT || '8080') + '/').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" + +ENTRYPOINT ["ccr-docker-entrypoint"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ea7e8ac --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 musistudio + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index f079394..10f4c32 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,89 @@ -

Claude Code Router Desktop

+

Claude Code Router

Chinese README Discord + X License + Desktop downloads + Documentation

-

- Claude Code Router Desktop screenshot -

+
+ + + + + + + + +
+ + Kimi K2.7 Code sponsor banner + +
+ + Kimi Code Subscription +  ·  + API Global +  ·  + API China + +
+

+ Thanks to Kimi for sponsoring this project! Kimi K2.7 Code is an open-source, coding-focused agentic model developed by Moonshot AI, with substantial gains on real-world long-horizon coding tasks and higher end-to-end success across complex software engineering workflows. It also cuts thinking-token usage by approximately 30% compared with K2.6. Inside CCR, Kimi ships as built-in provider presets: import the pay-as-you-go API or the Kimi Code subscription in one click and route your coding agent's requests to Kimi, the subscription endpoint passes straight through natively with no protocol conversion, API endpoints are adapted automatically, and your balance and subscription usage show up right in the CCR dashboard. +

+

+ CCR already supports Kimi. Visit the Kimi Open Platform (中文站 | Global) to try the API, or explore the cost-effective Coding Plan. +

+
+ +
Claude Code Router Desktop is a local gateway and desktop control panel for routing agent requests from Claude Code, Codex, ZCode, and compatible clients to the model provider you actually want to use. -CCR runs on your machine, keeps provider configuration in your local config directory, and exposes a local gateway at `http://127.0.0.1:3456`. +

+ Claude Code Router Desktop screenshot +

## Why Use CCR - Use one local endpoint for multiple agent tools instead of configuring every client separately. -- Route different workloads to different models, such as fast background work, reasoning tasks, long-context requests, image tasks, or web-search-capable models. -- Mix providers without changing your workflow. CCR supports OpenAI-compatible APIs, Anthropic Messages, Gemini Generate Content, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Mistral, Z.AI, Bailian, and custom providers. +- Route requests with default routing, conditional rules, fallback targets, and request rewrites instead of editing client configuration by hand. +- Mix providers without changing your workflow. CCR supports OpenAI-compatible APIs, Anthropic Messages, Gemini Generate Content, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Kimi Code, Mistral, Z.AI, Bailian, and custom providers. - Control cost and reliability with fallback routing, API key rotation, usage statistics, and request logs. -- Manage everything from a desktop UI instead of editing JSON by hand. -- Extend the gateway with plugins, proxy routes, local HTTP backends, and provider deeplinks. ## Features -- **Desktop dashboard**: start or stop the local gateway, inspect usage, configure the tray window, and manage runtime settings. -- **Provider management**: add provider presets or custom endpoints, test connectivity, manage credentials, and monitor supported account balances where available. -- **Routing rules**: set default, background, thinking, long-context, image, web-search, subagent, model-prefix, and conditional routing rules. -- **Agent profiles**: configure Claude Code, Codex, and ZCode profiles that point to the CCR gateway. -- **Gateway compatibility**: translate client requests through the local CCR wrapper and the core gateway runtime. +- **Overview dashboard**: inspect system status, usage widgets, account balances, model distribution, and share cards. +- **Provider management**: add provider presets or custom endpoints, probe protocol support, test model connectivity, manage credentials, and monitor supported account balances where available. +- **Routing rules**: configure default routing, conditional and model-prefix rules, fallback handling, and request rewrites. +- **Agent Config**: configure Claude Code, Codex, and ZCode launch entries, models, scopes, and multi-instance app profiles. +- **Gateway compatibility**: translate supported client requests through the local CCR model gateway. - **Proxy mode**: capture supported API traffic through a local proxy with optional system proxy integration and network capture. -- **Plugins**: install or load wrapper plugins, including routes for Claude Design and Cursor Proxy style integrations. -- **Virtual models**: expose aliases or composed model profiles for clients that expect a specific model name. -- **Provider deeplinks**: import provider configuration through `ccr://provider?...` links after user confirmation. +- **Fusion models**: combine a base model with vision, web search, or MCP tools into a reusable selectable model. + +## Documentation + +Read the full documentation at [ccrdesk.top](https://ccrdesk.top/). ## Download And Install 1. Open the [GitHub Releases page](https://github.com/musistudio/claude-code-router/releases). 2. Download the package for your platform: - - macOS: `Claude Code Router_.dmg` or `.zip` + - macOS Apple Silicon: `Claude-Code-Router_-mac-Apple-Silicon-arm64.dmg` or `.zip` + - macOS Intel: `Claude-Code-Router_-mac-Intel-x64.dmg` or `.zip` - Windows: `Claude Code Router_.exe` - Linux: `Claude Code Router_.AppImage` 3. Install and launch **Claude Code Router**. -4. On first launch, CCR creates its local configuration: - - macOS/Linux: `~/.claude-code-router/config.json` - - Windows: `%APPDATA%\Claude Code Router\config.json` +4. On first launch, CCR creates its local configuration database: + - macOS/Linux: `~/.claude-code-router/config.sqlite` + - Windows: `%APPDATA%\Claude Code Router\config.sqlite` -CCR starts two local services when the gateway is enabled: +CCR stores runtime configuration in SQLite. A legacy `config.json` is read only once for migration when no SQLite config exists. -- CCR wrapper gateway: `http://127.0.0.1:3456` -- Core gateway runtime: `http://127.0.0.1:3457` +After the service is started from the **Server** page, CCR listens on `http://localhost:8080` by default. The **Server** page controls the gateway `Host`, `Port`, proxy mode, system proxy, network capture, and CA certificate status. ## Quick Start @@ -58,226 +91,286 @@ CCR can be configured entirely from the desktop UI. Use this setup order for a c ### 1. Add a provider -Open **Providers**, click **Add Provider**, then choose a built-in preset or create a custom provider. Fill in the provider name, endpoint, protocol, API key, and model list in the form. Use the connectivity check when available, then save the provider. +Open **Providers**, click **Add Provider**, then choose a built-in preset or **Other / custom API endpoint**. Fill in the provider name, base URL, protocol, API key, and model list. Run protocol probing and model connectivity checks when available, then save the provider. ### 2. Configure routing -Open **Routing** and select which provider/model should handle the default route. Then fill optional routes for background work, thinking requests, long-context requests, image tasks, and web search if you want different models for those scenarios. +Open **Routing** to add conditional rules, configure request rewrites, and set fallback behavior. -Use **Add Routing Rule** when you need more control, such as model-prefix routing, subagent routing, request conditions, or fallback behavior. +Use **Add Routing Rule** for request conditions, model-prefix routing, or rule-level fallback targets. ### 3. Start the gateway -Open **Server** and click **Start**. Enable auto start if you want CCR to start the local gateway whenever the desktop app opens. +Open **Server** and click **Start**. After the page shows Running, CCR listens on `http://localhost:8080`. Enable **Auto start** if you want CCR to start the local gateway whenever the desktop app opens. ### 4. Connect your agent tool -Open **Profiles** and choose the client you want to use. Configure the Claude Code, Codex, or ZCode profile from the form, select the target model, and apply the profile. For app-based profiles, use the profile action button to open the target app through CCR. +Open **Agent Config** and choose the client you want to use. Configure Claude Code, Codex, or ZCode, select the target model and effect scope, then apply the config. For app entries, use the **Open Agent** action to open the target app through CCR. ### 5. Monitor and adjust -Use **Dashboard** for usage and provider health, the tray window for quick token and account status, **Network Logs** for debugging provider behavior, and **Extensions** for plugin configuration. - -## Provider Deeplink - -Provider websites can open CCR and import a model provider with a custom protocol link: - -```text -ccr://provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1&api_key=sk-example&models=example-chat%2Cexample-coder&protocol=openai_chat_completions -``` - -Supported query parameters: - -- `name`: display name for the provider. -- `base_url`: provider API base URL. Aliases: `baseUrl`, `api_base_url`, `url`, `endpoint`. -- `api_key`: optional provider API key. Aliases: `apiKey`, `apikey`, `key`, `token`. -- `models`: comma-separated or newline-separated model list. You can also repeat `model=...`. -- `protocol`: one of `openai_chat_completions`, `openai_responses`, `anthropic_messages`, or `gemini_generate_content`. - -For larger payloads, pass `payload` as URL-encoded JSON or base64url JSON with the same fields. CCR always opens a confirmation dialog before writing a provider imported from an external link. - -## Plugins - -CCR has two plugin layers: - -- Core gateway plugins: use `providerPlugins` and `virtualModelProfiles`; these are passed through to the core gateway. -- Wrapper plugins: use top-level `plugins` to extend the Electron wrapper, register local HTTP backends, add gateway routes, and route proxy-mode traffic to plugin backends. - -Example wrapper plugin route: - -```json -{ - "plugins": [ - { - "id": "local-admin-api", - "enabled": true, - "proxy": { - "routes": [ - { - "id": "admin-api", - "host": "api.example.com", - "paths": ["/v1/admin"], - "upstream": "http://127.0.0.1:4510", - "stripPathPrefix": false - } - ] - } - } - ] -} -``` - -Plugin modules export a function or object with `setup(ctx)`. The context supports: - -- `ctx.registerGatewayRoute({ method, path, auth, handler })` -- `ctx.registerHttpBackend({ id, host, port, handler })` -- `ctx.registerProxyRoute({ host, paths, upstream, stripPathPrefix, rewritePathPrefix, headers })` -- `ctx.openSqliteStore({ filename, migrate })` -- `ctx.registerCoreGatewayProviderPlugin(plugin)` -- `ctx.registerCoreGatewayVirtualModelProfile(profile)` - -Local plugin examples are available in [examples/plugins](examples/plugins). - -## Development - -```bash -npm install -npm run dev -npm run typecheck -npm run build:assets -npm run build:app:mac -npm run build:app:win -``` - -`npm run build:assets` compiles the Electron main process and renderer assets into `dist/`. - -`npm run build` packages the app for the current platform and writes installer artifacts to `release/`. - -`npm run build:app:mac` and `npm run build:app:win` package platform-specific app artifacts. Linux AppImage packaging is configured in `electron-builder.json`. - -`npm run build:app:mac` creates a local macOS test package in `release-local/` using ad-hoc signing. It is useful with a free Apple Account or Apple Development certificate, but it is not suitable for public distribution because downloaded copies will not pass Gatekeeper notarization checks. - -macOS release builds are signed and notarized for distribution. Before running `npm run build:app:mac:release`, the build machine must have a `Developer ID Application` certificate available through the keychain or `CSC_LINK`/`CSC_KEY_PASSWORD`, full Xcode selected with `xcode-select`, and one notarization credential set: - -- `APPLE_API_KEY`, `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER` -- `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and `APPLE_TEAM_ID` -- `APPLE_KEYCHAIN_PROFILE`, optionally with `APPLE_KEYCHAIN` - -The macOS packaging hook validates codesigning, the stapled notarization ticket, and Gatekeeper assessment before writing distributable artifacts. - -Packaged builds check GitHub Releases for updates through `electron-updater`. For local update feed testing, set `CCR_UPDATE_FEED_URL` to a generic electron-updater feed URL before starting the app. `CCR_UPDATE_ALLOW_PRERELEASE=1` enables prerelease updates. - -## Further Reading - -- [Project Motivation and How It Works](blog/en/project-motivation-and-how-it-works.md) -- [Maybe We Can Do More with the Router](blog/en/maybe-we-can-do-more-with-the-route.md) +Use **Settings → Logs & Observability** to enable request logs and agent observability. Use **Logs** to confirm `request model`, `resolved provider`, `resolved model`, status, tokens, latency, and errors; use the tray window for quick token and account status. ## Acknowledgements -Codex support and Bot handoff are powered by [musistudio/codexl](https://github.com/musistudio/codexl). +Codex support is powered by [musistudio/codexl](https://github.com/musistudio/codexl). ## Support & Sponsoring -If you find this project helpful, please consider sponsoring its development. Your support is greatly appreciated. +
-[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/F1F31GN2GM) - -[Paypal](https://paypal.me/musistudio1999) +

If you find this project helpful, please consider sponsoring its development. Your support is greatly appreciated.

- - + +
AlipayWeChat Pay + + Support on Ko-fi + +
+ One-time support via Ko-fi +
+ + Sponsor with PayPal + +
+ International sponsorship +
+ + + + + +
+ Alipay +
+ Alipay QR code +
+ WeChat Pay +
+ WeChat Pay QR code +
+ +
+ ### Our Sponsors -A huge thank you to all our sponsors for their generous support. +
-- [AIHubmix](https://aihubmix.com/) -- [BurnCloud](https://ai.burncloud.com) -- @Simon Leischnig -- [@duanshuaimin](https://github.com/duanshuaimin) -- [@vrgitadmin](https://github.com/vrgitadmin) -- @\*o -- [@ceilwoo](https://github.com/ceilwoo) -- @\*说 -- @\*更 -- @K\*g -- @R\*R -- [@bobleer](https://github.com/bobleer) -- @\*苗 -- @\*划 -- [@Clarence-pan](https://github.com/Clarence-pan) -- [@carter003](https://github.com/carter003) -- @S\*r -- @\*晖 -- @\*敏 -- @Z\*z -- @\*然 -- [@cluic](https://github.com/cluic) -- @\*苗 -- [@PromptExpert](https://github.com/PromptExpert) -- @\*应 -- [@yusnake](https://github.com/yusnake) -- @\*飞 -- @董\* -- @\*汀 -- @\*涯 -- @\*:-) -- @\*\*磊 -- @\*琢 -- @\*成 -- @Z\*o -- @\*琨 -- [@congzhangzh](https://github.com/congzhangzh) -- @\*\_ -- @Z\*m -- @\*鑫 -- @c\*y -- @\*昕 -- [@witsice](https://github.com/witsice) -- @b\*g -- @\*亿 -- @\*辉 -- @JACK -- @\*光 -- @W\*l -- [@kesku](https://github.com/kesku) -- [@biguncle](https://github.com/biguncle) -- @二吉吉 -- @a\*g -- @\*林 -- @\*咸 -- @\*明 -- @S\*y -- @f\*o -- @\*智 -- @F\*t -- @r\*c -- [@qierkang](http://github.com/qierkang) -- @\*军 -- [@snrise-z](http://github.com/snrise-z) -- @\*王 -- [@greatheart1000](http://github.com/greatheart1000) -- @\*王 -- @zcutlip -- [@Peng-YM](http://github.com/Peng-YM) -- @\*更 -- @\*. -- @F\*t -- @\*政 -- @\*铭 -- @\*叶 -- @七\*o -- @\*青 -- @\*\*晨 -- @\*远 -- @\*霄 -- @\*\*吉 -- @\*\*飞 -- @\*\*驰 -- @x\*g +

A huge thank you to all our sponsors for their generous support.

-(If your name is masked, please contact me via my homepage email to update it with your GitHub username.) + + + + + + + + + + + + + +
+ + Zhipu icon +
+ Z智谱 +
+
+ + AIHubmix icon +
+ AIHubmix +
+
+ + BurnCloud icon +
+ BurnCloud +
+
+ + 302.AI icon +
+ 302.AI +
+
+ + RunAPI icon +
+ RunAPI +
+
+ + TeamoRouter icon +
+ TeamoRouter +
+
+ + code0.ai icon +
+ code0.ai +
+
+ + claudeapi icon +
+ claudeapi +
+
+ +

Community Sponsors

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@Simon Leischnig@duanshuaimin@vrgitadmin@*o@ceilwoo@*说
@*更@K*g@R*R@bobleer@*苗@*划
@Clarence-pan@carter003@S*r@*晖@*敏@Z*z
@*然@cluic@*苗@PromptExpert@*应@yusnake
@*飞@董*@*汀@*涯@*:-)@**磊
@*琢@*成@Z*o@*琨@congzhangzh@*_
@Z*m@*鑫@c*y@*昕@witsice@b*g
@*亿@*辉@JACK@*光@W*l@kesku
@biguncle@二吉吉@a*g@*林@*咸@*明
@S*y@f*o@*智@F*t@r*c@qierkang
@*军@snrise-z@*王@greatheart1000@*王@zcutlip
@Peng-YM@*更@*.@F*t@*政@*铭
@*叶@七*o@*青@**晨@*远@*霄
@**吉@**飞@**驰@x*g@**东@*落
@哆*k@*涛@苗大@*呢@d*u@crizcraig
s*s*火*勤**锟*涛**明
*知*语*瓜
+ +If your name is masked, please contact me via my homepage email to update it with your GitHub username. + +
+ +## License + +This project is licensed under the [MIT License](LICENSE). diff --git a/README_zh.md b/README_zh.md index 4c07395..183df2d 100644 --- a/README_zh.md +++ b/README_zh.md @@ -1,56 +1,88 @@ -

Claude Code Router Desktop

+

Claude Code Router

English README Discord + X License + 桌面端下载次数 + 文档

-

- Claude Code Router Desktop 项目截图 -

+
+ + + + + + + + +
+ + Kimi K2.7 Code 赞助横幅 + +
+ + Kimi Code 订阅 +  ·  + API 中文站 +  ·  + API Global + +
+

+ 感谢 Kimi 赞助本项目!Kimi K2.7 Code 是 Moonshot AI 推出的编程专用开源智能体模型,在真实长程编程与复杂软件工程工作流中显著提升端到端任务成功率,同时优化推理效率,相比 K2.6 平均减少约 30% 的推理 token 消耗。在 CCR 中,Kimi 已作为内置供应商预设开箱即用:无论按量付费 API 还是 Kimi Code 订阅,一键导入即可把你的编程 Agent 请求路由到 Kimi,订阅端点原生直通、无需协议转换,API 端点自动适配,账户余额与订阅用量也能直接在 CCR 面板中查看。 +

+

+ CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(中文站Global)体验 API,或了解高性价比 Coding Plan 套餐。 +

+
+ +
Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。 -CCR 在你的本机运行,Provider 配置保存在本地配置目录,并默认暴露本地网关地址:`http://127.0.0.1:3456`。 +

+ Claude Code Router Desktop 项目截图 +

## 为什么使用 CCR - 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。 -- 不同任务使用不同模型,例如后台任务、推理任务、长上下文、图片任务或支持联网搜索的模型。 -- 在不改变工作流的情况下混用不同 Provider。CCR 支持 OpenAI 兼容 API、Anthropic Messages、Gemini Generate Content、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Mistral、Z.AI、百炼以及自定义 Provider。 +- 在不改变工作流的情况下混用不同 Provider。CCR 支持 OpenAI 兼容 API、Anthropic Messages、Gemini Generate Content、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Kimi Code、Mistral、Z.AI、百炼以及自定义 Provider。 - 通过 fallback 路由、API Key 轮换、用量统计和请求日志来控制成本和可靠性。 -- 使用桌面 UI 管理配置,减少手写 JSON。 -- 通过插件、代理路由、本地 HTTP 后端和 Provider deeplink 扩展网关能力。 ## 功能和特性 -- **桌面控制台**:启动或停止本地网关,查看用量,配置托盘窗口和运行时设置。 -- **Provider 管理**:添加预设或自定义端点,检测连通性,管理凭据,并在可用时查看账号余额。 -- **路由规则**:配置默认、后台、thinking、长上下文、图片、Web Search、Subagent、模型前缀和条件路由。 -- **Agent Profiles**:为 Claude Code、Codex 和 ZCode 配置指向 CCR 网关的 Profile。 -- **网关兼容层**:通过本地 CCR wrapper 和 core gateway runtime 转换客户端请求。 +- **概览仪表盘**:查看系统状态、用量组件、账号余额、模型分布和分享卡片。 +- **Provider 管理**:添加预设或自定义端点,探测协议支持,检测模型连通性,管理凭据,并在可用时查看账号余额。 +- **路由规则**:配置条件路由、模型前缀规则、失败降级和请求改写。 +- **Agent配置**:为 Claude Code、Codex 和 ZCode 配置启动入口、模型、作用范围和多开 App 配置。 +- **网关兼容层**:通过本地 CCR 模型网关转换支持的客户端请求。 - **代理模式**:通过本地代理捕获支持的 API 流量,可选系统代理和网络捕获。 -- **插件系统**:安装或加载 wrapper 插件,包括 Claude Design、Cursor Proxy 这类集成路由。 -- **虚拟模型**:为客户端暴露模型别名或组合模型配置,适配固定模型名场景。 -- **Provider Deeplink**:通过 `ccr://provider?...` 链接导入 Provider 配置,写入前会弹出确认。 +- **Fusion 组合模型**:把基础模型与视觉、联网搜索或 MCP 工具组合成新的可选模型。 + +## 文档 + +完整文档见 [ccrdesk.top](https://ccrdesk.top/)。 ## 下载和安装 1. 打开 [GitHub Releases 页面](https://github.com/musistudio/claude-code-router/releases)。 2. 按系统下载对应安装包: - - macOS:`Claude Code Router_.dmg` 或 `.zip` + - macOS Apple 芯片:`Claude-Code-Router_-mac-Apple-Silicon-arm64.dmg` 或 `.zip` + - macOS Intel 芯片:`Claude-Code-Router_-mac-Intel-x64.dmg` 或 `.zip` - Windows:`Claude Code Router_.exe` - Linux:`Claude Code Router_.AppImage` 3. 安装并启动 **Claude Code Router**。 -4. 首次启动后,CCR 会创建本地配置: - - macOS/Linux:`~/.claude-code-router/config.json` - - Windows:`%APPDATA%\Claude Code Router\config.json` +4. 首次启动后,CCR 会创建本地配置数据库: + - macOS/Linux:`~/.claude-code-router/config.sqlite` + - Windows:`%APPDATA%\Claude Code Router\config.sqlite` -启用网关后,CCR 会启动两个本地服务: +CCR 的运行配置存储在 SQLite 中。旧版 `config.json` 只会在没有 SQLite 配置时作为迁移来源读取一次。 -- CCR wrapper gateway:`http://127.0.0.1:3456` -- Core gateway runtime:`http://127.0.0.1:3457` +从 **服务** 页面启动后,CCR 默认监听 `http://localhost:8080`。**服务** 页面负责配置网关 `Host`、`Port`、代理模式、系统代理、网络捕获和 CA 证书状态。 ## 快速开始 @@ -58,226 +90,286 @@ CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序 ### 1. 添加 Provider -打开 **Providers**,点击 **Add Provider**,选择内置预设或创建自定义 Provider。按表单填写 Provider 名称、端点、协议、API Key 和模型列表。可用时先运行连通性检测,然后保存 Provider。 +打开 **供应商**,点击 **添加供应商**,选择内置预设或 **其他 / 自定义 API 端点**。按表单填写 Provider 名称、基础 URL、协议、API Key 和模型列表。可用时先运行协议探测和模型连通性检查,然后保存 Provider。 ### 2. 设置路由 -打开 **Routing**,先选择默认路由要使用的 provider/model。然后根据需要设置后台任务、Thinking、长上下文、图片任务和 Web Search 等场景的专用模型。 +打开 **路由**,添加条件规则,配置请求改写和失败降级。 -如果需要更细粒度控制,使用 **Add Routing Rule** 添加模型前缀、Subagent、请求条件或 fallback 规则。 +如果需要更细粒度控制,使用 **添加路由规则** 添加模型前缀、请求条件或规则级失败降级目标。 ### 3. 启动网关 -打开 **Server**,点击 **Start** 启动本地网关。如果希望每次打开桌面应用时自动启动网关,可以启用 auto start。 +打开 **服务**,点击 **启动**。页面显示运行中后,CCR 会在本机监听 `http://localhost:8080`。如果希望每次打开桌面应用时自动启动网关,可以启用自动启动。 ### 4. 连接 Agent 工具 -打开 **Profiles**,选择要使用的客户端。通过表单配置 Claude Code、Codex 或 ZCode Profile,选择目标模型并应用配置。对于 App 类型的 Profile,可以使用页面里的操作按钮通过 CCR 打开目标应用。 +打开 **Agent配置**,选择要使用的客户端。配置 Claude Code、Codex 或 ZCode,选择目标模型和作用范围,然后应用配置。对于 App 入口,可以使用 **打开 Agent** 操作通过 CCR 打开目标应用。 ### 5. 日常查看和调整 -使用 **Dashboard** 查看用量和 Provider 状态,使用托盘窗口快速查看 Token 和账号状态,使用 **Network Logs** 调试 Provider 行为,使用 **Extensions** 配置插件。 - -## Provider Deeplink - -Provider 网站可以通过自定义协议打开 CCR 并导入模型服务配置: - -```text -ccr://provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1&api_key=sk-example&models=example-chat%2Cexample-coder&protocol=openai_chat_completions -``` - -支持的 query 参数: - -- `name`:Provider 展示名称。 -- `base_url`:Provider API Base URL。别名:`baseUrl`、`api_base_url`、`url`、`endpoint`。 -- `api_key`:可选 Provider API Key。别名:`apiKey`、`apikey`、`key`、`token`。 -- `models`:逗号或换行分隔的模型列表,也可以重复传入 `model=...`。 -- `protocol`:`openai_chat_completions`、`openai_responses`、`anthropic_messages` 或 `gemini_generate_content`。 - -更大的 payload 可以通过 URL 编码 JSON 或 base64url JSON 传入 `payload` 字段。CCR 在写入外部链接导入的 Provider 前,总会弹出确认窗口。 - -## 插件 - -CCR 有两层插件: - -- Core gateway plugins:使用 `providerPlugins` 和 `virtualModelProfiles`,会透传给 core gateway。 -- Wrapper plugins:使用顶层 `plugins` 扩展 Electron wrapper,注册本地 HTTP 后端、添加 gateway route,或把代理模式流量路由到插件后端。 - -Wrapper plugin route 示例: - -```json -{ - "plugins": [ - { - "id": "local-admin-api", - "enabled": true, - "proxy": { - "routes": [ - { - "id": "admin-api", - "host": "api.example.com", - "paths": ["/v1/admin"], - "upstream": "http://127.0.0.1:4510", - "stripPathPrefix": false - } - ] - } - } - ] -} -``` - -插件模块需要导出函数或包含 `setup(ctx)` 的对象。上下文支持: - -- `ctx.registerGatewayRoute({ method, path, auth, handler })` -- `ctx.registerHttpBackend({ id, host, port, handler })` -- `ctx.registerProxyRoute({ host, paths, upstream, stripPathPrefix, rewritePathPrefix, headers })` -- `ctx.openSqliteStore({ filename, migrate })` -- `ctx.registerCoreGatewayProviderPlugin(plugin)` -- `ctx.registerCoreGatewayVirtualModelProfile(profile)` - -本地插件示例见 [examples/plugins](examples/plugins)。 - -## 开发 - -```bash -npm install -npm run dev -npm run typecheck -npm run build:assets -npm run build:app:mac -npm run build:app:win -``` - -`npm run build:assets` 会把 Electron main process 和 renderer assets 编译到 `dist/`。 - -`npm run build` 会为当前平台打包应用,并把安装包写入 `release/`。 - -`npm run build:app:mac` 和 `npm run build:app:win` 会分别打包对应平台的应用产物。Linux AppImage 打包配置在 `electron-builder.json` 中。 - -`npm run build:app:mac` 会在 `release-local/` 生成本地测试用 macOS 包,使用 ad-hoc 签名。它适合免费 Apple Account 或只有 Apple Development 证书的本机测试,但不适合公开分发,因为用户下载后仍无法通过 Gatekeeper 公证检查。 - -macOS 发布包会使用 Developer ID 签名并提交 Apple 公证。运行 `npm run build:app:mac:release` 前,打包机器必须具备:可用的 `Developer ID Application` 证书(在 keychain 中,或通过 `CSC_LINK`/`CSC_KEY_PASSWORD` 提供)、已通过 `xcode-select` 选择完整 Xcode,以及下面任意一组公证凭据: - -- `APPLE_API_KEY`、`APPLE_API_KEY_ID`、`APPLE_API_ISSUER` -- `APPLE_ID`、`APPLE_APP_SPECIFIC_PASSWORD`、`APPLE_TEAM_ID` -- `APPLE_KEYCHAIN_PROFILE`,可选 `APPLE_KEYCHAIN` - -macOS 打包 hook 会在产物生成前验证代码签名、公证票据 stapling 和 Gatekeeper 评估,避免发布未公证的安装包。 - -打包后的应用会通过 `electron-updater` 检查 GitHub Releases。测试本地更新源时,可以在启动应用前设置 `CCR_UPDATE_FEED_URL` 为 generic electron-updater feed URL。`CCR_UPDATE_ALLOW_PRERELEASE=1` 可以启用 prerelease 更新。 - -## 深入阅读 - -- [项目动机和工作原理](blog/zh/项目初衷及原理.md) -- [也许我们可以用路由器做更多事情](blog/zh/或许我们能在Router中做更多事情.md) +到 **设置 → 日志与观测** 打开请求日志和 Agent 观测。使用 **日志** 确认 `request model`、`resolved provider`、`resolved model`、状态码、tokens、耗时和错误;使用托盘窗口快速查看 Token 和账号状态。 ## 致谢 -对 Codex 的支持以及 Bot handoff 来自于 [musistudio/codexl](https://github.com/musistudio/codexl) 这个项目。 +对 Codex 的支持来自于 [musistudio/codexl](https://github.com/musistudio/codexl) 这个项目。 ## 支持与赞助 -如果你觉得这个项目有帮助,欢迎赞助项目开发。非常感谢你的支持。 +
-[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/F1F31GN2GM) - -[Paypal](https://paypal.me/musistudio1999) +

如果你觉得这个项目有帮助,欢迎赞助项目开发。非常感谢你的支持。

- - + +
AlipayWeChat Pay + + 通过 Ko-fi 赞助 + +
+ 通过 Ko-fi 单次赞助 +
+ + 通过 PayPal 赞助 + +
+ 国际赞助通道 +
+ + + + + +
+ 支付宝 +
+ 支付宝收款码 +
+ 微信支付 +
+ 微信支付收款码 +
+ +
+ ### 我们的赞助商 -非常感谢所有赞助商的慷慨支持。 +
-- [AIHubmix](https://aihubmix.com/) -- [BurnCloud](https://ai.burncloud.com) -- @Simon Leischnig -- [@duanshuaimin](https://github.com/duanshuaimin) -- [@vrgitadmin](https://github.com/vrgitadmin) -- @\*o -- [@ceilwoo](https://github.com/ceilwoo) -- @\*说 -- @\*更 -- @K\*g -- @R\*R -- [@bobleer](https://github.com/bobleer) -- @\*苗 -- @\*划 -- [@Clarence-pan](https://github.com/Clarence-pan) -- [@carter003](https://github.com/carter003) -- @S\*r -- @\*晖 -- @\*敏 -- @Z\*z -- @\*然 -- [@cluic](https://github.com/cluic) -- @\*苗 -- [@PromptExpert](https://github.com/PromptExpert) -- @\*应 -- [@yusnake](https://github.com/yusnake) -- @\*飞 -- @董\* -- @\*汀 -- @\*涯 -- @\*:-) -- @\*\*磊 -- @\*琢 -- @\*成 -- @Z\*o -- @\*琨 -- [@congzhangzh](https://github.com/congzhangzh) -- @\*\_ -- @Z\*m -- @\*鑫 -- @c\*y -- @\*昕 -- [@witsice](https://github.com/witsice) -- @b\*g -- @\*亿 -- @\*辉 -- @JACK -- @\*光 -- @W\*l -- [@kesku](https://github.com/kesku) -- [@biguncle](https://github.com/biguncle) -- @二吉吉 -- @a\*g -- @\*林 -- @\*咸 -- @\*明 -- @S\*y -- @f\*o -- @\*智 -- @F\*t -- @r\*c -- [@qierkang](http://github.com/qierkang) -- @\*军 -- [@snrise-z](http://github.com/snrise-z) -- @\*王 -- [@greatheart1000](http://github.com/greatheart1000) -- @\*王 -- @zcutlip -- [@Peng-YM](http://github.com/Peng-YM) -- @\*更 -- @\*. -- @F\*t -- @\*政 -- @\*铭 -- @\*叶 -- @七\*o -- @\*青 -- @\*\*晨 -- @\*远 -- @\*霄 -- @\*\*吉 -- @\*\*飞 -- @\*\*驰 -- @x\*g +

非常感谢所有赞助商的慷慨支持。

-(如果你的名字被打码,请通过我的主页邮箱联系我更新为 GitHub 用户名。) + + + + + + + + + + + + + +
+ + 智谱图标 +
+ Z智谱 +
+
+ + AIHubmix 图标 +
+ AIHubmix +
+
+ + BurnCloud 图标 +
+ BurnCloud +
+
+ + 302.AI 图标 +
+ 302.AI +
+
+ + RunAPI 图标 +
+ RunAPI +
+
+ + TeamoRouter 图标 +
+ TeamoRouter +
+
+ + code0.ai 图标 +
+ code0.ai +
+
+ + claudeapi 图标 +
+ claudeapi +
+
+ +

社区赞助者

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@Simon Leischnig@duanshuaimin@vrgitadmin@*o@ceilwoo@*说
@*更@K*g@R*R@bobleer@*苗@*划
@Clarence-pan@carter003@S*r@*晖@*敏@Z*z
@*然@cluic@*苗@PromptExpert@*应@yusnake
@*飞@董*@*汀@*涯@*:-)@**磊
@*琢@*成@Z*o@*琨@congzhangzh@*_
@Z*m@*鑫@c*y@*昕@witsice@b*g
@*亿@*辉@JACK@*光@W*l@kesku
@biguncle@二吉吉@a*g@*林@*咸@*明
@S*y@f*o@*智@F*t@r*c@qierkang
@*军@snrise-z@*王@greatheart1000@*王@zcutlip
@Peng-YM@*更@*.@F*t@*政@*铭
@*叶@七*o@*青@**晨@*远@*霄
@**吉@**飞@**驰@x*g@**东@*落
@哆*k@*涛@苗大@*呢@d*u@crizcraig
s*s*火*勤**锟*涛**明
*知*语*瓜
+ +如果你的名字被打码,请通过我的主页邮箱联系我更新为 GitHub 用户名。 + +
+ +## 许可证 + +本项目基于 [MIT License](LICENSE) 发布。 diff --git a/build/build.mjs b/build/build.mjs index e0bc9d4..ba43565 100644 --- a/build/build.mjs +++ b/build/build.mjs @@ -1,4 +1,4 @@ -import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyMarketplacePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml } from "./esbuild.config.mjs"; +import { buildBrowserRenderer, buildMain, buildRenderer, buildStyles, buildTrayRenderer, buildWebClientBridge, cleanDist, copyAppAssets, copyBrowserRendererHtml, copyMarketplacePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml, syncUiRendererToRuntimeDists } from "./esbuild.config.mjs"; const mode = process.argv.includes("--dev") ? "development" : "production"; @@ -15,7 +15,10 @@ await Promise.all([ buildBrowserRenderer({ mode }), buildRenderer({ mode }), buildTrayRenderer({ mode }), + buildWebClientBridge({ mode }), buildStyles({ minify: mode === "production" }) ]); -console.log(`Built Electron app assets in ${mode} mode.`); +syncUiRendererToRuntimeDists(); + +console.log(`Built monorepo package assets in ${mode} mode.`); diff --git a/build/dev.mjs b/build/dev.mjs index 0511592..b91a341 100644 --- a/build/dev.mjs +++ b/build/dev.mjs @@ -5,26 +5,30 @@ import { spawn } from "node:child_process"; import { existsSync, readdirSync, readFileSync, statSync, watch } from "node:fs"; import path from "node:path"; import { - binPath, buildStyles, cleanDist, browserRendererHtmlInput, + cliSourceRoot, + coreSourceRoot, copyAppAssets, copyBrowserRendererHtml, + copyCliRuntimeToElectronDist, copyMarketplacePlugins, copyModelCatalog, copyRendererHtml, copyTrayRendererHtml, createBrowserRendererBuildOptions, + createCliBuildOptions, createMainBuildOptions, createRendererBuildOptions, createTrayRendererBuildOptions, - cssInput, - cssOutput, + createWebClientBridgeBuildOptions, appAssetsInput, modelCatalogInput, projectRoot, + rendererRoot, rendererHtmlInput, + syncUiRendererToRuntimeDists, trayRendererHtmlInput, watchPlugin } from "./esbuild.config.mjs"; @@ -35,13 +39,46 @@ let pendingRestartReasons = []; const watchSignatures = new Map(); let shuttingDown = false; const restartDelayMs = 160; +const styleBuildDelayMs = 160; +const stylePollIntervalMs = 1000; const ignoredSignatureEntries = new Set([".DS_Store"]); +let styleBuildTimer = null; +let styleBuildInFlight = false; +let queuedStyleBuildReason = null; const ready = { browser: false, + cli: false, main: false, renderer: false, - tray: false + tray: false, + webBridge: false }; +const devTarget = parseDevTarget(process.argv.slice(2)); +const enabled = { + cli: devTarget === "cli" || devTarget === "electron", + electron: devTarget === "electron", + ui: true +}; +const coreSharedSourceRoot = path.join(coreSourceRoot, "shared"); +const styleWatchRoots = [rendererRoot, coreSharedSourceRoot].filter((watchRoot) => existsSync(watchRoot)); +const activeReadyNames = new Set([ + ...(enabled.ui ? ["browser", "renderer", "tray", "webBridge"] : []), + ...(enabled.cli ? ["cli"] : []), + ...(enabled.electron ? ["main"] : []) +]); + +function parseDevTarget(args) { + const target = args[0] ?? "electron"; + if (target === "--help" || target === "-h") { + console.log("Usage: node build/dev.mjs [ui|cli|electron]"); + process.exit(0); + } + if (target === "ui" || target === "cli" || target === "electron") { + return target; + } + console.error(`Unknown dev target "${target}". Expected ui, cli, or electron.`); + process.exit(2); +} function logDev(message) { console.log(`[dev] ${new Date().toISOString()} ${message}`); @@ -53,6 +90,7 @@ function relativePath(file) { function readyState() { return Object.entries(ready) + .filter(([name]) => activeReadyNames.has(name)) .map(([name, value]) => `${name}:${value ? "ready" : "pending"}`) .join(" "); } @@ -159,15 +197,71 @@ function handleWatchedInput(label, watchedPath, eventType, filename, options, on } onChange(); - scheduleRestart(reason); + if (enabled.electron && options?.restart !== false) { + scheduleRestart(reason); + } +} + +function scheduleStyleBuild(reason) { + queuedStyleBuildReason = reason; + if (styleBuildTimer) { + clearTimeout(styleBuildTimer); + } + styleBuildTimer = setTimeout(() => { + styleBuildTimer = null; + void rebuildStyles(queuedStyleBuildReason ?? reason); + }, styleBuildDelayMs); +} + +async function rebuildStyles(reason) { + if (styleBuildInFlight) { + queuedStyleBuildReason = reason; + return; + } + + styleBuildInFlight = true; + queuedStyleBuildReason = null; + try { + logDev(`rebuilding styles: ${reason}`); + await buildStyles({ minify: false }); + syncUiRendererToRuntimeDists(); + if (enabled.electron) { + scheduleRestart(`styles rebuilt: ${reason}`); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logDev(`style rebuild failed: ${message}`); + } finally { + styleBuildInFlight = false; + if (queuedStyleBuildReason) { + const queuedReason = queuedStyleBuildReason; + queuedStyleBuildReason = null; + scheduleStyleBuild(queuedReason); + } + } +} + +function pollStyleWatchRoots() { + for (const styleWatchRoot of styleWatchRoots) { + const label = `styles ${relativePath(styleWatchRoot)}`; + const signature = contentSignature(styleWatchRoot); + const previousSignature = watchSignatures.get(label); + if (previousSignature === signature.key) { + continue; + } + + watchSignatures.set(label, signature.key); + logDev(`watch event: ${label}; ${signature.summary}; content=changed`); + scheduleStyleBuild(label); + } } function markReady(name, reason = `${name} esbuild completed`) { - if (name === "browser" || name === "main" || name === "renderer" || name === "tray") { + if (name === "browser" || name === "cli" || name === "main" || name === "renderer" || name === "tray" || name === "webBridge") { ready[name] = true; } logDev(`build ready: ${reason}; ${readyState()}`); - if (ready.browser && ready.main && ready.renderer && ready.tray) { + if (enabled.electron && Array.from(activeReadyNames).every((readyName) => ready[readyName])) { scheduleRestart(reason); } } @@ -217,100 +311,153 @@ function restartElectron() { }); } -logDev("starting dev build"); +logDev(`starting dev build target=${devTarget} ui=${enabled.ui ? "on" : "off"} cli=${enabled.cli ? "on" : "off"} electron=${enabled.electron ? "on" : "off"}`); cleanDist(); -copyAppAssets(); -copyMarketplacePlugins(); -copyModelCatalog(); +if (enabled.electron) { + copyAppAssets(); +} +if (enabled.cli || enabled.electron) { + copyMarketplacePlugins(); + copyModelCatalog(); +} copyBrowserRendererHtml(); copyRendererHtml(); copyTrayRendererHtml(); await buildStyles({ minify: false }); - -const tailwindProcess = spawn(binPath("tailwindcss"), ["-i", cssInput, "-o", cssOutput, "--watch"], { - cwd: projectRoot, - stdio: "inherit", - shell: process.platform === "win32" -}); -logDev(`Tailwind watcher started pid=${tailwindProcess.pid ?? "unknown"} input=${relativePath(cssInput)} output=${relativePath(cssOutput)}`); -tailwindProcess.on("exit", (code, signal) => { - logDev(`Tailwind watcher exited code=${code ?? "null"} signal=${signal ?? "null"}`); -}); +syncUiRendererToRuntimeDists(); rememberWatchSignature("home html", rendererHtmlInput); rememberWatchSignature("browser html", browserRendererHtmlInput); rememberWatchSignature("tray html", trayRendererHtmlInput); -rememberWatchSignature("app assets", appAssetsInput); -if (existsSync(modelCatalogInput)) { +for (const styleWatchRoot of styleWatchRoots) { + rememberWatchSignature(`styles ${relativePath(styleWatchRoot)}`, styleWatchRoot); +} +if (enabled.electron) { + rememberWatchSignature("app assets", appAssetsInput); +} +if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) { rememberWatchSignature("model catalog", modelCatalogInput); } const htmlWatcher = watch(rendererHtmlInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("home html", rendererHtmlInput, eventType, filename, undefined, copyRendererHtml); + handleWatchedInput("home html", rendererHtmlInput, eventType, filename, undefined, () => { + copyRendererHtml(); + syncUiRendererToRuntimeDists(); + }); }); const browserHtmlWatcher = watch(browserRendererHtmlInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("browser html", browserRendererHtmlInput, eventType, filename, undefined, copyBrowserRendererHtml); + handleWatchedInput("browser html", browserRendererHtmlInput, eventType, filename, undefined, () => { + copyBrowserRendererHtml(); + syncUiRendererToRuntimeDists(); + }); }); const trayHtmlWatcher = watch(trayRendererHtmlInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("tray html", trayRendererHtmlInput, eventType, filename, undefined, copyTrayRendererHtml); + handleWatchedInput("tray html", trayRendererHtmlInput, eventType, filename, undefined, () => { + copyTrayRendererHtml(); + syncUiRendererToRuntimeDists(); + }); }); -const appAssetsWatcher = watch(appAssetsInput, { persistent: true }, (eventType, filename) => { - handleWatchedInput("app assets", appAssetsInput, eventType, filename, { isDirectory: true }, copyAppAssets); -}); +const stylePoller = setInterval(pollStyleWatchRoots, stylePollIntervalMs); -const modelCatalogWatcher = existsSync(modelCatalogInput) +const appAssetsWatcher = enabled.electron + ? watch(appAssetsInput, { persistent: true }, (eventType, filename) => { + handleWatchedInput("app assets", appAssetsInput, eventType, filename, { isDirectory: true }, copyAppAssets); + }) + : { close: () => undefined }; + +const modelCatalogWatcher = (enabled.cli || enabled.electron) && existsSync(modelCatalogInput) ? watch(modelCatalogInput, { persistent: true }, (eventType, filename) => { handleWatchedInput("model catalog", modelCatalogInput, eventType, filename, undefined, copyModelCatalog); }) : { close: () => undefined }; -const mainContext = await esbuild.context( - createMainBuildOptions({ - mode: "development", - plugins: [watchPlugin("main", (name) => markReady(name))] - }) -); +const contexts = []; -const rendererContext = await esbuild.context( - createRendererBuildOptions({ - mode: "development", - plugins: [ - watchPlugin("renderer", (name) => { - copyRendererHtml(); - markReady(name); +if (enabled.electron) { + contexts.push( + await esbuild.context( + createMainBuildOptions({ + mode: "development", + plugins: [watchPlugin("main", (name) => markReady(name))] }) - ] - }) -); + ) + ); +} -const trayRendererContext = await esbuild.context( - createTrayRendererBuildOptions({ - mode: "development", - plugins: [ - watchPlugin("tray", (name) => { - copyTrayRendererHtml(); - markReady(name); +if (enabled.cli) { + contexts.push( + await esbuild.context( + createCliBuildOptions({ + mode: "development", + plugins: [ + watchPlugin("cli", (name) => { + if (enabled.electron) { + copyCliRuntimeToElectronDist(); + } + markReady(name); + }) + ] }) - ] - }) -); + ) + ); +} -const browserRendererContext = await esbuild.context( - createBrowserRendererBuildOptions({ - mode: "development", - plugins: [ - watchPlugin("browser", (name) => { - copyBrowserRendererHtml(); - markReady(name); +if (enabled.ui) { + contexts.push( + await esbuild.context( + createRendererBuildOptions({ + mode: "development", + plugins: [ + watchPlugin("renderer", (name) => { + copyRendererHtml(); + syncUiRendererToRuntimeDists(); + markReady(name); + }) + ] }) - ] - }) -); + ), + await esbuild.context( + createTrayRendererBuildOptions({ + mode: "development", + plugins: [ + watchPlugin("tray", (name) => { + copyTrayRendererHtml(); + syncUiRendererToRuntimeDists(); + markReady(name); + }) + ] + }) + ), + await esbuild.context( + createBrowserRendererBuildOptions({ + mode: "development", + plugins: [ + watchPlugin("browser", (name) => { + copyBrowserRendererHtml(); + syncUiRendererToRuntimeDists(); + markReady(name); + }) + ] + }) + ), + await esbuild.context( + createWebClientBridgeBuildOptions({ + mode: "development", + plugins: [ + watchPlugin("webBridge", (name) => { + syncUiRendererToRuntimeDists(); + markReady(name); + }) + ] + }) + ) + ); +} -await Promise.all([mainContext.watch(), rendererContext.watch(), trayRendererContext.watch(), browserRendererContext.watch()]); +await Promise.all(contexts.map((context) => context.watch())); logDev("watchers are active"); async function shutdown() { @@ -322,13 +469,16 @@ async function shutdown() { if (electronProcess) { electronProcess.kill(); } - tailwindProcess.kill(); + if (styleBuildTimer) { + clearTimeout(styleBuildTimer); + } htmlWatcher.close(); browserHtmlWatcher.close(); trayHtmlWatcher.close(); + clearInterval(stylePoller); appAssetsWatcher.close(); modelCatalogWatcher.close(); - await Promise.all([mainContext.dispose(), rendererContext.dispose(), trayRendererContext.dispose(), browserRendererContext.dispose()]); + await Promise.all(contexts.map((context) => context.dispose())); process.exit(0); } diff --git a/build/docker-build.mjs b/build/docker-build.mjs new file mode 100644 index 0000000..75d4ed1 --- /dev/null +++ b/build/docker-build.mjs @@ -0,0 +1,37 @@ +import { + buildBrowserRenderer, + buildCoreServer, + buildRenderer, + buildStyles, + buildTrayRenderer, + buildWebClientBridge, + cleanDist, + copyBrowserRendererHtml, + copyMarketplacePlugins, + copyModelCatalog, + copyRendererHtml, + copyTrayRendererHtml, + syncUiRendererToRuntimeDists +} from "./esbuild.config.mjs"; + +const mode = process.argv.includes("--dev") ? "development" : "production"; + +cleanDist(); +copyMarketplacePlugins(); +copyModelCatalog(); +copyBrowserRendererHtml(); +copyRendererHtml(); +copyTrayRendererHtml(); + +await Promise.all([ + buildCoreServer({ mode }), + buildBrowserRenderer({ mode }), + buildRenderer({ mode }), + buildTrayRenderer({ mode }), + buildWebClientBridge({ mode }), + buildStyles({ minify: mode === "production" }) +]); + +syncUiRendererToRuntimeDists(); + +console.log(`Built Docker core server and UI assets in ${mode} mode.`); diff --git a/build/esbuild.config.mjs b/build/esbuild.config.mjs index 3507935..e4a05f6 100644 --- a/build/esbuild.config.mjs +++ b/build/esbuild.config.mjs @@ -1,23 +1,63 @@ import esbuild from "esbuild"; import { spawn } from "node:child_process"; -import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { builtinModules } from "node:module"; +import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { builtinModules, createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const requireFromHere = createRequire(import.meta.url); export const projectRoot = path.resolve(__dirname, ".."); -export const distDir = path.join(projectRoot, "dist"); -export const mainOutDir = path.join(distDir, "main"); -export const rendererOutDir = path.join(distDir, "renderer"); -export const appAssetsDir = path.join(distDir, "assets"); +export const packagesRoot = path.join(projectRoot, "packages"); +export const cliRoot = path.join(packagesRoot, "cli"); +export const coreRoot = path.join(packagesRoot, "core"); +export const electronRoot = path.join(packagesRoot, "electron"); +export const uiRoot = path.join(packagesRoot, "ui"); +export const cliSourceRoot = path.join(cliRoot, "src"); +export const coreSourceRoot = path.join(coreRoot, "src"); +export const electronSourceRoot = path.join(electronRoot, "src"); +export const uiSourceRoot = path.join(uiRoot, "src"); +export const legacyDistDir = path.join(projectRoot, "dist"); +export const cliDistDir = path.join(cliRoot, "dist"); +export const coreDistDir = path.join(coreRoot, "dist"); +export const electronDistDir = path.join(electronRoot, "dist"); +export const uiDistDir = path.join(uiRoot, "dist"); +export const distDir = electronDistDir; +export const cliMainOutDir = path.join(cliDistDir, "main"); +export const coreMainOutDir = path.join(coreDistDir, "main"); +export const electronMainOutDir = path.join(electronDistDir, "main"); +export const mainOutDir = electronMainOutDir; +export const gatewayPackageRoot = path.dirname(requireFromHere.resolve("@the-next-ai/ai-gateway/package.json")); +export const gatewayRuntimeInput = path.join(gatewayPackageRoot, "bin", "next-ai-gateway.js"); +export const electronGatewayRuntimeOutput = path.join(electronMainOutDir, "next-ai-gateway.js"); +export const botGatewaySdkPackageRoot = path.dirname(requireFromHere.resolve("@the-next-ai/bot-gateway-sdk/package.json")); +export const botGatewaySdkEntryInput = path.join(botGatewaySdkPackageRoot, "dist", "index.js"); +export const botGatewaySdkRunnerInput = path.join(botGatewaySdkPackageRoot, "bin", "bot-gateway-stdio.mjs"); +export const electronBotGatewaySdkRootDir = path.join(electronMainOutDir, "bot-gateway-sdk"); +export const electronBotGatewaySdkDistDir = path.join(electronBotGatewaySdkRootDir, "dist"); +export const electronBotGatewaySdkBinDir = path.join(electronBotGatewaySdkRootDir, "bin"); +export const electronBotGatewaySdkPackageOutput = path.join(electronBotGatewaySdkRootDir, "package.json"); +export const electronBotGatewaySdkEntryOutput = path.join(electronBotGatewaySdkDistDir, "index.js"); +export const electronBotGatewaySdkRunnerOutput = path.join(electronBotGatewaySdkBinDir, "bot-gateway-stdio.mjs"); +export const rendererOutDir = path.join(uiDistDir, "renderer"); +export const cliRendererOutDir = path.join(cliDistDir, "renderer"); +export const coreRendererOutDir = path.join(coreDistDir, "renderer"); +export const electronRendererOutDir = path.join(electronDistDir, "renderer"); +export const runtimeRendererOutDirs = [cliRendererOutDir, coreRendererOutDir, electronRendererOutDir]; +export const appAssetsDir = path.join(electronDistDir, "assets"); export const rendererAssetsDir = path.join(rendererOutDir, "assets"); -export const marketplacePluginsDir = path.join(distDir, "marketplace", "plugins"); -export const appAssetsInput = path.join(projectRoot, "assets"); -export const modelCatalogInput = path.join(projectRoot, "models.json"); -export const modelCatalogOutput = path.join(distDir, "models.json"); -export const rendererRoot = path.join(projectRoot, "src", "renderer"); +export const cliMarketplacePluginsDir = path.join(cliDistDir, "marketplace", "plugins"); +export const coreMarketplacePluginsDir = path.join(coreDistDir, "marketplace", "plugins"); +export const electronMarketplacePluginsDir = path.join(electronDistDir, "marketplace", "plugins"); +export const marketplacePluginsDir = electronMarketplacePluginsDir; +export const appAssetsInput = path.join(electronRoot, "assets"); +export const modelCatalogInput = path.join(coreRoot, "models.json"); +export const cliModelCatalogOutput = path.join(cliDistDir, "models.json"); +export const coreModelCatalogOutput = path.join(coreDistDir, "models.json"); +export const electronModelCatalogOutput = path.join(electronDistDir, "models.json"); +export const modelCatalogOutput = electronModelCatalogOutput; +export const rendererRoot = uiSourceRoot; export const rendererHtmlInput = path.join(rendererRoot, "pages", "home", "index.html"); export const rendererHtmlOutput = path.join(rendererOutDir, "pages", "home", "index.html"); export const browserRendererHtmlInput = path.join(rendererRoot, "pages", "browser", "index.html"); @@ -26,6 +66,19 @@ export const trayRendererHtmlInput = path.join(rendererRoot, "pages", "tray", "i export const trayRendererHtmlOutput = path.join(rendererOutDir, "pages", "tray", "index.html"); export const cssInput = path.join(rendererRoot, "styles", "globals.css"); export const cssOutput = path.join(rendererAssetsDir, "main.css"); +export const webClientBridgeOutput = path.join(rendererAssetsDir, "web-client-bridge.js"); +export const electronUndiciProxyAgentInput = path.join(coreSourceRoot, "proxy", "undici-proxy-agent.ts"); +const lightweightMcpBundleNames = ["browser-web-search-proxy-mcp.js", "fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js"]; +const lightweightMcpBundleMaxBytes = 128 * 1024; +const forbiddenLightweightMcpInputs = [ + { prefix: "packages/core/src/config/", reason: "config modules can pull in native storage side effects" }, + { prefix: "packages/core/src/storage/", reason: "native SQLite storage is not allowed in lightweight MCP subprocesses" }, + { prefix: "packages/electron/src/", reason: "Electron runtime modules are not allowed in lightweight MCP subprocesses" }, + { prefix: "packages/ui/src/", reason: "UI modules do not belong in stdio MCP subprocesses" }, + { prefix: "node_modules/better-sqlite3/", reason: "native SQLite is not allowed in lightweight MCP subprocesses" }, + { prefix: "node_modules/electron/", reason: "Electron runtime modules are not allowed in lightweight MCP subprocesses" } +]; +const forbiddenLightweightMcpExternalImports = new Set(["better-sqlite3", "electron"]); const nodeExternals = [ "electron", @@ -35,15 +88,28 @@ const nodeExternals = [ ]; export function cleanDist() { - rmSync(distDir, { force: true, recursive: true }); + rmSync(legacyDistDir, { force: true, recursive: true }); + rmSync(cliDistDir, { force: true, recursive: true }); + rmSync(coreDistDir, { force: true, recursive: true }); + rmSync(electronDistDir, { force: true, recursive: true }); + rmSync(uiDistDir, { force: true, recursive: true }); ensureDist(); } export function ensureDist() { - mkdirSync(mainOutDir, { recursive: true }); + mkdirSync(cliMainOutDir, { recursive: true }); + mkdirSync(coreMainOutDir, { recursive: true }); + mkdirSync(electronMainOutDir, { recursive: true }); + mkdirSync(electronBotGatewaySdkDistDir, { recursive: true }); + mkdirSync(electronBotGatewaySdkBinDir, { recursive: true }); mkdirSync(appAssetsDir, { recursive: true }); - mkdirSync(marketplacePluginsDir, { recursive: true }); + mkdirSync(cliMarketplacePluginsDir, { recursive: true }); + mkdirSync(coreMarketplacePluginsDir, { recursive: true }); + mkdirSync(electronMarketplacePluginsDir, { recursive: true }); mkdirSync(rendererAssetsDir, { recursive: true }); + for (const outputDir of runtimeRendererOutDirs) { + mkdirSync(path.join(outputDir, "assets"), { recursive: true }); + } mkdirSync(path.dirname(rendererHtmlOutput), { recursive: true }); mkdirSync(path.dirname(browserRendererHtmlOutput), { recursive: true }); mkdirSync(path.dirname(trayRendererHtmlOutput), { recursive: true }); @@ -59,12 +125,16 @@ export function copyAppAssets() { export function copyModelCatalog() { ensureDist(); if (existsSync(modelCatalogInput)) { - cpSync(modelCatalogInput, modelCatalogOutput); + cpSync(modelCatalogInput, cliModelCatalogOutput); + cpSync(modelCatalogInput, coreModelCatalogOutput); + cpSync(modelCatalogInput, electronModelCatalogOutput); } } export function copyRendererHtml() { - copyRendererPageHtml(rendererHtmlInput, rendererHtmlOutput, "main.js"); + copyRendererPageHtml(rendererHtmlInput, rendererHtmlOutput, "main.js", { + beforeModuleScriptTags: [' '] + }); } export function copyTrayRendererHtml() { @@ -79,14 +149,25 @@ export function copyMarketplacePlugins() { ensureDist(); for (const filename of ["claude-design-plugin.cjs", "cursor-proxy-plugin.cjs"]) { const source = path.join(projectRoot, "examples", "plugins", filename); - const target = path.join(marketplacePluginsDir, filename); if (existsSync(source)) { - cpSync(source, target); + cpSync(source, path.join(cliMarketplacePluginsDir, filename)); + cpSync(source, path.join(coreMarketplacePluginsDir, filename)); + cpSync(source, path.join(electronMarketplacePluginsDir, filename)); } } } -function copyRendererPageHtml(input, output, scriptName) { +export function syncUiRendererToRuntimeDists() { + ensureDist(); + for (const outputDir of runtimeRendererOutDirs) { + rmSync(outputDir, { force: true, recursive: true }); + if (existsSync(rendererOutDir)) { + cpSync(rendererOutDir, outputDir, { recursive: true }); + } + } +} + +function copyRendererPageHtml(input, output, scriptName, options = {}) { ensureDist(); const source = readFileSync(input, "utf8"); const styleTag = ' '; @@ -95,6 +176,12 @@ function copyRendererPageHtml(input, output, scriptName) { ? source.replace(' ', scriptTag) : source.replace("", `${scriptTag}\n `); + for (const extraScriptTag of options.beforeModuleScriptTags ?? []) { + if (!hasScriptTag(html, extraScriptTag)) { + html = html.replace(scriptTag, `${extraScriptTag}\n${scriptTag}`); + } + } + if (!html.includes('href="../../assets/main.css"')) { html = html.replace("", `${styleTag}\n `); } @@ -102,26 +189,96 @@ function copyRendererPageHtml(input, output, scriptName) { writeFileSync(output, html, "utf8"); } +function hasScriptTag(html, scriptTag) { + const sourceMatch = scriptTag.match(/\bsrc="([^"]+)"/); + return sourceMatch ? html.includes(sourceMatch[1]) : html.includes(scriptTag); +} + +function normalizeDuplicateShebangs(source) { + const lines = source.split("\n"); + if (!lines[0]?.startsWith("#!")) { + return source; + } + let index = 1; + while (lines[index]?.startsWith("#!")) { + index += 1; + } + return [lines[0], ...lines.slice(index)].join("\n"); +} + export function createMainBuildOptions({ mode = "production", plugins = [] } = {}) { return { absWorkingDir: projectRoot, bundle: true, entryNames: "[name]", entryPoints: [ - path.join(projectRoot, "src", "main", "main.ts"), - path.join(projectRoot, "src", "main", "browser-preload.ts"), - path.join(projectRoot, "src", "main", "cli.ts"), - path.join(projectRoot, "src", "server", "mcp", "fusion-vision-mcp.ts"), - path.join(projectRoot, "src", "main", "preload.ts") + path.join(electronSourceRoot, "main", "main.ts"), + path.join(electronSourceRoot, "main", "browser-preload.ts"), + gatewayRuntimeInput, + path.join(coreSourceRoot, "mcp", "browser-web-search-proxy-mcp.ts"), + path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"), + path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"), + path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts"), + electronUndiciProxyAgentInput, + path.join(electronSourceRoot, "main", "preload.ts") ], external: nodeExternals, format: "cjs", legalComments: "none", logLevel: "info", + metafile: true, minify: mode === "production", - outdir: mainOutDir, + outdir: electronMainOutDir, platform: "node", - plugins, + plugins: [packageAliasPlugin(), ...plugins], + sourcemap: mode !== "production", + target: "node22" + }; +} + +export function createCliBuildOptions({ mode = "production", plugins = [] } = {}) { + return { + absWorkingDir: projectRoot, + bundle: true, + entryNames: "[name]", + entryPoints: [ + path.join(cliSourceRoot, "cli.ts"), + path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"), + path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"), + path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts") + ], + external: nodeExternals.filter((moduleName) => moduleName !== "electron"), + format: "cjs", + legalComments: "none", + logLevel: "info", + minify: mode === "production", + outdir: cliMainOutDir, + platform: "node", + plugins: [forbidCliElectronPlugin(), packageAliasPlugin(), ...plugins], + sourcemap: mode !== "production", + target: "node22" + }; +} + +export function createCoreServerBuildOptions({ mode = "production", plugins = [] } = {}) { + return { + absWorkingDir: projectRoot, + bundle: true, + entryNames: "[name]", + entryPoints: [ + path.join(coreSourceRoot, "entrypoints", "server.ts"), + path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"), + path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"), + path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts") + ], + external: nodeExternals.filter((moduleName) => moduleName !== "electron"), + format: "cjs", + legalComments: "none", + logLevel: "info", + minify: mode === "production", + outdir: coreMainOutDir, + platform: "node", + plugins: [forbidCliElectronPlugin(), packageAliasPlugin(), ...plugins], sourcemap: mode !== "production", target: "node22" }; @@ -152,7 +309,7 @@ export function createRendererBuildOptions({ mode = "production", plugins = [] } minify: mode === "production", outfile: path.join(rendererAssetsDir, "main.js"), platform: "browser", - plugins: [rendererAliasPlugin(), ...plugins], + plugins: [rendererAliasPlugin(), packageAliasPlugin(), ...plugins], publicPath: "../../assets", sourcemap: mode !== "production", target: "chrome120" @@ -175,6 +332,44 @@ export function createBrowserRendererBuildOptions({ mode = "production", plugins }; } +export function createWebClientBridgeBuildOptions({ mode = "production", plugins = [] } = {}) { + return { + absWorkingDir: projectRoot, + bundle: true, + entryPoints: [path.join(uiSourceRoot, "web-client-bridge.ts")], + format: "iife", + legalComments: "none", + logLevel: "info", + minify: mode === "production", + outfile: webClientBridgeOutput, + platform: "browser", + plugins: [packageAliasPlugin(), ...plugins], + sourcemap: mode !== "production", + target: "chrome120" + }; +} + +export function createBotGatewaySdkBuildOptions({ mode = "production", plugins = [] } = {}) { + return { + absWorkingDir: projectRoot, + bundle: true, + entryPoints: [botGatewaySdkEntryInput], + external: [ + ...builtinModules, + ...builtinModules.map((moduleName) => `node:${moduleName}`) + ], + format: "esm", + legalComments: "none", + logLevel: "info", + minify: mode === "production", + outfile: electronBotGatewaySdkEntryOutput, + platform: "node", + plugins, + sourcemap: mode !== "production", + target: "node22" + }; +} + export function watchPlugin(name, onEnd) { return { name: `${name}-watch`, @@ -189,7 +384,38 @@ export function watchPlugin(name, onEnd) { } export async function buildMain(options = {}) { - await esbuild.build(createMainBuildOptions(options)); + const [mainBuildResult] = await Promise.all([ + esbuild.build(createMainBuildOptions(options)), + buildBotGatewaySdkRuntime(options), + buildCoreServer(options), + buildCli(options) + ]); + copyCliRuntimeToElectronDist(); + validateLightweightMcpBundles(mainBuildResult.metafile); +} + +export async function buildBotGatewaySdkRuntime(options = {}) { + ensureDist(); + await esbuild.build(createBotGatewaySdkBuildOptions(options)); + writeFileSync( + electronBotGatewaySdkPackageOutput, + `${JSON.stringify({ private: true, type: "module" }, null, 2)}\n`, + "utf8" + ); + writeFileSync( + electronBotGatewaySdkRunnerOutput, + normalizeDuplicateShebangs(readFileSync(botGatewaySdkRunnerInput, "utf8")), + "utf8" + ); + chmodSync(electronBotGatewaySdkRunnerOutput, 0o755); +} + +export async function buildCli(options = {}) { + await esbuild.build(createCliBuildOptions(options)); +} + +export async function buildCoreServer(options = {}) { + await esbuild.build(createCoreServerBuildOptions(options)); } export async function buildRenderer(options = {}) { @@ -204,6 +430,18 @@ export async function buildBrowserRenderer(options = {}) { await esbuild.build(createBrowserRendererBuildOptions(options)); } +export async function buildWebClientBridge(options = {}) { + await esbuild.build(createWebClientBridgeBuildOptions(options)); +} + +export function copyCliRuntimeToElectronDist() { + ensureDist(); + const cliRuntime = path.join(cliMainOutDir, "cli.js"); + if (existsSync(cliRuntime)) { + cpSync(cliRuntime, path.join(electronMainOutDir, "cli.js")); + } +} + export async function buildStyles({ minify = false } = {}) { ensureDist(); const args = ["-i", cssInput, "-o", cssOutput]; @@ -249,20 +487,109 @@ function rendererAliasPlugin() { }; } +function packageAliasPlugin() { + return { + name: "ccr-package-alias", + setup(build) { + build.onResolve({ filter: /^@ccr\/cli\// }, (args) => { + return { path: resolvePackageImport(cliSourceRoot, args.path.slice("@ccr/cli/".length)) }; + }); + build.onResolve({ filter: /^@ccr\/core\// }, (args) => { + return { path: resolvePackageImport(coreSourceRoot, args.path.slice("@ccr/core/".length)) }; + }); + build.onResolve({ filter: /^@ccr\/electron\// }, (args) => { + return { path: resolvePackageImport(electronSourceRoot, args.path.slice("@ccr/electron/".length)) }; + }); + build.onResolve({ filter: /^@ccr\/ui\// }, (args) => { + return { path: resolvePackageImport(uiSourceRoot, args.path.slice("@ccr/ui/".length)) }; + }); + } + }; +} + +function forbidCliElectronPlugin() { + return { + name: "forbid-cli-electron", + setup(build) { + build.onResolve({ filter: /^electron$/ }, () => { + return { + errors: [ + { + text: "CLI bundle must not import electron. Move the dependency behind a desktop-only boundary." + } + ] + }; + }); + } + }; +} + +function validateLightweightMcpBundles(metafile) { + if (!metafile) { + return; + } + + const outputsByName = new Map( + Object.entries(metafile.outputs).map(([outputPath, output]) => [path.basename(outputPath), { output, outputPath }]) + ); + + for (const bundleName of lightweightMcpBundleNames) { + const entry = outputsByName.get(bundleName); + if (!entry) { + continue; + } + + const violations = []; + if (entry.output.bytes > lightweightMcpBundleMaxBytes) { + violations.push(`bundle size ${entry.output.bytes} bytes exceeds ${lightweightMcpBundleMaxBytes} bytes`); + } + + for (const inputPath of Object.keys(entry.output.inputs ?? {})) { + const normalizedInput = normalizeBuildPath(inputPath); + for (const rule of forbiddenLightweightMcpInputs) { + if (normalizedInput.startsWith(rule.prefix)) { + violations.push(`${normalizedInput} (${rule.reason})`); + } + } + } + + for (const imported of entry.output.imports ?? []) { + if (imported.external && forbiddenLightweightMcpExternalImports.has(imported.path)) { + violations.push(`${imported.path} (external native/runtime dependency is not allowed)`); + } + } + + if (violations.length > 0) { + throw new Error([ + `Lightweight MCP bundle ${bundleName} crossed its dependency boundary.`, + ...violations.map((violation) => `- ${violation}`) + ].join("\n")); + } + } +} + +function normalizeBuildPath(value) { + return value.split(path.sep).join("/"); +} + function resolveRendererImport(importPath) { - const basePath = path.resolve(rendererRoot, importPath); + return resolvePackageImport(rendererRoot, importPath); +} + +function resolvePackageImport(rootDir, importPath) { + const packageBasePath = path.resolve(rootDir, importPath); const candidates = [ - basePath, - `${basePath}.tsx`, - `${basePath}.ts`, - `${basePath}.jsx`, - `${basePath}.js`, - `${basePath}.json`, - `${basePath}.css`, - path.join(basePath, "index.tsx"), - path.join(basePath, "index.ts"), - path.join(basePath, "index.jsx"), - path.join(basePath, "index.js") + packageBasePath, + `${packageBasePath}.tsx`, + `${packageBasePath}.ts`, + `${packageBasePath}.jsx`, + `${packageBasePath}.js`, + `${packageBasePath}.json`, + `${packageBasePath}.css`, + path.join(packageBasePath, "index.tsx"), + path.join(packageBasePath, "index.ts"), + path.join(packageBasePath, "index.jsx"), + path.join(packageBasePath, "index.js") ]; for (const candidate of candidates) { @@ -271,5 +598,5 @@ function resolveRendererImport(importPath) { } } - return basePath; + return packageBasePath; } diff --git a/build/merge-macos-update-metadata.mjs b/build/merge-macos-update-metadata.mjs new file mode 100644 index 0000000..d1e2da0 --- /dev/null +++ b/build/merge-macos-update-metadata.mjs @@ -0,0 +1,78 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import yaml from "js-yaml"; + +const [outputFile, ...inputFiles] = process.argv.slice(2); + +if (!outputFile || inputFiles.length < 2) { + console.error("Usage: node build/merge-macos-update-metadata.mjs [...]"); + process.exit(1); +} + +const updateInfos = inputFiles.map((file) => { + const info = yaml.load(readFileSync(file, "utf8")); + if (!info || typeof info !== "object") { + throw new Error(`${file} is not a valid update metadata object`); + } + if (!Array.isArray(info.files) || info.files.length === 0) { + throw new Error(`${file} does not contain any update files`); + } + return { file, info }; +}); + +const version = updateInfos[0].info.version; +if (!version || updateInfos.some(({ info }) => info.version !== version)) { + throw new Error("All macOS update metadata files must have the same version"); +} + +const files = uniqueByUrl( + updateInfos + .flatMap(({ info }) => info.files) + .filter((file) => file && typeof file.url === "string") + .sort(compareMacUpdateFile) +); + +const defaultZip = files.find((file) => file.url.endsWith(".zip") && file.url.includes("arm64")) ?? files.find((file) => file.url.endsWith(".zip")); +if (!defaultZip?.sha512) { + throw new Error("Merged macOS update metadata must include at least one ZIP with sha512"); +} + +const releaseDate = updateInfos + .map(({ info }) => info.releaseDate) + .filter(Boolean) + .sort() + .at(-1); + +const { files: _files, path: _path, sha512: _sha512, releaseDate: _releaseDate, ...baseInfo } = updateInfos[0].info; +const mergedInfo = { + ...baseInfo, + version, + files, + path: defaultZip.url, + sha512: defaultZip.sha512, + ...(releaseDate ? { releaseDate } : {}) +}; + +writeFileSync(outputFile, yaml.dump(mergedInfo, { lineWidth: 120, noRefs: true }), "utf8"); +console.log(`Wrote ${path.relative(process.cwd(), outputFile)} with ${files.length} macOS artifacts.`); + +function uniqueByUrl(files) { + const seen = new Set(); + return files.filter((file) => { + if (seen.has(file.url)) { + return false; + } + seen.add(file.url); + return true; + }); +} + +function compareMacUpdateFile(left, right) { + return fileRank(left) - fileRank(right) || left.url.localeCompare(right.url); +} + +function fileRank(file) { + const isZip = file.url.endsWith(".zip") ? 0 : 10; + const isAppleSilicon = file.url.includes("arm64") ? 0 : 1; + return isZip + isAppleSilicon; +} diff --git a/build/run-tests.mjs b/build/run-tests.mjs new file mode 100644 index 0000000..aaa9cfb --- /dev/null +++ b/build/run-tests.mjs @@ -0,0 +1,67 @@ +import electron from "electron"; +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, ".."); +const testHome = mkdtempSync(path.join(os.tmpdir(), "ccr-test-home-")); +const testSuites = ["main", "renderer"]; +const requestedSuites = process.argv.slice(2); +const suites = requestedSuites.length === 0 ? testSuites : requestedSuites; + +for (const suite of suites) { + if (!testSuites.includes(suite)) { + cleanup(); + throw new Error(`Unknown test suite: ${suite}`); + } +} + +try { + for (const suite of suites) { + await runSuite(suite); + } +} finally { + cleanup(); +} + +function runSuite(suite) { + console.log(`\nRunning ${suite} tests...`); + + return new Promise((resolve, reject) => { + const child = spawn(electron, ["--test", `dist/tests/${suite}/*.test.js`], { + cwd: projectRoot, + env: { + ...process.env, + CCR_INTERNAL_APP_DATA_DIR: path.join(testHome, "app-data"), + CCR_INTERNAL_HOME_DIR: testHome, + CCR_INTERNAL_USER_DATA_DIR: path.join(testHome, "user-data"), + HOME: testHome, + ELECTRON_RUN_AS_NODE: "1" + }, + shell: process.platform === "win32", + stdio: "inherit" + }); + + child.on("error", reject); + child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + + if (code === 0) { + resolve(); + return; + } + + reject(new Error(`${suite} tests exited with code ${code ?? 1}`)); + }); + }); +} + +function cleanup() { + rmSync(testHome, { force: true, recursive: true }); +} diff --git a/build/test.mjs b/build/test.mjs new file mode 100644 index 0000000..be7b4ad --- /dev/null +++ b/build/test.mjs @@ -0,0 +1,145 @@ +import esbuild from "esbuild"; +import { existsSync, readdirSync, rmSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, ".."); +const testsOutDir = path.join(projectRoot, "dist", "tests"); +const rendererRoot = path.join(projectRoot, "packages", "ui", "src"); +const cliSourceRoot = path.join(projectRoot, "packages", "cli", "src"); +const coreSourceRoot = path.join(projectRoot, "packages", "core", "src"); +const testSuites = [ + { name: "main", testDir: path.join(projectRoot, "tests", "main") }, + { name: "renderer", testDir: path.join(projectRoot, "tests", "renderer") } +]; +const requestedSuites = new Set(process.argv.slice(2)); +const suiteNames = new Set(testSuites.map((suite) => suite.name)); +const unknownSuites = [...requestedSuites].filter((suite) => !suiteNames.has(suite)); +const selectedSuites = requestedSuites.size === 0 + ? testSuites + : testSuites.filter((suite) => requestedSuites.has(suite.name)); + +if (unknownSuites.length > 0) { + throw new Error(`Unknown test suite: ${unknownSuites.join(", ")}`); +} + +rmSync(testsOutDir, { force: true, recursive: true }); + +for (const suite of selectedSuites) { + const entryPoints = [ + ...findTestFiles(suite.testDir), + ...runtimeEntryPointsForSuite(suite.name) + ]; + if (entryPoints.length === 0) { + continue; + } + + await esbuild.build({ + absWorkingDir: projectRoot, + bundle: true, + entryNames: "[name]", + entryPoints, + external: [ + "better-sqlite3", + "electron" + ], + format: "cjs", + legalComments: "none", + loader: { + ".gif": "file", + ".ico": "file", + ".jpg": "file", + ".jpeg": "file", + ".png": "file", + ".svg": "file", + ".webp": "file" + }, + logLevel: "info", + outdir: path.join(testsOutDir, suite.name), + platform: "node", + plugins: [rendererAliasPlugin(), packageAliasPlugin()], + target: "node22" + }); +} + +function runtimeEntryPointsForSuite(suiteName) { + if (suiteName !== "main") { + return []; + } + return [ + path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"), + path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts") + ]; +} + +function findTestFiles(dir) { + if (!existsSync(dir)) { + return []; + } + + const files = []; + for (const name of readdirSync(dir)) { + const file = path.join(dir, name); + const stat = statSync(file); + if (stat.isDirectory()) { + files.push(...findTestFiles(file)); + } else if (/\.(test|spec)\.(mjs|ts|tsx)$/.test(name)) { + files.push(file); + } + } + return files.sort(); +} + +function rendererAliasPlugin() { + return { + name: "renderer-test-alias", + setup(build) { + build.onResolve({ filter: /^@\// }, (args) => { + return { path: resolveRendererImport(args.path.slice(2)) }; + }); + } + }; +} + +function packageAliasPlugin() { + return { + name: "test-package-alias", + setup(build) { + build.onResolve({ filter: /^@ccr\/cli\// }, (args) => { + return { path: resolvePackageImport(cliSourceRoot, args.path.slice("@ccr/cli/".length)) }; + }); + build.onResolve({ filter: /^@ccr\/core\// }, (args) => { + return { path: resolvePackageImport(coreSourceRoot, args.path.slice("@ccr/core/".length)) }; + }); + } + }; +} + +function resolveRendererImport(importPath) { + return resolvePackageImport(rendererRoot, importPath); +} + +function resolvePackageImport(rootDir, importPath) { + const basePath = path.resolve(rootDir, importPath); + const candidates = [ + basePath, + `${basePath}.tsx`, + `${basePath}.ts`, + `${basePath}.jsx`, + `${basePath}.js`, + `${basePath}.json`, + path.join(basePath, "index.tsx"), + path.join(basePath, "index.ts"), + path.join(basePath, "index.jsx"), + path.join(basePath, "index.js") + ]; + + for (const candidate of candidates) { + if (existsSync(candidate) && statSync(candidate).isFile()) { + return candidate; + } + } + + return basePath; +} diff --git a/build/verify-packaged-app.cjs b/build/verify-packaged-app.cjs new file mode 100644 index 0000000..ee52160 --- /dev/null +++ b/build/verify-packaged-app.cjs @@ -0,0 +1,233 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const betterSqliteNativeRelativePath = path.join( + "app.asar.unpacked", + "node_modules", + "better-sqlite3", + "build", + "Release", + "better_sqlite3.node" +); +const betterSqlitePackageRelativePath = path.join("app.asar.unpacked", "node_modules", "better-sqlite3"); +const betterSqlitePrunablePaths = [ + "deps", + "src", + "binding.gyp", + "README.md", + "docs", + "benchmark", + "benchmarks", + "test", + path.join("build", "Release", "obj"), + path.join("build", "Release", "obj.target") +]; + +module.exports = async function verifyPackagedApp(context) { + const platform = context?.electronPlatformName; + const arch = normalizeArch(context?.arch); + const appOutDir = context?.appOutDir; + + if (!platform || !appOutDir) { + throw new Error("Packaged app verification did not receive electron-builder platform context."); + } + + const resourcesDir = findResourcesDir(appOutDir, platform); + assertFile(path.join(resourcesDir, "app.asar"), "Packaged app archive"); + cleanupBetterSqlitePackage(resourcesDir); + + const nativeModule = path.join(resourcesDir, betterSqliteNativeRelativePath); + assertFile(nativeModule, "better-sqlite3 native module"); + + const nativeInfo = inspectNativeModule(nativeModule); + if (nativeInfo.platform !== platform) { + throw new Error( + `Packaged better-sqlite3 native module targets ${formatNativeInfo(nativeInfo)}, ` + + `but electron-builder is packaging ${platform}/${arch}. Rebuild native dependencies for the target platform before packaging.` + ); + } + + if (arch && !nativeArchMatches(nativeInfo, arch)) { + throw new Error( + `Packaged better-sqlite3 native module targets ${formatNativeInfo(nativeInfo)}, ` + + `but electron-builder is packaging ${platform}/${arch}. Run npm run rebuild:sqlite3 on the target platform before packaging.` + ); + } +}; + +function cleanupBetterSqlitePackage(resourcesDir) { + const packageDir = path.join(resourcesDir, betterSqlitePackageRelativePath); + for (const relativePath of betterSqlitePrunablePaths) { + fs.rmSync(path.join(packageDir, relativePath), { force: true, recursive: true }); + } +} + +function findResourcesDir(appOutDir, platform) { + if (platform !== "darwin") { + return path.join(appOutDir, "resources"); + } + + const appBundle = fs.readdirSync(appOutDir) + .find((entry) => entry.endsWith(".app") && fs.statSync(path.join(appOutDir, entry)).isDirectory()); + if (!appBundle) { + throw new Error(`Could not find a .app bundle in ${appOutDir}`); + } + return path.join(appOutDir, appBundle, "Contents", "Resources"); +} + +function assertFile(file, label) { + let stat; + try { + stat = fs.statSync(file); + } catch { + throw new Error(`${label} is missing: ${file}`); + } + + if (!stat.isFile() || stat.size === 0) { + throw new Error(`${label} is empty or not a file: ${file}`); + } +} + +function inspectNativeModule(file) { + const buffer = fs.readFileSync(file); + if (buffer.length < 32) { + throw new Error(`Native module is too small to identify: ${file}`); + } + + if (buffer[0] === 0x4d && buffer[1] === 0x5a) { + return inspectPe(buffer, file); + } + if (buffer[0] === 0x7f && buffer[1] === 0x45 && buffer[2] === 0x4c && buffer[3] === 0x46) { + return inspectElf(buffer); + } + + const magicLe = buffer.readUInt32LE(0); + const magicBe = buffer.readUInt32BE(0); + if (magicLe === 0xfeedface || magicLe === 0xfeedfacf || magicLe === 0xcefaedfe || magicLe === 0xcffaedfe) { + return inspectMachO(buffer, magicLe); + } + if (magicBe === 0xcafebabe || magicBe === 0xcafebabf) { + return inspectFatMachO(buffer, magicBe); + } + + throw new Error(`Native module has an unknown binary format: ${file}`); +} + +function inspectPe(buffer, file) { + const peOffset = buffer.readUInt32LE(0x3c); + if (peOffset + 6 >= buffer.length || buffer.toString("ascii", peOffset, peOffset + 4) !== "PE\0\0") { + throw new Error(`Native module has an invalid PE header: ${file}`); + } + const machine = buffer.readUInt16LE(peOffset + 4); + return { + arch: peMachineArch(machine), + platform: "win32" + }; +} + +function inspectElf(buffer) { + const machine = buffer.readUInt16LE(18); + return { + arch: elfMachineArch(machine), + platform: "linux" + }; +} + +function inspectMachO(buffer, magicLe) { + const bigEndian = magicLe === 0xcefaedfe || magicLe === 0xcffaedfe; + const cpuType = bigEndian ? buffer.readUInt32BE(4) : buffer.readUInt32LE(4); + return { + arch: machCpuArch(cpuType), + platform: "darwin" + }; +} + +function inspectFatMachO(buffer, magicBe) { + const archs = []; + const count = buffer.readUInt32BE(4); + const entrySize = magicBe === 0xcafebabf ? 32 : 20; + for (let index = 0; index < count; index += 1) { + const offset = 8 + index * entrySize; + if (offset + 8 > buffer.length) { + break; + } + archs.push(machCpuArch(buffer.readUInt32BE(offset))); + } + return { + arch: "universal", + archs, + platform: "darwin" + }; +} + +function peMachineArch(machine) { + if (machine === 0x8664) { + return "x64"; + } + if (machine === 0xaa64) { + return "arm64"; + } + if (machine === 0x014c) { + return "ia32"; + } + return `unknown-pe-${machine.toString(16)}`; +} + +function elfMachineArch(machine) { + if (machine === 0x3e) { + return "x64"; + } + if (machine === 0xb7) { + return "arm64"; + } + if (machine === 0x03) { + return "ia32"; + } + if (machine === 0x28) { + return "armv7l"; + } + return `unknown-elf-${machine.toString(16)}`; +} + +function machCpuArch(cpuType) { + if (cpuType === 0x01000007) { + return "x64"; + } + if (cpuType === 0x0100000c) { + return "arm64"; + } + if (cpuType === 0x00000007) { + return "ia32"; + } + return `unknown-macho-${cpuType.toString(16)}`; +} + +function normalizeArch(arch) { + if (typeof arch === "string") { + return arch; + } + const electronBuilderArchNames = new Map([ + [0, "ia32"], + [1, "x64"], + [2, "armv7l"], + [3, "arm64"], + [4, "universal"] + ]); + return electronBuilderArchNames.get(arch); +} + +function nativeArchMatches(info, arch) { + if (info.arch === arch) { + return true; + } + if (info.arch === "universal") { + return arch === "universal" || Boolean(info.archs?.includes(arch)); + } + return false; +} + +function formatNativeInfo(info) { + return info.arch === "universal" && info.archs?.length + ? `${info.platform}/${info.arch} (${info.archs.join(", ")})` + : `${info.platform}/${info.arch}`; +} diff --git a/cdn/README.md b/cdn/README.md new file mode 100644 index 0000000..772e06a --- /dev/null +++ b/cdn/README.md @@ -0,0 +1,63 @@ +# CCR CDN + +This directory contains the static assets for the embeddable CCR provider import button. + +Default Cloudflare Pages project: + +```text +claude-code-router-cdn +``` + +Default CDN URLs: + +```text +https://cdn.ccrdesk.top/ccr-provider-buttons.js +https://cdn.ccrdesk.top/ccr-icon.png +``` + +Cloudflare Pages custom domain: + +```text +cdn.ccrdesk.top +``` + +The Pages custom domain must have a CNAME record pointing to the Pages project: + +```text +cdn.ccrdesk.top CNAME claude-code-router-cdn.pages.dev +``` + +The documentation site uses `ccrdesk.top` through GitHub Pages. Configure DNS like this: + +```text +ccrdesk.top A 185.199.108.153 +ccrdesk.top A 185.199.109.153 +ccrdesk.top A 185.199.110.153 +ccrdesk.top A 185.199.111.153 +ccrdesk.top AAAA 2606:50c0:8000::153 +ccrdesk.top AAAA 2606:50c0:8001::153 +ccrdesk.top AAAA 2606:50c0:8002::153 +ccrdesk.top AAAA 2606:50c0:8003::153 +cdn.ccrdesk.top CNAME claude-code-router-cdn.pages.dev +``` + +If `ccrdesk.top` is delegated to Cloudflare, use these Cloudflare nameservers at the registrar: + +```text +benedict.ns.cloudflare.com +evangeline.ns.cloudflare.com +``` + +Deploy manually: + +```sh +cd cdn +npx wrangler pages deploy public --project-name=claude-code-router-cdn +``` + +The GitHub Actions workflow requires these repository secrets: + +```text +CLOUDFLARE_API_TOKEN +CLOUDFLARE_ACCOUNT_ID +``` diff --git a/cdn/package.json b/cdn/package.json new file mode 100644 index 0000000..f49b77a --- /dev/null +++ b/cdn/package.json @@ -0,0 +1,11 @@ +{ + "name": "claude-code-router-cdn", + "private": true, + "version": "0.1.0", + "license": "MIT", + "type": "module", + "scripts": { + "deploy": "wrangler pages deploy public --project-name=claude-code-router-cdn", + "deploy:preview": "wrangler pages deploy public --project-name=claude-code-router-cdn --branch=preview" + } +} diff --git a/cdn/public/_headers b/cdn/public/_headers new file mode 100644 index 0000000..c4b9aa0 --- /dev/null +++ b/cdn/public/_headers @@ -0,0 +1,10 @@ +/* + Access-Control-Allow-Origin: * + X-Content-Type-Options: nosniff + +/ccr-provider-buttons.js + Cache-Control: public, max-age=300, s-maxage=86400 + Content-Type: application/javascript; charset=utf-8 + +/ccr-icon.png + Cache-Control: public, max-age=31536000, immutable diff --git a/cdn/public/ccr-icon.png b/cdn/public/ccr-icon.png new file mode 100644 index 0000000..42cdfc1 Binary files /dev/null and b/cdn/public/ccr-icon.png differ diff --git a/cdn/public/ccr-provider-buttons.js b/cdn/public/ccr-provider-buttons.js new file mode 100644 index 0000000..534070c --- /dev/null +++ b/cdn/public/ccr-provider-buttons.js @@ -0,0 +1,302 @@ +(function () { + "use strict"; + + var VERSION = "0.2.0"; + var STYLE_ID = "ccr-provider-buttons-style"; + var OFFICIAL_CCR_ICON_URL = "https://cdn.ccrdesk.top/ccr-icon.png"; + + var LINK_PARAMS = [ + "name", + "base_url", + "api_key", + "protocol", + "icon", + "source", + "payload", + "usage_url", + "usage_method", + "usage_headers", + "usage_body", + "balance", + "balance_unit", + "subscription", + "subscription_limit", + "subscription_reset", + "subscription_unit", + "subscription_window" + ]; + var ELEMENT_ATTRIBUTES = LINK_PARAMS.concat(["manifest", "models", "fetch_usage", "description", "color", "color_2", "color_3"]); + + var CSS = [ + ".ccr-provider-button{--ccrpb-brand:#17201c;--ccrpb-brand-2:#36624d;--ccrpb-brand-3:#dff8eb;position:relative;isolation:isolate;display:grid;grid-template-columns:132px minmax(0,1fr);align-items:center;min-height:86px;width:100%;max-width:420px;padding:13px 16px 13px 12px;overflow:hidden;border:1px solid color-mix(in srgb,var(--ccrpb-brand-2) 42%,transparent);border-radius:12px;background:radial-gradient(circle at 14% 22%,color-mix(in srgb,var(--ccrpb-brand-3) 34%,transparent) 0,transparent 30%),radial-gradient(circle at 96% 96%,color-mix(in srgb,var(--ccrpb-brand-2) 44%,transparent) 0,transparent 46%),linear-gradient(135deg,var(--ccrpb-brand),color-mix(in srgb,var(--ccrpb-brand-2) 88%,#111 12%));box-shadow:0 10px 26px color-mix(in srgb,var(--ccrpb-brand) 22%,transparent),inset 0 1px 0 rgba(255,255,255,.16);box-sizing:border-box;color:#fff;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;text-decoration:none;transform:translateY(0);transition:border-color 180ms ease,box-shadow 220ms ease,transform 220ms ease;}", + ".ccr-provider-button::before{content:\"\";position:absolute;inset:-40% auto -40% -70%;z-index:-1;width:58%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.26),transparent);transform:skewX(-18deg);transition:transform 620ms ease;}", + ".ccr-provider-button::after{content:\"\";position:absolute;right:-38px;bottom:-52px;z-index:-2;width:132px;height:132px;border-radius:999px;background:radial-gradient(circle,color-mix(in srgb,var(--ccrpb-brand-3) 38%,transparent),transparent 68%);opacity:.82;transform:scale(.96);transition:opacity 220ms ease,transform 220ms ease;}", + ".ccr-provider-button:hover,.ccr-provider-button:focus-visible{border-color:color-mix(in srgb,var(--ccrpb-brand-3) 68%,rgba(255,255,255,.34));box-shadow:0 16px 34px color-mix(in srgb,var(--ccrpb-brand) 30%,transparent),0 0 0 1px color-mix(in srgb,var(--ccrpb-brand-3) 26%,transparent),inset 0 1px 0 rgba(255,255,255,.22);color:#fff;text-decoration:none;transform:translateY(-3px);}", + ".ccr-provider-button:hover::before,.ccr-provider-button:focus-visible::before{transform:translateX(320%) skewX(-18deg);}", + ".ccr-provider-button:hover::after,.ccr-provider-button:focus-visible::after{opacity:1;transform:scale(1.08);}", + ".ccr-provider-button:active{transform:translateY(-1px) scale(.992);}", + ".ccrpb-provider-icon{position:relative;z-index:1;grid-column:1;grid-row:1;justify-self:start;display:grid;place-items:center;width:48px;height:48px;overflow:hidden;border:1px solid rgba(255,255,255,.46);border-radius:12px;background:linear-gradient(135deg,color-mix(in srgb,var(--ccrpb-brand-3) 34%,rgba(255,255,255,.9)),color-mix(in srgb,var(--ccrpb-brand-2) 64%,#111 36%));box-shadow:0 8px 18px rgba(0,0,0,.14),inset 0 1px 0 rgba(255,255,255,.42);color:#fff;font-size:15px;font-weight:820;letter-spacing:0;line-height:1;text-shadow:0 1px 2px rgba(0,0,0,.34);transform:rotate(0) scale(1);transition:border-color 180ms ease,box-shadow 220ms ease,transform 220ms ease;}", + ".ccrpb-provider-icon img{box-sizing:border-box;display:block;width:100%;height:100%;background:color-mix(in srgb,#fff 84%,var(--ccrpb-brand-3));object-fit:contain;filter:drop-shadow(0 1px 1px rgba(0,0,0,.12));}", + ".ccrpb-provider-mark{display:block;max-width:42px;overflow:hidden;text-align:center;text-overflow:ellipsis;white-space:nowrap;}", + ".ccrpb-flow{display:inline-flex;grid-column:1;grid-row:1;align-items:center;justify-self:end;justify-content:flex-end;gap:8px;width:72px;height:48px;padding:0;border:0;background:transparent;color:rgba(255,255,255,.86);font-size:14px;font-weight:760;line-height:1;box-shadow:none;transform:translateX(0);transition:transform 220ms ease;}", + ".ccrpb-ccr-icon{box-sizing:border-box;display:grid;flex:0 0 auto;place-items:center;width:48px;height:48px;overflow:hidden;border:0;border-radius:12px;background:transparent;box-shadow:none;transition:transform 220ms ease;}", + ".ccrpb-ccr-icon img{box-sizing:border-box;display:block;width:48px;height:48px;border-radius:12px;}", + ".ccrpb-arrow{display:inline-block;order:-1;line-height:1;transform:translateX(0);transition:transform 220ms ease;}", + ".ccrpb-copy{display:grid;min-width:0;gap:2px;padding:0 0 0 6px;}", + ".ccrpb-name{display:block;overflow:hidden;color:#fff;font-size:15px;font-weight:780;line-height:1.25;text-overflow:ellipsis;white-space:nowrap;}", + ".ccrpb-description{display:block;overflow:hidden;color:rgba(255,255,255,.74);font-size:11px;font-weight:650;letter-spacing:0;line-height:1.35;text-overflow:ellipsis;white-space:nowrap;}", + ".ccr-provider-button:hover .ccrpb-provider-icon,.ccr-provider-button:focus-visible .ccrpb-provider-icon{border-color:color-mix(in srgb,var(--ccrpb-brand-3) 74%,rgba(255,255,255,.54));box-shadow:0 12px 24px rgba(0,0,0,.18),0 0 0 4px color-mix(in srgb,var(--ccrpb-brand-3) 16%,transparent),inset 0 1px 0 rgba(255,255,255,.52);transform:rotate(-3deg) scale(1.06);}", + ".ccr-provider-button:hover .ccrpb-flow,.ccr-provider-button:focus-visible .ccrpb-flow{transform:translateX(3px);}", + ".ccr-provider-button:hover .ccrpb-ccr-icon,.ccr-provider-button:focus-visible .ccrpb-ccr-icon{transform:scale(1.04);}", + ".ccr-provider-button:hover .ccrpb-arrow,.ccr-provider-button:focus-visible .ccrpb-arrow{transform:translateX(2px);}", + "@media (max-width:520px){.ccr-provider-button{grid-template-columns:118px minmax(0,1fr);min-height:92px;padding:12px}.ccrpb-provider-icon,.ccrpb-provider-icon img,.ccrpb-flow,.ccrpb-ccr-icon,.ccrpb-ccr-icon img{width:44px;height:44px}.ccrpb-provider-icon img,.ccrpb-ccr-icon,.ccrpb-ccr-icon img{border-radius:11px}.ccrpb-flow{width:66px;justify-self:end}}", + "@media (prefers-reduced-motion:reduce){.ccr-provider-button,.ccr-provider-button::before,.ccr-provider-button::after,.ccrpb-provider-icon,.ccrpb-flow,.ccrpb-ccr-icon,.ccrpb-arrow{transition:none}.ccr-provider-button:hover,.ccr-provider-button:focus-visible,.ccr-provider-button:hover .ccrpb-provider-icon,.ccr-provider-button:focus-visible .ccrpb-provider-icon,.ccr-provider-button:hover .ccrpb-flow,.ccr-provider-button:focus-visible .ccrpb-flow,.ccr-provider-button:hover .ccrpb-ccr-icon,.ccr-provider-button:focus-visible .ccrpb-ccr-icon,.ccr-provider-button:hover .ccrpb-arrow,.ccr-provider-button:focus-visible .ccrpb-arrow{transform:none}.ccr-provider-button::before{display:none}}" + ].join(""); + + function injectStyles() { + if (document.getElementById(STYLE_ID)) { + return; + } + var style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = CSS; + document.head.appendChild(style); + } + + function isPresent(value) { + return value !== undefined && value !== null && String(value).trim() !== ""; + } + + function stringifyValue(value) { + if (value === true) { + return "1"; + } + if (value === false) { + return "0"; + } + if (typeof value === "object") { + return JSON.stringify(value); + } + return String(value); + } + + function appendParam(params, key, value) { + if (!isPresent(value)) { + return; + } + if (Array.isArray(value)) { + value.forEach(function (item) { + if (isPresent(item)) { + params.append(key, stringifyValue(item)); + } + }); + return; + } + params.append(key, stringifyValue(value)); + } + + function providerUrl(options) { + options = options || {}; + var params = new URLSearchParams(); + if (isPresent(options.manifest)) { + params.set("manifest", stringifyValue(options.manifest)); + return "ccr://provider?" + params.toString(); + } + LINK_PARAMS.forEach(function (key) { + appendParam(params, key, options[key]); + }); + appendParam(params, "models", options.models); + appendParam(params, "fetch_usage", options.fetch_usage); + return "ccr://provider?" + params.toString(); + } + + function createImage(src, alt) { + var img = document.createElement("img"); + img.src = src; + img.alt = alt || ""; + img.loading = "lazy"; + img.decoding = "async"; + return img; + } + + function providerInitials(name) { + var words = String(name || "") + .replace(/[^A-Za-z0-9\u4e00-\u9fff]+/g, " ") + .trim() + .split(/\s+/) + .filter(Boolean); + if (words.length === 0) { + return "AI"; + } + if (words.length === 1) { + return words[0].slice(0, 2).toUpperCase(); + } + return (words[0].slice(0, 1) + words[1].slice(0, 1)).toUpperCase(); + } + + function protocolDescription(options) { + if (options.description) { + return options.description; + } + if (options.manifest) { + return "Remote provider manifest"; + } + if (options.protocol) { + return options.protocol.replace(/_/g, " "); + } + return "Import provider to CCR"; + } + + function providerName(options) { + return options.name || (options.manifest ? "Provider manifest" : "Provider"); + } + + function providerId(options) { + return providerName(options).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "provider"; + } + + function applyColors(element, options) { + element.style.setProperty("--ccrpb-brand", options.color || "#17201c"); + element.style.setProperty("--ccrpb-brand-2", options.color_2 || "#36624d"); + element.style.setProperty("--ccrpb-brand-3", options.color_3 || "#dff8eb"); + } + + function createProviderIcon(options) { + var shell = document.createElement("span"); + shell.className = "ccrpb-provider-icon"; + if (options.icon) { + shell.appendChild(createImage(options.icon, providerName(options))); + return shell; + } + var mark = document.createElement("span"); + mark.className = "ccrpb-provider-mark"; + mark.textContent = providerInitials(providerName(options)); + shell.appendChild(mark); + return shell; + } + + function createButton(options) { + options = options || {}; + var button = document.createElement("a"); + button.className = "ccr-provider-button ccr-provider-" + providerId(options); + button.href = providerUrl(options); + button.dataset.ccrProviderName = providerName(options); + button.setAttribute("aria-label", "Import " + providerName(options) + " provider to CCR"); + applyColors(button, options); + + var providerIcon = createProviderIcon(options); + + var flow = document.createElement("span"); + flow.className = "ccrpb-flow"; + + var ccrIconShell = document.createElement("span"); + ccrIconShell.className = "ccrpb-ccr-icon"; + ccrIconShell.appendChild(createImage(OFFICIAL_CCR_ICON_URL, "CCR")); + + var arrow = document.createElement("span"); + arrow.className = "ccrpb-arrow"; + arrow.setAttribute("aria-hidden", "true"); + arrow.textContent = "→"; + + flow.appendChild(ccrIconShell); + flow.appendChild(arrow); + + var copy = document.createElement("span"); + copy.className = "ccrpb-copy"; + + var name = document.createElement("span"); + name.className = "ccrpb-name"; + name.textContent = providerName(options); + + var description = document.createElement("span"); + description.className = "ccrpb-description"; + description.textContent = protocolDescription(options); + + copy.appendChild(name); + copy.appendChild(description); + + button.appendChild(providerIcon); + button.appendChild(flow); + button.appendChild(copy); + return button; + } + + function render(target, options) { + options = options || {}; + var root = typeof target === "string" ? document.querySelector(target) : target; + if (!root) { + throw new Error("CCRProviderButtons.render target not found"); + } + injectStyles(); + var button = createButton(options); + if (options.clear !== false) { + root.textContent = ""; + } + root.appendChild(button); + return { + element: button, + href: button.href, + destroy: function () { + if (button.parentNode) { + button.parentNode.removeChild(button); + } + } + }; + } + + function attributeOptions(element) { + var options = {}; + ELEMENT_ATTRIBUTES.forEach(function (name) { + if (!element.hasAttribute(name)) { + return; + } + var value = element.getAttribute(name); + options[name] = name === "fetch_usage" && value === "" ? true : value; + }); + return options; + } + + function defineElement(name) { + if (!("customElements" in window) || customElements.get(name)) { + return; + } + customElements.define(name, class extends HTMLElement { + static get observedAttributes() { + return ELEMENT_ATTRIBUTES; + } + connectedCallback() { + this.render(); + } + attributeChangedCallback() { + if (this.isConnected) { + this.render(); + } + } + render() { + render(this, attributeOptions(this)); + } + }); + } + + function defineElements() { + defineElement("ccr-provider-button"); + defineElement("ccr-provider-buttons"); + } + + window.CCRProviderButtons = { + version: VERSION, + render: render, + renderButton: render, + createButton: function (options) { + injectStyles(); + return createButton(options || {}); + }, + createProviderUrl: providerUrl + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", defineElements, { once: true }); + } else { + defineElements(); + } +}()); diff --git a/cdn/wrangler.toml b/cdn/wrangler.toml new file mode 100644 index 0000000..3baae4b --- /dev/null +++ b/cdn/wrangler.toml @@ -0,0 +1,3 @@ +name = "claude-code-router-cdn" +compatibility_date = "2026-06-28" +pages_build_output_dir = "./public" diff --git a/components.json b/components.json index e6f7108..a1acbd1 100644 --- a/components.json +++ b/components.json @@ -5,7 +5,7 @@ "tsx": true, "tailwind": { "config": "", - "css": "src/renderer/styles/globals.css", + "css": "packages/ui/src/styles/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d4a0916 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + ccr: + build: + context: . + dockerfile: Dockerfile + image: claude-code-router:local + ports: + # Publish only Nginx. Internal web/gateway listeners stay inside the container. + - "3458:8080" + volumes: + - ccr-data:/data + restart: unless-stopped + +volumes: + ccr-data: diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..2b51a1f --- /dev/null +++ b/docker/README.md @@ -0,0 +1,91 @@ +# Docker deployment + +This image runs the core server package with PM2 and serves the built UI package +through Nginx. Nginx is the only published entrypoint: it serves the UI, proxies +management API calls to the internal core server, and proxies gateway API calls +to the internal gateway listener. + +## Build and run + +```sh +docker compose up --build +``` + +Then open: + +- Web UI: +- Gateway endpoint: + +`docker-compose.yml` publishes only Nginx (`3458:8080`). Behind Nginx, the image +runs separate container-private listeners for management RPC, API gateway +routing, and the core gateway runtime. They are implementation details and are +not published or configured by the default Compose file. + +To use a different host port, change the Compose port mapping and keep the +public router endpoint in sync: + +```yaml +services: + ccr: + ports: + - "8088:8080" + environment: + CCR_PUBLIC_BASE_URL: http://127.0.0.1:8088 +``` + +The container stores config and SQLite databases under `/data`, backed by the +`ccr-data` volume in `docker-compose.yml`. + +On a fresh data volume, the Web UI starts immediately. The gateway endpoint is +available through the same Nginx entrypoint, but the gateway only starts after at +least one provider and model are configured. + +## Image scripts + +```sh +npm run docker:build +npm run docker:run +``` + +## Smoke test + +```sh +npm run test:docker +``` + +The smoke test builds the image, starts an isolated temporary container with a +special-character `CCR_WEB_AUTH_TOKEN`, verifies that only the Nginx port is +published, checks UI and RPC authentication, confirms legacy Docker config is +migrated to the public Nginx router endpoint, and removes its temporary +container and volume. Set `CCR_DOCKER_TEST_SKIP_BUILD=1` to reuse an already +built image. + +The Dockerfile uses `node:22-bookworm` for build and native SQLite dependency +installation, then copies the production dependencies into a smaller +`node:22-bookworm-slim` runtime image. To use different base images: + +```sh +docker build \ + --build-arg NODE_IMAGE=node:22-bookworm \ + --build-arg RUNTIME_NODE_IMAGE=node:22-bookworm-slim \ + -t claude-code-router:local . +``` + +## Environment + +Most deployments only need the published Nginx port mapping, `CCR_WEB_AUTH_TOKEN`, +and optionally `CCR_PUBLIC_BASE_URL` when the host-facing URL is not +`http://127.0.0.1:3458`. + +| Variable | Default | Description | +| --- | --- | --- | +| `CCR_WEB_AUTH_TOKEN` | generated | Shared management UI token used by Nginx redirects and the core server. | +| `CCR_PUBLIC_BASE_URL` | `http://127.0.0.1:3458` | Full public router endpoint override. Set this when changing the host-facing Compose port. | +| `CCR_DATA_DIR` | `/data` | Container data root. | +| `CCR_NO_GATEWAY` | `0` | Set to `1` to run only the Web UI management service. | +| `CCR_DOCKER_INIT_CONFIG` | `1` | Set to `0` to disable first-run `config.json` bootstrap. | +| `CCR_DOCKER_SYNC_PUBLIC_ENDPOINT` | `1` | Sync existing Docker config to the Nginx public router endpoint on startup. | + +The first-run bootstrap writes a minimal legacy `config.json` only when neither +`config.json` nor `config.sqlite` exists in the mounted data directory. Once the +UI saves settings into SQLite, existing persisted configuration takes priority. diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..c4b8983 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,207 @@ +#!/bin/sh +set -eu + +CCR_DATA_DIR="${CCR_DATA_DIR:-/data}" +CCR_WEB_HOST="${CCR_WEB_HOST:-127.0.0.1}" +CCR_WEB_PORT="${CCR_WEB_PORT:-3459}" +CCR_NGINX_PORT="${CCR_NGINX_PORT:-8080}" +CCR_GATEWAY_HOST="${CCR_GATEWAY_HOST:-127.0.0.1}" +CCR_GATEWAY_PORT="${CCR_GATEWAY_PORT:-3456}" +CCR_GATEWAY_CORE_PORT="${CCR_GATEWAY_CORE_PORT:-3457}" +CCR_PUBLIC_HOST="${CCR_PUBLIC_HOST:-127.0.0.1}" +CCR_PUBLIC_PORT="${CCR_PUBLIC_PORT:-3458}" +CCR_PUBLIC_BASE_URL="${CCR_PUBLIC_BASE_URL:-http://${CCR_PUBLIC_HOST}:${CCR_PUBLIC_PORT}}" +CCR_NO_GATEWAY="${CCR_NO_GATEWAY:-0}" + +if [ -z "${CCR_WEB_AUTH_TOKEN:-}" ]; then + CCR_WEB_AUTH_TOKEN="$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('base64url'))")" +fi +CCR_WEB_AUTH_TOKEN_QUERY="$(node -e "process.stdout.write(encodeURIComponent(process.argv[1] || ''))" "${CCR_WEB_AUTH_TOKEN}")" + +export HOME="${CCR_DATA_DIR}" +export CCR_DATA_DIR +export CCR_GATEWAY_CORE_PORT +export CCR_GATEWAY_HOST +export CCR_GATEWAY_PORT +export CCR_NGINX_PORT +export CCR_NO_GATEWAY +export CCR_PUBLIC_BASE_URL +export CCR_PUBLIC_HOST +export CCR_PUBLIC_PORT +export CCR_WEB_AUTH_TOKEN +export CCR_WEB_AUTH_TOKEN_QUERY +export CCR_WEB_HOST +export CCR_WEB_PORT + +CONFIG_DIR="${HOME}/.claude-code-router" +CONFIG_FILE="${CONFIG_DIR}/config.json" +APP_CONFIG_DB_FILE="${CONFIG_DIR}/config.sqlite" + +mkdir -p "${CONFIG_DIR}" "${CONFIG_DIR}/app-data" /run/nginx /var/lib/nginx /var/log/nginx + +if [ "${CCR_DOCKER_INIT_CONFIG:-1}" != "0" ] && [ ! -f "${CONFIG_FILE}" ] && [ ! -f "${APP_CONFIG_DB_FILE}" ]; then + node - <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const configDir = path.join(process.env.HOME, ".claude-code-router"); +const configFile = path.join(configDir, "config.json"); +const gatewayHost = process.env.CCR_GATEWAY_HOST || "0.0.0.0"; +const gatewayPort = Number(process.env.CCR_GATEWAY_PORT || "3456"); +const gatewayCorePort = Number(process.env.CCR_GATEWAY_CORE_PORT || "3457"); +const publicBaseUrl = (process.env.CCR_PUBLIC_BASE_URL || `http://127.0.0.1:${process.env.CCR_PUBLIC_PORT || "3458"}`).replace(/\/+$/, ""); + +fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); +fs.writeFileSync(configFile, `${JSON.stringify({ + HOST: gatewayHost, + PORT: gatewayPort, + gateway: { + coreHost: "127.0.0.1", + corePort: gatewayCorePort, + enabled: true, + host: gatewayHost, + port: gatewayPort + }, + routerEndpoint: publicBaseUrl +}, null, 2)}\n`, { mode: 0o600 }); +NODE +fi + +if [ "${CCR_DOCKER_SYNC_PUBLIC_ENDPOINT:-1}" != "0" ]; then + node - <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const configDir = path.join(process.env.HOME, ".claude-code-router"); +const configFile = path.join(configDir, "config.json"); +const appConfigDbFile = path.join(configDir, "config.sqlite"); +const gatewayHost = process.env.CCR_GATEWAY_HOST || "127.0.0.1"; +const gatewayPort = Number(process.env.CCR_GATEWAY_PORT || "3456"); +const gatewayCorePort = Number(process.env.CCR_GATEWAY_CORE_PORT || "3457"); +const publicBaseUrl = (process.env.CCR_PUBLIC_BASE_URL || `http://127.0.0.1:${process.env.CCR_PUBLIC_PORT || "3458"}`).replace(/\/+$/, ""); + +function syncConfig(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return value; + } + value.HOST = gatewayHost; + value.PORT = gatewayPort; + value.gateway = { + ...(value.gateway && typeof value.gateway === "object" && !Array.isArray(value.gateway) ? value.gateway : {}), + coreHost: "127.0.0.1", + corePort: gatewayCorePort, + enabled: true, + host: gatewayHost, + port: gatewayPort + }; + value.routerEndpoint = publicBaseUrl; + return value; +} + +function syncJsonFile() { + if (!fs.existsSync(configFile)) { + return; + } + const parsed = JSON.parse(fs.readFileSync(configFile, "utf8")); + fs.writeFileSync(configFile, `${JSON.stringify(syncConfig(parsed), null, 2)}\n`, { mode: 0o600 }); +} + +function syncSqliteConfig() { + if (!fs.existsSync(appConfigDbFile)) { + return; + } + let Database; + try { + Database = require("better-sqlite3"); + } catch { + return; + } + const db = new Database(appConfigDbFile); + try { + const row = db.prepare("select value_json from app_config where key = ?").get("default"); + if (!row?.value_json) { + return; + } + const parsed = JSON.parse(row.value_json); + db.prepare("update app_config set value_json = ?, updated_at = ? where key = ?") + .run(JSON.stringify(syncConfig(parsed)), new Date().toISOString(), "default"); + } finally { + db.close(); + } +} + +syncJsonFile(); +syncSqliteConfig(); +NODE +fi + +cat > /etc/nginx/conf.d/default.conf < heading.depth === 2); +const tocHeadings = allHeadings.filter((heading) => heading.depth >= 2 && heading.depth <= 5); const isExplicitHref = (value: string) => value.startsWith("#") || value.startsWith("/") || @@ -155,9 +155,10 @@ const sidebarTree = sidebarNavItems.map((navItem, index) => { })), }; }); -const tocItems = headings.map((heading) => ({ +const tocItems = tocHeadings.map((heading) => ({ label: heading.text, href: `#${heading.slug}`, + depth: heading.depth, })); const pageMarkdown = doc.rawContent(); --- diff --git a/docs/src/content/docs/en/configuration.md b/docs/src/content/docs/en/configuration.md index 71b5d09..2294e3a 100644 --- a/docs/src/content/docs/en/configuration.md +++ b/docs/src/content/docs/en/configuration.md @@ -2,23 +2,37 @@ title: Claude Code Router Detailed Configuration pageTitle: Detailed Configuration eyebrow: Detailed Configuration -lead: Configure providers, routing, Agent Config, Fusion, Bots, and the config file location in detail. +lead: "Separate main app pages from settings pages while following the app's actual order: main pages cover overview, providers, Agent Config, routing, Fusion, API keys, logs and observability, server, and extensions; settings pages cover ToolHub, Bots, data, and tray." --- ## Page Structure -Detailed configuration docs are split into standalone pages. Every left-sidebar item opens a page; the right outline is reserved for headings inside the current page. +Detailed configuration docs are split into standalone pages. Every left-sidebar item opens a page; the right outline is reserved for headings inside the current page. Main pages follow the app's left navigation order. Settings pages are grouped separately and follow the settings dialog order. + +## Main Pages | Page | Covers | | --- | --- | +| Overview Dashboard | System status, account balance, usage widgets, layout editing, and share cards | | Provider Config | Upstream services, protocol, Base URL, model list, and credentials | -| Routing Config | Default routing, conditional rules, fallback, and request rewrites | -| Logs & Observability | Request logs, Agent execution traces, tool calls, and tool results | -| Fusion Models | Combine a base model with vision, search, or MCP tools into a new selectable model | +| One click import | Provider deeplink protocol, manifest import, one-click import buttons, and security boundaries | | Agent Config | Agent launch method, model, scope, multi-instance launching, and Bot binding | +| Routing Config | Default routing, conditional rules, fallback, and request rewrites | +| Fusion Models | Combine a base model with vision, search, or MCP tools into a new selectable model | +| API Keys | Client access keys, expiration, and local limits | +| Logs & Observability | Request logs, Agent execution traces, tool calls, and tool results | +| Server | Host, port, proxy mode, system proxy, network capture, and CA certificate | +| Extension Mechanism | Wrapper plugins, core gateway plugins, custom extension creation, and debugging | + +## Settings Pages + +| Page | Covers | +| --- | --- | +| ToolHub | Collapse many MCP servers into one dynamic tool resolution entry point for agents | | Bots And IM Agent Relay | Bot forwarding, handoff mode, and platform pages | -| Config File Location | Config file location maintained by the desktop app | +| Config Database Location | SQLite config database location maintained by the desktop app | +| Tray Configuration | Tray icon, balance progress, and tray window widgets | ## Content Relationships -Providers and routing determine where model requests go. Agent Config covers Claude Code, Codex, and ZCode launch, multi-instance usage, and model selection. Fusion covers vision, web search, and MCP tools. Bots cover IM platform relay. +Overview Dashboard shows system status and usage. Provider Config and One click import cover how upstream model services enter CCR. Agent Config covers Claude Code, Codex, and ZCode launch, multi-instance usage, and model selection. Routing determines where model requests go. Fusion covers vision, web search, and MCP tools. API Keys control client access to CCR. Logs & Observability cover request logs and agent execution traces. Server controls the local gateway listener and proxy features. Extension Mechanism covers local plugin creation, installation, and debugging. ToolHub, Bots, Config Database Location, and Tray Configuration match the corresponding settings pages in the settings dialog. diff --git a/docs/src/content/docs/en/configuration/api-keys.md b/docs/src/content/docs/en/configuration/api-keys.md new file mode 100644 index 0000000..a6a3ef0 --- /dev/null +++ b/docs/src/content/docs/en/configuration/api-keys.md @@ -0,0 +1,46 @@ +--- +title: API Keys +pageTitle: API Keys +eyebrow: Detailed Configuration +lead: Manage API keys that clients use to access the CCR gateway, with expiration and local limits. +--- + +## List Fields + +| Field | Capability | +| --- | --- | +| Search API keys | Filters the list by key name or key value. | +| Add API key | Opens the create dialog and generates a new client access key. | +| Name | Display name for the key. Use it to identify a client, team, purpose, or automation. | +| Key | Masked access key. Use `Copy API key` to copy the full key. | +| Expires | Expiration time. After expiration, clients can no longer use the key to access CCR. | +| Limits | Local limit summary. Shows `No limits configured` when no limits are set. | +| Edit API key | Edits expiration and limits. The key value itself is not shown again. | +| Remove API key | Deletes the client access key. Deleted keys stop working immediately. | + +## Create And Edit + +| Field | Capability | +| --- | --- | +| Name | Display name for the new key. Examples: `Claude Code - laptop`, `CI`, or a team name. | +| Expiration | Selects the validity period: `Never`, `7 days`, `30 days`, `90 days`, or `Custom`. | +| Expires at | Appears for `Custom` expiration and sets the exact date and time. | +| API key created | Confirmation dialog after creation. It displays the full key. | +| Copy this key now. It may not be shown again. | Reminder to copy the key immediately because CCR will not show it again after the dialog closes. | + +## Advanced Settings + +`Advanced settings` adds local limits to a client key. When a limit is reached, requests using that key are rejected or limited; provider-side quota is not changed. + +| Field | Capability | +| --- | --- | +| Advanced settings | Expands or collapses limit editing. | +| No limits configured | The key has no local limits. | +| Requests | Limits by request count. | +| Tokens | Limits by token count. | +| Images | Limits by image count. | +| per minute | Uses a 1-minute limit window. | +| per hour | Uses a 1-hour limit window. | +| per day | Uses a 1-day limit window. | +| Add limit | Adds one limit rule. | +| Remove limit | Removes the current limit rule. | diff --git a/docs/src/content/docs/en/configuration/bots.md b/docs/src/content/docs/en/configuration/bots.md index 27a1f13..614dd76 100644 --- a/docs/src/content/docs/en/configuration/bots.md +++ b/docs/src/content/docs/en/configuration/bots.md @@ -5,10 +5,6 @@ eyebrow: Detailed Configuration lead: Forward agent messages to instant-messaging platforms or hand off active work after desktop idle. --- -## What Bots Do - -Bots forward agent messages to instant-messaging platforms and can hand off active work after the desktop has been idle. - ## Common Modes - **Forward agent messages**: mirror agent messages into IM. diff --git a/docs/src/content/docs/en/configuration/configuration-file.md b/docs/src/content/docs/en/configuration/configuration-file.md index a4f0f4f..5543fc1 100644 --- a/docs/src/content/docs/en/configuration/configuration-file.md +++ b/docs/src/content/docs/en/configuration/configuration-file.md @@ -1,16 +1,17 @@ --- -title: Config File Location -pageTitle: Config File Location +title: Config Database Location +pageTitle: Config Database Location eyebrow: Detailed Configuration -lead: Locate the JSON configuration file maintained by the CCR desktop app. +lead: Locate the SQLite configuration database maintained by the CCR desktop app. --- ## Default Locations -- **macOS**: `~/Library/Application Support/claude-code-router/config.json` -- **Windows**: `%APPDATA%/claude-code-router/config.json` -- **Linux**: `~/.config/claude-code-router/config.json` +- **macOS/Linux**: `~/.claude-code-router/config.sqlite` +- **Windows**: `%APPDATA%\Claude Code Router\config.sqlite` ## Applying Changes -Restart CCR after editing the file directly. +CCR stores runtime configuration in SQLite. A legacy `config.json` is read only once as a migration source when no SQLite config exists; after migration, editing `config.json` does not affect the current configuration. + +Use the desktop UI to change configuration, or export a backup from **Settings**. Do not edit `config.sqlite` directly while CCR is running; SQLite also maintains companion `config.sqlite-wal` and `config.sqlite-shm` files in the same directory. diff --git a/docs/src/content/docs/en/configuration/extensions.md b/docs/src/content/docs/en/configuration/extensions.md new file mode 100644 index 0000000..bf6f503 --- /dev/null +++ b/docs/src/content/docs/en/configuration/extensions.md @@ -0,0 +1,281 @@ +--- +title: Extension Mechanism +pageTitle: Extension Mechanism +eyebrow: Detailed Configuration +lead: Learn how CCR extensions are loaded, what they can register, and how to create, install, and debug your own extension. +--- + +## Extension Types + +CCR has two extension layers: + +| Type | Config location | Runtime | Good for | +| --- | --- | --- | --- | +| Wrapper plugin | `plugins` | CCR Desktop's Electron wrapper process | Local HTTP routes, local backends, proxy capture routing, built-in browser entries, provider account meters | +| Core gateway plugin | `providerPlugins` or `plugins[].coreGateway.providerPlugins` | core gateway runtime | Provider, auth, or internal core gateway behavior | + +Most custom extensions should start as a Wrapper plugin. It receives CCR config, a private data directory, a logger, and registration helpers through `ctx`. + +## Loading Flow + +When the gateway starts, CCR reads the `plugins` array and processes each plugin whose `enabled !== false`: + +1. It first applies `apps`, `proxy.routes`, `coreGateway.providerPlugins`, `coreGateway.virtualModelProfiles`, and `coreGateway.config` declared in config. +2. It then loads the plugin module. `module` can be an absolute path, a `~/` path, a `./...` path relative to the CCR config directory, or a Node-resolvable package name. +3. If `module` is missing, CCR tries to match the plugin `id` with built-in marketplace plugins such as `claude-design` and `cursor-proxy`. +4. A module can export a function or an object with `setup(ctx)` or `activate(ctx)`. +5. On stop, CCR runs `stop` and `onStop` hooks in reverse order, then closes HTTP backends and SQLite stores registered by the plugin. + +Common module shape: + +```js +"use strict"; + +module.exports = { + async setup(ctx) { + ctx.logger.info("extension loaded"); + }, + async stop() { + // Optional: release resources owned by the extension. + } +}; +``` + +You can also export the setup function directly: + +```js +"use strict"; + +module.exports = async function setup(ctx) { + ctx.logger.info(`loaded ${ctx.pluginId}`); +}; +``` + +`setup(ctx)` or `activate(ctx)` can call `ctx.register...` methods directly, or return a registration object. Returned registrations support `apps`, `gatewayRoutes`, `proxyRoutes`, `providerAccountConnectors`, `coreGateway`, `virtualModelProfiles`, `stop`, and `onStop`. + +## ctx Reference + +`setup(ctx)` receives these common fields and helpers: + +| Field or method | Description | +| --- | --- | +| `ctx.pluginId` | Current plugin ID | +| `ctx.pluginConfig` | Custom value from `plugins[].config` | +| `ctx.config` | Current CCR AppConfig snapshot | +| `ctx.logger` | `debug/info/warn/error` logger prefixed with `[plugin:]` | +| `ctx.paths.configDir` | CCR config directory | +| `ctx.paths.dataDir` | CCR data directory | +| `ctx.paths.pluginDataDir` | Private data directory for this plugin | +| `ctx.registerGatewayRoute(route)` | Register a local HTTP route on the CCR gateway | +| `ctx.registerHttpBackend(backend)` | Start a local HTTP backend and return `{ url, host, port }` | +| `ctx.registerProxyRoute(route)` | Route proxy-captured host/path traffic to a plugin backend or another upstream | +| `ctx.registerApp(app)` | Add an entry to the built-in browser app list | +| `ctx.openSqliteStore(options)` | Open a SQLite store under the plugin data directory | +| `ctx.registerProviderAccountConnector(connector)` | Register a provider balance or quota connector | +| `ctx.registerCoreGatewayProviderPlugin(plugin)` | Inject a provider plugin into the core gateway | +| `ctx.registerCoreGatewayVirtualModelProfile(profile)` | Inject a virtual model profile into the core gateway | + +Gateway route handlers also receive helper functions: + +| Helper | Description | +| --- | --- | +| `helpers.readBody(request)` | Read request body as a `Buffer` | +| `helpers.readJson(request)` | Read and parse JSON request body | +| `helpers.sendJson(response, statusCode, body)` | Send a JSON response | + +`registerGatewayRoute` defaults to `auth: "gateway"`. If CCR has API keys configured, requests must include `Authorization: Bearer ` or `x-api-key: `. Use `auth: "none"` only for debugging or local public status routes. + +## Create Your First Extension + +Create a directory such as `~/ccr-extensions/hello-extension`: + +```text +hello-extension/ + plugin.json + index.cjs +``` + +`plugin.json` lets CCR's local extension picker discover the extension ID, name, and entrypoint: + +```json +{ + "id": "hello-extension", + "name": "Hello Extension", + "module": "index.cjs", + "apps": [ + { + "id": "hello-status", + "name": "Hello Status", + "url": "http://127.0.0.1:3456/plugins/hello" + } + ] +} +``` + +`index.cjs` registers a status route, an echo backend, and one proxy route: + +```js +"use strict"; + +module.exports = { + async setup(ctx) { + ctx.registerGatewayRoute({ + auth: "none", + id: "hello-status", + method: "GET", + path: "/plugins/hello", + handler(_request, response, helpers) { + helpers.sendJson(response, 200, { + ok: true, + plugin: ctx.pluginId, + message: ctx.pluginConfig?.message || "hello from CCR" + }); + } + }); + + const backend = await ctx.registerHttpBackend({ + id: "hello-echo", + async handler(request, response, helpers) { + const body = request.method === "POST" + ? (await helpers.readBody(request)).toString("utf8") + : ""; + + helpers.sendJson(response, 200, { + method: request.method, + path: request.url, + body + }); + } + }); + + ctx.registerProxyRoute({ + host: "api.example.local", + id: "hello-example-api", + paths: ["/v1"], + preserveHost: true, + upstream: backend.url + }); + + ctx.logger.info(`hello backend listening at ${backend.url}`); + } +}; +``` + +This exposes: + +- `GET /plugins/hello`: a route mounted directly on the CCR gateway to verify that the plugin loaded. +- A local echo backend: CCR assigns a free port automatically. +- A proxy rule: proxy-captured `api.example.local/v1...` traffic is forwarded to the echo backend. + +## Install The Extension + +The recommended flow is through the desktop UI: + +1. Open **Extensions**. +2. Add an extension and choose a local extension directory. +3. Select the `hello-extension` directory. +4. Save the config. +5. Open **Server** and restart the gateway. + +CCR stores runtime configuration in SQLite. Add extensions through the UI; the legacy JSON config file is kept here only as a reference. The extension entry has this shape: + +```json +{ + "plugins": [ + { + "id": "hello-extension", + "enabled": true, + "module": "/Users/you/ccr-extensions/hello-extension/index.cjs", + "config": { + "message": "hello from my config" + } + } + ] +} +``` + +Restart the gateway after saving the extension config. See [Config Database Location](/en/configuration/configuration-file/). + +The local directory picker recognizes entry metadata from: + +- `plugin.json` +- `ccr-plugin.json` +- `.ccr-plugin/plugin.json` +- `.codex-plugin/plugin.json` +- `main`, `ccr.module`, or `ccrPlugin.module` in `package.json` + +If no entrypoint is declared, CCR tries `index.cjs`, `index.mjs`, `index.js`, `plugin.cjs`, `plugin.mjs`, or `plugin.js` in the selected directory. + +## Debug Extensions + +### 1. Check syntax first + +For CommonJS extensions: + +```bash +node --check ~/ccr-extensions/hello-extension/index.cjs +``` + +If the extension depends on npm packages, install them in the extension directory and make sure Node can resolve the entrypoint. + +### 2. Start CCR from source + +From the CCR repository root: + +```bash +npm install +npm run dev +``` + +`ctx.logger.info/warn/error` output appears in the terminal that started CCR, with a prefix such as `[plugin:hello-extension]`. + +### 3. Verify the Gateway route + +After the gateway starts, request the status route: + +```bash +curl http://127.0.0.1:3456/plugins/hello +``` + +If the route uses the default `auth: "gateway"` and CCR has API keys configured: + +```bash +curl -H "Authorization: Bearer " http://127.0.0.1:3456/plugins/hello +``` + +You can also use: + +```bash +curl -H "x-api-key: " http://127.0.0.1:3456/plugins/hello +``` + +### 4. Verify the HTTP backend and proxy route + +`registerHttpBackend` returns `backend.url`, which the example writes to logs. Request that URL directly first, then enable proxy mode and verify whether the target host/path hits `registerProxyRoute`. + +Proxy route matching rules: + +- `host` must match the target hostname. Exact host, `.example.com` suffix, and `*.example.com` wildcard patterns are supported. +- Empty `paths` matches all paths for that host. +- When multiple paths match, CCR chooses the longest path prefix. +- `stripPathPrefix` removes the matched prefix from the forwarded path. +- `rewritePathPrefix` replaces the matched prefix with a configured prefix. + +### 5. Common Issues + +| Symptom | What to check | +| --- | --- | +| Extension does not load | Check `plugins[].enabled`, `plugins[].module`, and terminal errors prefixed with `[plugin:]` | +| `GET /plugins/hello` returns 404 | Restart the gateway and confirm `path` or `pathPrefix` starts with `/` | +| Response is 401 | Routes require gateway API key by default; set `auth: "none"` for debug routes | +| Code changes do not apply | Wrapper plugins are not hot reloaded; restart the gateway or CCR | +| Port is already in use | Omit `port` in `registerHttpBackend` so CCR can allocate one automatically | +| Proxy route misses requests | Confirm proxy mode is enabled, the certificate is installed, and host matches the real request hostname | + +## Security Notes + +- Use `auth: "none"` only for status pages, health checks, or local debugging routes. +- Do not log API keys, OAuth tokens, cookies, or complete request headers. +- Prefer `ctx.paths.pluginDataDir` for files written by your extension. +- Validate all external input returned by `readJson`. +- When proxying to external upstreams, handle headers explicitly so local auth material is not forwarded to untrusted services. diff --git a/docs/src/content/docs/en/configuration/fusion-web-search.md b/docs/src/content/docs/en/configuration/fusion-web-search.md index ac4461b..d12fff4 100644 --- a/docs/src/content/docs/en/configuration/fusion-web-search.md +++ b/docs/src/content/docs/en/configuration/fusion-web-search.md @@ -11,7 +11,20 @@ Use `ccr-fusion-builtins / web_search`. ## Search Providers -Supported providers include Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa. +Supported providers include In-app Browser, Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa. + +## In-app Browser + +`In-app Browser` runs searches through a hidden built-in browser window in CCR Desktop, opens result pages, extracts visible content, and passes that evidence to the Fusion model. It does not require an external search API key, so it is useful when you want desktop-side browser retrieval. + +Configuration options include search engine, language, country or region, and safe-search level: + +- Search engine: Bing, Google, or DuckDuckGo. +- Language: for example `en` or `zh-CN`. +- Country or region: for example `US` or `CN`. +- Safe search: default, moderate, strict, or off. + +> Note: `In-app Browser` depends on CCR Desktop's Electron built-in browser capability and is only available in the desktop app. CLI, server deployments, and pure web environments do not have the built-in browser integration; use Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, or Exa instead. ## Troubleshooting diff --git a/docs/src/content/docs/en/configuration/overview.md b/docs/src/content/docs/en/configuration/overview.md new file mode 100644 index 0000000..9a7bf49 --- /dev/null +++ b/docs/src/content/docs/en/configuration/overview.md @@ -0,0 +1,131 @@ +--- +title: Overview Dashboard +pageTitle: Overview Dashboard +eyebrow: Detailed Configuration +lead: Customize the CCR home dashboard to inspect system status, account balance, requests, tokens, cost, and model distribution. +--- + +## When To Use It + +| Scenario | What to inspect | +| --- | --- | +| Check gateway health | System status, success rate, errors, average latency | +| Estimate recent spend | Requests, input / output / cache tokens, estimated cost | +| Compare upstream usage | Provider analysis, model distribution, client analysis | +| Watch account quota | Balance, subscription quota, remaining quota, account status | +| Report or share usage | AI Usage Wrapped, CCR Route Map, Model Leaderboard, Spend Receipt, and other share cards | + +## Time Range + +The `Usage over time` control at the top drives every widget that depends on usage stats. After you switch ranges, requests, tokens, cost, trends, distribution, and share cards are recomputed for the selected window. + +| Option | Window | +| --- | --- | +| `Today` | Current local date from 00:00 to now, bucketed hourly. | +| `24h` | Last 24 hours, bucketed hourly. | +| `7d` | Last 7 days, bucketed daily. | +| `30d` | Last 30 days, bucketed daily. | + +The account balance widget does not use this time range. It shows the latest snapshot returned by provider account connectors. + +## Edit Layout + +Click the pencil button in the upper-right corner to enter editing mode. Editing mode has three columns: + +| Area | Purpose | +| --- | --- | +| Components | Left palette. Click a template to add it to the dashboard. | +| Preview | Middle layout preview. Drag widgets to reorder them or click a widget to select it. | +| Component properties | Right property panel for changing type, data, size, style, or removing the selected widget. | + +Common operations: + +1. Add a widget: click a template in `Components`. +2. Reorder widgets: drag them in `Preview`. +3. Resize a widget: select it, then drag the right, bottom, or bottom-right resize handle. +4. Change data: use `Component category` and `Data` in `Component properties`. +5. Change presentation: choose `Widget size` and `Style`. +6. Save the result: click `Done`; the layout is persisted in app configuration. +7. Restore defaults: click `Reset layout` while editing. + +Removing a widget only removes that card from the overview layout. It does not delete request logs, providers, account connectors, or upstream configuration. If all widgets are removed, the page shows `No widgets configured`. + +## Widget Catalog + +Sizes are written as `width:height`, with both dimensions from `1` to `4`. The overview grid has up to 4 columns on desktop and collapses automatically on narrow screens. + +| Widget | Data | Default size | Default style | Styles | +| --- | --- | --- | --- | --- | +| Status component | System status | `4:1` | Timeline | Timeline, Compact | +| Account component | All accounts or one account | `4:2` | Cards | Cards, Compact, Bars, Ring, Semicircle, Arc, Nested rings | +| Metric component | Requests, tokens, cost | `1:1` | Cards | Cards, Compact, Bar, Ring | +| Trend component | Usage over time | `3:2` | Composed | Composed, Area, Line, Bar | +| Activity component | Token activity | `4:2` | Heatmap | Heatmap | +| Breakdown component | Token distribution / Model distribution | Token distribution: `1:2`; Model distribution: `2:2` | Token distribution: Bars; Model distribution: Pie | Bars, Stacked, Donut, Pie | +| Analysis component | Client Analysis / Provider Analysis | `2:2` | Table | Table, Compact | +| Share card | AI Usage Wrapped, CCR Route Map, Model Leaderboard, AI Fuel Cockpit, Token Calendar Poster, Spend Receipt | `1:4` | Card | Card | + +Size constraints: + +| Rule | Reason | +| --- | --- | +| Share cards have a minimum size of `1:4`. | PNG export uses a vertical poster ratio and needs enough height. | +| The account widget has a minimum size of `2:2` when showing All accounts with the Compact style. | Multi-account lists need readable space. | +| Legacy aliases are still accepted: `small` -> `1:1`, `medium` / `large` -> `2:2`, `wide` -> `3:2`, `full` -> `4:1` or `4:2`. | Backward compatibility for older config. | + +## Metric Data + +`metric` widgets use the `metric` field to choose the displayed value. + +| `metric` | Meaning | +| --- | --- | +| `requests` | Request count | +| `total-tokens` | Total tokens | +| `input-tokens` | Input tokens | +| `output-tokens` | Output tokens | +| `cache-tokens` | Cache tokens | +| `cache-ratio` | Cache ratio | +| `estimated-cost` | Estimated cost, calculated from model pricing data | +| `success-rate` | Success rate | +| `errors` | Error count | +| `avg-latency` | Average latency | + +## Account Widget + +The account widget reads provider account / usage connectors. To show balance or remaining quota, first enable and test `Fetch usage` in provider configuration. + +| Data selection | Behavior | +| --- | --- | +| `All accounts` | Shows every available account snapshot. | +| One account | Shows only one provider or credential snapshot. The internal config value is usually `provider` or `provider::credentialId`. | + +If the account widget is empty, check: + +1. Whether the provider has an account / usage connector configured. +2. Whether the `Fetch usage` test succeeds. +3. Whether the API key or account endpoint is still valid. +4. Whether the selected account was deleted or renamed. + +## Share Cards + +Share card widgets can export PNGs through the download button in the card header. The desktop app uses native export when available; browser environments fall back to frontend canvas export. The exported image size is `1080 x 1350`. + +| Card | `type` | Content | +| --- | --- | --- | +| AI Usage Wrapped | `share-usage-wrapped` | Total tokens, requests, estimated cost, cache ratio, longest activity streak, top model, top provider, peak day. | +| CCR Route Map | `share-route-map` | Main client-to-provider/model route relationships, plus client, provider, and model counts. | +| Model Leaderboard | `share-model-leaderboard` | Models ranked by tokens. | +| AI Fuel Cockpit | `share-fuel-cockpit` | Up to 3 account quota gauges. Requires account / usage connectors. | +| Token Calendar Poster | `share-token-calendar` | Contribution-calendar style token activity poster. | +| Spend Receipt | `share-spend-receipt` | Estimated cost, requests, tokens, latency, and success rate for the selected range. | + +## Data Sources And Troubleshooting + +| Symptom | Likely cause | What to do | +| --- | --- | --- | +| Requests, tokens, or cost are 0 | No requests went through CCR in the selected range, or usage capture has not recorded data yet. | Try `24h` / `7d`, and confirm the client is actually using CCR. | +| Cost shows `$0.00` | The model has no pricing data, or usage is very small. | Check model catalog matching and provider model names; values under 0.01 USD are shown with extra decimals. | +| Success rate or errors look unexpected | The overview only aggregates request results captured by CCR. | Compare with records on the Logs page. | +| Account balance is empty | No account connector exists, or `Fetch usage` failed. | Test account / usage field mapping in provider configuration. | +| Distribution charts have no data | Request logs lack model, provider, or token information. | Confirm requests go through CCR and upstream responses include token usage. | +| PNG export fails | Canvas export is unavailable, the element has no size, or the save dialog was canceled. | Retry in the desktop app, and make sure the card is visible and not resized too small. | diff --git a/docs/src/content/docs/en/configuration/profiles.md b/docs/src/content/docs/en/configuration/profiles.md index f2eaeca..c3ce32e 100644 --- a/docs/src/content/docs/en/configuration/profiles.md +++ b/docs/src/content/docs/en/configuration/profiles.md @@ -5,11 +5,16 @@ eyebrow: Detailed Configuration lead: Create reusable launch configurations for Claude Code, Codex, and ZCode, and open separate agent instances from different configs. --- -## What Agent Config Is +## Configuration Flow -Agent Config is the desktop app capability for managing Claude Code, Codex, and ZCode launch entries. It is not a provider or a routing rule; it is the full entry point for one agent launch: agent type, entry mode, model, effect scope, config file location, and optional Bot binding. +1. Add at least one usable provider and model in **Provider Config**, or create the Fusion model you want to use. +2. Open **Agent Config** and click **Add profile**. +3. Choose the agent type, name the config, then choose the effect scope and entry mode. +4. Select a model. The value is usually `Provider name/model name`, and Fusion models can be selected too. +5. If the entry mode includes App, optionally bind a Bot and choose whether to forward agent messages or enable handoff. +6. Save the config, then open it from the Agent Config card: the terminal button copies the CLI command, and the play button starts the App instance. -This page exists in Detailed Configuration to explain which config opens which agent instance, rather than provider, routing, or Fusion fields. +During trial, prefer **Only opened from CCR** and always open the agent from CCR. That keeps the config limited to CCR-launched instances and avoids changing the Claude Code, Codex, or ZCode setup you open directly from the system. ## Multi-Instance Mechanism @@ -26,20 +31,80 @@ This lets you create multiple configs for the same agent, such as "Claude Code - ## Common Options -| Option | Description | +| Option | Applies to | Description | +| --- | --- | --- | +| Agent | All | Claude Code, Codex, or ZCode. ZCode supports App only. | +| Config name | All | Identifies the config in CCR and can be used as the `ccr ` launch target. Names can contain spaces; copied commands are quoted automatically. | +| Enabled | All | Disabled configs are not exposed as active launch entries and are not applied as effective startup configs. | +| Effect scope | All | **Only opened from CCR** uses CCR-managed isolated config; **System default** writes the agent's default config. Only one enabled system-default config is allowed per agent. | +| Entry mode | Claude Code, Codex | `CLI & APP` exposes both CLI and App entry points; `CLI only` only generates a CLI command; `App only` only exposes the App entry point. | +| Model | All | Default model for the opened agent, either a provider model or Fusion model. For Claude Code, leaving it empty keeps the Claude Code default. | +| Bot | App entry | Bot forwarding only works for App mode opened from CCR. CLI does not forward Bot messages yet. | +| Environment variables | All | Extra environment variables injected into this config. Claude Code includes `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` by default so gateway model discovery is enabled. | + +## Per-Agent Options + +### Claude Code + +| Option | What it does | | --- | --- | -| Agent | Claude Code, Codex, or ZCode | -| Config name | Identifies the config in CCR and can be used as the `ccr ` launch target | -| Effect scope | **Only opened from CCR** uses CCR-managed isolated config; **System default** writes the agent's default config | -| Entry mode | `CLI & APP`, `CLI only`, or `App only`; ZCode supports App only | -| Model | Default model for the opened agent, either a provider model or Fusion model | -| Bot | App entry can bind a Bot for IM forwarding or handoff | +| Model override | Writes `ANTHROPIC_MODEL` for Claude Code. Leave it empty to keep Claude Code's own default model. | +| Small fast model | Writes `ANTHROPIC_SMALL_FAST_MODEL` for Claude Code lightweight tasks. Leave it empty to keep the Claude Code default. | +| Settings file | System-default mode uses the Claude Code default settings file; Only opened from CCR creates an isolated settings file under CCR's config directory, separated by Agent Config `id`. | +| Environment variables | Merged into the Claude Code settings `env`. CCR also writes the gateway endpoint, API key helper, and launch wrapper. | +| Bot | Applies only to the Claude App entry. Select a saved Bot, then choose message forwarding or handoff. | + +After Claude Code CLI is opened from CCR, it uses CCR gateway model discovery. In Claude Code CLI, enter `/model` to view and switch the models exposed by CCR, including normal provider models and visible Fusion models. + +Claude App is **zero-config**: when CCR opens Claude App from the desktop app, CCR automatically writes the Claude App gateway config, API key, model discovery list, and isolated user-data directory. No extra user action is required; opening Claude App from CCR automatically completes all necessary configuration. If Claude App is already running, restart it or reopen it from CCR when prompted. + +Claude App and Claude Code CLI use different model-list adapters: + +| Entry | Model list source | Notes | +| --- | --- | --- | +| Claude Code CLI | CCR gateway model discovery | Use `/model` in the CLI to view the list; selected requests still go through CCR providers, routing, and Fusion. | +| Claude App | CCR-generated Claude App inference models | Claude App needs Claude-compatible model names. CCR maps `Provider/model` and Fusion models into model entries Claude App can recognize, while display labels keep the real model meaning visible. | + +### Codex + +| Option | What it does | +| --- | --- | +| Provider ID | Writes Codex `model_provider`, defaulting to `claude-code-router`. Keep it stable and use only letters, numbers, dots, underscores, or hyphens. | +| Provider name | Display name shown in Codex, defaulting to `Claude Code Router`. | +| Codex model | Default Codex model. It can be a provider model or Fusion model; if left empty, CCR uses the first available default model. | +| Show all sessions | Lets Codex show all sessions. ZCode does not expose this option. | +| Config file | Defaults to `~/.codex/config.toml`. Only opened from CCR writes into CCR-managed isolated config directories. | +| Environment variables | Injected into Codex CLI or Codex App. Claude Code-specific model discovery variables are not passed to Codex. | +| Bot | Applies only to the Codex App entry. | + +After saving, use the terminal button on the config card to copy the Codex CLI command, for example `ccr "Codex - Work"`. Use the play button to open Codex App. CCR generates `config.toml`, a model catalog file, and a middleware launcher so Codex CLI and Codex App use the same CCR model and provider information. + +### ZCode + +| Option | What it does | +| --- | --- | +| Provider ID | Writes the ZCode provider reference, defaulting to `claude-code-router`. | +| Provider name | Display name shown in ZCode, defaulting to `Claude Code Router`. | +| ZCode model | Default model when ZCode App opens. It can be a provider model or Fusion model. | +| Config file | Defaults to `~/.zcode/cli/config.json`; CCR also writes ZCode v2 config and model cache. | +| Environment variables | Injected into ZCode App and the middleware launcher. | +| Bot | Applies only to the ZCode App entry. | + +ZCode supports App only, so its entry mode is fixed to `App only`. The `Show all sessions` option is hidden for ZCode. + +## CLI And App Modes + +| Mode | How to open | Best for | Key differences | +| --- | --- | --- | --- | +| CLI | Click the terminal button to copy the command, then run `ccr ` in a terminal | Working inside a project directory, shell workflows, scripting | Uses the config-specific wrapper or middleware launcher; usually stays in the terminal without opening a desktop window; Bot forwarding support is pending. | +| App | Click the play button in the CCR desktop app | Desktop windows, side-by-side instances, Bot forwarding, handoff | Uses a separate user-data directory per Agent Config; reopening the same config activates the existing window, while different configs can run in parallel. | +| CLI & APP | One config exposes both CLI and App entry points | Reusing the same model config in both terminal and desktop App workflows | Both entries share the config name, model, effect scope, and environment variables, but launch differently. | ## Agent Differences ### Claude Code -Claude Code config writes a settings file. With **Only opened from CCR**, CCR creates an isolated settings file under its own config directory and opens Claude Code through a separate launch wrapper. +Claude Code CLI config writes a settings file. With **Only opened from CCR**, CCR creates an isolated settings file under its own config directory and opens Claude Code through a separate launch wrapper. When opening Claude App from the desktop app, CCR also prepares a separate user-data directory for that config. Different Agent Config entries use different directories, so multiple Claude App instances can run at the same time. diff --git a/docs/src/content/docs/en/configuration/provider-deeplink.md b/docs/src/content/docs/en/configuration/provider-deeplink.md new file mode 100644 index 0000000..b2dd290 --- /dev/null +++ b/docs/src/content/docs/en/configuration/provider-deeplink.md @@ -0,0 +1,295 @@ +--- +title: One click import +pageTitle: One click import +eyebrow: Detailed Configuration +lead: Quickly add common model providers, review the details, and save them without filling everything in by hand. +--- + +## One-Click Import + +Choose a provider below to get started. CCR shows what will be added before saving it; when using a custom entry point, make sure the source is one you trust. + + + +## Embeddable Button Component + +CCR also ships a framework-free button script that providers can embed on their own webpages so users can import that provider into CCR with one click. The script registers Web Components automatically. + +### HTML + +```html + + + +``` + +For larger configs, pass a manifest: + +```html + + + +``` + +### JavaScript + +```html +
+ + +``` + +### Render Parameters + +`CCRProviderButtons.render(target, options)` and `` support the same parameter set. Parameter names match the `ccr://provider` protocol: + +| Parameter | Description | +| --- | --- | +| `name` | Provider display name | +| `base_url` | Provider API Base URL, required for direct imports | +| `api_key` | Optional provider API key | +| `protocol` | Protocol, one of `openai_chat_completions`, `openai_responses`, `anthropic_messages`, `gemini_generate_content`, `gemini_interactions` | +| `models` | Model list. Use comma/newline-separated text in HTML, or a string/array in JavaScript | +| `icon` | Provider icon URL | +| `source` | Provider website or config source | +| `manifest` | Remote manifest URL. When present, the button creates a manifest import link | +| `payload` | JSON or base64url JSON config. JavaScript may pass an object | +| `usage_url` | Optional account usage endpoint | +| `fetch_usage` | Whether account usage fetching is enabled | +| `usage_method` | Usage request method, `GET` or `POST` | +| `usage_headers` | Usage request headers. JavaScript may pass an object; HTML must pass a JSON string | +| `usage_body` | Usage request body. JavaScript may pass an object; HTML must pass a JSON string | +| `balance` | Balance field path | +| `balance_unit` | Balance unit | +| `subscription` | Subscription remaining field path | +| `subscription_limit` | Subscription limit field path | +| `subscription_reset` | Subscription reset time field path | +| `subscription_unit` | Subscription unit | +| `subscription_window` | Subscription window, such as `monthly` | + +## URL Format + +CCR supports two URL shapes. The host form is recommended: + +```text +ccr://provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1&protocol=openai_chat_completions&models=example-chat%2Cexample-coder +``` + +The path form is also recognized: + +```text +ccr:///provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1 +``` + +For larger configs, put JSON in `payload`. The value can be URL-encoded JSON or base64url JSON: + +```text +ccr://provider?payload=%7B%22name%22%3A%22Example%20AI%22%2C%22base_url%22%3A%22https%3A%2F%2Fapi.example.com%2Fv1%22%2C%22models%22%3A%5B%22example-chat%22%5D%7D +``` + +## Manifest Import + +Providers can also pass a manifest URL: + +```text +ccr://provider?manifest=https%3A%2F%2Fexample.com%2Fccr-provider.json +``` + +The manifest must use HTTPS, return JSON, avoid local or private network hosts, and stay under 128 KB. CCR fetches the manifest inside the app, shows a confirmation dialog, and writes config only after user approval. + +The manifest can put provider information in a top-level `provider` object: + +| Field | Description | +| --- | --- | +| `provider.name` | Provider display name | +| `provider.base_url` | Provider API Base URL, required | +| `provider.protocol` | Protocol type | +| `provider.models` | Model list as a string array | +| `provider.icon` | Provider icon URL | +| `provider.source` | Provider website or config source | +| `provider.account.enabled` | Whether account usage fetching is enabled | +| `provider.account.refreshIntervalMs` | Usage refresh interval in milliseconds | +| `provider.account.connectors` | Usage connector list | +| `provider.account.connectors[].type` | Connector type, commonly `http-json` | +| `provider.account.connectors[].auth` | Auth mode, commonly `provider-api-key` | +| `provider.account.connectors[].endpoint` | Usage endpoint URL | +| `provider.account.connectors[].method` | Request method, `GET` or `POST` | +| `provider.account.connectors[].headers` | Request headers, without sensitive auth headers | +| `provider.account.connectors[].body` | Optional request body | +| `provider.account.connectors[].mapping.meters` | Usage meter mappings | + +Complete manifest example: + +```json +{ + "provider": { + "name": "Example AI", + "base_url": "https://api.example.com/v1", + "protocol": "openai_chat_completions", + "models": ["example-chat", "example-coder"], + "icon": "https://example.com/icon.png", + "source": "https://example.com", + "account": { + "enabled": true, + "refreshIntervalMs": 300000, + "connectors": [ + { + "type": "http-json", + "auth": "provider-api-key", + "endpoint": "https://api.example.com/v1/account/usage", + "method": "GET", + "headers": { + "accept": "application/json" + }, + "mapping": { + "meters": [ + { + "id": "balance", + "kind": "balance", + "label": "Balance", + "remaining": "data.balance.remaining", + "unit": "USD" + }, + { + "id": "subscription", + "kind": "subscription", + "label": "Monthly quota", + "remaining": "data.quota.remaining", + "limit": "data.quota.limit", + "resetAt": "data.quota.reset_at", + "unit": "tokens", + "window": "monthly" + } + ] + } + } + ] + } + } +} +``` + +## Supported Parameters + +| Parameter | Description | +| --- | --- | +| `name` | Provider display name | +| `base_url` | Provider API Base URL, required | +| `api_key` | Optional provider API key | +| `protocol` | Protocol, one of `openai_chat_completions`, `openai_responses`, `anthropic_messages`, `gemini_generate_content`, `gemini_interactions` | +| `models` | Model list, comma-separated, newline-separated, or repeated | +| `icon` | Provider icon URL | +| `source` | Provider website or config source | +| `manifest` | Remote manifest URL | +| `payload` | JSON or base64url JSON config | +| `usage_url` | Optional account usage endpoint | +| `fetch_usage` | Whether account usage fetching is enabled | +| `usage_method` | Usage request method, `GET` or `POST` | +| `usage_headers` | Usage request headers as a JSON string | +| `usage_body` | Usage request body as a JSON string | +| `balance` | Balance field path | +| `balance_unit` | Balance unit | +| `subscription` | Subscription remaining field path | +| `subscription_limit` | Subscription limit field path | +| `subscription_reset` | Subscription reset time field path | +| `subscription_unit` | Subscription unit | +| `subscription_window` | Subscription window, such as `monthly` | + +Parameter names and protocol values must use the exact names above. Aliases such as `baseUrl`, `apiKey`, `model`, `type`, or `openai` are not accepted. diff --git a/docs/src/content/docs/en/configuration/providers.md b/docs/src/content/docs/en/configuration/providers.md index 4a7830b..ce6a1bf 100644 --- a/docs/src/content/docs/en/configuration/providers.md +++ b/docs/src/content/docs/en/configuration/providers.md @@ -5,22 +5,166 @@ eyebrow: Detailed Configuration lead: Configure upstream model services, credentials, protocols, base URLs, and model lists. --- -## Basic Concept +## Import Local Agent Login -A provider is an upstream model service. CCR needs at least one provider with a valid protocol, Base URL, model list, and credential. +When you add a provider, CCR scans for reusable local agent login state. If usable credentials are found, the add dialog shows the matching import entry. Importing creates a normal provider plus provider plugins, so CCR can reuse the local agent authorization without requiring a pasted API key. + +### Claude Code + +Claude Code import reads local Claude Code OAuth credentials. When a usable access token is available, CCR can import it as a `Claude Code API` provider. + +After import: + +1. The protocol is `anthropic_messages`. +2. The default model list includes `claude-sonnet-4-20250514`; you can later add or remove models in the provider model list. +3. CCR creates OAuth provider plugins that convert requests to use the Claude Code login state. +4. Account usage uses the Anthropic OAuth usage endpoint, so quota state can appear in the provider list, tray, and account panels. + +If CCR only detects login traces but no usable access token, the import entry shows why it cannot be imported. Re-authenticate in Claude Code, then return to CCR and add the provider again. + +### Codex + +Codex import reads the local Codex auth file and model cache. When a Codex access token or refresh token is available, CCR can import it as a `Codex API` provider. + +After import: + +1. The protocol is `openai_responses`. +2. The API endpoint points to the Codex backend. The model list always includes at least `gpt-5-codex` and also merges models and display names from the local model cache. +3. CCR creates Codex OAuth provider plugins and refreshes access credentials when needed. +4. Account usage reads Codex quota, balance, and token-stat endpoints. + +After import, select `Codex API/model-name` in routing or Agent Config. If the model cache is stale, open Codex first so it refreshes the model list, then return to CCR to import again or edit the models. + +### ZCode + +ZCode import reads provider API keys, API endpoints, and model lists from local ZCode config. It can be imported as a `ZCode API` provider only when CCR finds a usable provider key and Base URL. + +After import: + +1. The protocol is `anthropic_messages`. +2. Models come from local ZCode config first; if none are configured, CCR uses the ZCode runtime cache or default models. +3. CCR creates API-key provider plugins that use the key from local ZCode config for request authentication. +4. If the API endpoint matches a built-in CCR preset, account usage settings are reused from that preset. + +If CCR detects ZCode login state but no usable provider API key, the import entry remains unavailable. Configure a usable model provider in ZCode first, then return to CCR and add the provider. + +## Main Fields + +| Field | Capability | +| --- | --- | +| Select preset provider | Applies a built-in provider template, including default endpoint, supported protocols, default models, icon, provider website, and sometimes account usage settings. Choose `Other / custom API endpoint` for any OpenAI, Anthropic, or Gemini compatible upstream. | +| Name | Internal CCR display name. It is also used by routing, model selectors, logs, and config references. Names must be unique. | +| API endpoint | Upstream API base URL. It controls where requests are sent, and is also used for protocol probing, model discovery, icon detection, and safety checks. Preset providers hide it by default while adding, but it can be overridden in Advanced settings. Custom providers must provide it. | +| API key | Default provider credential. When the credential pool is empty, model requests use this key. Protocol probing, model discovery, connection checks, and default usage fetching also use it. Only use a key issued for the selected endpoint. | +| Models | Model IDs exposed by CCR. Routing rules, profile model selectors, the model catalog, and client `/models` responses all use this list. | +| Search models / All / Clear | When CCR can discover models from the upstream or catalog, you can search, select all, clear, and choose models. Selected models are saved to the provider. | +| Custom models | Manually adds model IDs that discovery did not return. Use this when the provider lacks a `/models` endpoint or a new model is not in the catalog yet. | +| Check Connection | Sends real test requests with the current endpoint, API key, protocol, and selected models. It verifies key, model name, and protocol usability. | +| Models to check | Model selection inside the connection-check confirmation dialog. Use it to test only some models. | +| Check results | Shows whether each model is available, which protocol matched, and the upstream diagnostic message. Results are diagnostic. Add models through the main model selection when you want them saved. | + +## Connectivity Checks + +`Check Connection` sends real model requests for the models you select. It verifies whether the endpoint, API key, protocol, and model IDs are usable. The check limits generated output, but it can still create extra token usage or count against provider-side request limits. + +If the provider bills by request, input tokens, or output tokens, select only the models you need to verify. Checking every model at once can create unnecessary usage. Review the diagnostics, then adjust the model list or usage-fetching settings manually when needed. ## Credentials -Credentials store API keys. Multiple credentials can rotate by priority and weight, and can switch when a key hits a limit or fails. +`API key` is the simplest single-key setup. For multiple upstream keys, expand `Credential pool` in Advanced settings. -Use recognizable labels so failed keys are easy to identify in Logs. - -## Provider Options - -| Field | Description | +| Field | Capability | | --- | --- | -| Name | Internal display name in CCR; keep it short and recognizable | -| Base URL | Upstream service address; custom providers should include the correct API path | -| Protocol | OpenAI, Anthropic, Gemini, or compatible protocol | -| Models | Model list exposed to CCR selectors | -| Request headers | Extra headers required by some compatible services | +| Show credential settings | Expands or collapses credential pool editing. Collapsing does not remove saved credentials. | +| Import JSON | Imports credentials from a JSON file. CCR accepts a top-level array, or an object with a `credentials`, `keys`, or `apiKeys` array. | +| Add key | Adds one upstream API key row. | +| Enable | Controls whether this credential participates in request forwarding and usage fetching. Disabled credentials are kept but not selected. | +| Name | Display name for the credential. It appears in account usage, logs, and diagnostics, so use a recognizable purpose or quota source. | +| API key | The actual key sent to the upstream for this credential. When a credential pool is configured, CCR expands enabled credentials into internal upstream targets and prefers the pool over the main form API key for model requests. | +| Remove | Deletes the credential row. | +| Advanced key options | Expands per-key scheduling and limit fields. | +| Priority | Credential priority. Lower numbers are tried first. If omitted, the row order is used. | +| Weight | Tie-break weight among credentials with the same priority and similar usage. Higher numbers are preferred. Defaults to `1`. | +| Limits JSON | Local limit rules for this key. CCR tracks request, token, or image usage windows and skips a key once it would exceed its limit, then tries another key on the same provider. | + +Common `Limits JSON` fields: + +| Field | Meaning | +| --- | --- | +| `rpm` / `rph` / `rpd` | Max requests per minute / hour / day | +| `tpm` / `tph` / `tpd` | Max tokens per minute / hour / day | +| `ipm` / `iph` / `ipd` | Max images per minute / hour / day | +| `maxRequests` + `windowMs` | Max requests in a custom time window | +| `maxTokens` + `quotaWindowMs` | Max tokens in a custom time window | + +Example: + +```json +{ + "rpm": 60, + "tpm": 100000 +} +``` + +The credential pool is an upstream provider key pool. It is separate from the client access keys configured on the API Keys page. + +## Usage Fetching + +`Fetch usage` lets CCR show balance, subscription quota, status, and messages in the provider list, tray, and account panels. It does not affect whether models can be requested. + +| Field | Capability | +| --- | --- | +| Fetch usage | Enables or disables account usage fetching for this provider. | +| Usage mode | Usage connector mode. `Standard usage endpoint` uses CCR standard account endpoints; `HTTP JSON request` maps a custom JSON endpoint; `Raw connector JSON` edits the connector array directly. | +| Refresh interval ms | Usage refresh interval in milliseconds. Empty uses the default interval. The minimum effective interval is 30000ms. | + +### Standard Usage Endpoint + +This mode tries provider-hosted CCR account endpoints such as `/.well-known/ccr/account` and `/v1/account/limits`. It is best for providers or presets that already implement CCR's standard account format. + +### HTTP JSON Request + +Use this mode when the provider has a balance or quota endpoint that returns a custom JSON shape. + +| Field | Capability | +| --- | --- | +| Method | Usage request method, `GET` or `POST`. | +| Usage request URL | Usage endpoint URL. It can be a full URL. The request includes the provider API key unless changed through raw connector JSON. | +| Headers | Extra headers for the usage endpoint. Avoid hard-coding sensitive auth headers here; prefer provider API key auth. | +| Body | `POST` request body. Must be valid JSON. | +| Balance remaining field | JSON path for remaining balance. | +| Balance total field | JSON path for total balance or total credits. | +| Balance used field | JSON path for used balance. | +| Balance unit | Balance unit, such as `USD`, `CNY`, or `%`. | +| Subscription remaining field | JSON path for remaining subscription, token, quota, or package amount. | +| Subscription limit field | JSON path for subscription, token, quota, or package limit. | +| Subscription reset field | JSON path for reset time. It may resolve to an ISO string, seconds timestamp, or milliseconds timestamp. | +| Subscription unit | Subscription unit, such as `tokens`, `requests`, or `hours`. | +| Status field | JSON path for account status. Supported values are `ok`, `warning`, `critical`, `error`, and `unsupported`. | +| Message field | JSON path for account message. Useful for provider errors, plan notes, or risk-control messages. | +| Test usage request | Requests and parses the usage endpoint before saving. | +| Response fields | Lists selectable paths from the response. Buttons such as `Balance rem`, `Balance total`, `Balance used`, `Sub rem`, `Sub limit`, and `Reset` fill the matching field. | + +Field paths use CCR's lightweight JSONPath syntax: + +| Syntax | Meaning | +| --- | --- | +| `$` | Whole response object | +| `$.balance.remaining` | Object field | +| `$.items[0].value` | Array index | +| `$["weird-key"]` | Field name with special characters | +| `$.limits[?(@.type=="TOKENS")].remaining` | First array item matching simple equality filters | +| `100 - $.data.percentage` | Numeric subtraction expression, often used to convert used percent into remaining percent | + +### Raw Connector JSON + +`Connectors JSON` edits the `account.connectors` array directly for more complex provider setups. + +| Connector type | Capability | +| --- | --- | +| `standard` | Uses CCR standard account endpoints. | +| `http-json` | Requests a JSON endpoint and maps balance, subscription, status, and message fields. | +| `plugin` | Calls an account usage connector registered by an installed plugin. | +| `local-estimate` | Shows estimated quota from local time-window config without a remote request. | + +`Insert example` fills an example connector array containing `standard`, `http-json`, `plugin`, and `local-estimate` connectors. diff --git a/docs/src/content/docs/en/configuration/routing.md b/docs/src/content/docs/en/configuration/routing.md index 607570d..f21a521 100644 --- a/docs/src/content/docs/en/configuration/routing.md +++ b/docs/src/content/docs/en/configuration/routing.md @@ -5,20 +5,141 @@ eyebrow: Detailed Configuration lead: Choose the model for a request, then automatically retry or switch to fallback models when the request fails. --- -## How Routing Works +## Built-In Routing -CCR first decides which model the request should use, then forwards the request upstream. The current implementation follows this order: +### Claude Code -1. If the incoming `model` is already a known `provider/model` selector, CCR uses it directly. -2. If a custom router script is configured, the model returned by that script takes priority over UI routing rules. -3. Routing rules are evaluated from top to bottom. The first matching rule applies its request rewrite. -4. If no rule matches, CCR uses the default route. If no default is configured, it keeps the original request model. +The built-in Claude Code route detects requests from Claude Code and routes main requests to the Claude Code Agent Config model. -The core shape of a rule is **Condition + Request action**. The condition decides whether the rule matches; the request action changes request fields. The most common action is setting `request.body.model` to a provider model or Fusion model. +Claude Code **main requests** use the Claude Code Agent Config model. If that model is unset, the built-in route remains inactive. CCR also automatically removes the first `x-anthropic-billing-header` system message injected by Claude Code so that billing helper messages do not affect later routing decisions. Claude Code Subagent, Task, and Workflow-created agents can still choose different models through the tag mechanism below. -## What Fallback Does +#### Subagent / Workflow Auto-Routing -Fallback is the failure strategy after a model or upstream request fails. It does not pick the first model; it decides whether CCR should keep trying after the current target fails. +Claude Code Agent / Task / Workflow can spawn additional model requests. CCR uses tag injection to let those spawned requests choose a more appropriate CCR model: + +```text +provider/model +``` + +The full flow is: + +1. A Claude Code main request matches the built-in route, so CCR inspects the current tool list. +2. If at least one model has a **Description**, CCR injects the available models and descriptions into the `Agent` / `Task` tool description and `prompt` field description. +3. If the tool list includes `Workflow`, CCR appends a Workflow-specific instruction: whenever the workflow creates an `Agent` / `Task`, each spawned agent prompt must start with the same model tag. +4. When Claude Code calls `Agent` / `Task`, or when a Workflow creates an agent, the prompt starts with `provider/model`. +5. When the spawned request reaches CCR, CCR extracts and removes the tag from the system prompt or the first two user messages, then routes that request to the tagged model. + +Subagent / Workflow auto-routing therefore does not use headers such as `x-claude-code-agent-id` as the model selector. Those headers can help with observation, but the actual model selection comes from the prompt tag. + +##### Pairing It With The Models Page + +The **Description** field on the Models page is both the enablement switch and the selection guide for this mechanism. If no model has a Description, CCR does not inject Agent / Task / Workflow routing instructions, so it does not write an empty model list into tool descriptions. + +Recommended setup: + +1. Add usable models under **Providers**, and verify that the model IDs can be requested. +2. Open **Models** and fill Description for the models you want Subagents to choose automatically. Describe task fit, speed, cost, and limits. +3. Enable a Claude Code config under **Agent Config**, and choose the main model. This model handles the main Claude Code conversation. +4. Confirm that the built-in **Claude Code** route is enabled on the **Routing** page. +5. Use Agent, Task, or Workflow in Claude Code. When Claude Code spawns an agent, it can choose a CCR model from the descriptions and write the tag. + +Write descriptions around tasks instead of only naming the provider. For example: + +| Model purpose | Description example | +| --- | --- | +| Fast low-cost model | Good for code search, file triage, summaries, small edits, and low-cost parallel Subagents. | +| Strong reasoning model | Good for complex architecture analysis, large refactor planning, cross-file reasoning, and high-risk code review. | +| Long-context model | Good for reading large logs, long documents, repository-scale context gathering, and Workflow summaries. | + +After saving, CCR formats those descriptions as “Configured CCR gateway models” in the injected Claude Code instructions. When Claude Code picks a model, request logs should show `builtin:claude-code-subagent`, and the tagged model becomes the final `resolved model`. + +### Codex + +The built-in Codex route adapts Codex's `apply_patch` file-editing tool for third-party or non-GPT models. The goal is for those models to edit files through the patch tool instead of generating commands or scripts such as `cat >`, `sed -i`, `python`, or `node`. + +Technically, this is a tool protocol bridge. Native Codex `apply_patch` is a custom/freeform tool whose input is raw patch text, while many OpenAI-compatible third-party models handle ordinary function tools more reliably. CCR rewrites `apply_patch` into an upstream-visible `virtual_apply_patch` function tool and injects the full `apply_patch.lark` grammar into the tool description, requiring the model to put the patch in the `patch` field. + +When the model returns `virtual_apply_patch`, CCR rewrites it back to Codex's expected shape: `custom_tool_call` with `name = apply_patch` and `input = raw patch text`. CCR does not edit files directly; Codex still executes the resulting patch. This adaptation follows the built-in **Codex** route and has no separate switch. GPT-named models keep using Codex's native freeform `apply_patch` path. + +## Custom Routing + +Custom routes are configured in the Routing page rule list. The top **Search routing rules** field filters by rule name, condition, request action, and related row text; the top-right **Add** button opens the **Add Routing Rule** dialog. The table shows each rule under **Name**, **Condition**, **Request action**, **Status**, and **Action**. + +Custom rules match in list order, and the first enabled matching rule rewrites the request. Use the move up and move down buttons to adjust priority. Use the edit button to open **Edit Routing Rule**, and the delete button to open a confirmation dialog. Turning off the **Status** toggle keeps the rule in the list but removes it from matching. + +### Add Or Edit A Rule + +The dialog fields map directly to the saved rule: + +| UI field | How to fill it | Saved meaning | +| --- | --- | --- | +| **Name** | Enter a recognizable rule name. This field is required. | Shown in the **Name** column and included in search. | +| **Condition** | Choose `request.header` or `request.body`, then fill in field, operator, and value. | Builds `condition.left`, `condition.operator`, and `condition.right`. | +| **Rewrite request parameters** | Keep at least one rewrite row. Each row chooses an operation, target key, and required value fields. | Builds `rewrites`, applied when the rule matches. | +| **Enabled** | Turn the rule on or off. | Controls `enabled`; disabled rules do not match. | +| **On failure** | Configure fallback behavior for this rule. | Overrides **Default on failure** when this rule matches. | + +The **Add** or **Save** button is enabled only when the form is valid: name, condition field, and condition value are required; every rewrite row must have a key. **Delete** only requires a key. **Replace in array** requires both **Match value** and **Value**. Other operations require **Value**. + +### Condition + +The **Condition** area has four controls: source, field, operator, and value. + +| Source | Field examples | Matched path | +| --- | --- | --- | +| `request.header` | `user-agent`, `x-api-key`, `x-client-name` | `request.header.user-agent` | +| `request.body` | `model`, `messages`, `messages.0.role`, `tools` | `request.body.model` | + +Header names are case-insensitive. Body fields use dot-path lookup, and numeric segments address array indexes; for example, `messages.0.role` reads the first message role. For nested arrays such as `messages` or `tools`, `contains deep` is usually more robust than a fixed index. + +The value field is parsed as a common literal when possible: `true`, `false`, `null`, numbers, JSON objects, and JSON arrays compare as their corresponding types. Other input is treated as a string. To force a value to stay string-like, wrap it as `"123"` or `'123'`. + +| Operator | Use | +| --- | --- | +| `==` / `!=` | Compare actual and expected values. Numbers compare numerically; other values compare by comparable text. | +| `>` / `>=` / `<` / `<=` | Compare numerically when both sides are numbers; otherwise compare text order. | +| `starts with` | Check whether the actual value starts with the input value. Useful for model-prefix routing. | +| `contains` | Check substring containment for strings; for arrays, check direct array elements. | +| `contains deep` | Recursively checks objects and arrays. Useful for searching `messages` and `tools`. | +| `not contains` | The inverse of `contains`. | + +### Rewrite Request Parameters + +The **Rewrite request parameters** area starts with one `request.body.model` row. This is the common model-routing path: choose **Set**, use key `request.body.model`, and set the value to a target `provider/model` or Fusion model. + +Click **Add parameter** to add more rewrite rows. The trash button removes a row, but the last row cannot be removed. When the rule matches, CCR applies the rewrite rows in order. + +| Operation | Required fields | Behavior | +| --- | --- | --- | +| **Set** | key, value | Sets a request field, such as `request.body.model = provider/model` or `request.body.temperature = 0.2`. | +| **Delete** | key | Deletes a request field. Deleting `request.header.x-test` removes that header; deleting `request.body.foo` removes that body field. | +| **Append to array** | key, value | Appends the value to the target array. If the target is not an array, CCR starts from an empty array. | +| **Prepend to array** | key, value | Prepends the value to the target array. | +| **Remove from array** | key, value | Removes array elements equal to the value. | +| **Replace in array** | key, match value, value | Replaces array elements matching **Match value** with the new value. | + +Rewrite values are also parsed as literals, so `0.2` becomes a number, `true` becomes a boolean, and `{"type":"web_search"}` becomes an object. Only `request.body.model` receives additional CCR model-selector normalization. + +### On Failure + +The dialog **On failure** control is the same control used by the page-level **Default on failure** setting. Choose **Off** to avoid fallback. Choose **Retry** to reveal **Retries**. Choose **Fallback targets** to reveal the **Fallback target** input and **Add** button. Added targets appear as tags with move up, move down, and remove buttons for ordering the fallback chain. + +When a rule matches, its **On failure** setting is used. Requests that do not match a rule continue to use the page-level default. + +### Examples + +| Goal | Condition source | Field | Operator | Value | Rewrite request parameters | +| --- | --- | --- | --- | --- | --- | +| Route by client header | `request.header` | `x-client-name` | `==` | `claude-code` | **Set** `request.body.model = provider/model` | +| Route by original model prefix | `request.body` | `model` | `starts with` | `claude-` | **Set** `request.body.model = provider/model` | +| Route message content to a vision model | `request.body` | `messages` | `contains deep` | `image` | **Set** `request.body.model = vision-provider/model` | +| Remove a debug header | `request.header` | `x-debug-route` | `==` | `1` | **Delete** `request.header.x-debug-route` | + +After saving, the rule appears in the list. Use request logs, especially `request model`, `resolved provider`, `resolved model`, and route reason, to verify that it matched. + +## Fallback Handling + +Fallback is the failure strategy after a model or upstream request fails. Routing picks the first model; Fallback decides whether CCR should keep trying after the current target fails. The **Default on failure** control at the top of the Routing page is the global Fallback. Each rule also has **On failure**. When a rule matches, its rule-level Fallback overrides the global Fallback. @@ -41,7 +162,7 @@ Network errors move to the next attempt. Status-code fallback depends on the mod | Retry | `408`, `409`, `429`, `5xx` | | Fallback targets | Any `4xx` or `5xx` | -For `429` rate-limit responses, CCR waits before the next attempt. It honors `Retry-After` when the upstream provides it; otherwise it uses exponential backoff starting at 1 second and capped at 30 seconds per attempt. +Before moving to the next attempt, CCR waits for every fallback-triggering failure, including network errors. It honors a positive `Retry-After` header when the upstream provides one; otherwise it uses exponential backoff starting at 1 second and capped at 30 seconds per attempt. **Fallback targets** also switches on `4xx` because model-not-found, auth, or provider-side rejection errors may only affect the current target. If the fallback model works, the request can still succeed. @@ -55,7 +176,7 @@ Configure **Default on failure** at the top of the Routing page: 2. If you choose **Retry**, set `Retries`. 3. If you choose **Fallback targets**, add backup models in priority order. -Global Fallback applies to the default route and to rules that do not define their own Fallback. +Global Fallback applies to routing rules that do not define their own Fallback. ### Rule-Level Fallback @@ -63,24 +184,6 @@ When adding or editing a routing rule, configure **On failure** for that rule. Rule-level Fallback is useful for high-risk or expensive targets. For example, route image tasks to a Fusion vision model first, then fall back to another multimodal model; or route complex tasks to a strong model first, then fall back to a stable model. -### Conditional Routing - -The current UI primarily creates conditional rules. Conditions can read request headers or the request body: - -| Source | Example | -| --- | --- | -| `request.header` | `x-client-name == claude-code` | -| `request.body` | `model starts-with claude-` | -| `request.body` | `messages contains-deep image` | - -After a match, the request action can set, delete, or modify request fields. The most common action is: - -```text -set request.body.model = provider/model -``` - -The model can also be a Fusion model, so routing can send selected requests to vision, search, or tool-augmented models. - ## Verification After saving, send a request and inspect Logs: diff --git a/docs/src/content/docs/en/configuration/server.md b/docs/src/content/docs/en/configuration/server.md new file mode 100644 index 0000000..c671b31 --- /dev/null +++ b/docs/src/content/docs/en/configuration/server.md @@ -0,0 +1,28 @@ +--- +title: Server +pageTitle: Server +eyebrow: Detailed Configuration +lead: Configure the CCR gateway host, port, and Proxy mode for MITM interception and proxying into CCR. +--- + +## Main Fields + +| Field | Capability | +| --- | --- | +| Host | Host address the CCR gateway listens on. Common values are `127.0.0.1` and `0.0.0.0`. | +| Port | Gateway listening port. Clients should point their API base URL to this port. | + +## Proxy Mode + +Proxy mode is the local proxy capability. When enabled, clients can send HTTP/HTTPS traffic to CCR. CCR uses MITM interception to identify and decrypt HTTPS requests, then proxies supported model requests into the CCR gateway path. + +| Field | Capability | +| --- | --- | +| Proxy mode | Enables Proxy mode. CCR can receive client traffic as an HTTP/HTTPS proxy and use MITM interception to proxy model requests into CCR. | +| System proxy | Points the system proxy at CCR so apps that honor system proxy settings can go through CCR automatically. | +| Capture network | Stores network requests that pass through proxy mode so the Networking page can show request and response details. | +| CA certificate | Trust status of the current proxy CA certificate. | +| Install CA | Installs the CCR proxy CA into the system or user trust store. Installation differs by OS. | +| Check Trust | Checks again whether the proxy CA is trusted by the system. | +| Proxy status | Shows whether the proxy service is running. | +| Restart Proxy | Restarts the proxy service when proxy mode is enabled. | diff --git a/docs/src/content/docs/en/configuration/toolhub.md b/docs/src/content/docs/en/configuration/toolhub.md new file mode 100644 index 0000000..c098456 --- /dev/null +++ b/docs/src/content/docs/en/configuration/toolhub.md @@ -0,0 +1,167 @@ +--- +title: ToolHub +pageTitle: ToolHub +eyebrow: Detailed Configuration +lead: Collapse many MCP servers into one compact entry point so agents lazy-load task-specific tools and save context. +--- + +## When To Use It + +As your MCP setup grows, exposing every tool directly to an agent makes the eager tool list large and easier to misuse. ToolHub exposes one `ccr-toolhub` MCP server with two meta tools: + +- `tool_hub.resolve`: searches the available MCP tool catalog for the current task. +- `tool_hub.invoke`: calls a real MCP tool that was selected for this task. + +Use ToolHub for tools that are not needed often but are still useful occasionally. It lazy-loads them only when a task actually needs them. The main value is saving context: large low-frequency tool catalogs do not have to stay in the agent's eager tool list, which reduces context use and the chance of selecting the wrong tool. Simple local code, file, or conversation tasks usually do not need ToolHub. + +## How It Works + +1. Enable ToolHub in **Settings → ToolHub**. +2. Select a configured model as the **Resolver model**. It reads the MCP tool catalog and chooses the tools needed for the task. Prefer `deepseek-v4-flash`, or another stable lightweight model in a similar flash-price tier. +3. Add or import backend MCP servers. ToolHub supports `stdio`, `streamable-http`, and `sse`. +4. Open Claude Code or Codex from CCR. CCR writes the `ccr-toolhub` MCP server into that agent config. +5. When the agent receives a request about external services, installed MCP capabilities, or business APIs, it calls `tool_hub.resolve` first, then uses `tool_hub.invoke` to run the selected tools. + +ToolHub combines MCP servers configured on the ToolHub page with compatible global Agent MCP servers from older configs, and excludes `ccr-toolhub` itself to avoid recursive calls. + +## Built-In Browser Automation + +When ToolHub is enabled in CCR Desktop and **Built-in browser automation** is turned on, agents can use the desktop built-in browser for web tasks. You do not need to add a browser backend on the ToolHub page, and you do not need a separate API key; CCR connects to it through the local gateway authentication path. + +To enable it: + +1. Open **Settings → ToolHub** and turn on **Enable ToolHub**. +2. Turn on **Built-in browser automation** on the same page. This switch is shown only after ToolHub is enabled. +3. After saving settings, reopen Claude Code or Codex from CCR so the new agent instance loads the latest configuration. + +> Already-running agent instances usually do not pick up this switch immediately. Restart the agent instance, or use the agent's own controls to restart ToolHub. + +Built-in browser automation is useful for tasks that need real browser state, such as opening sites, reading pages, filling forms, clicking buttons, scrolling, or completing web flows like ordering, booking, lookup, and checkout when no domain-specific capability exists. After it is enabled, the agent can: + +- Open or attach built-in browser tabs, then navigate to URLs or search queries. +- Read page content and find buttons, links, form fields, and other page elements. +- Click, type, select, press keys, and scroll page elements. +- Wait for page loads, navigation, dialogs, or human handoff results before continuing. +- Request human help for login, verification codes, CAPTCHA, human checks, or manual confirmation. + +When a web flow needs login, verification codes, CAPTCHA, a human check, or manual confirmation, CCR shows the built-in browser window and displays the requested action in the top toolbar. After the user clicks **Done** or **Hide**, the agent receives the result and continues. Handoff waits support up to 10 minutes. + +### Chrome Login Import Extension + +Built-in browser automation can also import login state for selected domains from system Chrome into CCR's in-app browser. This lets the agent reuse sites where you are already signed in to Chrome. It requires the unpacked Chrome extension in this repository: `extension/chrome`. + +Install it: + +1. Open `chrome://extensions` in Chrome. +2. Enable **Developer mode**. +3. Click **Load unpacked**. +4. Select the repository's `extension/chrome` directory. + +Import flow: + +1. When a task needs existing Chrome login state, the agent can request an import; the user can also click the key button in CCR's in-app browser toolbar. +2. CCR creates a one-time import job and opens a confirmation page. If your default browser is not Chrome, copy the confirmation URL into Chrome with the extension installed. +3. Review the requested domains on the confirmation page, then click **Confirm and Import**. +4. The Chrome extension reads cookies and localStorage for those domains and submits them to CCR. After it completes, the agent can continue the task in the built-in browser. + +The extension reads only the domains listed in the CCR import job. It does not enumerate every Chrome cookie. For localStorage, the extension temporarily opens non-active tabs for the selected origins, reads `localStorage`, and closes those tabs. If the confirmation page says the extension does not have site access, allow the extension to access the target domains in Chrome extension settings, reload the unpacked extension, and try again. + +> Note: Built-in browser automation depends on CCR Desktop's built-in browser and is only available in the desktop app. CLI, server deployments, and pure web environments do not include this built-in capability; use an external browser automation MCP server instead. + +## Options + +| Option | Description | +| --- | --- | +| Enable ToolHub | Exposes `ccr-toolhub` to agents. If no backend MCP server is available, CCR does not generate a ToolHub MCP config. | +| Built-in browser automation | Shown only after ToolHub is enabled. Lets agents use CCR Desktop's built-in browser for web tasks. | +| Resolver model | Choose from configured provider models. Prefer `deepseek-v4-flash`, or another stable lightweight model in a similar flash-price tier with enough tool-description understanding. | +| Max tools | Maximum tools returned by one resolve call. Range `1` to `20`, default `10`. | +| Timeout ms | Base timeout for ToolHub resolving and invocation. Range `8000` to `300000`, default `60000`. If a backend MCP server needs a longer request timeout, CCR raises the effective invocation timeout to match the backend. | +| MCP servers | Backend tool sources. Each server needs a unique name plus transport, command or URL, environment variables, headers, and timeouts. | +| Import JSON | Imports common MCP JSON shapes. Supports a root object, array, `mcpServers`, or `mcp_servers`. | + +## Add MCP Servers + +### stdio + +Use `stdio` for local command-line MCP servers. Configure: + +- **Command**: launch command, such as `npx`, `node`, or `python`. +- **Arguments**: command arguments. +- **Working directory**: optional working directory. +- **Stdio message mode**: keep `content-length` by default; use `newline-json` for line-delimited JSON servers. +- **Environment variables**: variables needed only by this MCP server. + +### streamable-http / sse + +Remote MCP servers need a URL. Authentication can use: + +- **API key**: stored directly in the config. +- **API key env**: read from an environment variable. +- **Headers**: custom request headers. + +If a remote server starts slowly or has long-running calls, adjust that server's **Startup timeout** or **Request timeout**. + +## JSON Example + +The desktop app's SQLite config is the effective source, so prefer editing through the UI. The fields below are useful for backups, migration, or troubleshooting: + +```json +{ + "toolHub": { + "enabled": true, + "browserAutomation": true, + "llm": { + "apiKey": "sk-...", + "baseUrl": "https://api.openai.com/v1", + "model": "gpt-5-mini" + }, + "maxTools": 10, + "requestTimeoutMs": 60000, + "mcpServers": [ + { + "name": "filesystem", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + "env": {}, + "stdioMessageMode": "content-length", + "requestTimeoutMs": 30000, + "startupTimeoutMs": 600000 + } + ] + } +} +``` + +The import dialog also accepts common MCP JSON: + +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + } +} +``` + +## ToolHub vs Fusion MCP + +| Capability | ToolHub | Fusion Custom MCP Tool | +| --- | --- | --- | +| Entry point | Agent-side `ccr-toolhub` MCP server | Capability inside one Fusion model | +| Tool selection | Dynamically resolves a tool bundle for each task | Fixed tools selected in the model config | +| Best for | Many MCP servers, changing tool catalogs, agent-led capability discovery | Adding a known tool set to one model | +| Visibility | Claude Code or Codex configs opened through CCR | Routes or agents that select that Fusion model | + +## Troubleshooting + +- Agent cannot see ToolHub: make sure ToolHub is enabled and at least one backend MCP server is configured or **Built-in browser automation** is turned on, then reopen Claude Code or Codex from CCR. +- Missing resolver model or API key: select a configured resolver model and confirm the provider credential works. +- The agent cannot use built-in browser automation: make sure you are using CCR Desktop, turned on **Built-in browser automation** in **Settings → ToolHub**, and reopened Claude Code or Codex from CCR. CLI, server deployments, and pure web environments do not include this built-in capability. +- Chrome login import confirmation keeps waiting for the extension: make sure the unpacked `extension/chrome` extension is loaded in Chrome and has site access for the target domains. If your default browser is not Chrome, copy the confirmation URL into Chrome manually. +- No tools are resolved: confirm the MCP server can list tools, improve tool names and descriptions, or increase **Max tools**. +- Calls time out: check ToolHub **Timeout ms** and the backend server request/startup timeouts. +- Import fails: validate JSON, avoid duplicate server names, make sure `stdio` entries have a command and remote entries have a URL. diff --git a/docs/src/content/docs/en/configuration/tray.md b/docs/src/content/docs/en/configuration/tray.md new file mode 100644 index 0000000..a09f199 --- /dev/null +++ b/docs/src/content/docs/en/configuration/tray.md @@ -0,0 +1,41 @@ +--- +title: Tray Configuration +pageTitle: Tray Configuration +eyebrow: Detailed Configuration +lead: Configure the CCR system tray icon, balance progress, and tray window widgets. +--- + +## Top Fields + +| Field | Capability | +| --- | --- | +| Tray mascot | Selects the tray icon style. Options include `Random`, `Auralis`, `Solara`, `Vesper`, and `Balance progress`. | +| Balance progress | Uses provider account usage as tray icon progress. Requires `Fetch usage` on a provider first. | +| Account | Selects the provider account used for balance progress. | +| Data | Selects the balance, subscription, or quota meter used as the progress source. | + +If no account data is available, the page shows `No account data is available. Enable account monitoring on a provider first.` This usually means no provider has `Fetch usage` enabled, or usage fetching has not succeeded yet. + +## Tray Window Layout + +| Area | Capability | +| --- | --- | +| Components | Left-side component palette for adding or enabling tray window widgets. | +| Preview | Middle preview area showing the current tray window layout. Widgets can be dragged to reorder. | +| Component properties | Right-side editor for the selected widget's `Style`, or for removing the widget. | + +## Component Types + +| Component | Capability | +| --- | --- | +| Provider component | Shows `Provider tabs` for switching provider data in the tray window. It is a singleton component and can be enabled only once. | +| Header component | Shows `Title and status`. It is a singleton component and can be enabled only once. | +| Account component | Shows `Account balance`. Multiple account components can be added with different styles. | +| Trend component | Shows `Token flow chart`. | +| Activity component | Shows `Token activity`. | +| Metric component | Shows `Token stats`. | +| Breakdown component | Shows `Token mix`, `Circular metrics`, or `Model share`. | + +## Styles + +Different components support different `Style` options. Common styles include `Cards`, `Compact`, `List`, `Pills`, `Line`, `Area`, `Bar`, `Ring`, `Donut`, `Gauges`, `Sparkline`, and `Stacked`. Style changes only affect the tray display; they do not change routing, providers, or usage stats. diff --git a/docs/src/content/docs/en/index.md b/docs/src/content/docs/en/index.md index 0091b7d..a9a08ba 100644 --- a/docs/src/content/docs/en/index.md +++ b/docs/src/content/docs/en/index.md @@ -5,19 +5,6 @@ eyebrow: Product Documentation lead: Learn what CCR is for, where its boundaries are, and how the docs are organized. Start with Quick Start when you want to configure it; use Detailed Configuration for fields, Bots, or Fusion. --- -## What CCR Can Do - -**Claude Code Router (CCR) is a local model gateway.** It sits between agents such as Claude Code, Codex, and ZCode and upstream model services, then centralizes model management, API keys, routing rules, logs, observability, and Bot relay. - -CCR is useful when: - -- you do not want to maintain the same models and keys separately in every agent. -- you want different tasks to automatically use different models: fast models for lightweight background work, stronger models for complex tasks, and Fusion for image or web-search work. -- you need request logs showing which provider and model handled a request, whether it succeeded, how long it took, and roughly how much it cost. -- you want to forward long-running agent messages to Slack, Telegram, Feishu, WeCom, or other IM platforms. - -CCR listens on the local default address `http://localhost:8080`. Once an agent points to this address, CCR can take over the request and forward it to upstream providers according to routing rules. - ## Documentation Structure The top navigation is split into four standalone pages: @@ -26,7 +13,7 @@ The top navigation is split into four standalone pages: | --- | --- | | [Documentation](./) | Product positioning, architecture overview, and reading path | | [Quick Start](guides/) | From installation and provider setup to connecting an agent | -| [Detailed Configuration](configuration/) | Providers, routing, Agent Config, Fusion, Bots, and config file location | +| [Detailed Configuration](configuration/overview/) | Overview dashboard, API keys, server, providers, routing, Agent Config, Fusion, Bots, tray, and config database location | | [Q&A](troubleshooting/) | Request logs, observability panel, and common questions | Bot platform guides are child pages under Detailed Configuration. Each platform has its own page so platform dashboard fields, callback URLs, signatures, and FAQs can be expanded independently. @@ -37,5 +24,5 @@ If this is your first time using CCR: 1. Start with [Quick Start](guides/) to connect a provider and Agent Config. 2. Use the app's request logs to confirm whether requests are passing through CCR. -3. Open [Detailed Configuration](configuration/) for vision, web search, MCP tools, and IM relay. +3. Open [Detailed Configuration](configuration/overview/) for the overview dashboard, API keys, server, providers, vision, web search, MCP tools, tray, and IM relay. 4. Use [Q&A](troubleshooting/) for 401, 404, timeout, wrong-routing, or Bot delivery questions. diff --git a/docs/src/content/docs/zh/bot-与-im-接力-agent/line.md b/docs/src/content/docs/zh/bot-与-im-接力-agent/line.md index 4c208aa..75040f8 100644 --- a/docs/src/content/docs/zh/bot-与-im-接力-agent/line.md +++ b/docs/src/content/docs/zh/bot-与-im-接力-agent/line.md @@ -13,7 +13,7 @@ LINE 适合把 Agent 消息接入已有的 LINE 好友、群聊或 LINE Official ## 你会用到哪些字段 -CCR 里 LINE 的认证方式叫 **Bot Token**,但填的不是 Telegram 那种 token,而是下面这两个 channel 字段: +CCR 里 LINE 的认证方式叫 **Bot Token**,这里需要填写下面这两个 channel 字段: | LINE 后台里的名字 | CCR 字段 | 是否必填 | 说明 | | --- | --- | --- | --- | diff --git a/docs/src/content/docs/zh/configuration.md b/docs/src/content/docs/zh/configuration.md index e566930..c4db009 100644 --- a/docs/src/content/docs/zh/configuration.md +++ b/docs/src/content/docs/zh/configuration.md @@ -2,22 +2,37 @@ title: Claude Code Router 详细配置 pageTitle: 详细配置 eyebrow: 详细配置 -lead: 深入配置供应商、路由、Agent配置、Fusion、Bot 和配置文件位置。这里是按功能查字段和扩展能力的地方。 +lead: 按应用中的实际顺序,将主页页面和设置页分开说明:主页覆盖概览、供应商、Agent配置、路由、Fusion、API 密钥、日志&观测、服务和扩展;设置页覆盖 ToolHub、Bot、数据和托盘。 --- ## 页面结构 -详细配置文档已经拆成独立页面。左侧目录中的每一项都会进入一个页面;当前页面内的标题由右侧大纲负责。 +详细配置文档已经拆成独立页面。左侧目录中的每一项都会进入一个页面;当前页面内的标题由右侧大纲负责。主页页面跟随应用左侧主导航顺序;设置页单独分组,并按设置弹窗顺序排列。 + +## 主页页面 | 页面 | 内容 | | --- | --- | +| 概览仪表盘 | 系统状态、账户余额、用量组件、布局编辑和分享卡片 | | 供应商配置 | 上游服务、协议、基础 URL、模型列表和凭据 | -| 路由配置 | 默认路由、条件规则、fallback 和请求改写 | -| Fusion 组合模型 | 把基础模型与视觉、搜索、MCP 工具组合成新的可选模型 | +| 一键导入供应商 | Provider deeplink 协议、Manifest 导入、一键导入按钮和安全边界 | | Agent配置 | Agent 启动方式、模型、作用范围、多开和 Bot 绑定 | +| 路由配置 | 条件规则、fallback 和请求改写 | +| Fusion 组合模型 | 把基础模型与视觉、搜索、MCP 工具组合成新的可选模型 | +| API 密钥 | 客户端访问 Key、过期时间和本地限额 | +| 日志&观测 | 请求日志、Agent 执行追踪、工具调用和工具结果 | +| 服务配置 | Host、Port、代理模式、系统代理、网络捕获和 CA 证书 | +| 扩展机制 | Wrapper plugin、Core gateway plugin、自定义扩展创建和调试 | + +## 设置页 + +| 页面 | 内容 | +| --- | --- | +| ToolHub | 将多个 MCP server 收束成一个 Agent 可用的动态工具检索入口 | | Bot 与 IM 接力 Agent | Bot 转发、接力模式和平台页面 | -| 配置文件位置 | 桌面 App 维护的配置文件位置 | +| 配置数据库位置 | 桌面 App 维护的 SQLite 配置数据库位置 | +| 托盘配置 | 托盘图标、余额进度条和托盘窗口组件 | ## 内容关系 -供应商和路由决定模型请求的上游去向;Agent配置页面覆盖 Claude Code、Codex 和 ZCode 的启动、多开与模型选择;Fusion 页面覆盖图像、搜索和 MCP 工具;Bot 页面覆盖 IM 平台接力。 +概览仪表盘用于查看系统状态和用量;供应商配置和一键导入供应商页面覆盖上游模型服务如何进入 CCR;Agent配置页面覆盖 Claude Code、Codex 和 ZCode 的启动、多开与模型选择;路由决定模型请求的上游去向;Fusion 页面覆盖图像、搜索和 MCP 工具;API 密钥控制客户端访问 CCR;日志&观测覆盖请求日志和 Agent 执行链路;服务配置控制本地网关监听和代理能力;扩展机制覆盖本地插件的创建、安装和调试。ToolHub、Bot、配置数据库位置和托盘配置对应设置弹窗中的同名配置页。 diff --git a/docs/src/content/docs/zh/configuration/api-keys.md b/docs/src/content/docs/zh/configuration/api-keys.md new file mode 100644 index 0000000..d30604c --- /dev/null +++ b/docs/src/content/docs/zh/configuration/api-keys.md @@ -0,0 +1,46 @@ +--- +title: API 密钥 +pageTitle: API 密钥 +eyebrow: 详细配置 +lead: 管理客户端访问 CCR 网关时使用的 API Key,并为每个 Key 设置过期时间和本地限额。 +--- + +## 列表字段 + +| 字段 | 代表的能力 | +| --- | --- | +| 搜索 API 密钥 | 按名称或 Key 内容过滤列表。 | +| 添加 API 密钥 | 打开创建弹窗,生成新的客户端访问 Key。 | +| 名称 | API Key 的显示名,用于区分客户端、团队、用途或自动化来源。 | +| Key | 脱敏后的访问 Key。可以点击 `复制 API 密钥` 复制完整 Key。 | +| 过期 | 当前 Key 的过期时间。过期后客户端不能继续使用这个 Key 访问 CCR。 | +| 限制 | 当前 Key 的本地限额摘要。没有配置限额时显示“未配置限制”。 | +| 编辑 API 密钥 | 修改过期时间和限额。出于安全原因,已创建的 Key 本身不会重新明文展示。 | +| 移除 API 密钥 | 删除当前客户端访问 Key。删除后立即不能再用于请求。 | + +## 创建和编辑 + +| 字段 | 代表的能力 | +| --- | --- | +| 名称 | 新 Key 的显示名。建议写成客户端或用途,例如 `Claude Code - laptop`、`CI` 或团队名。 | +| 过期时间 | 选择 Key 的有效期:`永不`、`7 天`、`30 天`、`90 天` 或 `自定义`。 | +| 过期于 | 选择 `自定义` 时出现,用于填写精确的过期日期和时间。 | +| API 密钥已创建 | 创建成功后的确认弹窗。这里会显示完整 Key。 | +| 请现在复制保存这个密钥,之后可能不会再次完整显示。 | 提醒你立即复制 Key。关闭弹窗后,CCR 不会再次展示完整 Key。 | + +## 高级设置 + +`高级设置` 用于给单个客户端 Key 添加本地限额。限额命中后,使用这个 Key 的客户端请求会被拒绝或限制;它不会修改供应商侧额度。 + +| 字段 | 代表的能力 | +| --- | --- | +| 高级设置 | 展开或收起限额编辑区。 | +| 未配置限制 | 当前 Key 没有任何本地限额。 | +| 请求 | 按请求次数限制。 | +| 令牌 | 按 token 数量限制。 | +| 图片 | 按图片数量限制。 | +| 每分钟 | 限额窗口为 1 分钟。 | +| 每小时 | 限额窗口为 1 小时。 | +| 每天 | 限额窗口为 1 天。 | +| 添加限制 | 新增一条限额规则。 | +| 移除限制 | 删除当前限额规则。 | diff --git a/docs/src/content/docs/zh/configuration/bot-relay.md b/docs/src/content/docs/zh/configuration/bot-relay.md index db2cfae..b5a40db 100644 --- a/docs/src/content/docs/zh/configuration/bot-relay.md +++ b/docs/src/content/docs/zh/configuration/bot-relay.md @@ -5,10 +5,6 @@ eyebrow: 详细配置 lead: 通过 IM Bot 转发 Agent 消息,或在桌面空闲后把任务接力到手机。 --- -## 能做什么 - -Bot 能把 Agent 消息转发到 IM 平台,也可以在桌面空闲后把任务接力到手机上继续看。 - ## 常见模式 - **转发 Agent 消息**:把消息同步到 IM。 diff --git a/docs/src/content/docs/zh/configuration/config-file.md b/docs/src/content/docs/zh/configuration/config-file.md index c60f808..fe34677 100644 --- a/docs/src/content/docs/zh/configuration/config-file.md +++ b/docs/src/content/docs/zh/configuration/config-file.md @@ -1,16 +1,17 @@ --- -title: 配置文件位置 -pageTitle: 配置文件位置 +title: 配置数据库位置 +pageTitle: 配置数据库位置 eyebrow: 详细配置 -lead: 找到 CCR 桌面 App 默认维护的配置文件。 +lead: 找到 CCR 桌面 App 默认维护的 SQLite 配置数据库。 --- ## 默认位置 -- macOS:`~/Library/Application Support/claude-code-router/config.json` -- Windows:`%APPDATA%/claude-code-router/config.json` -- Linux:`~/.config/claude-code-router/config.json` +- macOS/Linux:`~/.claude-code-router/config.sqlite` +- Windows:`%APPDATA%\Claude Code Router\config.sqlite` ## 生效方式 -直接编辑配置文件后,需要重启 CCR。 +CCR 的运行配置存储在 SQLite 中。旧版 `config.json` 只会在没有 SQLite 配置时作为迁移来源读取一次,迁移完成后继续编辑 `config.json` 不会影响当前配置。 + +建议通过桌面 UI 修改配置,或在 **Settings** 中导出备份。不要在 CCR 运行时直接编辑 `config.sqlite`;SQLite 还会维护同目录的 `config.sqlite-wal` 和 `config.sqlite-shm` 辅助文件。 diff --git a/docs/src/content/docs/zh/configuration/extensions.md b/docs/src/content/docs/zh/configuration/extensions.md new file mode 100644 index 0000000..04354bd --- /dev/null +++ b/docs/src/content/docs/zh/configuration/extensions.md @@ -0,0 +1,281 @@ +--- +title: 扩展机制 +pageTitle: 扩展机制 +eyebrow: 详细配置 +lead: 了解 CCR 扩展如何加载、能注册哪些能力,并从零创建、安装和调试自己的扩展。 +--- + +## 扩展类型 + +CCR 的扩展分为两层: + +| 类型 | 配置位置 | 运行位置 | 适合做什么 | +| --- | --- | --- | --- | +| Wrapper plugin | `plugins` | CCR Desktop 的 Electron wrapper 进程 | 注册本地 HTTP 路由、启动本地后端、拦截代理流量、添加内置浏览器入口、连接 Provider 账号用量 | +| Core gateway plugin | `providerPlugins` 或 `plugins[].coreGateway.providerPlugins` | core gateway runtime | 扩展上游 Provider、认证方式或 core gateway 内部能力 | + +多数用户自定义扩展应从 Wrapper plugin 开始。它能拿到 CCR 配置、私有数据目录和日志对象,并通过 `ctx` 注册能力。 + +## 加载机制 + +启动网关时,CCR 会读取配置里的 `plugins` 数组,并按顺序处理每个 `enabled !== false` 的扩展: + +1. 先应用配置中声明的 `apps`、`proxy.routes`、`coreGateway.providerPlugins`、`coreGateway.virtualModelProfiles` 和 `coreGateway.config`。 +2. 再加载扩展模块。`module` 可以是绝对路径、`~/` 开头路径、相对配置目录的 `./...` 路径,或 Node 可以解析到的包名。 +3. 如果没有配置 `module`,CCR 会尝试用扩展 `id` 匹配内置市场扩展,例如 `claude-design` 和 `cursor-proxy`。 +4. 模块可以导出函数,也可以导出包含 `setup(ctx)` 或 `activate(ctx)` 的对象。 +5. 扩展停止时,CCR 会反向执行 `stop`、`onStop` 钩子,并关闭该扩展注册的 HTTP 后端和 SQLite store。 + +扩展模块常见导出形式: + +```js +"use strict"; + +module.exports = { + async setup(ctx) { + ctx.logger.info("extension loaded"); + }, + async stop() { + // 可选:释放扩展自己持有的资源。 + } +}; +``` + +也可以直接导出函数: + +```js +"use strict"; + +module.exports = async function setup(ctx) { + ctx.logger.info(`loaded ${ctx.pluginId}`); +}; +``` + +`setup(ctx)` 或 `activate(ctx)` 可以直接调用 `ctx.register...` 方法,也可以返回注册对象。返回对象支持 `apps`、`gatewayRoutes`、`proxyRoutes`、`providerAccountConnectors`、`coreGateway`、`virtualModelProfiles`、`stop` 和 `onStop`。 + +## ctx 能力参考 + +`setup(ctx)` 的 `ctx` 包含这些常用字段和方法: + +| 字段或方法 | 说明 | +| --- | --- | +| `ctx.pluginId` | 当前扩展 ID | +| `ctx.pluginConfig` | `plugins[].config` 中的自定义配置 | +| `ctx.config` | 当前 CCR AppConfig 快照 | +| `ctx.logger` | 带 `[plugin:]` 前缀的 `debug/info/warn/error` 日志 | +| `ctx.paths.configDir` | CCR 配置目录 | +| `ctx.paths.dataDir` | CCR 数据目录 | +| `ctx.paths.pluginDataDir` | 当前扩展专属数据目录 | +| `ctx.registerGatewayRoute(route)` | 在 CCR 网关上注册本地 HTTP 路由 | +| `ctx.registerHttpBackend(backend)` | 启动一个本地 HTTP 后端,返回 `{ url, host, port }` | +| `ctx.registerProxyRoute(route)` | 把代理模式捕获到的某个 host/path 转发到扩展后端或其他 upstream | +| `ctx.registerApp(app)` | 在内置浏览器应用列表里添加入口 | +| `ctx.openSqliteStore(options)` | 在扩展数据目录打开 SQLite store | +| `ctx.registerProviderAccountConnector(connector)` | 注册 Provider 账号余额或额度读取器 | +| `ctx.registerCoreGatewayProviderPlugin(plugin)` | 向 core gateway 注入 provider plugin | +| `ctx.registerCoreGatewayVirtualModelProfile(profile)` | 向 core gateway 注入虚拟模型配置 | + +Gateway route handler 会额外收到 helper: + +| Helper | 说明 | +| --- | --- | +| `helpers.readBody(request)` | 读取请求 body,返回 `Buffer` | +| `helpers.readJson(request)` | 读取并解析 JSON body | +| `helpers.sendJson(response, statusCode, body)` | 返回 JSON 响应 | + +`registerGatewayRoute` 默认使用 `auth: "gateway"`。如果 CCR 配置了 API Key,请求必须带 `Authorization: Bearer ` 或 `x-api-key: `。仅调试或本地公开状态页建议使用 `auth: "none"`。 + +## 创建第一个扩展 + +创建一个目录,例如 `~/ccr-extensions/hello-extension`: + +```text +hello-extension/ + plugin.json + index.cjs +``` + +`plugin.json` 用于让 CCR 的本地扩展选择器识别扩展 ID、名称和入口文件: + +```json +{ + "id": "hello-extension", + "name": "Hello Extension", + "module": "index.cjs", + "apps": [ + { + "id": "hello-status", + "name": "Hello Status", + "url": "http://127.0.0.1:3456/plugins/hello" + } + ] +} +``` + +`index.cjs` 注册一个状态路由、一个 echo 后端,以及一个代理转发规则: + +```js +"use strict"; + +module.exports = { + async setup(ctx) { + ctx.registerGatewayRoute({ + auth: "none", + id: "hello-status", + method: "GET", + path: "/plugins/hello", + handler(_request, response, helpers) { + helpers.sendJson(response, 200, { + ok: true, + plugin: ctx.pluginId, + message: ctx.pluginConfig?.message || "hello from CCR" + }); + } + }); + + const backend = await ctx.registerHttpBackend({ + id: "hello-echo", + async handler(request, response, helpers) { + const body = request.method === "POST" + ? (await helpers.readBody(request)).toString("utf8") + : ""; + + helpers.sendJson(response, 200, { + method: request.method, + path: request.url, + body + }); + } + }); + + ctx.registerProxyRoute({ + host: "api.example.local", + id: "hello-example-api", + paths: ["/v1"], + preserveHost: true, + upstream: backend.url + }); + + ctx.logger.info(`hello backend listening at ${backend.url}`); + } +}; +``` + +这个扩展会暴露: + +- `GET /plugins/hello`:直接挂在 CCR 网关上,用来验证扩展是否加载。 +- 一个本地 echo 后端:由 CCR 自动分配端口。 +- 一个代理规则:当代理模式捕获到 `api.example.local/v1...` 时转发到 echo 后端。 + +## 安装扩展 + +推荐使用桌面 UI 安装本地扩展: + +1. 打开 **Extensions** 页面。 +2. 点击添加扩展,选择本地扩展目录。 +3. 选择刚创建的 `hello-extension` 目录。 +4. 保存配置。 +5. 打开 **Server** 页面,重启网关。 + +CCR 的运行配置存储在 SQLite 中。请通过 UI 添加扩展;旧版 JSON 配置文件仅用于参考。扩展条目的配置结构如下: + +```json +{ + "plugins": [ + { + "id": "hello-extension", + "enabled": true, + "module": "/Users/you/ccr-extensions/hello-extension/index.cjs", + "config": { + "message": "hello from my config" + } + } + ] +} +``` + +保存扩展配置后需要重启网关。配置数据库位置见 [配置数据库位置](/configuration/config-file/)。 + +本地目录选择器会按顺序识别这些入口信息: + +- `plugin.json` +- `ccr-plugin.json` +- `.ccr-plugin/plugin.json` +- `.codex-plugin/plugin.json` +- `package.json` 里的 `main`、`ccr.module` 或 `ccrPlugin.module` + +如果没有显式入口文件,CCR 会尝试目录里的 `index.cjs`、`index.mjs`、`index.js`、`plugin.cjs`、`plugin.mjs` 或 `plugin.js`。 + +## 调试扩展 + +### 1. 先做语法检查 + +CommonJS 扩展可以运行: + +```bash +node --check ~/ccr-extensions/hello-extension/index.cjs +``` + +如果扩展依赖 npm 包,先在扩展目录安装依赖,并确保入口文件能被 Node 解析。 + +### 2. 用源码模式启动 CCR + +在 CCR 仓库根目录运行: + +```bash +npm install +npm run dev +``` + +扩展里的 `ctx.logger.info/warn/error` 会出现在启动 CCR 的终端中,前缀类似 `[plugin:hello-extension]`。 + +### 3. 验证 Gateway route + +启动网关后,请求状态路由: + +```bash +curl http://127.0.0.1:3456/plugins/hello +``` + +如果路由使用默认的 `auth: "gateway"`,并且 CCR 已配置 API Key: + +```bash +curl -H "Authorization: Bearer " http://127.0.0.1:3456/plugins/hello +``` + +也可以使用: + +```bash +curl -H "x-api-key: " http://127.0.0.1:3456/plugins/hello +``` + +### 4. 验证 HTTP 后端和代理规则 + +`registerHttpBackend` 返回的 `backend.url` 会写入日志。先直接请求这个地址,确认后端工作正常;再开启代理模式,验证目标 host/path 是否被 `registerProxyRoute` 命中。 + +代理规则匹配逻辑: + +- `host` 必须匹配目标 hostname,支持精确 host、`.example.com` 后缀和 `*.example.com` 通配。 +- `paths` 为空时匹配该 host 的所有路径。 +- 多个路径匹配时,CCR 会选最长的 path prefix。 +- `stripPathPrefix` 会从转发路径中移除匹配前缀。 +- `rewritePathPrefix` 会把匹配前缀替换成指定前缀。 + +### 5. 常见问题 + +| 现象 | 排查方向 | +| --- | --- | +| 扩展没有加载 | 检查 `plugins[].enabled`、`plugins[].module` 路径和终端里的 `[plugin:]` 报错 | +| `GET /plugins/hello` 返回 404 | 确认网关已重启,路由 `path` 或 `pathPrefix` 是否以 `/` 开头 | +| 返回 401 | 路由默认需要 gateway API Key;调试路由可显式设置 `auth: "none"` | +| 修改代码不生效 | Wrapper plugin 不会热重载,修改后需要重启网关或重启 CCR | +| 端口被占用 | `registerHttpBackend` 不传 `port` 会自动分配端口;固定端口冲突时改回自动分配 | +| 代理规则不命中 | 检查代理模式是否开启、证书是否安装、host 是否匹配真实请求的 hostname | + +## 安全建议 + +- 只有状态页、健康检查或本机调试路由才使用 `auth: "none"`。 +- 不要在日志里打印 API Key、OAuth token、Cookie 或完整请求头。 +- 扩展写入文件时优先使用 `ctx.paths.pluginDataDir`。 +- 对 `readJson` 得到的外部输入做类型校验。 +- 代理转发到外部 upstream 时,明确处理 header 白名单,避免把本地鉴权信息转发到不可信服务。 diff --git a/docs/src/content/docs/zh/configuration/fusion-web-search.md b/docs/src/content/docs/zh/configuration/fusion-web-search.md index 1862677..a8be16c 100644 --- a/docs/src/content/docs/zh/configuration/fusion-web-search.md +++ b/docs/src/content/docs/zh/configuration/fusion-web-search.md @@ -11,7 +11,20 @@ lead: 使用 CCR 内置 Web Search 工具为模型提供联网检索能力。 ## 搜索服务 -支持 Brave、Bing、Google CSE、Serper、SerpAPI、Tavily、Exa 等搜索服务。 +支持 In-app Browser、Brave、Bing、Google CSE、Serper、SerpAPI、Tavily、Exa 等搜索服务。 + +## In-app Browser + +`In-app Browser` 会通过 CCR Desktop 的隐藏内置浏览器窗口执行搜索,打开搜索结果页面并提取可见内容,再把证据提供给 Fusion 模型。它不需要外部搜索 API Key,适合希望用桌面端内置浏览器完成联网检索的场景。 + +可配置项包括搜索引擎、语言、地区和安全搜索级别: + +- 搜索引擎:Bing、Google、DuckDuckGo。 +- 语言:例如 `en`、`zh-CN`。 +- 地区:例如 `US`、`CN`。 +- 安全搜索:默认、中等、严格或关闭。 + +> 注意:`In-app Browser` 依赖 CCR Desktop 的 Electron 内置浏览器能力,只在桌面端可用。CLI、服务器部署或纯 Web 环境没有内置浏览器集成,请改用 Brave、Bing、Google CSE、Serper、SerpAPI、Tavily 或 Exa 等搜索服务。 ## 排查要点 diff --git a/docs/src/content/docs/zh/configuration/fusion.md b/docs/src/content/docs/zh/configuration/fusion.md index 88ff479..0d1eccf 100644 --- a/docs/src/content/docs/zh/configuration/fusion.md +++ b/docs/src/content/docs/zh/configuration/fusion.md @@ -9,7 +9,7 @@ lead: 把基础模型和能力模型组合成新的可选模型,让你已经 Fusion 的价值在于保留基础模型的手感,同时补齐它缺少的能力。基础模型继续负责推理、写作和代码生成;CCR 在需要时调用图像、搜索或 MCP 工具,把结果整理进上下文,再交给基础模型完成回答。 -保存后的 Fusion 模型会像普通模型一样出现在路由和配置中。它不是额外的一次手动流程,而是一个可复用的新模型:把强文本模型升级成视觉模型,把稳定代码模型升级成可联网模型,也可以把 Agent 模型接入团队内部工具。 +保存后的 Fusion 模型会像普通模型一样出现在路由和配置中。保存结果是一个可复用的新模型:把强文本模型升级成视觉模型,把稳定代码模型升级成可联网模型,也可以把 Agent 模型接入团队内部工具。 ## 适用能力 diff --git a/docs/src/content/docs/zh/configuration/overview.md b/docs/src/content/docs/zh/configuration/overview.md new file mode 100644 index 0000000..d7b4ff9 --- /dev/null +++ b/docs/src/content/docs/zh/configuration/overview.md @@ -0,0 +1,131 @@ +--- +title: 概览仪表盘 +pageTitle: 概览仪表盘 +eyebrow: 详细配置 +lead: 自定义 CCR 首页仪表盘,查看系统状态、账户余额、请求量、令牌、成本和模型分布。 +--- + +## 适用场景 + +| 场景 | 看什么 | +| --- | --- | +| 检查网关是否正常 | 系统状态、成功率、错误数、平均延迟 | +| 估算今日或近期消耗 | 请求数、输入 / 输出 / 缓存令牌、估算成本 | +| 比较上游使用情况 | 供应商分析、模型分布、客户端分析 | +| 关注账户额度 | 账户余额、套餐额度、剩余额度、账户状态 | +| 汇报或分享用量 | AI 用量年报、CCR 路由图、模型排行榜、消费小票等分享卡片 | + +## 时间范围 + +顶部的 `按时间查看用量` 控制所有依赖用量统计的组件。切换后,请求、令牌、成本、趋势、分布和分享卡片会按新的窗口重新聚合。 + +| 选项 | 统计窗口 | +| --- | --- | +| `今天` | 当前本地日期从 00:00 到现在,按小时分桶。 | +| `24 小时` | 最近 24 小时,按小时分桶。 | +| `7 天` | 最近 7 天,按天分桶。 | +| `30 天` | 最近 30 天,按天分桶。 | + +账户余额组件不按这个时间窗口重算,它展示供应商账户连接器最近一次获取到的快照。 + +## 编辑布局 + +点击右上角铅笔按钮进入编辑模式。编辑模式分为三栏: + +| 区域 | 作用 | +| --- | --- | +| 组件 | 左侧组件库,点击任一组件即可添加到当前仪表盘。 | +| 预览 | 中间布局预览。可以拖拽组件排序,也可以点击组件选中它。 | +| 组件属性 | 右侧属性面板,用于修改类型、数据、尺寸、样式或移除组件。 | + +常用操作: + +1. 添加组件:在左侧 `组件` 中点击组件模板。 +2. 调整顺序:在 `预览` 中拖拽组件。 +3. 调整尺寸:选中组件后拖动右侧、底部或右下角的缩放手柄。 +4. 修改数据:在 `组件属性` 里切换 `组件类型` 和 `数据`。 +5. 修改展示:在 `组件大小` 和 `样式` 中选择合适的布局。 +6. 保存结果:点击 `完成` 退出编辑模式;布局会保存在应用配置中。 +7. 恢复默认:编辑模式下点击 `重置布局`。 + +移除组件只会从概览布局中删除该卡片,不会删除请求日志、供应商、账户连接器或任何上游配置。如果所有组件都被移除,页面会显示 `未配置组件`。 + +## 组件目录 + +尺寸使用 `宽:高` 表示,宽高范围都是 `1` 到 `4`。概览网格在桌面端最多 4 列,在窄屏上会自动折叠。 + +| 组件 | 可选数据 | 默认尺寸 | 默认样式 | 可选样式 | +| --- | --- | --- | --- | --- | +| 状态组件 | 系统状态 | `4:1` | 时间线 | 时间线、紧凑 | +| 账户组件 | 所有账户或指定账户 | `4:2` | 卡片 | 卡片、紧凑、横条、圆环、半圆、弧形、内外圆 | +| 指标组件 | 请求、Token、成本 | `1:1` | 卡片 | 卡片、紧凑、柱状图、圆环 | +| 趋势组件 | 按时间查看用量 | `3:2` | 组合图 | 组合图、面积图、折线图、柱状图 | +| 活跃度组件 | Token 活跃度 | `4:2` | 热力图 | 热力图 | +| 构成组件 | Token 分布 / 模型分布 | Token 分布:`1:2`;模型分布:`2:2` | Token 分布:横条;模型分布:饼图 | 横条、堆叠、环形图、饼图 | +| 分析组件 | 客户端分析 / 供应商分析 | `2:2` | 表格 | 表格、紧凑 | +| 分享卡片 | AI 用量年报、CCR 路由图、模型排行榜、AI 燃料仪表、Token 日历海报、消费小票 | `1:4` | 卡片 | 卡片 | + +尺寸约束: + +| 规则 | 原因 | +| --- | --- | +| 分享卡片最小高度是 `1:4`。 | 导出图片使用竖版海报比例,需要足够高度。 | +| 账户组件在展示所有账户且使用“紧凑”样式时,最小尺寸是 `2:2`。 | 多账户列表需要保留可读空间。 | +| 旧版尺寸别名仍可被解析:`small` -> `1:1`,`medium` / `large` -> `2:2`,`wide` -> `3:2`,`full` -> `4:1` 或 `4:2`。 | 用于兼容旧配置。 | + +## 指标数据 + +`metric` 组件通过 `metric` 字段选择要展示的指标。 + +| `metric` | 含义 | +| --- | --- | +| `requests` | 请求数 | +| `total-tokens` | 总令牌 | +| `input-tokens` | 输入令牌 | +| `output-tokens` | 输出令牌 | +| `cache-tokens` | 缓存令牌 | +| `cache-ratio` | 缓存率 | +| `estimated-cost` | 估算成本,按模型价格信息计算 | +| `success-rate` | 成功率 | +| `errors` | 错误数 | +| `avg-latency` | 平均延迟 | + +## 账户组件 + +账户组件读取供应商配置里的账户 / 用量连接器。要让它显示余额或剩余额度,需要先在供应商配置中启用并测试 `获取用量`。 + +| 数据选择 | 行为 | +| --- | --- | +| `所有账户` | 展示所有可用账户快照。 | +| 指定账户 | 只展示某个供应商或某个凭据的账户快照。内部配置值格式通常是 `provider` 或 `provider::credentialId`。 | + +如果账户组件为空,优先检查: + +1. 供应商是否配置了账户 / 用量连接器。 +2. `获取用量` 测试是否成功。 +3. API Key 或账户接口是否仍有效。 +4. 当前选择的指定账户是否已经被删除或重命名。 + +## 分享卡片 + +分享卡片组件可以通过右上角下载按钮导出 PNG。桌面 App 会优先使用原生导出能力,浏览器环境会退回到前端 canvas 导出。导出图片尺寸为 `1080 x 1350`。 + +| 卡片 | `type` | 内容 | +| --- | --- | --- | +| AI 用量年报 | `share-usage-wrapped` | 总令牌、请求数、估算成本、缓存率、最长连续、最高频模型、最高频供应商、峰值日期。 | +| CCR 路由图 | `share-route-map` | 客户端到供应商 / 模型的主要路由关系,以及客户端、供应商、模型数量。 | +| 模型排行榜 | `share-model-leaderboard` | 按令牌排序的模型排行榜。 | +| AI 燃料仪表 | `share-fuel-cockpit` | 最多 3 个账户额度仪表,依赖账户 / 用量连接器。 | +| Token 日历海报 | `share-token-calendar` | 类似贡献日历的 Token 活跃度海报。 | +| 消费小票 | `share-spend-receipt` | 当前时间范围内的估算成本、请求、令牌、延迟和成功率小票。 | + +## 数据来源与排障 + +| 现象 | 可能原因 | 处理方式 | +| --- | --- | --- | +| 请求、令牌或成本为 0 | 当前时间范围内没有经过 CCR 的请求,或用量捕获尚未记录。 | 切换到 `24h` / `7d`,确认客户端请求确实走 CCR。 | +| 成本显示为 `$0.00` | 模型没有价格信息,或用量过小。 | 检查模型目录和供应商模型名是否能匹配价格;低于 0.01 美元会显示更多小数。 | +| 成功率、错误数不符合预期 | 只统计 CCR 捕获到的请求结果。 | 对照日志页面中的请求记录。 | +| 账户余额为空 | 没有账户连接器,或 `获取用量` 失败。 | 到供应商配置中测试账户 / 用量字段映射。 | +| 分布图没有数据 | 请求日志里缺少模型、供应商或 token 信息。 | 确认请求经过 CCR,并检查上游响应是否返回 token usage。 | +| PNG 导出失败 | 浏览器不支持 canvas 导出、元素尺寸为空,或用户取消了保存。 | 在桌面 App 中重试,确保卡片可见且没有被缩到过小。 | diff --git a/docs/src/content/docs/zh/configuration/profile.md b/docs/src/content/docs/zh/configuration/profile.md index 7f7282e..f5b1ffc 100644 --- a/docs/src/content/docs/zh/configuration/profile.md +++ b/docs/src/content/docs/zh/configuration/profile.md @@ -5,11 +5,16 @@ eyebrow: 详细配置 lead: 为 Claude Code、Codex、ZCode 创建可复用的启动配置,并通过不同配置打开不同的 Agent 实例。 --- -## Agent配置是什么 +## 配置流程 -Agent配置是桌面 App 中管理 Claude Code、Codex、ZCode 启动入口的能力。它不是供应商或路由规则,而是一次 Agent 启动所需的完整入口:Agent 类型、打开方式、模型、作用范围、配置文件位置,以及可选的 Bot 绑定。 +1. 先在 **供应商配置** 中添加至少一个可用供应商和模型,或先创建需要使用的 Fusion 模型。 +2. 打开 **Agent配置**,点击 **添加配置**。 +3. 选择 Agent 类型,填写配置名称,并选择作用范围和入口模式。 +4. 选择模型。模型值通常是 `供应商名称/模型名称`,也可以选择 Fusion 模型。 +5. 如果入口模式包含 App,可以绑定 Bot,并选择是否转发 Agent 消息或开启接力。 +6. 保存后,从 Agent配置卡片打开:终端图标会复制 CLI 命令,播放图标会启动 App 实例。 -因此详细配置里会有这个页面。它用于解释“用哪个配置打开哪个 Agent 实例”,而不是继续拆分供应商、路由或 Fusion 的字段。 +试用阶段建议选择 **仅从 CCR 打开时生效**,并且总是从 CCR 打开 Agent。这样配置只影响 CCR 启动的实例,不会改掉你系统里原本直接打开的 Claude Code、Codex 或 ZCode。 ## 多开机制 @@ -26,20 +31,80 @@ Agent配置是桌面 App 中管理 Claude Code、Codex、ZCode 启动入口的 ## 常用选项 -| 选项 | 说明 | +| 选项 | 适用范围 | 说明 | +| --- | --- | --- | +| Agent | 全部 | 选择 Claude Code、Codex 或 ZCode。ZCode 只支持 App。 | +| 配置名称 | 全部 | 用于在 CCR 中识别配置,也会作为 `ccr <配置名称>` 的打开目标。名称可以有空格,复制命令时 CCR 会自动加引号。 | +| 启用开关 | 全部 | 关闭后该配置不会出现在打开入口中,也不会被应用为有效启动配置。 | +| 作用范围 | 全部 | **仅从 CCR 打开时生效** 会使用 CCR 管理的独立配置;**系统默认** 会写入对应 Agent 的默认配置。同一个 Agent 同时只能有一个启用的系统默认配置。 | +| 入口模式 | Claude Code、Codex | `CLI & APP` 同时显示 CLI 和 App 打开入口;`CLI only` 只生成 CLI 命令;`App only` 只显示 App 打开入口。 | +| 模型 | 全部 | 该 Agent 打开后的默认模型,可以选择普通供应商模型或 Fusion 模型。Claude Code 留空表示保留 Claude Code 默认模型。 | +| Bot | App 入口 | 只有从 CCR 打开的 App 模式会转发 Bot 消息。CLI 当前不转发 Bot 消息。 | +| 环境变量 | 全部 | 为该配置注入额外环境变量。Claude Code 默认带 `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`,用于启用网关模型发现。 | + +## 各 Agent 的配置项 + +### Claude Code + +| 配置项 | 作用 | | --- | --- | -| Agent | 选择 Claude Code、Codex 或 ZCode | -| 配置名称 | 用于在 CCR 中识别配置,也会作为 `ccr <配置名称>` 的打开目标 | -| 作用范围 | “仅从 CCR 打开时生效”会使用 CCR 管理的独立配置;“系统默认”会写入对应 Agent 的默认配置 | -| 入口模式 | `CLI & APP`、`CLI only`、`App only`;ZCode 只支持 App | -| 模型 | 该 Agent 打开后的默认模型,可以选择普通供应商模型或 Fusion 模型 | -| Bot | App 入口可以绑定 Bot,用于 IM 消息转发或接力 | +| 模型覆盖 | 写入 Claude Code 使用的 `ANTHROPIC_MODEL`。留空时不覆盖 Claude Code 自己的默认模型。 | +| 小模型 | 写入 `ANTHROPIC_SMALL_FAST_MODEL`,供 Claude Code 的轻量任务使用。留空时保留 Claude Code 默认值。 | +| 设置文件 | 系统默认模式使用 Claude Code 默认设置文件;仅从 CCR 打开时生效会在 CCR 配置目录下按 Agent配置 `id` 生成独立设置文件。 | +| 环境变量 | 会合并到 Claude Code 设置文件的 `env` 中。CCR 同时写入网关地址、API Key helper 和启动包装器。 | +| Bot | 只在 Claude App 入口生效,可选择已保存 Bot,并配置转发 Agent 消息或接力。 | + +Claude Code CLI 从 CCR 打开后,会通过 CCR 网关获取模型发现信息。进入 Claude Code CLI 后可以输入 `/model` 查看并切换 CCR 暴露的模型列表,包括普通供应商模型和可见的 Fusion 模型。 + +Claude App 是 **零配置(zero-config)**:从 CCR 桌面 App 打开 Claude App 时,CCR 会自动写入 Claude App 网关配置、API Key、模型发现列表和独立用户数据目录。用户不需要增加额外操作,直接使用 CCR 打开就会自动完成所有必要配置;如果 Claude App 已经打开,按提示重启或从 CCR 重新打开即可。 + +Claude App 和 Claude Code CLI 的模型列表适配方式不同: + +| 入口 | 模型列表来源 | 说明 | +| --- | --- | --- | +| Claude Code CLI | CCR 网关模型发现 | CLI 内使用 `/model` 查看列表;选择后请求仍走 CCR 的供应商、路由和 Fusion。 | +| Claude App | CCR 生成的 Claude App inference models | Claude App 需要 Claude 兼容的模型名。CCR 会把 `供应商/模型` 和 Fusion 模型映射成 Claude App 可识别的模型项,并用显示名称保留真实模型含义。 | + +### Codex + +| 配置项 | 作用 | +| --- | --- | +| Provider ID | 写入 Codex 的 `model_provider`,默认是 `claude-code-router`。建议保持稳定,只使用字母、数字、点、下划线或短横线。 | +| Provider name | Codex 中展示的供应商名称,默认是 `Claude Code Router`。 | +| Codex model | 写入 Codex 默认模型。可以选择普通供应商模型或 Fusion 模型;留空时 CCR 使用可用模型中的默认值。 | +| Show all sessions | 让 Codex 显示所有会话。ZCode 不提供该项。 | +| 配置文件 | 默认是 `~/.codex/config.toml`。仅从 CCR 打开时生效会写入 CCR 管理的独立配置目录。 | +| 环境变量 | 注入 Codex CLI 或 Codex App。Claude Code 专用的模型发现变量不会传给 Codex。 | +| Bot | 只在 Codex App 入口生效。 | + +保存后,Codex CLI 使用配置卡片里的终端图标复制命令,例如 `ccr "Codex - Work"`。Codex App 使用播放图标打开。CCR 会生成 `config.toml`、模型目录文件和中间层启动器,让 Codex CLI 与 Codex App 都使用同一套 CCR 模型和供应商信息。 + +### ZCode + +| 配置项 | 作用 | +| --- | --- | +| Provider ID | 写入 ZCode 供应商引用,默认是 `claude-code-router`。 | +| Provider name | ZCode 中展示的供应商名称,默认是 `Claude Code Router`。 | +| ZCode model | ZCode App 打开后的默认模型。可以选择普通供应商模型或 Fusion 模型。 | +| 配置文件 | 默认是 `~/.zcode/cli/config.json`,CCR 还会写入 ZCode v2 配置和模型缓存。 | +| 环境变量 | 注入 ZCode App 和中间层启动器。 | +| Bot | 只在 ZCode App 入口生效。 | + +ZCode 只支持 App 打开,因此入口模式固定为 `App only`,也不会显示 `Show all sessions`。 + +## CLI 与 App 模式区别 + +| 模式 | 如何打开 | 适合场景 | 主要差异 | +| --- | --- | --- | --- | +| CLI | 点击终端图标复制命令,然后在终端运行 `ccr <配置名称>` | 在项目目录中运行 Agent、需要 shell 工作流、需要把命令放进脚本 | 使用对应配置的包装器或中间层启动;通常不启动桌面窗口;当前不转发 Bot 消息。 | +| App | 点击播放图标从 CCR 桌面 App 启动 | 需要桌面窗口、多实例并存、Bot 消息转发或接力 | 每个 Agent配置使用独立用户数据目录;同一配置重复打开会激活已有窗口,不同配置可以并行打开。 | +| CLI & APP | 同一个配置同时提供 CLI 和 App 入口 | 同一套模型配置既用于终端,也用于桌面 App | 两个入口共用配置名称、模型、作用范围和环境变量,但启动方式不同。 | ## 各 Agent 的差异 ### Claude Code -Claude Code 配置会写入设置文件。选择“仅从 CCR 打开时生效”时,CCR 会在自己的配置目录下为这个 Agent配置生成独立设置文件,并通过独立启动包装器打开 Claude Code。 +Claude Code CLI 配置会写入设置文件。选择“仅从 CCR 打开时生效”时,CCR 会在自己的配置目录下为这个 Agent配置生成独立设置文件,并通过独立启动包装器打开 Claude Code。 从桌面 App 打开 Claude App 时,CCR 还会为该配置准备独立用户数据目录。不同 Agent配置使用不同目录,因此可以同时打开多个 Claude App 实例。 diff --git a/docs/src/content/docs/zh/configuration/provider-deeplink.md b/docs/src/content/docs/zh/configuration/provider-deeplink.md new file mode 100644 index 0000000..ac471c7 --- /dev/null +++ b/docs/src/content/docs/zh/configuration/provider-deeplink.md @@ -0,0 +1,295 @@ +--- +title: 一键导入供应商 +pageTitle: 一键导入供应商 +eyebrow: 详细配置 +lead: 快速添加常见模型供应商,确认无误后即可保存,减少手动配置的繁琐步骤。 +--- + +## 一键导入 + +选择下面的供应商即可开始添加。CCR 会先显示即将添加的内容,确认无误后再保存;使用自定义入口时,请确保来源可信。 + + + +## 嵌入式按钮组件 + +CCR 也提供了一个无框架的按钮脚本,供应商可以嵌入到自己的网页,让用户一键把该供应商导入 CCR。脚本会自动注册 Web Components。 + +### HTML 写法 + +```html + + + +``` + +如果配置较大,可以只传 manifest: + +```html + + + +``` + +### JS 写法 + +```html +
+ + +``` + +### render 参数 + +`CCRProviderButtons.render(target, options)` 和 `` 支持同一组参数,参数名与 `ccr://provider` 协议保持一致: + +| 参数 | 说明 | +| --- | --- | +| `name` | Provider 展示名称 | +| `base_url` | Provider API Base URL,直链导入时必填 | +| `api_key` | 可选 Provider API Key | +| `protocol` | 协议类型,支持 `openai_chat_completions`、`openai_responses`、`anthropic_messages`、`gemini_generate_content`、`gemini_interactions` | +| `models` | 模型列表。HTML 中用逗号或换行分隔,JS 中可传字符串或数组 | +| `icon` | Provider 图标 URL | +| `source` | Provider 官网或配置来源 | +| `manifest` | 远程 manifest URL。传入后按钮会生成 manifest 导入链接 | +| `payload` | JSON 或 base64url JSON 配置。JS 中也可以传对象 | +| `usage_url` | 可选账号用量接口 | +| `fetch_usage` | 是否启用账号用量读取 | +| `usage_method` | 用量接口请求方法,`GET` 或 `POST` | +| `usage_headers` | 用量接口请求头。JS 中可传对象,HTML 中传 JSON 字符串 | +| `usage_body` | 用量接口请求体。JS 中可传对象,HTML 中传 JSON 字符串 | +| `balance` | 余额字段路径 | +| `balance_unit` | 余额单位 | +| `subscription` | 订阅剩余额度字段路径 | +| `subscription_limit` | 订阅总额度字段路径 | +| `subscription_reset` | 订阅重置时间字段路径 | +| `subscription_unit` | 订阅额度单位 | +| `subscription_window` | 订阅窗口,例如 `monthly` | + +## 协议格式 + +CCR 支持两种写法,推荐使用 host 写法: + +```text +ccr://provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1&protocol=openai_chat_completions&models=example-chat%2Cexample-coder +``` + +路径写法也可以被识别: + +```text +ccr:///provider?name=Example%20AI&base_url=https%3A%2F%2Fapi.example.com%2Fv1 +``` + +如果配置较大,可以把 JSON 放入 `payload` 参数。值可以是 URL 编码 JSON,也可以是 base64url JSON: + +```text +ccr://provider?payload=%7B%22name%22%3A%22Example%20AI%22%2C%22base_url%22%3A%22https%3A%2F%2Fapi.example.com%2Fv1%22%2C%22models%22%3A%5B%22example-chat%22%5D%7D +``` + +## Manifest 导入 + +供应商也可以只传一个 manifest URL: + +```text +ccr://provider?manifest=https%3A%2F%2Fexample.com%2Fccr-provider.json +``` + +Manifest 必须使用 HTTPS,返回 JSON,不能指向本地或内网地址,体积不能超过 128 KB。CCR 会在 App 内拉取 manifest,展示确认页,然后再写入配置。 + +Manifest 可以把供应商信息放在顶层 `provider` 对象中: + +| 字段 | 说明 | +| --- | --- | +| `provider.name` | Provider 展示名称 | +| `provider.base_url` | Provider API Base URL,必填 | +| `provider.protocol` | 协议类型 | +| `provider.models` | 模型列表,字符串数组 | +| `provider.icon` | Provider 图标 URL | +| `provider.source` | Provider 官网或配置来源 | +| `provider.account.enabled` | 是否启用账号用量读取 | +| `provider.account.refreshIntervalMs` | 用量刷新间隔,单位毫秒 | +| `provider.account.connectors` | 用量读取 connector 列表 | +| `provider.account.connectors[].type` | Connector 类型,常用 `http-json` | +| `provider.account.connectors[].auth` | 认证方式,常用 `provider-api-key` | +| `provider.account.connectors[].endpoint` | 用量接口 URL | +| `provider.account.connectors[].method` | 请求方法,`GET` 或 `POST` | +| `provider.account.connectors[].headers` | 请求头,不能包含敏感认证头 | +| `provider.account.connectors[].body` | 可选请求体 | +| `provider.account.connectors[].mapping.meters` | 用量指标映射列表 | + +完整 manifest 示例: + +```json +{ + "provider": { + "name": "Example AI", + "base_url": "https://api.example.com/v1", + "protocol": "openai_chat_completions", + "models": ["example-chat", "example-coder"], + "icon": "https://example.com/icon.png", + "source": "https://example.com", + "account": { + "enabled": true, + "refreshIntervalMs": 300000, + "connectors": [ + { + "type": "http-json", + "auth": "provider-api-key", + "endpoint": "https://api.example.com/v1/account/usage", + "method": "GET", + "headers": { + "accept": "application/json" + }, + "mapping": { + "meters": [ + { + "id": "balance", + "kind": "balance", + "label": "Balance", + "remaining": "data.balance.remaining", + "unit": "USD" + }, + { + "id": "subscription", + "kind": "subscription", + "label": "Monthly quota", + "remaining": "data.quota.remaining", + "limit": "data.quota.limit", + "resetAt": "data.quota.reset_at", + "unit": "tokens", + "window": "monthly" + } + ] + } + } + ] + } + } +} +``` + +## 支持的参数 + +| 参数 | 说明 | +| --- | --- | +| `name` | Provider 展示名称 | +| `base_url` | Provider API Base URL,必填 | +| `api_key` | 可选 Provider API Key | +| `protocol` | 协议类型,支持 `openai_chat_completions`、`openai_responses`、`anthropic_messages`、`gemini_generate_content`、`gemini_interactions` | +| `models` | 模型列表,支持逗号或换行分隔,也可以重复传入 | +| `icon` | Provider 图标 URL | +| `source` | Provider 官网或配置来源 | +| `manifest` | 远程 manifest URL | +| `payload` | JSON 或 base64url JSON 配置 | +| `usage_url` | 可选账号用量接口 | +| `fetch_usage` | 是否启用账号用量读取 | +| `usage_method` | 用量接口请求方法,`GET` 或 `POST` | +| `usage_headers` | 用量接口请求头,JSON 字符串 | +| `usage_body` | 用量接口请求体,JSON 字符串 | +| `balance` | 余额字段路径 | +| `balance_unit` | 余额单位 | +| `subscription` | 订阅剩余额度字段路径 | +| `subscription_limit` | 订阅总额度字段路径 | +| `subscription_reset` | 订阅重置时间字段路径 | +| `subscription_unit` | 订阅额度单位 | +| `subscription_window` | 订阅窗口,例如 `monthly` | + +参数名和协议值必须使用上表中的完整规范名。不再接受 `baseUrl`、`apiKey`、`model`、`type` 或 `openai` 等别名。 diff --git a/docs/src/content/docs/zh/configuration/provider.md b/docs/src/content/docs/zh/configuration/provider.md index ded9a4b..292ce5e 100644 --- a/docs/src/content/docs/zh/configuration/provider.md +++ b/docs/src/content/docs/zh/configuration/provider.md @@ -5,22 +5,166 @@ eyebrow: 详细配置 lead: 配置 CCR 的上游模型服务,包括协议、基础 URL、模型列表和凭据。 --- -## 基本概念 +## 导入本机 Agent 登录态 -供应商是 CCR 转发请求的上游模型服务。每个供应商至少需要协议、基础 URL、模型列表和一条可用凭据。 +添加供应商时,CCR 会扫描本机已有的 Agent 登录状态。检测到可复用凭据后,添加弹窗会显示对应导入入口。导入会创建一个普通供应商和配套 provider plugin,让 CCR 复用本机 Agent 授权访问上游服务,不需要再手动粘贴常规 API Key。 + +### Claude Code + +Claude Code 导入会读取本机 Claude Code OAuth 凭据。检测到可用 access token 时,可以导入为 `Claude Code API` 供应商。 + +导入后: + +1. 协议使用 `anthropic_messages`。 +2. 默认模型包含 `claude-sonnet-4-20250514`,后续可以在供应商模型列表中增减模型。 +3. CCR 会创建 OAuth provider plugin,把请求认证转换为 Claude Code 登录态。 +4. 账号用量会使用 Anthropic OAuth 用量接口,适合在供应商列表、托盘或账号面板里查看额度状态。 + +如果只检测到登录痕迹但没有可用 access token,导入入口会显示不可导入原因。此时先在 Claude Code 中重新登录,再回到 CCR 添加供应商。 + +### Codex + +Codex 导入会读取本机 Codex 登录文件和模型缓存。检测到 Codex access token 或 refresh token 时,可以导入为 `Codex API` 供应商。 + +导入后: + +1. 协议使用 `openai_responses`。 +2. API 地址指向 Codex 后端,默认模型至少包含 `gpt-5-codex`,也会合并本机模型缓存中的模型和显示名。 +3. CCR 会创建 Codex OAuth provider plugin,并在需要时刷新访问凭据。 +4. 账号用量会读取 Codex 额度、余额和 token 统计接口。 + +导入后可以直接在路由或 Agent配置中选择 `Codex API/模型名`。如果模型缓存较旧,可以先打开 Codex 让它刷新模型列表,再回到 CCR 重新导入或编辑模型。 + +### ZCode + +ZCode 导入会读取本机 ZCode 配置中的供应商 API Key、API 地址和模型列表。只有检测到可用供应商 Key 和 Base URL 时,才能导入为 `ZCode API` 供应商。 + +导入后: + +1. 协议使用 `anthropic_messages`。 +2. 模型优先来自 ZCode 本机配置;没有配置时会使用 ZCode 运行缓存或默认模型。 +3. CCR 会创建 API Key provider plugin,把 ZCode 本机配置中的 Key 用于请求认证。 +4. 如果 API 地址命中 CCR 内置预设,账号用量配置会复用对应预设。 + +如果只检测到 ZCode 登录态,但没有检测到可用供应商 API Key,导入入口会显示不可导入。此时需要先在 ZCode 中配置可用模型供应商,再回到 CCR 添加供应商。 + +## 主字段 + +| 字段 | 代表的能力 | +| --- | --- | +| 选择 预设供应商 | 套用 CCR 内置供应商模板,包括默认 API 地址、可用协议、默认模型、图标、官网链接和部分供应商的用量读取配置。选择 `其他 / 自定义 API 地址` 时,可以接入任意兼容 OpenAI、Anthropic 或 Gemini 协议的上游服务。 | +| 名称 | CCR 内部显示名,也是路由、模型选择、日志和配置中识别供应商的名字。名称必须唯一,建议短且稳定。 | +| API 地址 | 上游 API Base URL。它决定请求实际发往哪里,也用于协议探测、模型列表探测、图标探测和安全校验。预设供应商添加时默认隐藏该字段,可在高级设置里覆盖。自定义供应商必须填写。 | +| API 密钥 | 默认供应商凭据。没有配置凭据池时,模型请求会使用这条 Key;协议探测、模型探测、连通性检测和默认用量读取也会使用它。只填写由当前 API 地址对应供应商签发的 Key。 | +| 模型 | 暴露给 CCR 的模型 ID 列表。路由规则、Profile 模型选择、模型目录和客户端 `/models` 响应都会基于这里的模型。 | +| 搜索模型 / 全部 / 清除 | 当 CCR 能从上游或模型目录拿到模型列表时,可以搜索、全选、清除并勾选模型。勾选结果会保存到供应商的模型列表。 | +| 自定义模型 | 手动添加没有被探测出来的模型 ID。适合供应商没有 `/models` 接口,或新模型还未进入模型目录的情况。 | +| 检测连通性 | 用当前 API 地址、API 密钥、协议和所选模型发送真实测试请求。它可以验证 Key、模型名和协议是否真的可调用。 | +| 要检测的模型 | 连接检查确认弹窗中的模型选择。用于控制只测试部分模型,避免一次性检查全部模型造成额外消耗。 | +| 检测结果 | 展示每个模型是否可用、命中的协议和上游返回的诊断信息。可用结果不会自动增加模型,仍以弹窗主表单中的模型选择为准。 | + +## 连通性检测 + +`检测连通性` 会对你选择的模型发送真实的模型请求,用来确认 API 地址、API 密钥、协议和模型 ID 是否可用。检测请求会限制输出长度,但仍然可能产生额外 token 消耗或计入供应商侧请求次数。 + +如果供应商按请求、输入 token 或输出 token 计费,建议只勾选需要确认的模型,不要一次性检查全部模型。检测结果只用于诊断连通性,不会自动修改模型列表或用量读取配置。 ## 凭据 -凭据用来保存 API Key。多条凭据可以按优先级和权重轮换,也可以在某条 Key 触发限额或失败时自动切换。 +`API 密钥` 是最简单的单 Key 配置。需要管理多条上游 Key 时,展开“高级设置”中的“凭据池”。 -建议给每条 Key 设置容易识别的名称,方便在请求日志里定位问题。 - -## 供应商选项 - -| 字段 | 说明 | +| 字段 | 代表的能力 | | --- | --- | -| 名称 | CCR 内部显示名,建议短且可识别 | -| 基础 URL | 上游服务地址,自定义供应商要确认包含正确 API 路径 | -| 协议 | OpenAI / Anthropic / Gemini 等协议 | -| 模型 | 暴露给 CCR 的模型列表 | -| 请求头 | 少数兼容服务需要的额外请求头 | +| 显示凭据配置 | 展开或收起凭据池配置。未展开不影响已保存的凭据,只是隐藏编辑区域。 | +| 导入 JSON | 从 JSON 文件批量导入凭据。支持顶层数组,或对象中的 `credentials`、`keys`、`apiKeys` 数组。 | +| 添加 Key | 新增一条上游 API Key。 | +| 启用 | 控制单条凭据是否参与请求转发和用量读取。关闭后保留配置,但不会被选中。 | +| 名称 | 单条凭据的显示名。会出现在账号用量、日志和内部诊断里,建议写成可识别的用途或额度来源。 | +| API 密钥 | 该凭据实际发送给上游的 Key。配置了凭据池后,CCR 会把启用的凭据展开成多个内部上游目标,并优先使用凭据池中的 Key。主表单的默认 `API 密钥` 只作为单 Key 配置使用。 | +| 移除 | 删除当前凭据行。 | +| Key 高级选项 | 展开单条凭据的调度和限额字段。 | +| 优先级 | 凭据优先级,数字越小越优先。未填写时按凭据行顺序作为优先级。 | +| 权重 | 同优先级、相近使用率下的排序权重,数字越大越优先。未填写时为 `1`。 | +| 限制 JSON | 该 Key 的本地限额规则。CCR 会按请求、tokens 或图片数量统计窗口使用量,达到上限后自动跳过该 Key,尝试同供应商的其他可用 Key。 | + +`限制 JSON` 支持的常用字段: + +| 字段 | 含义 | +| --- | --- | +| `rpm` / `rph` / `rpd` | 每分钟 / 每小时 / 每天最多请求数 | +| `tpm` / `tph` / `tpd` | 每分钟 / 每小时 / 每天最多 tokens | +| `ipm` / `iph` / `ipd` | 每分钟 / 每小时 / 每天最多图片数 | +| `maxRequests` + `windowMs` | 自定义时间窗口内最多请求数 | +| `maxTokens` + `quotaWindowMs` | 自定义时间窗口内最多 tokens | + +示例: + +```json +{ + "rpm": 60, + "tpm": 100000 +} +``` + +凭据池的作用是“上游 Key 池”,不同于“API 密钥”页面里的 CCR 客户端访问 Key。前者控制 CCR 调用供应商时使用哪个 Key,后者控制客户端访问 CCR 时使用哪个 Key。 + +## 用量读取 + +“获取用量”用于让 CCR 在供应商列表、托盘或账号面板中展示余额、套餐额度、状态和错误信息。它不会影响模型是否能请求,只影响账号用量展示。 + +| 字段 | 代表的能力 | +| --- | --- | +| 获取用量 | 启用或关闭该供应商的账号用量读取。关闭后不请求用量接口。 | +| 用量模式 | 用量读取方式。`标准用量端点` 使用 CCR 标准账号端点;`HTTP JSON 请求` 手动配置一个 JSON 接口;`原始连接器 JSON` 直接编辑 connector 数组。 | +| 刷新间隔(毫秒) | 用量刷新间隔,单位毫秒。未填写时使用默认刷新间隔,最小有效间隔为 30000ms。 | + +### 标准用量端点 + +该模式会尝试供应商侧的 CCR 标准账号端点,例如 `/.well-known/ccr/account` 和 `/v1/account/limits`。适合已经适配 CCR 标准格式的供应商或内置预设。 + +### HTTP JSON 请求 + +该模式适合供应商已有自己的余额或额度接口,且返回自定义 JSON 格式的情况。 + +| 字段 | 代表的能力 | +| --- | --- | +| 方法 | 用量请求方法,支持 `GET` 或 `POST`。 | +| 用量请求 URL | 用量接口地址。可以是完整 URL。请求会附带供应商 API Key,除非在 raw connector 中改成其他认证方式。 | +| 请求头 | 用量接口需要的额外请求头。不要在这里写固定的敏感认证头,优先使用供应商 API Key 认证。 | +| 请求体 | `POST` 请求体,必须是合法 JSON。 | +| 余额剩余字段 | 余额剩余值在响应 JSON 中的路径。 | +| 余额总额字段 | 余额总量或充值总额在响应 JSON 中的路径。 | +| 余额已用字段 | 已用余额在响应 JSON 中的路径。 | +| 余额单位 | 余额单位,例如 `USD`、`CNY` 或 `%`。 | +| 订阅剩余字段 | 套餐、订阅、tokens 或配额剩余量路径。 | +| 订阅上限字段 | 套餐、订阅、tokens 或配额总量路径。 | +| 订阅重置字段 | 套餐重置时间路径。可以返回 ISO 时间,也可以返回秒级或毫秒级时间戳。 | +| 订阅单位 | 套餐单位,例如 `tokens`、`requests`、`hours`。 | +| 状态字段 | 账号状态路径。支持 `ok`、`warning`、`critical`、`error`、`unsupported`。 | +| 消息字段 | 账号提示信息路径。适合展示供应商返回的错误、套餐说明或风控提示。 | +| 测试用量请求 | 立即请求用量接口并解析映射结果,方便在保存前验证字段路径。 | +| 响应字段 | 测试后列出响应 JSON 中可选字段。点击 `余额剩余`、`余额总额`、`余额已用`、`订阅剩余`、`订阅上限`、`重置时间` 可以把该路径快速填入对应字段。 | + +字段路径支持 CCR 的轻量 JSONPath 语法: + +| 写法 | 说明 | +| --- | --- | +| `$` | 整个响应对象 | +| `$.balance.remaining` | 读取对象字段 | +| `$.items[0].value` | 读取数组下标 | +| `$["weird-key"]` | 读取包含特殊字符的字段名 | +| `$.limits[?(@.type=="TOKENS")].remaining` | 在数组中查找第一个满足简单等值条件的对象 | +| `100 - $.data.percentage` | 数值字段支持简单减法表达式,常用于把“已用百分比”转换成“剩余百分比” | + +### 原始连接器 JSON + +`连接器 JSON` 允许直接编辑 `account.connectors` 数组,适合需要更复杂能力的供应商。 + +| 连接器类型 | 能力 | +| --- | --- | +| `standard` | 使用 CCR 标准账号端点。 | +| `http-json` | 请求一个 JSON 接口,并用 mapping 字段映射余额、套餐、状态和消息。 | +| `plugin` | 调用已安装插件注册的账号用量 connector。 | +| `local-estimate` | 不请求远程接口,基于本地窗口配置展示估算额度。 | + +点击 `插入示例` 会填入一个包含 `standard`、`http-json`、`plugin` 和 `local-estimate` 的示例 connector 数组。 diff --git a/docs/src/content/docs/zh/configuration/routing.md b/docs/src/content/docs/zh/configuration/routing.md index 9718356..332eda9 100644 --- a/docs/src/content/docs/zh/configuration/routing.md +++ b/docs/src/content/docs/zh/configuration/routing.md @@ -5,20 +5,141 @@ eyebrow: 详细配置 lead: 设置请求如何选择模型,并在失败时通过 Fallback 自动重试或切换到备用模型。 --- -## 路由如何生效 +## 内置路由 -CCR 的路由会先决定本次请求使用哪个模型,然后再把请求交给上游。当前代码中的主要顺序是: +### Claude Code -1. 如果请求里的 `model` 已经是已知的 `供应商/模型` 形式,CCR 会直接使用这个模型。 -2. 如果配置了自定义路由脚本,脚本返回的模型优先级高于界面里的路由规则。 -3. 路由规则按列表顺序匹配,第一条命中的规则会执行对应的请求改写。 -4. 没有规则命中时,使用默认路由;如果没有默认路由,就保留请求原本的模型。 +Claude Code 内置路由的作用是识别 Claude Code 发来的请求,并把主请求路由到 Claude Code Agent 配置中的模型。 -路由规则的核心是 **条件 + 请求动作**。条件判断请求是否命中,请求动作修改请求字段。最常用的动作是把 `request.body.model` 设置为目标模型或 Fusion 模型。 +Claude Code **主请求** 使用 Claude Code Agent 配置中的模型;如果未设置,该内置路由不会生效。CCR 也会自动删除 Claude Code 注入的第一条 `x-anthropic-billing-header` system 消息,避免这类计费辅助消息影响后续路由判断。Claude Code 创建的 Subagent、Task 或 Workflow 内部 Agent 可以继续用下面的标签机制自动选择不同模型。 -## Fallback 是什么 +#### Subagent / Workflow 自动路由 -Fallback 是请求失败后的降级策略。它不负责第一次选模型,而是在当前模型或上游失败时决定是否继续尝试。 +Claude Code 的 Agent / Task / Workflow 可以派生新的模型请求。CCR 使用标签注入来让这些派生请求选择更合适的 CCR 模型: + +```text +供应商/模型 +``` + +完整流程如下: + +1. Claude Code 主请求命中内置路由后,CCR 会检查当前工具列表。 +2. 如果至少有一个模型配置了 **Description**,CCR 会把可用模型及其说明注入到 `Agent` / `Task` 工具说明和 `prompt` 字段说明里。 +3. 如果工具列表里有 `Workflow`,CCR 会给 Workflow 工具说明追加要求:workflow 内部创建 `Agent` / `Task` 时,每个派生 Agent 的 prompt 第一行都要带同样的模型标签。 +4. Claude Code 调用 `Agent` / `Task`,或 Workflow 内部创建 Agent 时,prompt 第一行会携带 `供应商/模型`。 +5. 派生请求进入 CCR 后,CCR 从 system 或前两条 user message 中提取并删除这个标签,然后把该请求路由到标签里的模型。 + +因此,Subagent / Workflow 的自动路由不是靠 `x-claude-code-agent-id` 之类的 Header 决定模型,而是靠 prompt 标签。Header 只能作为观测线索,真正的模型选择来自标签。 + +##### 与模型页配合 + +模型页里的 **Description** 是这套机制的开关和选择依据。没有任何模型 Description 时,CCR 不会注入 Agent / Task / Workflow 路由提示词,避免把空模型列表写进工具说明。 + +推荐配置步骤: + +1. 在 **供应商** 中添加可用模型,确认模型 ID 可以真实请求。 +2. 打开 **模型** 页面,为希望 Subagent 自动选择的模型填写 Description。说明要写清模型适合的任务、速度、成本和限制。 +3. 在 **Agent配置** 中启用 Claude Code 配置,并设置主模型。这个模型负责 Claude Code 主会话。 +4. 在 **路由** 页面确认 **Claude Code** 内置路由已启用。 +5. 在 Claude Code 中使用 Agent、Task 或 Workflow。需要派生 Agent 时,Claude Code 会根据模型 Description 选择一个 CCR 模型并写入标签。 + +Description 建议写成任务导向,而不是只写模型厂商名。例如: + +| 模型用途 | Description 示例 | +| --- | --- | +| 快速便宜模型 | 适合代码搜索、文件梳理、摘要、简单修改和低成本并行 Subagent。 | +| 强推理模型 | 适合复杂架构分析、大规模重构计划、跨文件推理和高风险代码审查。 | +| 长上下文模型 | 适合读取大量日志、长文档、仓库级上下文整理和 Workflow 汇总。 | + +保存后,CCR 会把这些 Description 组织成 “Configured CCR gateway models” 注入给 Claude Code。Claude Code 选择模型后,CCR 会在派生请求上看到 `builtin:claude-code-subagent`,并把标签里的模型作为最终 `resolved model`。 + +### Codex + +Codex 内置路由会为第三方或非 GPT 模型适配 Codex 的 `apply_patch` 文件编辑工具。目标是让这些模型通过 patch 工具完成文件修改,而不是生成 `cat >`、`sed -i`、`python`、`node` 等命令或脚本来编辑文件。 + +技术原理是做一次工具协议桥接:Codex 原生的 `apply_patch` 是 custom/freeform 工具,入参是原始 patch 文本;很多 OpenAI-compatible 三方模型更擅长普通 function tool。CCR 会在上游请求中把 `apply_patch` 转成 `virtual_apply_patch` function tool,并在工具说明里注入完整的 `apply_patch.lark` 语法,要求模型把 patch 写入 `patch` 字段。 + +模型返回 `virtual_apply_patch` 后,CCR 会把它转换回 Codex 期望的 `custom_tool_call`:`name = apply_patch`,`input = 原始 patch 文本`。CCR 不直接修改文件,真正执行 patch 的仍然是 Codex 客户端。这个适配跟随 **Codex** 内置路由启用或关闭,没有单独开关;GPT 命名模型继续使用 Codex 原生 freeform `apply_patch` 路径。 + +## 自定义路由 + +自定义路由在路由页的规则列表中配置。页面顶部的 **搜索路由规则** 可以按名称、条件、请求动作等文本过滤列表;右上角 **添加** 按钮打开 **添加路由规则** 弹窗。规则表格按 **名称**、**条件**、**请求动作**、**状态**、**操作** 展示每条规则。 + +自定义规则按列表顺序匹配,第一条命中的启用规则会改写请求。表格右侧的上移、下移按钮用来调整优先级,编辑按钮打开 **编辑路由规则**,删除按钮会先弹出确认框。**状态** 列的开关关闭后,规则保留在列表里,但不会参与匹配。 + +### 添加或编辑规则 + +弹窗里的字段和保存后的配置一一对应: + +| UI 字段 | 填写方式 | 保存后的含义 | +| --- | --- | --- | +| **名称** | 填一个便于识别的规则名。该字段不能为空。 | 显示在列表 **名称** 列,也参与搜索。 | +| **条件** | 选择 `request.header` 或 `request.body`,填写字段名、操作符和值。 | 生成 `condition.left`、`condition.operator` 和 `condition.right`。 | +| **改写请求参数** | 至少保留一行 rewrite。每行选择操作、目标 key 和需要的值。 | 生成 `rewrites`,规则命中后按行改写请求。 | +| **启用** | 打开或关闭规则。 | 控制 `enabled`,关闭时不会匹配。 | +| **失败时** | 配置这条规则自己的 Fallback。 | 规则命中后覆盖页面顶部的 **默认失败处理**。 | + +**添加** 或 **保存** 按钮只有在表单有效时才可点击:名称、条件字段、条件值都必须填写;每条 rewrite 都必须有 key。`删除` 操作只需要 key;`替换数组元素` 需要同时填写 **匹配值** 和 **值**;其他操作需要填写 **值**。 + +### 条件 + +**条件** 区域有四个输入:来源、字段、操作符和值。 + +| 来源 | 字段示例 | 实际匹配路径 | +| --- | --- | --- | +| `request.header` | `user-agent`、`x-api-key`、`x-client-name` | `request.header.user-agent` | +| `request.body` | `model`、`messages`、`messages.0.role`、`tools` | `request.body.model` | + +Header 名不区分大小写。Body 字段按点号路径读取,数字片段表示数组下标;例如 `messages.0.role` 读取第一条 message 的 role。对于 messages、tools 这类嵌套数组,通常用 `contains deep` 比固定下标更稳。 + +值输入框会按常见字面量解析:`true`、`false`、`null`、数字、JSON 对象或数组会按对应类型比较;其他内容按字符串处理。需要强制作为字符串时,可以写成 `"123"` 或 `'123'`。 + +| 操作符 | 用法 | +| --- | --- | +| `==` / `!=` | 比较实际值和输入值。数字会按数字比较,其他值按可比较文本比较。 | +| `>` / `>=` / `<` / `<=` | 两边都是数字时按数字比较,否则按文本顺序比较。 | +| `starts with` | 判断实际值是否以输入值开头,适合模型前缀分流。 | +| `contains` | 对字符串做包含判断;对数组只检查数组元素。 | +| `contains deep` | 递归检查对象和数组,适合在 `messages`、`tools` 中查找内容。 | +| `not contains` | `contains` 的反向判断。 | + +### 改写请求参数 + +**改写请求参数** 区域默认给出一行 `request.body.model`。这也是最常用的模型路由写法:选择 **设置**,key 填 `request.body.model`,值填目标 `供应商/模型` 或 Fusion 模型。 + +点击 **添加参数** 可以追加多行 rewrite;垃圾桶按钮删除当前行,最后一行不能删除。规则命中后,CCR 会按列表顺序应用这些 rewrite。 + +| 操作 | 需要填写 | 行为 | +| --- | --- | --- | +| **设置** | key、值 | 设置请求里的字段,例如 `request.body.model = provider/model` 或 `request.body.temperature = 0.2`。 | +| **删除** | key | 删除请求字段。删除 `request.header.x-test` 会移除对应 Header;删除 `request.body.foo` 会移除 body 字段。 | +| **追加到数组** | key、值 | 把值追加到目标数组末尾。目标不是数组时按空数组开始。 | +| **插入到数组开头** | key、值 | 把值插到目标数组开头。 | +| **从数组移除** | key、值 | 从目标数组中移除等于该值的元素。 | +| **替换数组元素** | key、匹配值、值 | 把数组中匹配 **匹配值** 的元素替换为新值。 | + +Rewrite 的值也会按字面量解析,所以 `0.2` 会变成数字,`true` 会变成布尔值,`{"type":"web_search"}` 会变成对象。只有 `request.body.model` 的值会额外按 CCR 的模型选择器格式规范化。 + +### 失败时 + +弹窗底部的 **失败时** 和页面顶部的 **默认失败处理** 是同一套控件。选择 **关闭** 时不降级;选择 **继续重试** 时会出现 **重试次数**;选择 **失败降级目标** 时会出现 **失败降级目标** 输入框和 **添加** 按钮。添加后的目标会以标签形式显示,标签上的上移、下移、移除按钮用于调整降级顺序。 + +规则命中时会使用这条规则自己的 **失败时** 设置;没有命中的请求才继续使用页面顶部的默认设置。 + +### 配置示例 + +| 目标 | 条件来源 | 字段 | 操作符 | 值 | 改写请求参数 | +| --- | --- | --- | --- | --- | --- | +| 按客户端 Header 分流 | `request.header` | `x-client-name` | `==` | `claude-code` | **设置** `request.body.model = 供应商/模型` | +| 按原始模型前缀分流 | `request.body` | `model` | `starts with` | `claude-` | **设置** `request.body.model = 供应商/模型` | +| 按消息内容分流到视觉模型 | `request.body` | `messages` | `contains deep` | `image` | **设置** `request.body.model = 视觉供应商/模型` | +| 删除调试 Header | `request.header` | `x-debug-route` | `==` | `1` | **删除** `request.header.x-debug-route` | + +保存后,规则会出现在列表中。请求日志里的 `request model`、`resolved provider`、`resolved model` 和路由原因可以用来确认规则是否命中。 + +## Fallback 处理 + +Fallback 处理请求失败后的降级。第一次选模型由路由完成;当前模型或上游失败时,Fallback 决定是否继续尝试。 路由页面顶部的 **默认失败处理** 是全局 Fallback。每条路由规则里的 **失败时** 是规则级 Fallback:当某条规则命中时,规则级配置会覆盖全局配置。 @@ -41,7 +162,7 @@ Fallback 是请求失败后的降级策略。它不负责第一次选模型, | 继续重试 | `408`、`409`、`429`、`5xx` | | 失败降级目标 | 任意 `4xx` 或 `5xx` | -对于 `429` 限流响应,CCR 会在下一次尝试前等待。上游提供 `Retry-After` 时会优先遵守;否则使用从 1 秒开始、单次最多 30 秒的指数退避。 +进入下一次尝试前,CCR 会对每个触发 Fallback 的失败进行等待,包括网络错误。上游提供正数 `Retry-After` 时会优先遵守;否则使用从 1 秒开始、单次最多 30 秒的指数退避。 **失败降级目标** 对 `4xx` 也会切换,是因为模型不存在、鉴权或供应商侧拒绝等错误可能只影响当前目标。切换后如果备用模型可用,请求仍然可以成功。 @@ -55,7 +176,7 @@ Fallback 是请求失败后的降级策略。它不负责第一次选模型, 2. 如果选择 **继续重试**,填写 `Retries`。 3. 如果选择 **失败降级目标**,按优先级添加备用模型。 -全局 Fallback 会应用到默认路由,以及没有单独配置 Fallback 的规则。 +全局 Fallback 会应用到没有单独配置 Fallback 的规则。 ### 规则级失败降级 @@ -63,24 +184,6 @@ Fallback 是请求失败后的降级策略。它不负责第一次选模型, 规则级 Fallback 适合高风险或高成本模型。例如:图片任务先走 Fusion 视觉模型,失败后切到另一个多模态模型;复杂任务先走强模型,失败后切到稳定模型。 -### 条件路由 - -当前界面新增规则时主要使用条件路由。条件可以读取请求 Header 或请求 Body: - -| 来源 | 示例 | -| --- | --- | -| `request.header` | `x-client-name == claude-code` | -| `request.body` | `model starts-with claude-` | -| `request.body` | `messages contains-deep image` | - -命中后,请求动作可以设置、删除或修改请求字段。最常用的是: - -```text -set request.body.model = 供应商/模型 -``` - -模型也可以是 Fusion 模型。这样路由可以把特定请求导向视觉、搜索或工具增强模型。 - ## 验证方式 保存后发一次请求,到请求日志里检查: diff --git a/docs/src/content/docs/zh/configuration/server.md b/docs/src/content/docs/zh/configuration/server.md new file mode 100644 index 0000000..3942239 --- /dev/null +++ b/docs/src/content/docs/zh/configuration/server.md @@ -0,0 +1,28 @@ +--- +title: 服务配置 +pageTitle: 服务配置 +eyebrow: 详细配置 +lead: 配置 CCR 网关监听地址、端口,以及通过代理模式进行 MITM 劫持并代理到 CCR 的能力。 +--- + +## 主字段 + +| 字段 | 代表的能力 | +| --- | --- | +| Host | CCR 网关监听的主机地址。常见值是 `127.0.0.1` 或 `0.0.0.0`。 | +| Port | CCR 网关监听端口。客户端需要把 API Base URL 指向这个端口。 | + +## 代理模式 + +代理模式是本地代理能力。开启后,客户端可以把 HTTP/HTTPS 流量交给 CCR;CCR 会通过 MITM 劫持识别和解密 HTTPS 请求,并把可处理的模型请求代理到 CCR 网关链路。 + +| 字段 | 代表的能力 | +| --- | --- | +| 代理模式 | 启用本地代理能力。开启后 CCR 可以作为 HTTP/HTTPS 代理接收客户端流量,并通过 MITM 劫持把模型请求代理到 CCR。 | +| 系统代理 | 将系统代理指向 CCR。适合让支持系统代理的应用自动经过 CCR。 | +| 捕获网络 | 保存代理模式下经过 CCR 的网络请求,用于“网络”页面查看请求和响应详情。 | +| CA 证书 | 当前代理 CA 证书的信任状态。 | +| 安装 CA | 将 CCR 代理 CA 安装到系统或当前用户信任存储中。不同系统的安装方式不同。 | +| 检查信任 | 重新检测代理 CA 是否已被系统信任。 | +| 代理状态 | 显示代理服务当前是否运行。 | +| 重启代理 | 代理模式开启时,重新启动代理服务。 | diff --git a/docs/src/content/docs/zh/configuration/toolhub.md b/docs/src/content/docs/zh/configuration/toolhub.md new file mode 100644 index 0000000..defadd0 --- /dev/null +++ b/docs/src/content/docs/zh/configuration/toolhub.md @@ -0,0 +1,167 @@ +--- +title: ToolHub +pageTitle: ToolHub +eyebrow: 详细配置 +lead: 将多个 MCP server 收束成一个紧凑入口,让 Agent 按任务懒加载需要的工具,减少工具列表占用的上下文。 +--- + +## 适用场景 + +当你接入的 MCP server 越来越多时,直接把所有工具暴露给 Agent 会让工具列表变长,也更容易选错工具。ToolHub 会向 Agent 暴露一个 `ccr-toolhub` MCP server,里面只有两个元工具: + +- `tool_hub.resolve`:根据用户任务和上下文检索可用 MCP 工具。 +- `tool_hub.invoke`:调用已经被本轮任务选中的真实 MCP 工具。 + +它适合把不常用但偶尔会用到的工具统一懒加载,在任务真正需要时才交给 Agent 使用。核心价值是节省上下文:避免把大量低频工具常驻在 Agent 的工具列表里,减少上下文占用和选错工具的概率。简单本地代码、文件或普通聊天任务通常不需要经过 ToolHub。 + +## 工作方式 + +1. 在 **设置 → ToolHub** 中启用 ToolHub。 +2. 选择一个已配置模型作为 **检索模型**。它负责阅读 MCP 工具目录并挑选本轮任务需要的工具;建议使用 `deepseek-v4-flash`,或同等 Flash 价位、响应稳定的轻量模型。 +3. 添加或导入后端 MCP server。ToolHub 支持 `stdio`、`streamable-http` 和 `sse`。 +4. 从 CCR 打开 Claude Code 或 Codex。CCR 会在对应 Agent 配置中写入 `ccr-toolhub`。 +5. Agent 遇到外部服务、已安装 MCP 能力或业务 API 相关请求时,先调用 `tool_hub.resolve`,再用 `tool_hub.invoke` 执行选中的工具。 + +ToolHub 会合并 **ToolHub 页面配置的 MCP servers** 和兼容旧配置中的全局 Agent MCP servers,并自动排除 `ccr-toolhub` 自身,避免递归调用。 + +## 内置浏览器自动化 + +在 CCR Desktop 中启用 ToolHub,并打开 **内置浏览器自动化** 开关后,Agent 可以使用桌面端内置浏览器完成网页操作。不需要在 ToolHub 页面手动添加浏览器后端,也不需要额外 API Key;CCR 会使用本地网关鉴权连接它。 + +启用步骤: + +1. 打开 **设置 → ToolHub**,先开启 **启用 ToolHub**。 +2. 在同一页打开 **内置浏览器自动化** 开关。该开关只会在 ToolHub 已启用时显示。 +3. 保存设置后,从 CCR 重新打开 Claude Code 或 Codex,让新的 Agent 实例加载最新配置。 + +> 已经运行中的 Agent 实例通常不会立即拿到这个开关变化。要让现有会话生效,请重启该 Agent 实例,或使用 Agent 自身能力重启 ToolHub。 + +内置浏览器自动化适合让 Agent 处理需要真实浏览器状态的任务,例如打开网站、读取页面、填写表单、点击按钮、在页面中滚动,或在没有专用业务能力时完成下单、预约、查询、结账等网页流程。开启后 Agent 可以: + +- 打开或附加内置浏览器标签页、导航 URL 或搜索词。 +- 读取页面内容,并找到按钮、链接、输入框等页面元素。 +- 点击、输入、选择、按键和滚动页面元素。 +- 等待页面加载、跳转、弹窗或人类接管结果,再继续后续步骤。 +- 在登录、验证码、CAPTCHA、人机验证或人工确认时请求用户接管。 + +当网页流程需要登录、验证码、CAPTCHA、人机验证或人工确认时,CCR 会显示内置浏览器窗口,并在顶部工具栏提示用户需要完成的步骤。用户点击 **Done** 或 **Hide** 后,Agent 会收到结果并继续执行。接管等待最长支持 10 分钟。 + +### Chrome 登录态导入扩展 + +内置浏览器自动化还支持把系统 Chrome 中指定域名的登录状态导入 CCR 内置浏览器。这样 Agent 处理网页任务时,可以复用你已经在 Chrome 中登录过的网站状态。该能力需要安装仓库里的 Chrome 解包扩展:`extension/chrome`。 + +安装方式: + +1. 在 Chrome 打开 `chrome://extensions`。 +2. 开启 **Developer mode**。 +3. 点击 **Load unpacked**。 +4. 选择仓库中的 `extension/chrome` 目录。 + +导入流程: + +1. 当任务需要复用 Chrome 登录状态时,Agent 会请求导入;用户也可以在 CCR 内置浏览器工具栏点击钥匙按钮主动发起。 +2. CCR 创建一次性导入任务,并打开确认页。如果默认浏览器不是 Chrome,请把确认页 URL 复制到已安装扩展的 Chrome 中打开。 +3. 用户在确认页检查要导入的域名,点击 **Confirm and Import**。 +4. Chrome 扩展读取这些域名的 cookies 和 localStorage,提交给 CCR;完成后 Agent 可以继续使用内置浏览器执行任务。 + +扩展只读取 CCR 导入任务列出的域名,不会枚举 Chrome 中的全部 cookies。读取 localStorage 时,扩展会临时打开对应 origin 的非激活标签页,读取后自动关闭。若确认页提示扩展没有站点访问权限,请在 Chrome 扩展设置中允许该扩展访问目标域名,然后重新加载解包扩展再重试。 + +> 注意:内置浏览器自动化依赖 CCR Desktop 的内置浏览器,只在桌面端可用。CLI、服务器部署或纯 Web 环境没有这项内置能力,请改用外部浏览器自动化 MCP server。 + +## 配置项 + +| 配置项 | 说明 | +| --- | --- | +| 启用 ToolHub | 开启后才会向 Agent 暴露 `ccr-toolhub`。如果没有可用后端 MCP server,CCR 不会生成 ToolHub MCP 配置。 | +| 内置浏览器自动化 | 仅在启用 ToolHub 后显示。开启后让 Agent 可以使用 CCR Desktop 的内置浏览器完成网页操作。 | +| 检索模型 | 从已配置供应商模型中选择。建议使用 `deepseek-v4-flash`,或同等 Flash 价位、响应稳定、工具理解能力足够的轻量模型。 | +| 最大工具数 | 单次解析最多返回的工具数量,范围 `1` 到 `20`,默认 `10`。 | +| 超时毫秒 | ToolHub 解析和调用的基础超时时间,范围 `8000` 到 `300000`,默认 `60000`。如果后端 MCP server 需要更长 request timeout,CCR 会按后端超时自动抬高实际调用超时。 | +| MCP servers | 后端工具来源。每个 server 需要唯一名称,并配置 transport、命令或 URL、环境变量、headers 和超时。 | +| Import JSON | 导入常见 MCP JSON。支持根对象、数组、`mcpServers` 或 `mcp_servers`。 | + +## 添加 MCP Server + +### stdio + +`stdio` 适合本地命令行 MCP server。需要填写: + +- **Command**:启动命令,例如 `npx`、`node`、`python`。 +- **Arguments**:命令参数。 +- **Working directory**:可选工作目录。 +- **Stdio message mode**:默认 `content-length`,如果 server 使用逐行 JSON,选择 `newline-json`。 +- **Environment variables**:只放这个 MCP server 需要的变量。 + +### streamable-http / sse + +远程 MCP server 需要填写 URL。鉴权可以使用: + +- **API key**:直接保存在配置中。 +- **API key env**:从环境变量读取。 +- **Headers**:添加自定义请求头。 + +如果远程服务启动慢或请求耗时长,可以单独调高该 server 的 **Startup timeout** 或 **Request timeout**。 + +## JSON 示例 + +桌面 App 的 SQLite 配置是当前生效来源,建议优先通过 UI 修改。下面字段适用于备份、迁移或排查时理解 ToolHub 配置结构: + +```json +{ + "toolHub": { + "enabled": true, + "browserAutomation": true, + "llm": { + "apiKey": "sk-...", + "baseUrl": "https://api.openai.com/v1", + "model": "gpt-5-mini" + }, + "maxTools": 10, + "requestTimeoutMs": 60000, + "mcpServers": [ + { + "name": "filesystem", + "transport": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + "env": {}, + "stdioMessageMode": "content-length", + "requestTimeoutMs": 30000, + "startupTimeoutMs": 600000 + } + ] + } +} +``` + +导入 MCP JSON 时也可以使用常见格式: + +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + } + } +} +``` + +## 与 Fusion MCP 的区别 + +| 能力 | ToolHub | Fusion 自定义 MCP 工具 | +| --- | --- | --- | +| 使用入口 | Agent 侧的 `ccr-toolhub` MCP server | 某个 Fusion 模型内部能力 | +| 工具选择 | 每个任务动态检索并返回工具包 | 模型配置中固定选择工具 | +| 适合场景 | MCP server 很多、工具目录经常变化、希望 Agent 自主发现能力 | 给某个模型补一组明确工具 | +| 可见范围 | 通过 CCR 打开的 Claude Code 或 Codex 配置 | 选择该 Fusion 模型的路由或 Agent | + +## 排查 + +- Agent 看不到 ToolHub:确认已启用 ToolHub,并且至少配置了一个后端 MCP server 或开启了 **内置浏览器自动化**,然后从 CCR 重新打开 Claude Code 或 Codex。 +- 提示缺少检索模型或 API Key:在 **检索模型** 中选择已配置模型,并确认供应商凭据可用。 +- Agent 无法使用内置浏览器自动化:确认正在使用 CCR Desktop,并且已在 **设置 → ToolHub** 中开启 **内置浏览器自动化**,然后从 CCR 重新打开 Claude Code 或 Codex。CLI、服务器部署或纯 Web 环境没有这项内置能力。 +- Chrome 登录态导入确认页一直等待扩展:确认已在 Chrome 中加载 `extension/chrome` 解包扩展,并允许扩展访问要导入的目标域名。如果默认浏览器不是 Chrome,请手动把确认页 URL 复制到 Chrome。 +- 解析不到工具:检查 MCP server 是否能正常列出工具,工具名称和描述是否足够清楚,必要时提高 **最大工具数**。 +- 调用超时:分别检查 ToolHub 的 **超时毫秒** 和单个 MCP server 的 request/startup timeout。 +- 导入失败:检查 JSON 是否有效、server 名称是否重复、`stdio` 是否有 command,远程 transport 是否有 URL。 diff --git a/docs/src/content/docs/zh/configuration/tray.md b/docs/src/content/docs/zh/configuration/tray.md new file mode 100644 index 0000000..48fc932 --- /dev/null +++ b/docs/src/content/docs/zh/configuration/tray.md @@ -0,0 +1,41 @@ +--- +title: 托盘配置 +pageTitle: 托盘配置 +eyebrow: 详细配置 +lead: 配置 CCR 系统托盘图标、余额进度条和托盘窗口组件。 +--- + +## 顶部字段 + +| 字段 | 代表的能力 | +| --- | --- | +| 托盘小精灵 | 选择托盘图标样式。可选“随机”“晴岚”“暖阳”“星澜”和“余额进度条”。 | +| 余额进度条 | 使用供应商账户用量作为托盘图标进度。需要先为供应商启用“获取用量”。 | +| 账户 | 选择用于余额进度条的供应商账户。 | +| 数据 | 选择账户里的余额、套餐或额度数据作为进度来源。 | + +如果没有可用账户数据,页面会提示“暂无可用账户数据,请先为供应商启用账户监控。”这通常意味着还没有供应商开启“获取用量”,或用量读取尚未成功。 + +## 托盘窗口布局 + +| 区域 | 代表的能力 | +| --- | --- | +| 组件区 | 左侧组件库,用于添加或启用托盘窗口组件。 | +| 预览 | 中间预览区,展示当前托盘窗口布局。组件可以拖拽排序。 | +| 组件属性 | 右侧配置区,用于调整选中组件的“样式”,或移除当前组件。 | + +## 组件类型 + +| 组件 | 代表的能力 | +| --- | --- | +| 供应商组件 | 显示“供应商切换”,用于在托盘窗口中按供应商查看数据。该组件是单例组件,只能启用一次。 | +| 标题组件 | 显示“标题和状态”。该组件是单例组件,只能启用一次。 | +| 账户组件 | 显示“账户余额”。可以添加多个账户组件,并选择不同样式。 | +| 趋势组件 | 显示“Token 趋势图”。 | +| 活跃度组件 | 显示“Token 活跃度”。 | +| 指标组件 | 显示“Token 指标”。 | +| 构成组件 | 显示“Token 构成”“环形指标”或“模型占比”。 | + +## 样式 + +不同组件支持不同“样式”。常见样式包括“卡片”“紧凑”“列表”“胶囊”“折线图”“面积图”“柱状图”“圆环”“环形图”“仪表盘”“迷你折线”“堆叠”等。样式只影响托盘窗口展示方式,不改变请求路由、供应商配置或用量统计。 diff --git a/docs/src/content/docs/zh/index.md b/docs/src/content/docs/zh/index.md index 698cb07..45caad3 100644 --- a/docs/src/content/docs/zh/index.md +++ b/docs/src/content/docs/zh/index.md @@ -5,19 +5,6 @@ eyebrow: 产品文档 lead: 了解 CCR 的定位、能力边界和文档结构。需要动手配置时,从顶部的「快速开始」开始;需要查字段、Bot 或 Fusion 时,进入「详细配置」。 --- -## CCR 能帮你做什么 - -**Claude Code Router(CCR)是本地运行的模型网关。** 它位于 Claude Code、Codex、ZCode 等 Agent 和上游模型服务之间,统一管理模型、API Key、路由规则、日志观测和 Bot 接力。 - -CCR 适合解决这些问题: - -- 你不想在每个 Agent 里重复维护模型和 Key。 -- 你希望不同任务自动走不同模型:轻量后台任务用快模型,复杂任务用强模型,看图或联网任务走 Fusion。 -- 你需要在请求日志里看到请求实际去了哪个供应商、哪个模型、是否成功、延迟和成本大概是多少。 -- 你想把长时间运行的 Agent 消息转发到 Slack、Telegram、飞书、企业微信等 IM 平台。 - -CCR 默认监听本机地址 `http://localhost:8080`。Agent 只要指向这个地址,请求就可以被 CCR 接管并按路由规则转发到上游供应商。 - ## 文档结构 顶部栏现在对应四个独立页面: @@ -26,7 +13,7 @@ CCR 默认监听本机地址 `http://localhost:8080`。Agent 只要指向这个 | --- | --- | | [文档](./) | 产品定位、架构概览、阅读路径 | | [快速开始](guides/) | 从安装、接供应商,到接入 Agent 的上手流程 | -| [详细配置](configuration/) | 供应商、路由、配置、Fusion、Bot 和配置文件位置 | +| [详细配置](configuration/overview/) | 概览仪表盘、API 密钥、服务、供应商、路由、Agent配置、Fusion、Bot、托盘和配置数据库位置 | | [Q&A](troubleshooting/) | 请求日志、观测面板和常见问题 | Bot 平台教程是「详细配置」分类下的子页面,每个平台有独立页面,方便逐步补齐平台后台字段、回调 URL、签名和 FAQ。 @@ -37,7 +24,7 @@ Bot 平台教程是「详细配置」分类下的子页面,每个平台有独 1. [快速开始](guides/) 覆盖供应商接入和 Agent配置。 2. App 的请求日志页面展示请求是否经过 CCR。 -3. [详细配置](configuration/) 覆盖图像、联网搜索、MCP 工具和 IM 接力。 +3. [详细配置](configuration/overview/) 覆盖概览仪表盘、API 密钥、服务、供应商、图像、联网搜索、MCP 工具、托盘和 IM 接力。 4. [Q&A](troubleshooting/) 覆盖 401、404、超时、路由不对或 Bot 收不到消息等常见问题。 这样文档不会挤在一个长页面里,后续也能按顶部分类逐步扩展。 diff --git a/docs/src/content/docs/zh/troubleshooting.md b/docs/src/content/docs/zh/troubleshooting.md index a55adec..f37bc12 100644 --- a/docs/src/content/docs/zh/troubleshooting.md +++ b/docs/src/content/docs/zh/troubleshooting.md @@ -13,9 +13,9 @@ A: 服务运行状态、Agent 启动方式、配置应用状态和作用范围 ### Q: 请求命中了错误模型怎么办? -A: 请求日志会展示 `request model`、`resolved provider` 和 `resolved model`。路由配置页包含默认路由、规则顺序、匹配条件和 fallback。 +A: 请求日志会展示 `request model`、`resolved provider` 和 `resolved model`。路由配置页包含规则顺序、匹配条件和 fallback。 -### Q: 供应商返回 401 或 403 是什么原因? +### Q: 供应商返回 401 或 403 怎么处理? A: 相关字段包括 API Key、凭据启用状态、基础 URL、协议和额外请求头。供应商页面提供模型连通性检查。 diff --git a/docs/src/i18n/content.ts b/docs/src/i18n/content.ts index 8e1e279..58a9dc1 100644 --- a/docs/src/i18n/content.ts +++ b/docs/src/i18n/content.ts @@ -15,7 +15,7 @@ export const docsContent = { navItems: [ { label: "文档", href: "/", pageKey: "documentation" }, { label: "快速开始", href: "/guides/", pageKey: "guides" }, - { label: "详细配置", href: "/configuration/", pageKey: "configuration" }, + { label: "详细配置", href: "/configuration/overview/", pageKey: "configuration" }, { label: "Q&A", href: "/troubleshooting/", pageKey: "troubleshooting" }, ], pages: { @@ -60,18 +60,32 @@ export const docsContent = { configuration: { sidebarGroups: [ { - label: "详细配置", + label: "主页页面", icon: "wand", items: [ + "概览仪表盘", "供应商配置", - "路由配置", - "日志&观测", - "Fusion 组合模型", + "一键导入供应商", "Agent配置", - "Bot 与 IM 接力 Agent", - "配置文件位置", + "路由配置", + "Fusion 组合模型", + "API 密钥", + "日志&观测", + "服务配置", + "扩展机制", ], - active: "供应商配置", + active: "概览仪表盘", + }, + { + label: "设置页", + icon: "book", + items: [ + "ToolHub", + "Bot 与 IM 接力 Agent", + "配置数据库位置", + "托盘配置", + ], + active: "", }, ], expandableSidebarItems: ["Fusion 组合模型", "Bot 与 IM 接力 Agent"], @@ -90,14 +104,21 @@ export const docsContent = { ], }, sidebarLinks: { + 概览仪表盘: "/configuration/overview/", 供应商配置: "/configuration/provider/", + "一键导入供应商": "/configuration/provider-deeplink/", 路由配置: "/configuration/routing/", "日志&观测": "/configuration/observability/", "Fusion 组合模型": "/configuration/fusion/", 内置图像能力: "/configuration/fusion-vision/", 内置联网搜索: "/configuration/fusion-web-search/", "自定义 MCP 工具": "/configuration/fusion-mcp-tool/", + ToolHub: "/configuration/toolhub/", Agent配置: "/configuration/profile/", + "API 密钥": "/configuration/api-keys/", + 服务配置: "/configuration/server/", + 托盘配置: "/configuration/tray/", + 扩展机制: "/configuration/extensions/", "Bot 与 IM 接力 Agent": "/configuration/bot-relay/", 配置步骤: "/configuration/bot-setup/", Slack: "/bot-与-im-接力-agent/slack/", @@ -108,7 +129,7 @@ export const docsContent = { 企业微信: "/bot-与-im-接力-agent/wecom/", 飞书: "/bot-与-im-接力-agent/feishu/", 钉钉: "/bot-与-im-接力-agent/dingtalk/", - 配置文件位置: "/configuration/config-file/", + 配置数据库位置: "/configuration/config-file/", }, sidebarTargets: {}, }, @@ -151,7 +172,7 @@ export const docsContent = { navItems: [ { label: "Documentation", href: "/en/", pageKey: "documentation" }, { label: "Quick Start", href: "/en/guides/", pageKey: "guides" }, - { label: "Detailed Configuration", href: "/en/configuration/", pageKey: "configuration" }, + { label: "Detailed Configuration", href: "/en/configuration/overview/", pageKey: "configuration" }, { label: "Q&A", href: "/en/troubleshooting/", pageKey: "troubleshooting" }, ], pages: { @@ -196,18 +217,32 @@ export const docsContent = { configuration: { sidebarGroups: [ { - label: "Detailed Configuration", + label: "Main Pages", icon: "wand", items: [ + "Overview Dashboard", "Provider Config", - "Routing Config", - "Logs & Observability", - "Fusion Models", + "One click import", "Agent Config", - "Bots And IM Agent Relay", - "Config File Location", + "Routing Config", + "Fusion Models", + "API Keys", + "Logs & Observability", + "Server", + "Extension Mechanism", ], - active: "Provider Config", + active: "Overview Dashboard", + }, + { + label: "Settings Pages", + icon: "book", + items: [ + "ToolHub", + "Bots And IM Agent Relay", + "Config Database Location", + "Tray Configuration", + ], + active: "", }, ], expandableSidebarItems: ["Fusion Models", "Bots And IM Agent Relay"], @@ -216,14 +251,21 @@ export const docsContent = { "Bots And IM Agent Relay": ["Setup", "Slack", "Discord", "Telegram", "LINE", "Weixin", "WeCom", "Feishu", "DingTalk"], }, sidebarLinks: { + "Overview Dashboard": "/en/configuration/overview/", "Provider Config": "/en/configuration/providers/", + "One click import": "/en/configuration/provider-deeplink/", "Routing Config": "/en/configuration/routing/", "Logs & Observability": "/en/configuration/observability/", "Fusion Models": "/en/configuration/fusion-models/", "Built-In Vision": "/en/configuration/fusion-vision/", "Built-In Web Search": "/en/configuration/fusion-web-search/", "Custom MCP Tool": "/en/configuration/fusion-mcp-tool/", + ToolHub: "/en/configuration/toolhub/", "Agent Config": "/en/configuration/profiles/", + "API Keys": "/en/configuration/api-keys/", + Server: "/en/configuration/server/", + "Tray Configuration": "/en/configuration/tray/", + "Extension Mechanism": "/en/configuration/extensions/", "Bots And IM Agent Relay": "/en/configuration/bots/", Setup: "/en/configuration/bot-setup/", Slack: "/en/relay-agents-in-im-with-bots/slack/", @@ -234,7 +276,7 @@ export const docsContent = { WeCom: "/en/relay-agents-in-im-with-bots/wecom/", Feishu: "/en/relay-agents-in-im-with-bots/feishu/", DingTalk: "/en/relay-agents-in-im-with-bots/dingtalk/", - "Config File Location": "/en/configuration/configuration-file/", + "Config Database Location": "/en/configuration/configuration-file/", }, sidebarTargets: {}, }, diff --git a/docs/src/layouts/DocsLayout.astro b/docs/src/layouts/DocsLayout.astro index ea5dde8..60d714c 100644 --- a/docs/src/layouts/DocsLayout.astro +++ b/docs/src/layouts/DocsLayout.astro @@ -80,7 +80,7 @@ const resolvedNavItems = navItems.map((item, index) => { }; }); const homeHref = withBase(locale === "en" ? "/en/" : "/"); -const faviconHref = withBase("/favicon.svg"); +const faviconHref = withBase("/ccr-icon.png"); const logoSrc = withBase("/logo.png"); const sidebarIcons = { rocket: Rocket, @@ -177,7 +177,7 @@ const sidebarCloseLabel = locale === "zh" ? "关闭目录" : "Close navigation"; } })(); - + { resolvedLanguageOptions.map((option) => ( {section.groups.map((group) => (
  • + {(section.groups.length > 1 || group.label !== section.label) && ( +
    + {group.label} +
    + )}
      {group.items.map((item) => { const isExpandable = item.children.length > 0; @@ -394,14 +399,21 @@ const sidebarCloseLabel = locale === "zh" ? "关闭目录" : "Close navigation"; diff --git a/docs/src/pages/configuration.astro b/docs/src/pages/configuration.astro index e241173..5484cef 100644 --- a/docs/src/pages/configuration.astro +++ b/docs/src/pages/configuration.astro @@ -1,6 +1,17 @@ --- -import DocPage from "../components/DocPage.astro"; -import * as doc from "../content/docs/zh/configuration.md"; +const target = "/configuration/overview/"; --- - + + + + + + + + + 概览仪表盘 + + diff --git a/docs/src/pages/configuration/[slug].astro b/docs/src/pages/configuration/[slug].astro index 99b34a7..6f2271f 100644 --- a/docs/src/pages/configuration/[slug].astro +++ b/docs/src/pages/configuration/[slug].astro @@ -4,7 +4,10 @@ import { configurationSlugFromPath, zhConfigurationDocs } from "../../configurat export function getStaticPaths() { const activeLabels: Record = { + "api-keys": "API 密钥", + overview: "概览仪表盘", provider: "供应商配置", + "provider-deeplink": "一键导入供应商", routing: "路由配置", profile: "Agent配置", observability: "日志&观测", @@ -12,9 +15,13 @@ export function getStaticPaths() { "fusion-vision": "内置图像能力", "fusion-web-search": "内置联网搜索", "fusion-mcp-tool": "自定义 MCP 工具", + toolhub: "ToolHub", + extensions: "扩展机制", + server: "服务配置", + tray: "托盘配置", "bot-relay": "Bot 与 IM 接力 Agent", "bot-setup": "配置步骤", - "config-file": "配置文件位置", + "config-file": "配置数据库位置", }; return Object.entries(zhConfigurationDocs).map(([filePath, mod]) => { diff --git a/docs/src/pages/en/configuration.astro b/docs/src/pages/en/configuration.astro index c2ce75b..e77c3b1 100644 --- a/docs/src/pages/en/configuration.astro +++ b/docs/src/pages/en/configuration.astro @@ -1,6 +1,17 @@ --- -import DocPage from "../../components/DocPage.astro"; -import * as doc from "../../content/docs/en/configuration.md"; +const target = "/en/configuration/overview/"; --- - + + + + + + + + + Overview Dashboard + + diff --git a/docs/src/pages/en/configuration/[slug].astro b/docs/src/pages/en/configuration/[slug].astro index 974c6ab..fbc4927 100644 --- a/docs/src/pages/en/configuration/[slug].astro +++ b/docs/src/pages/en/configuration/[slug].astro @@ -4,7 +4,10 @@ import { configurationSlugFromPath, enConfigurationDocs } from "../../../configu export function getStaticPaths() { const activeLabels: Record = { + "api-keys": "API Keys", + overview: "Overview Dashboard", providers: "Provider Config", + "provider-deeplink": "One click import", routing: "Routing Config", profiles: "Agent Config", observability: "Logs & Observability", @@ -12,9 +15,13 @@ export function getStaticPaths() { "fusion-vision": "Built-In Vision", "fusion-web-search": "Built-In Web Search", "fusion-mcp-tool": "Custom MCP Tool", + toolhub: "ToolHub", + extensions: "Extension Mechanism", + server: "Server", + tray: "Tray Configuration", bots: "Bots And IM Agent Relay", "bot-setup": "Setup", - "configuration-file": "Config File Location", + "configuration-file": "Config Database Location", }; return Object.entries(enConfigurationDocs).map(([filePath, mod]) => { diff --git a/docs/src/styles/global.css b/docs/src/styles/global.css index 10ae1cb..094438b 100644 --- a/docs/src/styles/global.css +++ b/docs/src/styles/global.css @@ -642,28 +642,26 @@ pre { } .directory-group { - margin: 2px 0 9px; + margin: 8px 0 14px; } .directory-group:last-child { margin-bottom: 4px; } -.directory-group-label { - display: flex; - align-items: center; - gap: 8px; - min-height: 28px; - padding: 7px 8px 3px 0; - color: var(--sidebar-heading); - font-size: 12px; - font-weight: 720; - line-height: 1.35; +.directory-group + .directory-group { + margin-top: 20px; } -.directory-group-label .group-icon { - width: 14px; - height: 14px; +.directory-group-label { + display: block; + min-height: 20px; + padding: 2px 8px 5px 0; + color: var(--sidebar-subtle); + font-size: 11px; + font-weight: 760; + letter-spacing: 0.04em; + line-height: 1.3; } .sidebar-group h2 { @@ -768,7 +766,7 @@ pre { } .sidebar-directory .sidebar-children { - padding-left: 24px; + padding-left: 18px; } .sidebar-children::before { @@ -786,6 +784,14 @@ pre { left: 8px; } +.sidebar-directory .directory-children { + padding-left: 13px; +} + +.sidebar-directory .directory-children::before { + display: none; +} + .sidebar-details[open] > .sidebar-children { margin-bottom: 7px; } @@ -847,6 +853,8 @@ pre { h1, h2, h3, +h4, +h5, p { overflow-wrap: anywhere; } @@ -913,8 +921,8 @@ h1 { text-underline-offset: 3px; } -.doc-article code, -.doc-markdown code { +.doc-article :not(pre) > code, +.doc-markdown :not(pre) > code { padding: 2px 6px; border: 1px solid var(--code-border); border-radius: 6px; @@ -956,6 +964,24 @@ h1 { font-weight: 720; } +.doc-article h4, +.doc-markdown > h4 { + margin: 24px 0 8px; + color: var(--heading); + font-size: 15px; + line-height: 1.45; + font-weight: 700; +} + +.doc-article h5, +.doc-markdown > h5 { + margin: 18px 0 6px; + color: var(--heading); + font-size: 14px; + line-height: 1.45; + font-weight: 680; +} + .doc-markdown > ul, .doc-markdown > ol { margin: 0 0 22px; @@ -1048,6 +1074,328 @@ h1 { color: var(--quote-text); } +.provider-import-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 14px; + margin: 24px 0 34px; +} + +.doc-markdown a.provider-import-button { + --provider-brand: #17201c; + --provider-brand-2: #36624d; + --provider-brand-3: #dff8eb; + --provider-accent: rgba(255, 255, 255, 0.72); + position: relative; + isolation: isolate; + display: grid; + grid-template-columns: 48px minmax(0, 1fr); + column-gap: 14px; + align-items: center; + min-height: 86px; + padding: 13px 16px 13px 12px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--provider-brand-2) 42%, transparent); + border-radius: 12px; + background: + radial-gradient(circle at 14% 22%, color-mix(in srgb, var(--provider-brand-3) 34%, transparent) 0, transparent 30%), + radial-gradient(circle at 96% 96%, color-mix(in srgb, var(--provider-brand-2) 44%, transparent) 0, transparent 46%), + linear-gradient(135deg, var(--provider-brand), color-mix(in srgb, var(--provider-brand-2) 88%, #111 12%)); + box-shadow: + 0 10px 26px color-mix(in srgb, var(--provider-brand) 22%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.16); + color: #fff; + text-decoration: none; + text-decoration-thickness: 0; + transform: translateY(0); + transition: + border-color 180ms ease, + box-shadow 220ms ease, + transform 220ms ease; +} + +.doc-markdown a.provider-import-button::before { + content: ""; + position: absolute; + inset: -40% auto -40% -70%; + z-index: -1; + width: 58%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.26), transparent); + transform: skewX(-18deg); + transition: transform 620ms ease; +} + +.doc-markdown a.provider-import-button::after { + content: ""; + position: absolute; + right: -38px; + bottom: -52px; + z-index: -2; + width: 132px; + height: 132px; + border-radius: 999px; + background: radial-gradient(circle, color-mix(in srgb, var(--provider-brand-3) 38%, transparent), transparent 68%); + opacity: 0.82; + transform: scale(0.96); + transition: + opacity 220ms ease, + transform 220ms ease; +} + +.doc-markdown a.provider-import-button:hover, +.doc-markdown a.provider-import-button:focus-visible { + border-color: color-mix(in srgb, var(--provider-brand-3) 68%, rgba(255, 255, 255, 0.34)); + box-shadow: + 0 16px 34px color-mix(in srgb, var(--provider-brand) 30%, transparent), + 0 0 0 1px color-mix(in srgb, var(--provider-brand-3) 26%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.22); + color: #fff; + transform: translateY(-3px); + text-decoration: none; +} + +.doc-markdown a.provider-import-button:hover::before, +.doc-markdown a.provider-import-button:focus-visible::before { + transform: translateX(320%) skewX(-18deg); +} + +.doc-markdown a.provider-import-button:hover::after, +.doc-markdown a.provider-import-button:focus-visible::after { + opacity: 1; + transform: scale(1.08); +} + +.doc-markdown a.provider-import-button:active { + transform: translateY(-1px) scale(0.992); +} + +.provider-import-icon-shell { + position: relative; + z-index: 1; + grid-column: 1; + grid-row: 1; + justify-self: start; + display: grid; + place-items: center; + width: 48px; + height: 48px; + border: 0; + background: transparent; + box-shadow: none; + transform: rotate(0) scale(1); + transition: transform 220ms ease; +} + +.provider-import-icon-shell::before { + content: none; +} + +.provider-import-icon-shell img { + box-sizing: border-box; + display: block; + width: 48px; + height: 48px; + border: 1px solid rgba(255, 255, 255, 0.46); + border-radius: 12px; + background: color-mix(in srgb, #ffffff 84%, var(--provider-brand-3)); + box-shadow: + 0 8px 18px rgba(0, 0, 0, 0.14), + inset 0 1px 0 rgba(255, 255, 255, 0.6); + object-fit: contain; + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.12)); + transition: + border-color 180ms ease, + box-shadow 220ms ease; +} + +.provider-import-mark { + box-sizing: border-box; + display: grid; + place-items: center; + width: 48px; + height: 48px; + border: 1px solid rgba(255, 255, 255, 0.46); + border-radius: 12px; + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--provider-brand-3) 36%, rgba(255, 255, 255, 0.88)), + color-mix(in srgb, var(--provider-brand-2) 72%, #111111 28%) + ); + box-shadow: + 0 8px 18px rgba(0, 0, 0, 0.14), + inset 0 1px 0 rgba(255, 255, 255, 0.52); + color: #ffffff; + font-size: 14px; + font-weight: 820; + letter-spacing: 0; + line-height: 1; + text-align: center; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.34); + transition: + border-color 180ms ease, + box-shadow 220ms ease; +} + +.doc-markdown a.provider-import-button:hover .provider-import-icon-shell, +.doc-markdown a.provider-import-button:focus-visible .provider-import-icon-shell { + transform: rotate(-3deg) scale(1.06); +} + +.doc-markdown a.provider-import-button:hover .provider-import-icon-shell img, +.doc-markdown a.provider-import-button:focus-visible .provider-import-icon-shell img, +.doc-markdown a.provider-import-button:hover .provider-import-mark, +.doc-markdown a.provider-import-button:focus-visible .provider-import-mark { + border-color: color-mix(in srgb, var(--provider-brand-3) 74%, rgba(255, 255, 255, 0.54)); + box-shadow: + 0 12px 24px rgba(0, 0, 0, 0.18), + 0 0 0 4px color-mix(in srgb, var(--provider-brand-3) 16%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.72); +} + +.provider-import-copy { + display: grid; + min-width: 0; + gap: 2px; + padding: 0; +} + +.provider-import-name { + display: block; + overflow: hidden; + color: #fff; + font-size: 15px; + font-weight: 780; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.provider-import-meta { + display: block; + overflow: hidden; + color: rgba(255, 255, 255, 0.74); + font-size: 11px; + font-weight: 650; + letter-spacing: 0; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.doc-markdown a.provider-import-button.provider-openai { + --provider-brand: #0b0f0e; + --provider-brand-2: #39413e; + --provider-brand-3: #f2f5f1; +} + +.doc-markdown a.provider-import-button.provider-anthropic { + --provider-brand: #3f2118; + --provider-brand-2: #c56a4b; + --provider-brand-3: #ffd1bf; +} + +.doc-markdown a.provider-import-button.provider-gemini { + --provider-brand: #1a73e8; + --provider-brand-2: #a142f4; + --provider-brand-3: #fbbc04; +} + +.doc-markdown a.provider-import-button.provider-openrouter { + --provider-brand: #111722; + --provider-brand-2: #66758c; + --provider-brand-3: #d9e3f1; +} + +.doc-markdown a.provider-import-button.provider-runapi { + --provider-brand: #070707; + --provider-brand-2: #747474; + --provider-brand-3: #f3f3f3; +} + +.doc-markdown a.provider-import-button.provider-teamorouter { + --provider-brand: #0c0c0f; + --provider-brand-2: #555b64; + --provider-brand-3: #f4f4f5; +} + +.doc-markdown a.provider-import-button.provider-code0 { + --provider-brand: #101214; + --provider-brand-2: #267dff; + --provider-brand-3: #d8e8ff; +} + +.doc-markdown a.provider-import-button.provider-claudeapi { + --provider-brand: #0d1b2a; + --provider-brand-2: #2f8f83; + --provider-brand-3: #d7fff8; +} + +.doc-markdown a.provider-import-button.provider-deepseek { + --provider-brand: #173aa8; + --provider-brand-2: #4e69ff; + --provider-brand-3: #d6deff; +} + +.doc-markdown a.provider-import-button.provider-zhipu-coding { + --provider-brand: #0d4fd7; + --provider-brand-2: #22b8f0; + --provider-brand-3: #d7f2ff; +} + +.doc-markdown a.provider-import-button.provider-zhipu-general { + --provider-brand: #1728a6; + --provider-brand-2: #5f6dff; + --provider-brand-3: #e0e4ff; +} + +.doc-markdown a.provider-import-button.provider-zai-coding { + --provider-brand: #111111; + --provider-brand-2: #49515d; + --provider-brand-3: #f2f5f8; +} + +.doc-markdown a.provider-import-button.provider-zai-general { + --provider-brand: #0d1424; + --provider-brand-2: #3267ac; + --provider-brand-3: #dce8ff; +} + +.doc-markdown a.provider-import-button.provider-mistral { + --provider-brand: #581900; + --provider-brand-2: #ff8a00; + --provider-brand-3: #ffd84f; +} + +.doc-markdown a.provider-import-button.provider-moonshot { + --provider-brand: #10133a; + --provider-brand-2: #7465ff; + --provider-brand-3: #e4ddff; +} + +.doc-markdown a.provider-import-button.provider-moonshot-global { + --provider-brand: #0f1f42; + --provider-brand-2: #4f7cff; + --provider-brand-3: #dce7ff; +} + +.doc-markdown a.provider-import-button.provider-kimi-coding { + --provider-brand: #111237; + --provider-brand-2: #5c6bff; + --provider-brand-3: #dfe4ff; +} + +.doc-markdown a.provider-import-button.provider-bailian { + --provider-brand: #5a2300; + --provider-brand-2: #ff6a00; + --provider-brand-3: #ffd1a3; +} + +.doc-markdown a.provider-import-button.provider-siliconflow { + --provider-brand: #041f1b; + --provider-brand-2: #00c2a8; + --provider-brand-3: #b4fff4; +} + .doc-markdown > pre, .doc-markdown > .code-panel { margin: 22px 0 32px; @@ -1145,23 +1493,44 @@ pre, line-height: 1.7; } +pre.astro-code, +pre.astro-code span { + color: var(--shiki-light, var(--code-text)); +} + +:root[data-theme="dark"] pre.astro-code, :root[data-theme="dark"] pre.astro-code span { - color: var(--code-text) !important; + color: var(--shiki-dark, var(--code-text)); } @media (prefers-color-scheme: dark) { + :root:not([data-theme]) pre.astro-code, :root:not([data-theme]) pre.astro-code span { - color: var(--code-text) !important; + color: var(--shiki-dark, var(--code-text)); } } .code-content code, pre code { + display: block; padding: 0; + border: 0; border-radius: 0; background: transparent; + box-shadow: none; color: inherit; font-size: inherit; + line-height: inherit; + white-space: pre; +} + +.code-content code span, +pre code span { + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; } .steps { @@ -1233,6 +1602,7 @@ pre code { } .toc a { + --toc-dot-left: 0px; position: relative; display: block; padding: 5px 0 5px 21px; @@ -1240,6 +1610,25 @@ pre code { line-height: 1.35; } +.toc a.toc-depth-3 { + --toc-dot-left: 12px; + padding-left: 33px; + font-size: 13.5px; +} + +.toc a.toc-depth-4 { + --toc-dot-left: 24px; + padding-left: 45px; + font-size: 13px; +} + +.toc a.toc-depth-5, +.toc a.toc-depth-6 { + --toc-dot-left: 36px; + padding-left: 57px; + font-size: 12.5px; +} + .toc a.active { color: var(--green); font-weight: 720; @@ -1247,7 +1636,7 @@ pre code { .toc a.active::before { position: absolute; - left: 0; + left: var(--toc-dot-left); top: 13px; width: 5px; height: 5px; @@ -1431,3 +1820,46 @@ pre code { } } + +@media (max-width: 520px) { + .provider-import-grid { + grid-template-columns: 1fr; + gap: 12px; + } + + .doc-markdown a.provider-import-button { + grid-template-columns: 44px minmax(0, 1fr); + column-gap: 12px; + min-height: 92px; + padding: 12px; + } + + .provider-import-icon-shell { + width: 44px; + height: 44px; + } + + .provider-import-icon-shell img, + .provider-import-mark { + width: 44px; + height: 44px; + border-radius: 11px; + } + +} + +@media (prefers-reduced-motion: reduce) { + .doc-markdown a.provider-import-button, + .doc-markdown a.provider-import-button::before, + .doc-markdown a.provider-import-button::after, + .provider-import-icon-shell { + transition: none; + } + + .doc-markdown a.provider-import-button:hover, + .doc-markdown a.provider-import-button:focus-visible, + .doc-markdown a.provider-import-button:hover .provider-import-icon-shell, + .doc-markdown a.provider-import-button:focus-visible .provider-import-icon-shell { + transform: none; + } +} diff --git a/electron-builder.json b/electron-builder.json index 2918119..3455751 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -5,17 +5,21 @@ "asarUnpack": [ "**/*.node" ], + "electronLanguages": ["en-US", "zh-CN", "zh-TW", "zh_CN", "zh_TW"], "npmRebuild": true, "publish": [ { "provider": "github", "owner": "musistudio", - "repo": "claude-code-router" + "repo": "claude-code-router", + "releaseType": "release" } ], "directories": { + "app": "packages/electron", "output": "release/${version}" }, + "afterPack": "build/verify-packaged-app.cjs", "afterAllArtifactBuild": "build/verify-update-metadata.cjs", "protocols": [ { @@ -25,7 +29,14 @@ ], "files": [ "dist", - "package.json" + "package.json", + "!node_modules/better-sqlite3/deps/**", + "!node_modules/better-sqlite3/src/**", + "!node_modules/better-sqlite3/binding.gyp", + "!node_modules/better-sqlite3/README.md", + "!node_modules/better-sqlite3/docs/**", + "!node_modules/better-sqlite3/benchmark/**", + "!node_modules/better-sqlite3/test/**" ], "mac": { "icon": "build/icon.icns", @@ -50,6 +61,7 @@ "artifactName": "Claude-Code-Router_${version}.${ext}" }, "linux": { + "executableName": "claude-code-router", "target": ["AppImage"], "artifactName": "Claude-Code-Router_${version}.${ext}" }, diff --git a/extension/chrome/README.md b/extension/chrome/README.md new file mode 100644 index 0000000..86a5f26 --- /dev/null +++ b/extension/chrome/README.md @@ -0,0 +1,23 @@ +# CCR Login Import Chrome Extension + +This unpacked Chrome extension imports cookies and localStorage for explicitly selected domains into CCR's in-app browser. + +## Development install + +1. Open `chrome://extensions`. +2. Enable **Developer mode**. +3. Click **Load unpacked**. +4. Select this `extension/chrome` directory. + +After changing extension files, click **Reload** for this unpacked extension in `chrome://extensions`. +The confirmation-page flow uses the site access declared in `manifest.json`; it does not request new host permissions from the page click. + +## Flow + +1. An agent calls CCR's Chrome login import browser tool, or the user clicks the key button in CCR's in-app browser. +2. CCR opens a one-time confirmation page in the system browser. +3. Review the requested domains and click **Confirm and Import**. + +The extension reads only the domains listed in the CCR job. It does not enumerate all Chrome cookies. + +For localStorage, the extension temporarily opens non-active tabs for the selected origins, reads `localStorage`, then closes those tabs. diff --git a/extension/chrome/background.js b/extension/chrome/background.js new file mode 100644 index 0000000..e86e03b --- /dev/null +++ b/extension/chrome/background.js @@ -0,0 +1,262 @@ +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (!message || message.type !== "ccr-login-import-confirm") { + return false; + } + + runImport(message.importUrl) + .then((result) => sendResponse({ ok: true, result })) + .catch((error) => sendResponse({ error: formatError(error), ok: false })); + return true; +}); + +async function runImport(importUrl) { + const normalizedImportUrl = normalizeImportUrl(importUrl); + if (!normalizedImportUrl) { + throw new Error("Invalid CCR import URL."); + } + + const job = await fetchImportJob(normalizedImportUrl); + const domains = Array.isArray(job.domains) ? job.domains.map(normalizeDomain).filter(Boolean) : []; + if (domains.length === 0) { + throw new Error("CCR import job does not include any domains."); + } + + await ensureHostPermissions(domains); + + const cookies = await readCookiesForDomains(domains); + const localStorageEntries = await readLocalStorageForDomains(domains, cookies); + if (cookies.length === 0 && localStorageEntries.length === 0) { + throw new Error("No cookies or localStorage entries were found for the selected domains."); + } + + return await submitLoginState(normalizedImportUrl, cookies, localStorageEntries, domains); +} + +async function fetchImportJob(importUrl) { + const response = await fetch(importUrl, { + headers: { + "x-ccr-login-import": "chrome-extension" + } + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body?.error?.message || `CCR import job request failed (${response.status}).`); + } + if (!body.job || body.job.status !== "pending") { + throw new Error(`CCR import job is ${body.job?.status || "unavailable"}.`); + } + return body.job; +} + +async function submitLoginState(importUrl, cookies, localStorage, domains) { + const response = await fetch(`${importUrl.replace(/\/+$/, "")}/cookies`, { + body: JSON.stringify({ + cookies, + domains, + localStorage, + source: { + browser: "chrome", + extension: chrome.runtime.getManifest().version + } + }), + headers: { + "content-type": "application/json", + "x-ccr-login-import": "chrome-extension" + }, + method: "POST" + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body?.error?.message || `CCR login import failed (${response.status}).`); + } + return body.result || {}; +} + +async function readCookiesForDomains(domains) { + const cookies = []; + for (const domain of domains) { + cookies.push(...await chrome.cookies.getAll({ domain })); + } + return dedupeCookies(cookies); +} + +async function readLocalStorageForDomains(domains, cookies) { + const origins = localStorageOriginsForDomains(domains, cookies); + const entries = []; + for (const origin of origins) { + const entry = await readLocalStorageForOrigin(origin).catch(() => undefined); + if (entry && Object.keys(entry.items).length > 0) { + entries.push(entry); + } + } + return entries; +} + +async function readLocalStorageForOrigin(origin) { + const tab = await chrome.tabs.create({ + active: false, + url: `${origin}/` + }); + try { + await waitForTabLoad(tab.id, 15000); + const [frameResult] = await chrome.scripting.executeScript({ + func: () => { + const items = {}; + for (let index = 0; index < window.localStorage.length; index += 1) { + const key = window.localStorage.key(index); + if (key !== null) { + items[key] = window.localStorage.getItem(key) || ""; + } + } + return { + items, + origin: window.location.origin + }; + }, + target: { tabId: tab.id }, + world: "MAIN" + }); + const result = frameResult?.result; + if (!result || !allowedLocalStorageOrigin(result.origin, origin)) { + return undefined; + } + return result; + } finally { + if (tab.id !== undefined) { + await chrome.tabs.remove(tab.id).catch(() => undefined); + } + } +} + +function waitForTabLoad(tabId, timeoutMs) { + return new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) { + return; + } + done = true; + chrome.tabs.onUpdated.removeListener(listener); + clearTimeout(timeout); + resolve(); + }; + const listener = (updatedTabId, changeInfo) => { + if (updatedTabId === tabId && changeInfo.status === "complete") { + finish(); + } + }; + const timeout = setTimeout(finish, timeoutMs); + chrome.tabs.onUpdated.addListener(listener); + }); +} + +function dedupeCookies(cookies) { + const seen = new Set(); + const result = []; + for (const cookie of cookies) { + const partitionKey = cookie.partitionKey ? JSON.stringify(cookie.partitionKey) : ""; + const key = `${cookie.storeId || ""}\n${cookie.domain}\n${cookie.path}\n${cookie.name}\n${partitionKey}`; + if (!seen.has(key)) { + seen.add(key); + result.push(cookie); + } + } + return result; +} + +function localStorageOriginsForDomains(domains, cookies) { + const origins = new Set(); + for (const domain of domains) { + if (domain === "localhost" || isIpAddress(domain)) { + origins.add(`http://${domain}`); + origins.add(`https://${domain}`); + } else { + origins.add(`https://${domain}`); + } + } + + for (const cookie of cookies) { + const host = normalizeDomain(cookie.domain); + if (!host || !cookie.hostOnly || !domains.some((domain) => host === domain || host.endsWith(`.${domain}`))) { + continue; + } + origins.add(`${cookie.secure === false ? "http" : "https"}://${host}`); + } + + return [...origins]; +} + +function originsForDomains(domains) { + return [...new Set(domains.flatMap((domain) => { + if (domain === "localhost" || isIpAddress(domain)) { + return [ + `http://${domain}/*`, + `https://${domain}/*` + ]; + } + return [ + `http://${domain}/*`, + `https://${domain}/*`, + `http://*.${domain}/*`, + `https://*.${domain}/*` + ]; + }))]; +} + +async function ensureHostPermissions(domains) { + const origins = originsForDomains(domains); + const granted = await chrome.permissions.contains({ origins }); + if (granted) { + return; + } + throw new Error( + [ + `CCR Login Import does not have Chrome site access for ${domains.join(", ")}.`, + "Reload the unpacked extension after updating it, then grant the extension site access for the requested domains in Chrome extensions settings." + ].join(" ") + ); +} + +function normalizeImportUrl(value) { + const raw = typeof value === "string" ? value.trim() : ""; + if (!raw) { + return ""; + } + try { + const url = new URL(raw); + if (!["127.0.0.1", "localhost"].includes(url.hostname)) { + return ""; + } + if (!/^\/chrome-import\/jobs\/[^/]+\/?$/.test(url.pathname)) { + return ""; + } + url.pathname = url.pathname.replace(/\/+$/, ""); + return url.toString(); + } catch { + return ""; + } +} + +function normalizeDomain(value) { + return typeof value === "string" + ? value.trim().replace(/^\*\./, "").replace(/^\./, "").toLowerCase() + : ""; +} + +function allowedLocalStorageOrigin(actualOrigin, requestedOrigin) { + try { + const actual = new URL(actualOrigin); + const requested = new URL(requestedOrigin); + return actual.protocol === requested.protocol && actual.hostname === requested.hostname && actual.port === requested.port; + } catch { + return false; + } +} + +function isIpAddress(value) { + return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value) || value.includes(":"); +} + +function formatError(error) { + return error instanceof Error ? error.message : String(error); +} diff --git a/extension/chrome/confirm.js b/extension/chrome/confirm.js new file mode 100644 index 0000000..56a6aa1 --- /dev/null +++ b/extension/chrome/confirm.js @@ -0,0 +1,46 @@ +const root = document.getElementById("ccr-chrome-login-import"); +const button = document.getElementById("ccr-confirm-import"); +const statusElement = document.getElementById("ccr-import-status"); + +if (root && button && statusElement) { + const importUrl = root.getAttribute("data-import-url") || ""; + button.disabled = false; + button.textContent = "Confirm and Import"; + setStatus("CCR Login Import extension is connected. Review the domains, then confirm."); + + button.addEventListener("click", () => { + void confirmImport(importUrl); + }); +} + +async function confirmImport(importUrl) { + button.disabled = true; + setStatus("Importing Chrome cookies and localStorage into CCR..."); + try { + const response = await chrome.runtime.sendMessage({ + importUrl, + type: "ccr-login-import-confirm" + }); + if (!response?.ok) { + throw new Error(response?.error || "Chrome login import failed."); + } + const result = response.result || {}; + setStatus( + `Imported ${result.cookieImported || 0} cookies and ${result.localStorageImported || 0} localStorage items. Skipped ${result.skipped || 0}.`, + "ok" + ); + button.textContent = "Imported"; + } catch (error) { + button.disabled = false; + setStatus(formatError(error), "error"); + } +} + +function setStatus(message, kind = "") { + statusElement.textContent = message; + statusElement.className = `status ${kind}`.trim(); +} + +function formatError(error) { + return error instanceof Error ? error.message : String(error); +} diff --git a/extension/chrome/manifest.json b/extension/chrome/manifest.json new file mode 100644 index 0000000..262869a --- /dev/null +++ b/extension/chrome/manifest.json @@ -0,0 +1,35 @@ +{ + "manifest_version": 3, + "name": "CCR Login Import", + "description": "Import selected Chrome site login cookies into CCR's in-app browser.", + "version": "0.1.0", + "action": { + "default_popup": "popup.html", + "default_title": "CCR Login Import" + }, + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "js": [ + "confirm.js" + ], + "matches": [ + "http://127.0.0.1/*", + "http://localhost/*" + ] + } + ], + "permissions": [ + "cookies", + "scripting", + "storage" + ], + "host_permissions": [ + "http://127.0.0.1/*", + "http://localhost/*", + "http://*/*", + "https://*/*" + ] +} diff --git a/extension/chrome/popup.html b/extension/chrome/popup.html new file mode 100644 index 0000000..391063b --- /dev/null +++ b/extension/chrome/popup.html @@ -0,0 +1,110 @@ + + + + + + CCR Login Import + + + +
      +

      CCR Login Import

      + + +
      +
      + + + diff --git a/extension/chrome/popup.js b/extension/chrome/popup.js new file mode 100644 index 0000000..4b12be1 --- /dev/null +++ b/extension/chrome/popup.js @@ -0,0 +1,297 @@ +const importUrlInput = document.getElementById("import-url"); +const importButton = document.getElementById("import-button"); +const statusElement = document.getElementById("status"); + +document.addEventListener("DOMContentLoaded", () => { + void restoreLastImportUrl(); +}); + +importButton.addEventListener("click", () => { + void runImport(); +}); + +async function restoreLastImportUrl() { + const stored = await chrome.storage.local.get(["lastImportUrl"]); + if (typeof stored.lastImportUrl === "string") { + importUrlInput.value = stored.lastImportUrl; + } +} + +async function runImport() { + const importUrl = normalizeImportUrl(importUrlInput.value); + if (!importUrl) { + setStatus("Paste the import URL copied from CCR.", "error"); + return; + } + + importButton.disabled = true; + try { + await chrome.storage.local.set({ lastImportUrl: importUrl }); + setStatus("Reading CCR import job..."); + const job = await fetchImportJob(importUrl); + const domains = Array.isArray(job.domains) ? job.domains.map(normalizeDomain).filter(Boolean) : []; + if (domains.length === 0) { + throw new Error("CCR import job does not include any domains."); + } + + await ensureHostPermissions(domains); + + setStatus(`Reading cookies for ${domains.join(", ")}...`); + const cookies = await readCookiesForDomains(domains); + setStatus(`Reading localStorage for ${domains.join(", ")}...`); + const localStorageEntries = await readLocalStorageForDomains(domains, cookies); + + if (cookies.length === 0 && localStorageEntries.length === 0) { + throw new Error("No cookies or localStorage entries were found for the selected domains."); + } + + setStatus(`Sending ${cookies.length} cookies and ${localStorageItemCount(localStorageEntries)} localStorage items to CCR...`); + const result = await submitCookies(importUrl, cookies, localStorageEntries, domains); + setStatus( + `Imported ${result.cookieImported ?? 0} cookies and ${result.localStorageImported ?? 0} localStorage items. Skipped ${result.skipped ?? 0}.`, + "ok" + ); + } catch (error) { + setStatus(formatError(error), "error"); + } finally { + importButton.disabled = false; + } +} + +async function fetchImportJob(importUrl) { + const response = await fetch(importUrl, { + headers: { + "x-ccr-login-import": "chrome-extension" + } + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body?.error?.message || `CCR import job request failed (${response.status}).`); + } + if (!body.job || body.job.status !== "pending") { + throw new Error(`CCR import job is ${body.job?.status || "unavailable"}.`); + } + return body.job; +} + +async function submitCookies(importUrl, cookies, localStorage, domains) { + const response = await fetch(`${importUrl.replace(/\/+$/, "")}/cookies`, { + body: JSON.stringify({ + cookies, + domains, + localStorage, + source: { + browser: "chrome", + extension: chrome.runtime.getManifest().version + } + }), + headers: { + "content-type": "application/json", + "x-ccr-login-import": "chrome-extension" + }, + method: "POST" + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body?.error?.message || `CCR cookie import failed (${response.status}).`); + } + return body.result || {}; +} + +async function readCookiesForDomains(domains) { + const cookies = []; + for (const domain of domains) { + cookies.push(...await chrome.cookies.getAll({ domain })); + } + return dedupeCookies(cookies); +} + +async function readLocalStorageForDomains(domains, cookies) { + const origins = localStorageOriginsForDomains(domains, cookies); + const entries = []; + for (const origin of origins) { + const entry = await readLocalStorageForOrigin(origin).catch(() => undefined); + if (entry && Object.keys(entry.items).length > 0) { + entries.push(entry); + } + } + return entries; +} + +async function readLocalStorageForOrigin(origin) { + const tab = await chrome.tabs.create({ + active: false, + url: `${origin}/` + }); + try { + await waitForTabLoad(tab.id, 15000); + const [frameResult] = await chrome.scripting.executeScript({ + func: () => { + const items = {}; + for (let index = 0; index < window.localStorage.length; index += 1) { + const key = window.localStorage.key(index); + if (key !== null) { + items[key] = window.localStorage.getItem(key) || ""; + } + } + return { + items, + origin: window.location.origin + }; + }, + target: { tabId: tab.id }, + world: "MAIN" + }); + const result = frameResult?.result; + if (!result || !allowedLocalStorageOrigin(result.origin, origin)) { + return undefined; + } + return result; + } finally { + if (tab.id !== undefined) { + await chrome.tabs.remove(tab.id).catch(() => undefined); + } + } +} + +function waitForTabLoad(tabId, timeoutMs) { + return new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) { + return; + } + done = true; + chrome.tabs.onUpdated.removeListener(listener); + window.clearTimeout(timeout); + resolve(); + }; + const listener = (updatedTabId, changeInfo) => { + if (updatedTabId === tabId && changeInfo.status === "complete") { + finish(); + } + }; + const timeout = window.setTimeout(finish, timeoutMs); + chrome.tabs.onUpdated.addListener(listener); + }); +} + +function dedupeCookies(cookies) { + const seen = new Set(); + const result = []; + for (const cookie of cookies) { + const partitionKey = cookie.partitionKey ? JSON.stringify(cookie.partitionKey) : ""; + const key = `${cookie.storeId || ""}\n${cookie.domain}\n${cookie.path}\n${cookie.name}\n${partitionKey}`; + if (!seen.has(key)) { + seen.add(key); + result.push(cookie); + } + } + return result; +} + +function localStorageOriginsForDomains(domains, cookies) { + const origins = new Set(); + for (const domain of domains) { + if (domain === "localhost" || isIpAddress(domain)) { + origins.add(`http://${domain}`); + origins.add(`https://${domain}`); + } else { + origins.add(`https://${domain}`); + } + } + + for (const cookie of cookies) { + const host = normalizeDomain(cookie.domain); + if (!host || !cookie.hostOnly || !domains.some((domain) => host === domain || host.endsWith(`.${domain}`))) { + continue; + } + origins.add(`${cookie.secure === false ? "http" : "https"}://${host}`); + } + + return [...origins]; +} + +function allowedLocalStorageOrigin(actualOrigin, requestedOrigin) { + try { + const actual = new URL(actualOrigin); + const requested = new URL(requestedOrigin); + return actual.protocol === requested.protocol && actual.hostname === requested.hostname && actual.port === requested.port; + } catch { + return false; + } +} + +function localStorageItemCount(entries) { + return entries.reduce((count, entry) => count + Object.keys(entry.items || {}).length, 0); +} + +function originsForDomains(domains) { + return [...new Set(domains.flatMap((domain) => { + if (domain === "localhost" || isIpAddress(domain)) { + return [ + `http://${domain}/*`, + `https://${domain}/*` + ]; + } + return [ + `http://${domain}/*`, + `https://${domain}/*`, + `http://*.${domain}/*`, + `https://*.${domain}/*` + ]; + }))]; +} + +async function ensureHostPermissions(domains) { + const origins = originsForDomains(domains); + const granted = await chrome.permissions.contains({ origins }); + if (granted) { + return; + } + throw new Error( + [ + `CCR Login Import does not have Chrome site access for ${domains.join(", ")}.`, + "Reload the unpacked extension after updating it, then grant the extension site access for the requested domains in Chrome extensions settings." + ].join(" ") + ); +} + +function normalizeImportUrl(value) { + const raw = value.trim(); + if (!raw) { + return ""; + } + try { + const url = new URL(raw); + if (!["127.0.0.1", "localhost"].includes(url.hostname)) { + return ""; + } + if (!/^\/chrome-import\/jobs\/[^/]+\/?$/.test(url.pathname)) { + return ""; + } + url.pathname = url.pathname.replace(/\/+$/, ""); + return url.toString(); + } catch { + return ""; + } +} + +function normalizeDomain(value) { + return typeof value === "string" + ? value.trim().replace(/^\*\./, "").replace(/^\./, "").toLowerCase() + : ""; +} + +function isIpAddress(value) { + return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value) || value.includes(":"); +} + +function setStatus(message, kind = "") { + statusElement.textContent = message; + statusElement.className = `status ${kind}`.trim(); +} + +function formatError(error) { + return error instanceof Error ? error.message : String(error); +} diff --git a/package-lock.json b/package-lock.json index f6ab110..6bd7bb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,27 +1,30 @@ { - "name": "claude-code-router", - "version": "3.0.1", + "name": "claude-code-router-monorepo", + "version": "3.0.10", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "claude-code-router", - "version": "3.0.1", + "name": "claude-code-router-monorepo", + "version": "3.0.10", + "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { - "@the-next-ai/ai-gateway": "^1.0.1", + "@the-next-ai/ai-gateway": "^1.0.6", "@the-next-ai/bot-gateway-sdk": "^0.1.0", "better-sqlite3": "^12.11.1", "electron-updater": "^6.8.9", "node-forge": "^1.4.0", + "openai": "^6.27.0", "undici": "^7.27.2" }, - "bin": { - "ccr": "dist/main/cli.js" - }, "devDependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@playwright/test": "^1.61.1", "@tailwindcss/cli": "^4.3.0", "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.10.2", @@ -43,30 +46,42 @@ "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.0", "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22" } }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, + "node_modules/@claude-code-router/core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/@claude-code-router/electron": { + "resolved": "packages/electron", + "link": true + }, + "node_modules/@claude-code-router/ui": { + "resolved": "packages/ui", + "link": true + }, "node_modules/@date-io/core": { "version": "2.17.0", "resolved": "https://registry.npmjs.org/@date-io/core/-/core-2.17.0.tgz", "integrity": "sha512-+EQE8xZhRM/hsY0CDTVyayMDDY5ihc4MqXCrPxooKw19yAzUIC6uUqsZeaOFNL9YKTNxYKrJP5DFgE8o5xRCOw==", - "dev": true, "license": "MIT" }, "node_modules/@date-io/date-fns": { "version": "2.17.0", "resolved": "https://registry.npmjs.org/@date-io/date-fns/-/date-fns-2.17.0.tgz", "integrity": "sha512-L0hWZ/mTpy3Gx/xXJ5tq5CzHo0L7ry6KEO9/w/JWiFWFLZgiNVo3ex92gOl3zmzjHqY/3Ev+5sehAr8UnGLEng==", - "dev": true, "license": "MIT", "dependencies": { "@date-io/core": "^2.17.0" @@ -84,7 +99,6 @@ "version": "2.17.0", "resolved": "https://registry.npmjs.org/@date-io/moment/-/moment-2.17.0.tgz", "integrity": "sha512-e4nb4CDZU4k0WRVhz1Wvl7d+hFsedObSauDHKtZwU9kt7gdYEAzKgnrSCTHsEaXrDumdrkCYTeZ0Tmyk7uV4tw==", - "dev": true, "license": "MIT", "dependencies": { "@date-io/core": "^2.17.0" @@ -102,7 +116,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.0.0" @@ -115,7 +128,6 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", - "dev": true, "license": "MIT", "dependencies": { "@dnd-kit/accessibility": "^3.1.1", @@ -131,7 +143,6 @@ "version": "10.0.0", "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", - "dev": true, "license": "MIT", "dependencies": { "@dnd-kit/utilities": "^3.2.2", @@ -146,7 +157,6 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.0.0" @@ -1166,7 +1176,6 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", - "dev": true, "license": "ISC", "dependencies": { "get-stream": "^6.0.1", @@ -1180,14 +1189,12 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@mapbox/geojson-types/-/geojson-types-1.0.2.tgz", "integrity": "sha512-e9EBqHHv3EORHrSfbR9DqecPNn+AmuAoQxV6aL8Xu30bJMJR1o8PZLZzpk1Wq7/NfCbuhmakHTPYRhoqLsXRnw==", - "dev": true, "license": "ISC" }, "node_modules/@mapbox/jsonlint-lines-primitives": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -1196,7 +1203,6 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-1.5.0.tgz", "integrity": "sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg==", - "dev": true, "license": "BSD-3-Clause", "peerDependencies": { "mapbox-gl": ">=0.32.1 <2.0.0" @@ -1206,28 +1212,24 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", - "dev": true, "license": "ISC" }, "node_modules/@mapbox/tiny-sdf": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-1.2.5.tgz", "integrity": "sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==", - "dev": true, "license": "BSD-2-Clause" }, "node_modules/@mapbox/unitbezier": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==", - "dev": true, "license": "BSD-2-Clause" }, "node_modules/@mapbox/vector-tile": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "@mapbox/point-geometry": "~0.1.0" @@ -1237,7 +1239,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", - "dev": true, "license": "ISC", "engines": { "node": ">=6.0.0" @@ -1247,13 +1248,16 @@ "version": "3.6.3", "resolved": "https://registry.npmjs.org/@math.gl/web-mercator/-/web-mercator-3.6.3.tgz", "integrity": "sha512-UVrkSOs02YLehKaehrxhAejYMurehIHPfFQvPFZmdJHglHOU4V2cCUApTVEwOksvCp161ypEqVp+9H6mGhTTcw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.0", "gl-matrix": "^3.4.0" } }, + "node_modules/@musistudio/claude-code-router": { + "resolved": "packages/cli", + "link": true + }, "node_modules/@noble/hashes": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", @@ -1634,11 +1638,259 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@pm2/agent": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@pm2/agent/-/agent-2.1.1.tgz", + "integrity": "sha512-0V9ckHWd/HSC8BgAbZSoq8KXUG81X97nSkAxmhKDhmF8vanyaoc1YXwc2KVkbWz82Rg4gjd2n9qiT3i7bdvGrQ==", + "license": "AGPL-3.0", + "dependencies": { + "async": "~3.2.0", + "chalk": "~3.0.0", + "dayjs": "~1.8.24", + "debug": "~4.3.1", + "eventemitter2": "~5.0.1", + "fast-json-patch": "^3.1.0", + "fclone": "~1.0.11", + "pm2-axon": "~4.0.1", + "pm2-axon-rpc": "~0.7.0", + "proxy-agent": "~6.4.0", + "semver": "~7.5.0", + "ws": "~7.5.10" + } + }, + "node_modules/@pm2/agent/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@pm2/agent/node_modules/dayjs": { + "version": "1.8.36", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.8.36.tgz", + "integrity": "sha512-3VmRXEtw7RZKAf+4Tv1Ym9AGeo8r8+CjDi26x+7SYQil1UqtqdaokhzoEJohqlzt0m5kacJSDhJQkG/LWhpRBw==", + "license": "MIT" + }, + "node_modules/@pm2/agent/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@pm2/agent/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@pm2/agent/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@pm2/blessed": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@pm2/blessed/-/blessed-0.1.81.tgz", + "integrity": "sha512-ZcNHqQjMuNRcQ7Z1zJbFIQZO/BDKV3KbiTckWdfbUaYhj7uNmUwb+FbdDWSCkvxNr9dBJQwvV17o6QBkAvgO0g==", + "license": "MIT", + "bin": { + "blessed": "bin/tput.js" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@pm2/io": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@pm2/io/-/io-6.1.0.tgz", + "integrity": "sha512-IxHuYURa3+FQ6BKePlgChZkqABUKFYH6Bwbw7V/pWU1pP6iR1sCI26l7P9ThUEB385ruZn/tZS3CXDUF5IA1NQ==", + "license": "Apache-2", + "dependencies": { + "async": "~2.6.1", + "debug": "~4.3.1", + "eventemitter2": "^6.3.1", + "require-in-the-middle": "^5.0.0", + "semver": "~7.5.4", + "shimmer": "^1.2.0", + "signal-exit": "^3.0.3", + "tslib": "1.9.3" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/@pm2/io/node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.14" + } + }, + "node_modules/@pm2/io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@pm2/io/node_modules/eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", + "license": "MIT" + }, + "node_modules/@pm2/io/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@pm2/io/node_modules/tslib": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz", + "integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==", + "license": "Apache-2.0" + }, + "node_modules/@pm2/js-api": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@pm2/js-api/-/js-api-0.8.1.tgz", + "integrity": "sha512-n9tDOz1ojyDOs05XthEXrLFVQYbbh2oAN19UakLPyEZDrUyEq05h8wIZU8+dNXBQY/KeFlWMLVA76nnX52ofRg==", + "license": "Apache-2", + "dependencies": { + "async": "^2.6.3", + "debug": "~4.3.1", + "eventemitter2": "^6.3.1", + "extrareqp2": "^1.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@pm2/js-api/node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.14" + } + }, + "node_modules/@pm2/js-api/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@pm2/js-api/node_modules/eventemitter2": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", + "license": "MIT" + }, + "node_modules/@pm2/pm2-version-check": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@pm2/pm2-version-check/-/pm2-version-check-1.0.4.tgz", + "integrity": "sha512-SXsM27SGH3yTWKc2fKR4SYNxsmnvuBQ9dd6QHtEWmiZ/VqaOYPAIlS8+vMcn27YLtAEBGvNRSh3TPNvtjZgfqA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", - "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -1665,7 +1917,6 @@ "version": "11.1.8", "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", - "dev": true, "license": "MIT", "funding": { "type": "opencollective", @@ -1676,7 +1927,6 @@ "version": "2.6.5-forked.0", "resolved": "https://registry.npmjs.org/@rtsao/csstype/-/csstype-2.6.5-forked.0.tgz", "integrity": "sha512-0HwnY8uPWcCloTgdbbaJG3MbDUfNf6yKWZfCKxFv9yj2Sbp4mSKaIjC7Cr/5L4hMxvrrk85CU3wlAg7EtBBJ1Q==", - "dev": true, "license": "MIT" }, "node_modules/@sindresorhus/is": { @@ -1696,14 +1946,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, "license": "MIT" }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "dev": true, "license": "MIT" }, "node_modules/@szmarczak/http-timer": { @@ -2062,9 +2310,9 @@ } }, "node_modules/@the-next-ai/ai-gateway": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.1.tgz", - "integrity": "sha512-o752COZV+AV1NiaO9TO7myr3SoeIHL5Cb9ZpPi74vPm1Eo3gP0oSxOzd48WbTQFN5ZP0YPmsMCzEBjqkyTNa9g==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.6.tgz", + "integrity": "sha512-EIJjwvc//hql1jwE94yyMWNGrJLuCJQlTNgn8p3KWhDYSiOWp4vmLU2Gw3/A+J49X21V7HYdX/W9YuJUai80eQ==", "license": "MIT", "dependencies": { "diff": "^8.0.3", @@ -2092,6 +2340,12 @@ "node": ">=22.0.0" } }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -2119,28 +2373,24 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-color": "*" @@ -2150,14 +2400,12 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-time": "*" @@ -2167,7 +2415,6 @@ "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-path": "*" @@ -2177,14 +2424,12 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-timer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "dev": true, "license": "MIT" }, "node_modules/@types/debug": { @@ -2211,7 +2456,6 @@ "version": "2.0.46", "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", - "dev": true, "license": "MIT" }, "node_modules/@types/http-cache-semantics": { @@ -2262,14 +2506,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -2300,7 +2544,6 @@ "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "dev": true, "license": "MIT" }, "node_modules/@xmldom/xmldom": { @@ -2333,7 +2576,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -2372,6 +2614,30 @@ } } }, + "node_modules/amp": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/amp/-/amp-0.3.1.tgz", + "integrity": "sha512-OwIuC4yZaRogHKiuU5WlMR5Xk/jAcpPtawWL05Gj8Lvm2F6mwoJt4O/bHI+DHwG79vWd+8OFYM4/BzYqyRd3qw==", + "license": "MIT" + }, + "node_modules/amp-message": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/amp-message/-/amp-message-0.1.2.tgz", + "integrity": "sha512-JqutcFwoU1+jhv7ArgW38bqrE+LQdcRv4NxNw0mp0JHQyB6tXesWRjtYKlDgHRY2o3JE5UTaBGUK8kSWUdxWUg==", + "license": "MIT", + "dependencies": { + "amp": "0.3.1" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2386,7 +2652,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2398,6 +2663,28 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansis": { + "version": "4.0.0-node10", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.0.0-node10.tgz", + "integrity": "sha512-BRrU0Bo1X9dFGw6KgGz6hWrqQuOlVEDOzkb0QSLZY9sXHqA7pNj7yHPVJRz7y/rj4EOJ3d/D5uxH+ee9leYgsg==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/app-builder-lib": { "version": "26.15.3", "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", @@ -2582,11 +2869,22 @@ "node": ">=12.0.0" } }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "dev": true, "license": "MIT" }, "node_modules/async-exit-hook": { @@ -2629,7 +2927,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-1.1.3.tgz", "integrity": "sha512-iT40nudw8zmCweivz6j58g+RT33I4KbaIvRUhjNmDwO2WmsQUxFEZZYZ5w3vXe5x5MX9D7mfvA/XaLOZYFR9EQ==", - "dev": true, "license": "MIT", "dependencies": { "core-js": "^2.5.0" @@ -2669,7 +2966,6 @@ "version": "4.12.1", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", - "dev": true, "license": "MPL-2.0", "engines": { "node": ">=4" @@ -2708,7 +3004,6 @@ "version": "16.1.1", "resolved": "https://registry.npmjs.org/baseui/-/baseui-16.1.1.tgz", "integrity": "sha512-pux441GCtmYrI8NE1SYqwsDnBad47msc7T/6p9JhHYrqYKo2y52YAO+7oSoFqIdaJxoY9r443U14hDkF68p56w==", - "dev": true, "license": "MIT", "dependencies": { "@date-io/date-fns": "^2.13.1", @@ -2755,21 +3050,18 @@ "version": "2.6.11", "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.11.tgz", "integrity": "sha512-l8YyEC9NBkSm783PFTvh0FmJy7s5pFKrDp49ZL7zBGX3fWkO+N4EEyan1qqp8cwPLDcD0OSdyY6hAMoxp34JFw==", - "dev": true, "license": "MIT" }, "node_modules/baseui/node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, "license": "MIT" }, "node_modules/baseui/node_modules/react-uid": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/react-uid/-/react-uid-2.3.0.tgz", "integrity": "sha512-tsPZ77GR0pISGYmpCLHAbZTabKXZ7zBniKPVqVMMfnXFyo39zq5g/psIlD5vLTKkjQEhWOO8JhqcHnxkwNu6eA==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^1.10.0" @@ -2791,7 +3083,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz", "integrity": "sha512-MYXhTY1BZpdJFjUovvYHVBmkq79szK/k7V3MO+36gJkWGkrXKtyr4vCPtpphaTLRAdDNoYEYFZWE8LjN+PIHNg==", - "dev": true, "license": "MIT", "engines": { "node": ">8.0.0" @@ -2805,7 +3096,6 @@ "version": "1.8.5", "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.5.tgz", "integrity": "sha512-HeTwlNa37AFa8MDZFZOKcNEkuF2YflA0hpGPiTT9vR7OawEt+GZbfM6wqkBahD3D3pUjIabQYzsnY/BSJbgq6Q==", - "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.0.0", @@ -2823,9 +3113,17 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, "license": "0BSD" }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/better-sqlite3": { "version": "12.11.1", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", @@ -2840,6 +3138,18 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -2881,6 +3191,12 @@ "dev": true, "license": "MIT" }, + "node_modules/bodec": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bodec/-/bodec-0.1.0.tgz", + "integrity": "sha512-Ylo+MAo5BDUq1KA3f3R/MFhh+g8cnHmo8bz3YPGhI1znrMaf77ol1sfvYJzsw3nTE+Y2GryfDxBaR+AqpAkEHQ==", + "license": "MIT" + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -2906,7 +3222,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -2943,7 +3258,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, "node_modules/builder-util": { @@ -3058,7 +3372,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/card-validator/-/card-validator-6.2.0.tgz", "integrity": "sha512-1vYv45JaE9NmixZr4dl6ykzbFKv7imI9L7MQDs235b/a7EGbG8rrEsipeHtVvscLSUbl3RX6UP5gyOe0Y2FlHA==", - "dev": true, "license": "MIT", "dependencies": { "credit-card-type": "^8.0.0" @@ -3081,6 +3394,36 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/charm": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/charm/-/charm-0.1.2.tgz", + "integrity": "sha512-syedaZ9cPe7r3hoQA9twWYKu5AIyCswN5+szkmPBe9ccdLrj4bYaCnLVPTLd2kgVRc7+zoX4tyPgRnFKCj5YjQ==", + "license": "MIT/X11" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -3114,6 +3457,30 @@ "node": ">=8" } }, + "node_modules/cli-tableau": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cli-tableau/-/cli-tableau-2.0.1.tgz", + "integrity": "sha512-he+WTicka9cl0Fg/y+YyxcN6/bfQ/1O3QmgxRXDhABKqLzvoOSM4fMzp39uMyLBulAFuywD2N7UaoQE7WaADxQ==", + "dependencies": { + "chalk": "3.0.0" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/cli-tableau/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3146,7 +3513,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3156,7 +3522,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3169,7 +3534,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -3189,7 +3553,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10" @@ -3230,7 +3593,6 @@ "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", - "dev": true, "hasInstallScript": true, "license": "MIT" }, @@ -3245,7 +3607,12 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/credit-card-type/-/credit-card-type-8.3.0.tgz", "integrity": "sha512-czfZUpQ7W9CDxZL4yFLb1kFtM/q2lTOY975hL2aO+DC8+GRNDVSXVCHXhVFZPxiUKmQCZbFP8vIhxx5TBQaThw==", - "dev": true, + "license": "MIT" + }, + "node_modules/croner": { + "version": "4.1.97", + "resolved": "https://registry.npmjs.org/croner/-/croner-4.1.97.tgz", + "integrity": "sha512-/f6gpQuxDaqXu+1kwQYSckUglPaOrHdbIlBAu0YuW8/Cdb45XwXYNUBXg3r/9Mo6n540Kn/smKcZWko5x99KrQ==", "license": "MIT" }, "node_modules/cross-dirname": { @@ -3299,7 +3666,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-2.0.1.tgz", "integrity": "sha512-PJF0SpJT+WdbVVt0AOYp9C8GnuruRlL/UFW7932nLWmFLQTaWEzTBQEx7/hn4BuV+WON75iAViSUJLiU3PKbpA==", - "dev": true, "license": "MIT", "dependencies": { "hyphenate-style-name": "^1.0.2", @@ -3310,21 +3676,24 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", - "dev": true, "license": "MIT" }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "license": "MIT" + }, + "node_modules/culvert": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/culvert/-/culvert-0.1.2.tgz", + "integrity": "sha512-yi1x3EAWKjQTreYWeSd98431AV+IEE0qoDyOoaHJ7KJ21gv6HtBXHVLX74opVSGqcR8/AbjJBHAHpcOy2bj5Gg==", "license": "MIT" }, "node_modules/d3": { "version": "7.9.0", "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", - "dev": true, "license": "ISC", "dependencies": { "d3-array": "3", @@ -3366,7 +3735,6 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "dev": true, "license": "ISC", "dependencies": { "internmap": "1 - 2" @@ -3379,7 +3747,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -3389,7 +3756,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", - "dev": true, "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", @@ -3406,7 +3772,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", - "dev": true, "license": "ISC", "dependencies": { "d3-path": "1 - 3" @@ -3419,7 +3784,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -3429,7 +3793,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", - "dev": true, "license": "ISC", "dependencies": { "d3-array": "^3.2.0" @@ -3442,7 +3805,6 @@ "version": "6.0.4", "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", - "dev": true, "license": "ISC", "dependencies": { "delaunator": "5" @@ -3455,7 +3817,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -3465,7 +3826,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "dev": true, "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", @@ -3479,7 +3839,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", - "dev": true, "license": "ISC", "dependencies": { "commander": "7", @@ -3505,7 +3864,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=12" @@ -3515,7 +3873,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", - "dev": true, "license": "ISC", "dependencies": { "d3-dsv": "1 - 3" @@ -3528,7 +3885,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", - "dev": true, "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", @@ -3543,7 +3899,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -3553,7 +3908,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", - "dev": true, "license": "ISC", "dependencies": { "d3-array": "2.5.0 - 3" @@ -3566,7 +3920,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -3576,7 +3929,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "dev": true, "license": "ISC", "dependencies": { "d3-color": "1 - 3" @@ -3589,7 +3941,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -3599,7 +3950,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -3609,7 +3959,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -3619,7 +3968,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -3629,7 +3977,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "dev": true, "license": "ISC", "dependencies": { "d3-array": "2.10.0 - 3", @@ -3646,7 +3993,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", - "dev": true, "license": "ISC", "dependencies": { "d3-color": "1 - 3", @@ -3660,7 +4006,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -3670,7 +4015,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "dev": true, "license": "ISC", "dependencies": { "d3-path": "^3.1.0" @@ -3683,7 +4027,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "dev": true, "license": "ISC", "dependencies": { "d3-array": "2 - 3" @@ -3696,7 +4039,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "dev": true, "license": "ISC", "dependencies": { "d3-time": "1 - 3" @@ -3709,7 +4051,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -3719,7 +4060,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "dev": true, "license": "ISC", "dependencies": { "d3-color": "1 - 3", @@ -3739,7 +4079,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "dev": true, "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", @@ -3752,11 +4091,19 @@ "node": ">=12" } }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.21.0" @@ -3773,12 +4120,17 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-1.3.8.tgz", "integrity": "sha512-qwNXUFtMHTTU6CFSFjoJ80W8Fzzp24LntbjFFBgL/faqds4e5mo9mftoRLgr3Vi1trISsg4awSpYVsOQCRnapQ==", - "dev": true, "license": "MIT", "peerDependencies": { "date-fns": ">=2.0.0" } }, + "node_modules/dayjs": { + "version": "1.11.15", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.15.tgz", + "integrity": "sha512-MC+DfnSWiM9APs7fpiurHGCoeIx0Gdl6QZBy+5lu8MbYKN5FZEXqOgrundfibdfhGZ15o9hzmZ2xJjZnbvgKXQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3800,7 +4152,6 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", - "dev": true, "license": "MIT" }, "node_modules/decompress-response": { @@ -3887,11 +4238,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/delaunator": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", - "dev": true, "license": "ISC", "dependencies": { "robust-predicates": "^3.0.2" @@ -3941,7 +4305,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "dev": true, "license": "MIT" }, "node_modules/diff": { @@ -4012,7 +4375,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.7", @@ -4077,7 +4439,6 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", - "dev": true, "license": "ISC" }, "node_modules/ejs": { @@ -4307,6 +4668,18 @@ "node": ">=10.13.0" } }, + "node_modules/enquirer": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", + "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -4341,7 +4714,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4380,7 +4752,6 @@ "version": "1.47.1", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz", "integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==", - "dev": true, "license": "MIT", "workspaces": [ "docs", @@ -4451,9 +4822,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=10" }, @@ -4461,11 +4830,68 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter2": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-5.0.1.tgz", + "integrity": "sha512-5EM1GHXycJBS6mauYAbVKT1cVs7POKWb2NXD4Vyt8dDqeZa7LaDK1/sjtL+Zb0lzTpSNil4596Dyu97hz37QLg==", + "license": "MIT" + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, "license": "MIT" }, "node_modules/expand-template": { @@ -4484,6 +4910,15 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/extrareqp2": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/extrareqp2/-/extrareqp2-1.0.0.tgz", + "integrity": "sha512-Gum0g1QYb6wpPJCVypWP3bbIuaibcFiJcpuPM10YSXp/tzqi84x9PJageob+eN4xVRIOto4wjSGNLyMD54D2xA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -4496,6 +4931,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-json-patch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==", + "license": "MIT" + }, "node_modules/fast-json-stringify": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.4.0.tgz", @@ -4587,11 +5028,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fclone": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/fclone/-/fclone-1.0.11.tgz", + "integrity": "sha512-GDqVQezKzRABdeqflsgMr7ktzgF9CyS+p2oe0jJqUY6izSSbhPIQJDpoU4PtGcD7VPM9xh/dVrTu6z1nwgmEGw==", + "license": "MIT" + }, "node_modules/file-selector": { "version": "0.1.19", "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.1.19.tgz", "integrity": "sha512-kCWw3+Aai8Uox+5tHCNgMFaUdgidxvMnLWO6fM5sZ0hA2wlHP5/DHGF0ECe84BiB95qdJbKNEJhWKVDvMN+JDQ==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.0.1" @@ -4650,7 +5096,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -4677,7 +5122,6 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/focus-lock/-/focus-lock-1.3.6.tgz", "integrity": "sha512-Ik/6OCk9RQQ0T5Xw+hKNLWrjSMtv51dD4GRmJjbD5a58TIEpI5a5iXagKVl3Z5UuyslMCA8Xwnu76jQob62Yhg==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.0.3" @@ -4686,6 +5130,26 @@ "node": ">=10" } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -4707,7 +5171,6 @@ "version": "12.40.0", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz", "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==", - "dev": true, "license": "MIT", "dependencies": { "motion-dom": "^12.40.0", @@ -4758,11 +5221,24 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4772,7 +5248,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==", - "dev": true, "license": "ISC" }, "node_modules/get-caller-file": { @@ -4828,7 +5303,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -4837,6 +5311,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/git-node-fs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/git-node-fs/-/git-node-fs-1.0.0.tgz", + "integrity": "sha512-bLQypt14llVXBg0S0u8q8HmU7g9p3ysH+NvVlae5vILuUvs759665HvmR5+wb04KjHyjFcDRxdYb4kyNnluMUQ==", + "license": "MIT" + }, + "node_modules/git-sha1": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/git-sha1/-/git-sha1-0.1.2.tgz", + "integrity": "sha512-2e/nZezdVlyCopOCYHeW0onkbZg7xP1Ad6pndPy1rCygeRykefUS6r7oA5cJRGEFvseiaz5a/qUHFVX1dd6Isg==", + "license": "MIT" + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -4847,7 +5347,6 @@ "version": "3.4.4", "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", - "dev": true, "license": "MIT" }, "node_modules/glob": { @@ -4867,6 +5366,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/global-agent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", @@ -4953,14 +5464,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", - "dev": true, "license": "ISC" }, "node_modules/hammerjs": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.0" @@ -4970,7 +5479,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5023,7 +5531,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5056,7 +5563,6 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -5084,7 +5590,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -5098,14 +5603,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -5138,7 +5641,6 @@ "version": "10.2.0", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", - "dev": true, "license": "MIT", "funding": { "type": "opencollective", @@ -5173,7 +5675,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-5.1.2.tgz", "integrity": "sha512-PYUF+94gDfhy+LsQxM0g3d6Hge4l1pAqOSOiZuHWzMvQEGsbRQ/ck2WioLqrY2ZkHyPgVUXxn+hrkF7D6QUGbA==", - "dev": true, "license": "MIT", "dependencies": { "css-in-js-utils": "^2.0.0" @@ -5183,7 +5684,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -5193,12 +5693,20 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.0.0" } }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", @@ -5208,11 +5716,37 @@ "node": ">= 10" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5232,7 +5766,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -5245,7 +5778,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -5285,7 +5817,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5319,11 +5850,22 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-git": { + "version": "0.7.8", + "resolved": "https://registry.npmjs.org/js-git/-/js-git-0.7.8.tgz", + "integrity": "sha512-+E5ZH/HeRnoc/LW0AmAyhU+mNcWBzAKE+30+IDMLSLbbK+Tdt02AdkOKq9u15rlJsDEGFqtgckc8ZM59LhhiUA==", + "license": "MIT", + "dependencies": { + "bodec": "^0.1.0", + "culvert": "^0.1.2", + "git-sha1": "^0.1.2", + "pako": "^0.2.5" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -5384,7 +5926,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, "license": "ISC", "optional": true }, @@ -5417,14 +5958,12 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.1.1.tgz", "integrity": "sha512-aWgeGFW67BP3e5181Ep1Fv2v8z//iBJfrvyTnq8wG86vEESwmonn1zPBJ0VfmT9CJq2FIT0VsETtrNFm2a+SHA==", - "dev": true, "license": "MIT" }, "node_modules/kdbush": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==", - "dev": true, "license": "ISC" }, "node_modules/keyv": { @@ -5755,7 +6294,6 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, "license": "MIT" }, "node_modules/lodash.escaperegexp": { @@ -5775,7 +6313,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -5798,7 +6335,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -5811,7 +6347,6 @@ "version": "1.20.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.20.0.tgz", "integrity": "sha512-jhXLeC/7m0/tjL1nzMdKk6x256zWA6AtbhTVreHOiKPoeX2d6MK4FbyIQPpVq0E6iPWBisyy1TW+pEge/uMEuQ==", - "dev": true, "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -5831,7 +6366,6 @@ "version": "1.13.3", "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.3.tgz", "integrity": "sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==", - "dev": true, "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@mapbox/geojson-rewind": "^0.5.2", @@ -5889,7 +6423,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.0.0.tgz", "integrity": "sha512-7g0+ejkOaI9w5x6LvQwmj68kUj6rxROywPSCqmclG/HBacmFnZqhVscQ8kovkn9FBCNJmOz6SY42+jnvZzDWdw==", - "dev": true, "license": "MIT" }, "node_modules/micromatch": { @@ -6002,7 +6535,6 @@ "version": "2.7.3", "resolved": "https://registry.npmjs.org/mjolnir.js/-/mjolnir.js-2.7.3.tgz", "integrity": "sha512-Z5z/+FzZqOSO3juSVKV3zcm4R2eAlWwlKMcqHmyFEJAaLILNcDKnIbnb4/kbcGyIuhtdWrzu8WOIR7uM6I34aw==", - "dev": true, "license": "MIT", "dependencies": { "@types/hammerjs": "^2.0.41", @@ -6037,14 +6569,18 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/mockdate/-/mockdate-2.0.5.tgz", "integrity": "sha512-ST0PnThzWKcgSLyc+ugLVql45PvESt3Ul/wrdV/OPc/6Pr8dbLAIJsN1cIp41FLzbN+srVTNIRn+5Cju0nyV6A==", - "dev": true, + "license": "MIT" + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", "license": "MIT" }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -6054,7 +6590,6 @@ "version": "12.40.0", "resolved": "https://registry.npmjs.org/motion/-/motion-12.40.0.tgz", "integrity": "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==", - "dev": true, "license": "MIT", "dependencies": { "framer-motion": "^12.40.0", @@ -6081,7 +6616,6 @@ "version": "12.40.0", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz", "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==", - "dev": true, "license": "MIT", "dependencies": { "motion-utils": "^12.39.0" @@ -6091,7 +6625,6 @@ "version": "12.39.0", "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", - "dev": true, "license": "MIT" }, "node_modules/mri": { @@ -6114,15 +6647,67 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", - "dev": true, "license": "MIT" }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "license": "ISC" + }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "license": "MIT" }, + "node_modules/needle": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/needle/-/needle-2.4.0.tgz", + "integrity": "sha512-4Hnwzr3mi5L97hMYeNl8wRW/Onhy4nUKR/lVemJ8gJedxxUyBLm9kkrDColJvoSfwi0jCNhD+xCdOtiGDQiRZg==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/needle/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/needle/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/node-abi": { "version": "4.31.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.31.0.tgz", @@ -6256,6 +6841,15 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/normalize-url": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", @@ -6273,7 +6867,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6352,6 +6945,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -6372,6 +7003,12 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, "node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", @@ -6401,7 +7038,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "ieee754": "^1.1.12", @@ -6437,7 +7073,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -6446,6 +7081,38 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidusage": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-3.0.2.tgz", + "integrity": "sha512-g0VU+y08pKw5M8EZ2rIGiEBaB8wrQMjYGFfW2QVIfyT8V+fq8YFLkvlz4bz5ljvFDJYNFCWT3PWqcRr2FKO81w==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pidusage/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/pino": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", @@ -6514,6 +7181,38 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -6529,11 +7228,204 @@ "node": ">=10.4.0" } }, + "node_modules/pm2": { + "version": "6.0.14", + "resolved": "https://registry.npmjs.org/pm2/-/pm2-6.0.14.tgz", + "integrity": "sha512-wX1FiFkzuT2H/UUEA8QNXDAA9MMHDsK/3UHj6Dkd5U7kxyigKDA5gyDw78ycTQZAuGCLWyUX5FiXEuVQWafukA==", + "license": "AGPL-3.0", + "dependencies": { + "@pm2/agent": "~2.1.1", + "@pm2/blessed": "0.1.81", + "@pm2/io": "~6.1.0", + "@pm2/js-api": "~0.8.0", + "@pm2/pm2-version-check": "^1.0.4", + "ansis": "4.0.0-node10", + "async": "3.2.6", + "chokidar": "3.6.0", + "cli-tableau": "2.0.1", + "commander": "2.15.1", + "croner": "4.1.97", + "dayjs": "1.11.15", + "debug": "4.4.3", + "enquirer": "2.3.6", + "eventemitter2": "5.0.1", + "fclone": "1.0.11", + "js-yaml": "4.1.1", + "mkdirp": "1.0.4", + "needle": "2.4.0", + "pidusage": "3.0.2", + "pm2-axon": "~4.0.1", + "pm2-axon-rpc": "~0.7.1", + "pm2-deploy": "~1.0.2", + "pm2-multimeter": "^0.1.2", + "promptly": "2.2.0", + "semver": "7.7.2", + "source-map-support": "0.5.21", + "sprintf-js": "1.1.2", + "vizion": "~2.2.1" + }, + "bin": { + "pm2": "bin/pm2", + "pm2-dev": "bin/pm2-dev", + "pm2-docker": "bin/pm2-docker", + "pm2-runtime": "bin/pm2-runtime" + }, + "engines": { + "node": ">=16.0.0" + }, + "optionalDependencies": { + "pm2-sysmonit": "^1.2.8" + } + }, + "node_modules/pm2-axon": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pm2-axon/-/pm2-axon-4.0.1.tgz", + "integrity": "sha512-kES/PeSLS8orT8dR5jMlNl+Yu4Ty3nbvZRmaAtROuVm9nYYGiaoXqqKQqQYzWQzMYWUKHMQTvBlirjE5GIIxqg==", + "license": "MIT", + "dependencies": { + "amp": "~0.3.1", + "amp-message": "~0.1.1", + "debug": "^4.3.1", + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=5" + } + }, + "node_modules/pm2-axon-rpc": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/pm2-axon-rpc/-/pm2-axon-rpc-0.7.1.tgz", + "integrity": "sha512-FbLvW60w+vEyvMjP/xom2UPhUN/2bVpdtLfKJeYM3gwzYhoTEEChCOICfFzxkxuoEleOlnpjie+n1nue91bDQw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1" + }, + "engines": { + "node": ">=5" + } + }, + "node_modules/pm2-deploy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pm2-deploy/-/pm2-deploy-1.0.2.tgz", + "integrity": "sha512-YJx6RXKrVrWaphEYf++EdOOx9EH18vM8RSZN/P1Y+NokTKqYAca/ejXwVLyiEpNju4HPZEk3Y2uZouwMqUlcgg==", + "license": "MIT", + "dependencies": { + "run-series": "^1.1.8", + "tv4": "^1.3.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pm2-multimeter": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/pm2-multimeter/-/pm2-multimeter-0.1.2.tgz", + "integrity": "sha512-S+wT6XfyKfd7SJIBqRgOctGxaBzUOmVQzTAS+cg04TsEUObJVreha7lvCfX8zzGVr871XwCSnHUU7DQQ5xEsfA==", + "license": "MIT/X11", + "dependencies": { + "charm": "~0.1.1" + } + }, + "node_modules/pm2-sysmonit": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/pm2-sysmonit/-/pm2-sysmonit-1.2.8.tgz", + "integrity": "sha512-ACOhlONEXdCTVwKieBIQLSi2tQZ8eKinhcr9JpZSUAL8Qy0ajIgRtsLxG/lwPOW3JEKqPyw/UaHmTWhUzpP4kA==", + "license": "Apache", + "optional": true, + "dependencies": { + "async": "^3.2.0", + "debug": "^4.3.1", + "pidusage": "^2.0.21", + "systeminformation": "^5.7", + "tx2": "~1.0.4" + } + }, + "node_modules/pm2-sysmonit/node_modules/pidusage": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-2.0.21.tgz", + "integrity": "sha512-cv3xAQos+pugVX+BfXpHsbyz/dLzX+lr44zNMsYiGxUw+kV5sgQCIcLd1z+0vq+KyC7dJ+/ts2PsfgWfSC3WXA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pm2-sysmonit/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/pm2/node_modules/commander": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", + "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", + "license": "MIT" + }, + "node_modules/pm2/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/pm2/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pm2/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pm2/node_modules/sprintf-js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", + "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==", + "license": "BSD-3-Clause" + }, "node_modules/polished": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.17.8" @@ -6547,7 +7439,6 @@ "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==", "deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1", - "dev": true, "license": "MIT", "funding": { "type": "opencollective", @@ -6588,7 +7479,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", - "dev": true, "license": "ISC" }, "node_modules/prebuild-install": { @@ -6696,11 +7586,19 @@ "node": ">=10" } }, + "node_modules/promptly": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/promptly/-/promptly-2.2.0.tgz", + "integrity": "sha512-aC9j+BZsRSSzEsXBNBwDnAxujdx19HycZoKgRgzWnS8eOHg1asuf9heuLprfbe739zY3IdUQx+Egv6Jn135WHA==", + "license": "MIT", + "dependencies": { + "read": "^1.0.4" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -6712,7 +7610,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", - "dev": true, "license": "MIT", "dependencies": { "react-is": "^16.3.2", @@ -6726,14 +7623,12 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/proper-lockfile": { @@ -6752,7 +7647,40 @@ "version": "3.6.1", "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", - "dev": true, + "license": "MIT" + }, + "node_modules/proxy-agent": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.4.0.tgz", + "integrity": "sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.3", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.0.1", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, "node_modules/pump": { @@ -6808,7 +7736,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", - "dev": true, "license": "ISC" }, "node_modules/rc": { @@ -6830,7 +7757,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -6843,7 +7769,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/react-clientside-effect/-/react-clientside-effect-1.2.8.tgz", "integrity": "sha512-ma2FePH0z3px2+WOu6h+YycZcEvFmmxIlAb62cF52bG86eMySciO/EQZeQMXd07kPCYB0a1dWDT5J+KE9mCDUw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.13" @@ -6856,7 +7781,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -6870,7 +7794,6 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-9.0.0.tgz", "integrity": "sha512-wZ2o9B2qkdE3RumWhfyZT9swgJYJPeU5qHEcMU8weYpmLex1eeWX0CC32/Y0VutB+BBi2D+iePV/YZIiB4kZGw==", - "dev": true, "license": "MIT", "dependencies": { "attr-accept": "^1.1.3", @@ -6889,7 +7812,6 @@ "version": "2.13.7", "resolved": "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.13.7.tgz", "integrity": "sha512-20lpZHEQrXPb+pp1tzd4ULL6DyO5D2KnR0G69tTDdydrmNhU7pdFmbQUYVyHUgp+xN29IuFR0PVuhOmvaZL9Og==", - "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.0.0", @@ -6913,7 +7835,6 @@ "version": "7.79.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.79.0.tgz", "integrity": "sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw==", - "dev": true, "license": "MIT", "engines": { "node": ">=18.0.0" @@ -6930,7 +7851,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/react-input-mask/-/react-input-mask-2.0.4.tgz", "integrity": "sha512-1hwzMr/aO9tXfiroiVCx5EtKohKwLk/NT8QlJXHQ4N+yJJFyUuMT+zfTpLBwX/lK3PkuMlievIffncpMZ3HGRQ==", - "dev": true, "license": "MIT", "dependencies": { "invariant": "^2.2.4", @@ -6945,7 +7865,6 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", - "dev": true, "license": "MIT", "peer": true }, @@ -6953,14 +7872,12 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", - "dev": true, "license": "MIT" }, "node_modules/react-map-gl": { "version": "5.2.13", "resolved": "https://registry.npmjs.org/react-map-gl/-/react-map-gl-5.2.13.tgz", "integrity": "sha512-yYqpZJADB7o5epiYpkcUED5MWevFnOsVn9mgKfizxjsenLMpVNgNgb/x0e9DS4VZtpOLWW2ZXYBB4BJZJlZmTQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.0.0", @@ -6982,7 +7899,6 @@ "version": "1.0.26", "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz", "integrity": "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A==", - "dev": true, "license": "MIT", "peerDependencies": { "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0", @@ -6993,7 +7909,6 @@ "version": "3.4.1", "resolved": "https://registry.npmjs.org/react-movable/-/react-movable-3.4.1.tgz", "integrity": "sha512-VTrBKjRWV4yVUDGj5dIZIhf07fyoFDXzIV6xs3KNNHpgPbkPT7FNu/Vbe11ZjHmT48uOykCmRMpS66DrzRbZZg==", - "dev": true, "license": "MIT", "peerDependencies": { "react": "*", @@ -7004,7 +7919,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/react-multi-ref/-/react-multi-ref-1.0.2.tgz", "integrity": "sha512-6oS5yzrZ4UrdMHbF6QAnnaoIe9h8R+Xv4m8uJWVK8/Q4RCc6RTT0XJ/LZ7llVgFcVbnDHeUAcVIhtRgFyzjJpA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.24.4" @@ -7014,7 +7928,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/react-range/-/react-range-1.10.0.tgz", "integrity": "sha512-kDo0LiBUHIQIP8menx0UoxTnHr7UXBYpIYl/DR9jCaO1o29VwvCLpkP/qOTNQz5hkJadPg1uEM07XJcJ1XGoKw==", - "dev": true, "license": "MIT", "peerDependencies": { "react": "*", @@ -7025,7 +7938,6 @@ "version": "9.3.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", - "dev": true, "license": "MIT", "dependencies": { "@types/use-sync-external-store": "^0.0.6", @@ -7049,7 +7961,6 @@ "version": "9.22.6", "resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.22.6.tgz", "integrity": "sha512-U5j7KuUQt3AaMatlMJ0UJddqSiX+Km0YJxSqbAzIiGw5EmNz0khMyqP2hzgu4+QUtm+QPIrxzUX4raJxmVJnHg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.7.2", @@ -7068,12 +7979,23 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", + "license": "ISC", + "dependencies": { + "mute-stream": "~0.0.4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/read-binary-file-arch": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", @@ -7103,6 +8025,18 @@ "util-deprecate": "~1.0.1" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -7116,7 +8050,6 @@ "version": "3.8.1", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", - "dev": true, "license": "MIT", "workspaces": [ "www" @@ -7147,14 +8080,12 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "dev": true, "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "dev": true, "license": "MIT", "peerDependencies": { "redux": "^5.0.0" @@ -7179,6 +8110,20 @@ "node": ">=0.10.0" } }, + "node_modules/require-in-the-middle": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-5.2.0.tgz", + "integrity": "sha512-efCx3b+0Z69/LGJmm9Yvi4cqEdxnoGnxYxGxBghkkTTFeXRtTCmmhO0AnAfHz59k957uTSuy8WaHqOs8wbYUWg==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "module-details-from-path": "^1.0.3", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -7201,16 +8146,35 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", - "dev": true, "license": "MIT" }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", - "dev": true, "license": "MIT" }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-alpn": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", @@ -7222,7 +8186,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", - "dev": true, "license": "MIT", "dependencies": { "protocol-buffers-schema": "^3.3.1" @@ -7371,14 +8334,32 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", - "dev": true, "license": "Unlicense" }, + "node_modules/run-series": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/run-series/-/run-series-1.1.9.tgz", + "integrity": "sha512-Arc4hUN896vjkqCYrUXquBFtRZdv1PfLbTYP71efP6butxyQ0kWpiNJyAgsxscmQg1cqvHY32/UCBzXedTpU2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/safe-buffer": { @@ -7422,7 +8403,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/sanitize-filename": { @@ -7448,7 +8428,6 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -7536,11 +8515,16 @@ "node": ">=8" } }, + "node_modules/shimmer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==", + "license": "BSD-2-Clause" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, "license": "ISC" }, "node_modules/simple-concat": { @@ -7601,6 +8585,44 @@ "node": ">=10" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -7614,7 +8636,6 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -7634,7 +8655,6 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -7718,7 +8738,6 @@ "version": "1.6.2", "resolved": "https://registry.npmjs.org/styletron-engine-atomic/-/styletron-engine-atomic-1.6.2.tgz", "integrity": "sha512-vWSIxpCkYbVD3xhnxYwnJDJePD8p8pJCu7g1zFxdQIM4z34Eo1zj3LgXJI6yeNMrJ7PkE56SYK6ZOo3hpTOBzA==", - "dev": true, "license": "MIT", "dependencies": { "inline-style-prefixer": "^5.1.0", @@ -7729,7 +8748,6 @@ "version": "6.1.1", "resolved": "https://registry.npmjs.org/styletron-react/-/styletron-react-6.1.1.tgz", "integrity": "sha512-K04BwKZTrdRG/wR5BaFG8z0bFu1jkT2HAp0UP5ZeMAKW6Ix8J3yuROWLoLUMZafaRRQ9LjiLpIl65u75L7YZow==", - "dev": true, "license": "MIT", "dependencies": { "prop-types": "^15.6.0", @@ -7743,7 +8761,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/styletron-standard/-/styletron-standard-3.1.0.tgz", "integrity": "sha512-Cr2q0IFsag6OaIeD/LBNRuCxNTPa/WtTbKP1X3o50mDudN8FGwmD5h1sMJ/Bu5+mO/2NfrNAv9V9zUXn6lXXMA==", - "dev": true, "license": "MIT", "dependencies": { "@rtsao/csstype": "2.6.5-forked.0", @@ -7768,7 +8785,6 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", "integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==", - "dev": true, "license": "ISC", "dependencies": { "kdbush": "^3.0.0" @@ -7778,7 +8794,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -7787,11 +8802,49 @@ "node": ">=8" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/systeminformation": { + "version": "5.31.12", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.12.tgz", + "integrity": "sha512-qnTtO5wHrKeKE/MvQ6iIt6XAV+5fgt/kPIQf27DYgjVQQuHUfWkV4Gu6k04ZpEzAMuyQ3ZsovY7Ivhp+E9JyWw==", + "license": "MIT", + "optional": true, + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, "node_modules/tailwind-merge": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -7962,7 +9015,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "dev": true, "license": "MIT" }, "node_modules/tiny-typed-emitter": { @@ -8023,7 +9075,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", - "dev": true, "license": "ISC" }, "node_modules/tmp": { @@ -8050,7 +9101,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -8082,7 +9132,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tunnel-agent": { @@ -8097,6 +9146,34 @@ "node": "*" } }, + "node_modules/tv4": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz", + "integrity": "sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==", + "license": [ + { + "type": "Public Domain", + "url": "http://geraintluff.github.io/tv4/LICENSE.txt" + }, + { + "type": "MIT", + "url": "http://jsonary.com/LICENSE.txt" + } + ], + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/tx2": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tx2/-/tx2-1.0.5.tgz", + "integrity": "sha512-sJ24w0y03Md/bxzK4FU8J8JveYYUbSs2FViLJ2D/8bytSiyPRbuE3DyL/9UKYXTZlV3yXq0L8GLlhobTnekCVg==", + "license": "MIT", + "optional": true, + "dependencies": { + "json-stringify-safe": "^5.0.1" + } + }, "node_modules/type-fest": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", @@ -8183,7 +9260,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.0.0" @@ -8205,7 +9281,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", - "dev": true, "license": "MIT", "dependencies": { "detect-node-es": "^1.1.0", @@ -8228,7 +9303,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "dev": true, "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -8251,7 +9325,6 @@ "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", - "dev": true, "license": "MIT AND ISC", "dependencies": { "@types/d3-array": "^3.0.3", @@ -8275,17 +9348,39 @@ "resolved": "https://registry.npmjs.org/viewport-mercator-project/-/viewport-mercator-project-7.0.4.tgz", "integrity": "sha512-0jzpL6pIMocCKWg1C3mqi/N4UPgZC3FzwghEm1H+XsUo8hNZAyJc3QR7YqC816ibOR8aWT5pCsV+gCu8/BMJgg==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dev": true, "license": "MIT", "dependencies": { "@math.gl/web-mercator": "^3.5.5" } }, + "node_modules/vizion": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vizion/-/vizion-2.2.1.tgz", + "integrity": "sha512-sfAcO2yeSU0CSPFI/DmZp3FsFE9T+8913nv1xWBOyzODv13fwkn6Vl7HqxGpkr9F608M+8SuFId3s+BlZqfXww==", + "license": "Apache-2.0", + "dependencies": { + "async": "^2.6.3", + "git-node-fs": "^1.0.0", + "ini": "^1.3.5", + "js-git": "^0.7.8" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/vizion/node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.14" + } + }, "node_modules/vt-pbf": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", - "dev": true, "license": "MIT", "dependencies": { "@mapbox/point-geometry": "0.1.0", @@ -8297,7 +9392,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.0.0" @@ -8402,7 +9496,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC" }, "node_modules/yargs": { @@ -8446,6 +9539,71 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "packages/cli": { + "name": "@musistudio/claude-code-router", + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "@the-next-ai/ai-gateway": "^1.0.4", + "@the-next-ai/bot-gateway-sdk": "^0.1.0", + "better-sqlite3": "^12.11.1", + "node-forge": "^1.4.0", + "undici": "^7.27.2" + }, + "bin": { + "ccr": "dist/main/cli.js" + }, + "engines": { + "node": ">=22" + } + }, + "packages/core": { + "name": "@claude-code-router/core", + "version": "3.0.2", + "dependencies": { + "@the-next-ai/ai-gateway": "^1.0.4", + "@the-next-ai/bot-gateway-sdk": "^0.1.0", + "better-sqlite3": "^12.11.1", + "node-forge": "^1.4.0", + "pm2": "^6.0.13", + "undici": "^7.27.2" + }, + "bin": { + "ccr-core-server": "dist/main/server.js" + }, + "engines": { + "node": ">=22" + } + }, + "packages/electron": { + "name": "@claude-code-router/electron", + "version": "3.0.10", + "dependencies": { + "better-sqlite3": "^12.11.1" + }, + "devDependencies": { + "electron-updater": "^6.8.9" + } + }, + "packages/ui": { + "name": "@claude-code-router/ui", + "version": "3.0.10", + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "baseui": "^16.1.1", + "clsx": "^2.1.1", + "lucide-react": "^1.17.0", + "motion": "^12.40.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "recharts": "^3.8.1", + "styletron-engine-atomic": "^1.6.2", + "styletron-react": "^6.1.1", + "tailwind-merge": "^3.6.0" + } } } } diff --git a/package.json b/package.json index 4a01640..2333cb7 100644 --- a/package.json +++ b/package.json @@ -1,36 +1,74 @@ { - "name": "claude-code-router", + "name": "claude-code-router-monorepo", + "version": "3.0.10", "private": true, - "version": "3.0.1", - "description": "Desktop scaffold for Claude Code Router.", - "main": "dist/main/main.js", - "bin": { - "ccr": "dist/main/cli.js" + "license": "MIT", + "description": "Local Claude Code Router gateway with CLI and web management UI.", + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/musistudio/claude-code-router.git" + }, + "bugs": { + "url": "https://github.com/musistudio/claude-code-router/issues" + }, + "homepage": "https://github.com/musistudio/claude-code-router#readme", + "keywords": [ + "claude-code", + "codex", + "llm", + "gateway", + "router" + ], + "main": "packages/electron/dist/main/main.js", + "workspaces": [ + "packages/*" + ], + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public" }, "scripts": { - "dev": "node build/dev.mjs", - "build": "npm run build:assets && electron-builder", + "dev": "npm run dev:cli", + "dev:ui": "node build/dev.mjs ui", + "dev:cli": "node build/dev.mjs cli", + "dev:electron": "node build/dev.mjs electron", + "build": "node build/build.mjs && electron-builder", "build:assets": "node build/build.mjs", + "build:docker": "node build/docker-build.mjs", "build:app:mac": "npm run build:app:mac:local", "build:app:mac:local": "npm run build:assets && electron-builder --config build/electron-builder.local.cjs --mac --publish never", "build:app:mac:release": "node build/macos-release-preflight.mjs && npm run build:assets && electron-builder --mac --publish never", "build:app:win": "npm run build:assets && electron-builder --win", + "prepack": "npm run build:assets", + "prepublishOnly": "npm run typecheck", "preview": "npm run build:assets && electron .", + "docker:build": "docker build -t claude-code-router:local .", + "docker:run": "docker run --rm -p 3458:8080 -v ccr-data:/data claude-code-router:local", + "test": "node build/test.mjs && node build/run-tests.mjs", + "test:docker": "node tests/docker/docker-smoke.mjs", + "test:e2e": "npm run build:assets && playwright test", + "test:e2e:install": "playwright install chromium", + "test:main": "node build/test.mjs main && node build/run-tests.mjs main", + "test:renderer": "node build/test.mjs renderer && node build/run-tests.mjs renderer", "typecheck": "tsc --noEmit", "rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3" }, "dependencies": { - "@the-next-ai/ai-gateway": "^1.0.1", + "@the-next-ai/ai-gateway": "^1.0.6", "@the-next-ai/bot-gateway-sdk": "^0.1.0", "better-sqlite3": "^12.11.1", "electron-updater": "^6.8.9", "node-forge": "^1.4.0", + "openai": "^6.27.0", "undici": "^7.27.2" }, "devDependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@playwright/test": "^1.61.1", "@tailwindcss/cli": "^4.3.0", "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.10.2", diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE new file mode 100644 index 0000000..ea7e8ac --- /dev/null +++ b/packages/cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 musistudio + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..1862494 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,361 @@ +

      Claude Code Router Desktop

      + +

      + Chinese README + Discord + X + License + Documentation +

      + +
      + + + + + + + + +
      + + Kimi K2.7 Code sponsor banner + +
      + + Kimi Code Subscription +  ·  + API Global +  ·  + API China + +
      +

      + Thanks to Kimi for sponsoring this project! Kimi K2.7 Code is an open-source, coding-focused agentic model developed by Moonshot AI, with substantial gains on real-world long-horizon coding tasks and higher end-to-end success across complex software engineering workflows. It also cuts thinking-token usage by approximately 30% compared with K2.6. Inside CCR, Kimi ships as built-in provider presets: import the pay-as-you-go API or the Kimi Code subscription in one click and route your coding agent's requests to Kimi, the subscription endpoint passes straight through natively with no protocol conversion, API endpoints are adapted automatically, and your balance and subscription usage show up right in the CCR dashboard. +

      +

      + CCR already supports Kimi. Visit the Kimi Open Platform (中文站 | Global) to try the API, or explore the cost-effective Coding Plan. +

      +
      + +
      + +Claude Code Router Desktop is a local gateway and desktop control panel for routing agent requests from Claude Code, Codex, ZCode, and compatible clients to the model provider you actually want to use. + +

      + Claude Code Router Desktop screenshot +

      + +## Why Use CCR + +- Use one local endpoint for multiple agent tools instead of configuring every client separately. +- Route requests with default routing, conditional rules, fallback targets, and request rewrites instead of editing client configuration by hand. +- Mix providers without changing your workflow. CCR supports OpenAI-compatible APIs, Anthropic Messages, Gemini Generate Content, OpenRouter, DeepSeek, SiliconFlow, Moonshot, Kimi Code, Mistral, Z.AI, Bailian, and custom providers. +- Control cost and reliability with fallback routing, API key rotation, usage statistics, and request logs. + +## Features + +- **Overview dashboard**: inspect system status, usage widgets, account balances, model distribution, and share cards. +- **Provider management**: add provider presets or custom endpoints, probe protocol support, test model connectivity, manage credentials, and monitor supported account balances where available. +- **Routing rules**: configure default routing, conditional and model-prefix rules, fallback handling, and request rewrites. +- **Agent Config**: configure Claude Code, Codex, and ZCode launch entries, models, scopes, and multi-instance app profiles. +- **Gateway compatibility**: translate supported client requests through the local CCR model gateway. +- **Proxy mode**: capture supported API traffic through a local proxy with optional system proxy integration and network capture. +- **Fusion models**: combine a base model with vision, web search, or MCP tools into a reusable selectable model. + +## Documentation + +Read the full documentation at [ccrdesk.top](https://ccrdesk.top/). + +## Download And Install + +1. Open the [GitHub Releases page](https://github.com/musistudio/claude-code-router/releases). +2. Download the package for your platform: + - macOS Apple Silicon: `Claude-Code-Router_-mac-Apple-Silicon-arm64.dmg` or `.zip` + - macOS Intel: `Claude-Code-Router_-mac-Intel-x64.dmg` or `.zip` + - Windows: `Claude Code Router_.exe` + - Linux: `Claude Code Router_.AppImage` +3. Install and launch **Claude Code Router**. +4. On first launch, CCR creates its local configuration database: + - macOS/Linux: `~/.claude-code-router/config.sqlite` + - Windows: `%APPDATA%\Claude Code Router\config.sqlite` + +CCR stores runtime configuration in SQLite. A legacy `config.json` is read only once for migration when no SQLite config exists. + +After the service is started from the **Server** page, CCR listens on `http://localhost:8080` by default. The **Server** page controls the gateway `Host`, `Port`, proxy mode, system proxy, network capture, and CA certificate status. + +## Quick Start + +CCR can be configured entirely from the desktop UI. Use this setup order for a clean first run. + +### 1. Add a provider + +Open **Providers**, click **Add Provider**, then choose a built-in preset or **Other / custom API endpoint**. Fill in the provider name, base URL, protocol, API key, and model list. Run protocol probing and model connectivity checks when available, then save the provider. + +### 2. Configure routing + +Open **Routing** to add conditional rules, configure request rewrites, and set fallback behavior. + +Use **Add Routing Rule** for request conditions, model-prefix routing, or rule-level fallback targets. + +### 3. Start the gateway + +Open **Server** and click **Start**. After the page shows Running, CCR listens on `http://localhost:8080`. Enable **Auto start** if you want CCR to start the local gateway whenever the desktop app opens. + +### 4. Connect your agent tool + +Open **Agent Config** and choose the client you want to use. Configure Claude Code, Codex, or ZCode, select the target model and effect scope, then apply the config. For app entries, use the **Open Agent** action to open the target app through CCR. + +### 5. Monitor and adjust + +Use **Settings → Logs & Observability** to enable request logs and agent observability. Use **Logs** to confirm `request model`, `resolved provider`, `resolved model`, status, tokens, latency, and errors; use the tray window for quick token and account status. + +## Acknowledgements + +Codex support is powered by [musistudio/codexl](https://github.com/musistudio/codexl). + +## Support & Sponsoring + +
      + +

      If you find this project helpful, please consider sponsoring its development. Your support is greatly appreciated.

      + + + + + + +
      + + Support on Ko-fi + +
      + One-time support via Ko-fi +
      + + Sponsor with PayPal + +
      + International sponsorship +
      + + + + + + +
      + Alipay +
      + Alipay QR code +
      + WeChat Pay +
      + WeChat Pay QR code +
      + +
      + +### Our Sponsors + +
      + +

      A huge thank you to all our sponsors for their generous support.

      + + + + + + + + + + + + +
      + + Zhipu icon +
      + Z智谱 +
      +
      + + AIHubmix icon +
      + AIHubmix +
      +
      + + BurnCloud icon +
      + BurnCloud +
      +
      + + 302.AI icon +
      + 302.AI +
      +
      + + RunAPI icon +
      + RunAPI +
      +
      + + TeamoRouter icon +
      + TeamoRouter +
      +
      + +

      Community Sponsors

      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      @Simon Leischnig@duanshuaimin@vrgitadmin@*o@ceilwoo@*说
      @*更@K*g@R*R@bobleer@*苗@*划
      @Clarence-pan@carter003@S*r@*晖@*敏@Z*z
      @*然@cluic@*苗@PromptExpert@*应@yusnake
      @*飞@董*@*汀@*涯@*:-)@**磊
      @*琢@*成@Z*o@*琨@congzhangzh@*_
      @Z*m@*鑫@c*y@*昕@witsice@b*g
      @*亿@*辉@JACK@*光@W*l@kesku
      @biguncle@二吉吉@a*g@*林@*咸@*明
      @S*y@f*o@*智@F*t@r*c@qierkang
      @*军@snrise-z@*王@greatheart1000@*王@zcutlip
      @Peng-YM@*更@*.@F*t@*政@*铭
      @*叶@七*o@*青@**晨@*远@*霄
      @**吉@**飞@**驰@x*g@**东@*落
      @哆*k@*涛@苗大@*呢@d*u@crizcraig
      s*s*火*勤**锟*涛**明
      *知*语*瓜
      + +If your name is masked, please contact me via my homepage email to update it with your GitHub username. + +
      + +## License + +This project is licensed under the [MIT License](LICENSE). diff --git a/packages/cli/README_zh.md b/packages/cli/README_zh.md new file mode 100644 index 0000000..959c239 --- /dev/null +++ b/packages/cli/README_zh.md @@ -0,0 +1,360 @@ +

      Claude Code Router Desktop

      + +

      + English README + Discord + X + License + 文档 +

      + +
      + + + + + + + + +
      + + Kimi K2.7 Code 赞助横幅 + +
      + + Kimi Code 订阅 +  ·  + API 中文站 +  ·  + API Global + +
      +

      + 感谢 Kimi 赞助本项目!Kimi K2.7 Code 是 Moonshot AI 推出的编程专用开源智能体模型,在真实长程编程与复杂软件工程工作流中显著提升端到端任务成功率,同时优化推理效率,相比 K2.6 平均减少约 30% 的推理 token 消耗。在 CCR 中,Kimi 已作为内置供应商预设开箱即用:无论按量付费 API 还是 Kimi Code 订阅,一键导入即可把你的编程 Agent 请求路由到 Kimi,订阅端点原生直通、无需协议转换,API 端点自动适配,账户余额与订阅用量也能直接在 CCR 面板中查看。 +

      +

      + CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(中文站Global)体验 API,或了解高性价比 Coding Plan 套餐。 +

      +
      + +
      + +Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。 + +

      + Claude Code Router Desktop 项目截图 +

      + +## 为什么使用 CCR + +- 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。 +- 在不改变工作流的情况下混用不同 Provider。CCR 支持 OpenAI 兼容 API、Anthropic Messages、Gemini Generate Content、OpenRouter、DeepSeek、SiliconFlow、Moonshot、Kimi Code、Mistral、Z.AI、百炼以及自定义 Provider。 +- 通过 fallback 路由、API Key 轮换、用量统计和请求日志来控制成本和可靠性。 + +## 功能和特性 + +- **概览仪表盘**:查看系统状态、用量组件、账号余额、模型分布和分享卡片。 +- **Provider 管理**:添加预设或自定义端点,探测协议支持,检测模型连通性,管理凭据,并在可用时查看账号余额。 +- **路由规则**:配置条件路由、模型前缀规则、失败降级和请求改写。 +- **Agent配置**:为 Claude Code、Codex 和 ZCode 配置启动入口、模型、作用范围和多开 App 配置。 +- **网关兼容层**:通过本地 CCR 模型网关转换支持的客户端请求。 +- **代理模式**:通过本地代理捕获支持的 API 流量,可选系统代理和网络捕获。 +- **Fusion 组合模型**:把基础模型与视觉、联网搜索或 MCP 工具组合成新的可选模型。 + +## 文档 + +完整文档见 [ccrdesk.top](https://ccrdesk.top/)。 + +## 下载和安装 + +1. 打开 [GitHub Releases 页面](https://github.com/musistudio/claude-code-router/releases)。 +2. 按系统下载对应安装包: + - macOS Apple 芯片:`Claude-Code-Router_-mac-Apple-Silicon-arm64.dmg` 或 `.zip` + - macOS Intel 芯片:`Claude-Code-Router_-mac-Intel-x64.dmg` 或 `.zip` + - Windows:`Claude Code Router_.exe` + - Linux:`Claude Code Router_.AppImage` +3. 安装并启动 **Claude Code Router**。 +4. 首次启动后,CCR 会创建本地配置数据库: + - macOS/Linux:`~/.claude-code-router/config.sqlite` + - Windows:`%APPDATA%\Claude Code Router\config.sqlite` + +CCR 的运行配置存储在 SQLite 中。旧版 `config.json` 只会在没有 SQLite 配置时作为迁移来源读取一次。 + +从 **服务** 页面启动后,CCR 默认监听 `http://localhost:8080`。**服务** 页面负责配置网关 `Host`、`Port`、代理模式、系统代理、网络捕获和 CA 证书状态。 + +## 快速开始 + +CCR 可以完全通过桌面 UI 完成配置。首次使用建议按下面顺序操作。 + +### 1. 添加 Provider + +打开 **供应商**,点击 **添加供应商**,选择内置预设或 **其他 / 自定义 API 端点**。按表单填写 Provider 名称、基础 URL、协议、API Key 和模型列表。可用时先运行协议探测和模型连通性检查,然后保存 Provider。 + +### 2. 设置路由 + +打开 **路由**,添加条件规则,配置请求改写和失败降级。 + +如果需要更细粒度控制,使用 **添加路由规则** 添加模型前缀、请求条件或规则级失败降级目标。 + +### 3. 启动网关 + +打开 **服务**,点击 **启动**。页面显示运行中后,CCR 会在本机监听 `http://localhost:8080`。如果希望每次打开桌面应用时自动启动网关,可以启用自动启动。 + +### 4. 连接 Agent 工具 + +打开 **Agent配置**,选择要使用的客户端。配置 Claude Code、Codex 或 ZCode,选择目标模型和作用范围,然后应用配置。对于 App 入口,可以使用 **打开 Agent** 操作通过 CCR 打开目标应用。 + +### 5. 日常查看和调整 + +到 **设置 → 日志与观测** 打开请求日志和 Agent 观测。使用 **日志** 确认 `request model`、`resolved provider`、`resolved model`、状态码、tokens、耗时和错误;使用托盘窗口快速查看 Token 和账号状态。 + +## 致谢 + +对 Codex 的支持来自于 [musistudio/codexl](https://github.com/musistudio/codexl) 这个项目。 + +## 支持与赞助 + +
      + +

      如果你觉得这个项目有帮助,欢迎赞助项目开发。非常感谢你的支持。

      + + + + + + +
      + + 通过 Ko-fi 赞助 + +
      + 通过 Ko-fi 单次赞助 +
      + + 通过 PayPal 赞助 + +
      + 国际赞助通道 +
      + + + + + + +
      + 支付宝 +
      + 支付宝收款码 +
      + 微信支付 +
      + 微信支付收款码 +
      + +
      + +### 我们的赞助商 + +
      + +

      非常感谢所有赞助商的慷慨支持。

      + + + + + + + + + + + + +
      + + 智谱图标 +
      + Z智谱 +
      +
      + + AIHubmix 图标 +
      + AIHubmix +
      +
      + + BurnCloud 图标 +
      + BurnCloud +
      +
      + + 302.AI 图标 +
      + 302.AI +
      +
      + + RunAPI 图标 +
      + RunAPI +
      +
      + + TeamoRouter 图标 +
      + TeamoRouter +
      +
      + +

      社区赞助者

      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      @Simon Leischnig@duanshuaimin@vrgitadmin@*o@ceilwoo@*说
      @*更@K*g@R*R@bobleer@*苗@*划
      @Clarence-pan@carter003@S*r@*晖@*敏@Z*z
      @*然@cluic@*苗@PromptExpert@*应@yusnake
      @*飞@董*@*汀@*涯@*:-)@**磊
      @*琢@*成@Z*o@*琨@congzhangzh@*_
      @Z*m@*鑫@c*y@*昕@witsice@b*g
      @*亿@*辉@JACK@*光@W*l@kesku
      @biguncle@二吉吉@a*g@*林@*咸@*明
      @S*y@f*o@*智@F*t@r*c@qierkang
      @*军@snrise-z@*王@greatheart1000@*王@zcutlip
      @Peng-YM@*更@*.@F*t@*政@*铭
      @*叶@七*o@*青@**晨@*远@*霄
      @**吉@**飞@**驰@x*g@**东@*落
      @哆*k@*涛@苗大@*呢@d*u@crizcraig
      s*s*火*勤**锟*涛**明
      *知*语*瓜
      + +如果你的名字被打码,请通过我的主页邮箱联系我更新为 GitHub 用户名。 + +
      + +## 许可证 + +本项目基于 [MIT License](LICENSE) 发布。 diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..723d03d --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,48 @@ +{ + "name": "@musistudio/claude-code-router", + "version": "3.0.2", + "license": "MIT", + "description": "Local Claude Code Router gateway with CLI and web management UI.", + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/musistudio/claude-code-router.git" + }, + "bugs": { + "url": "https://github.com/musistudio/claude-code-router/issues" + }, + "homepage": "https://github.com/musistudio/claude-code-router#readme", + "keywords": [ + "claude-code", + "codex", + "llm", + "gateway", + "router" + ], + "main": "dist/main/cli.js", + "bin": { + "ccr": "dist/main/cli.js" + }, + "files": [ + "dist", + "LICENSE", + "README.md", + "README_zh.md" + ], + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "prepack": "npm --prefix ../.. run build:assets", + "prepublishOnly": "npm --prefix ../.. run typecheck" + }, + "dependencies": { + "@the-next-ai/ai-gateway": "^1.0.4", + "@the-next-ai/bot-gateway-sdk": "^0.1.0", + "better-sqlite3": "^12.11.1", + "node-forge": "^1.4.0", + "undici": "^7.27.2" + } +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 0000000..60cd3a8 --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,796 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env"; +import { applyClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service"; +import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "@ccr/core/agents/claude-app/launch"; +import { launchCodexAppProfile, launchZcodeAppProfile } from "@ccr/core/agents/codex/app-launch"; +import { loadAppConfig } from "@ccr/core/config/config"; +import { CONFIGDIR } from "@ccr/core/config/constants"; +import { applyProfileConfig, applyProfileRuntimeConfig } from "@ccr/core/profiles/service"; +import { ensureProfileGateway } from "@ccr/core/profiles/launch-service"; +import { buildProfileLaunchPlan, defaultProfileOpenSurface, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface } from "@ccr/core/profiles/launch-core"; +import { openSystemExternal, startWebManagementServer } from "@ccr/core/web/management-server"; +import { assertAvailableGatewayModels, type ProfileConfig, type ProfileOpenSurface } from "@ccr/core/contracts/app"; + +type ProfileCliOptions = { + agentArgs: string[]; + command: "profile"; + help: boolean; + profileRef: string; + surface?: ProfileOpenSurface; +}; + +type WebCliOptions = { + command: "start" | "ui" | "web"; + daemonChild: boolean; + help: boolean; + host?: string; + open: boolean; + port?: number; + startGateway: boolean; +}; + +type StopCliOptions = { + command: "stop"; + help: boolean; +}; + +type CliOptions = ProfileCliOptions | StopCliOptions | WebCliOptions; + +type ServiceState = { + host?: string; + pid: number; + serviceToken?: string; + startedAt: string; + startGateway: boolean; + url: string; +}; + +const serviceStateFileName = "service.json"; +const serviceInstanceTokenEnv = "CCR_SERVICE_INSTANCE_TOKEN"; +const serviceRpcTimeoutMs = 2_000; +const serviceStartTimeoutMs = 30_000; +const serviceStopTimeoutMs = 10_000; +const webAuthHeader = "x-ccr-web-auth"; +const webAuthQueryParam = "ccr_web_token"; +const defaultCliCommandName = "ccr"; + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + if (options.command === "start") { + if (options.help) { + printStartHelp(0); + return; + } + await startService(options); + return; + } + if (options.command === "ui") { + if (options.help) { + printUiHelp(0); + return; + } + await openManagementUi(options); + return; + } + if (options.command === "stop") { + if (options.help) { + printStopHelp(0); + return; + } + await stopService(); + return; + } + if (options.command === "web") { + if (options.help) { + printWebHelp(0); + return; + } + await runWebServer(options); + return; + } + + const profileOptions = options as ProfileCliOptions; + if (profileOptions.help || !profileOptions.profileRef) { + printHelp(profileOptions.help ? 0 : 2); + return; + } + + const configDir = CONFIGDIR; + const config = await loadAppConfig(); + assertAvailableGatewayModels(config); + await applyProfileConfig(config); + const profile = findProfileForOpen(config, profileOptions.profileRef); + const surface = profileOptions.surface ?? defaultProfileOpenSurface(profile); + const resolvedSurface = resolveProfileOpenSurface(profile, surface); + if (profile.agent === "zcode" && profileOptions.agentArgs.length > 0) { + throw new Error("ZCode profiles can only open the app; agent arguments are not supported."); + } + if (profile.agent === "claude-code" && resolvedSurface === "app" && profileOptions.agentArgs.length > 0) { + throw new Error("Claude App profiles do not support agent arguments."); + } + + const launchConfig = await ensureProfileGateway(config, profile, resolvedSurface === "app" ? profileAppName(profile) : profile.name || profile.id || "profile", { + reuseExisting: true, + startIfMissing: false + }); + if (resolvedSurface === "cli") { + const runtimeResult = applyProfileRuntimeConfig(launchConfig, profile, launchConfig.APIKEY); + if (!runtimeResult.ok) { + throw new Error(runtimeResult.message); + } + } + if (profile.agent === "claude-code" && resolvedSurface === "app") { + applyClaudeAppGatewayConfig(launchConfig); + applyClaudeAppGatewayConfig(launchConfig, { + backup: false, + dataDir: resolveClaudeAppProfileUserDataDir(configDir, profile), + refreshModelDiscoveryCache: true + }); + const launch = await launchClaudeAppProfile(configDir, profile, launchConfig); + const spawnError = await waitForImmediateSpawnError(launch.child, 500); + if (spawnError) { + throw new Error(`Failed to open Claude App: ${spawnError}`); + } + process.stdout.write(`Opened Claude App with ${profile.name || profile.id}.\n`); + return; + } + if ((profile.agent === "codex" || profile.agent === "zcode") && resolvedSurface === "app" && profileOptions.agentArgs.length === 0) { + if (profile.agent === "zcode") { + const launch = launchZcodeAppProfile(configDir, profile, launchConfig); + const spawnError = await waitForImmediateSpawnError(launch.child, 500); + if (spawnError) { + throw new Error(`Failed to open ZCode App: ${spawnError}`); + } + process.stdout.write(`Opened ZCode App with ${profile.name || profile.id}.\n`); + } else { + const launch = launchCodexAppProfile(configDir, profile, launchConfig); + const spawnError = await waitForImmediateSpawnError(launch.child, 500); + if (spawnError) { + throw new Error(`Failed to open Codex App: ${spawnError}`); + } + process.stdout.write(`Opened Codex App with ${profile.name || profile.id}.\n`); + } + return; + } + + const plan = buildProfileLaunchPlan(configDir, profile, resolvedSurface, profileOptions.agentArgs); + + if (path.isAbsolute(plan.command) && !existsSync(plan.command)) { + throw new Error(`Profile launcher was not found: ${plan.command}. Open CCR once or re-save the profile.`); + } + + const childEnv = { + ...process.env, + ...plan.env, + ...botGatewayProfileEnv(launchConfig, profile, resolvedSurface) + }; + delete childEnv.ELECTRON_RUN_AS_NODE; + + const launch = profileLaunchSpawnCommand(plan); + const child = spawn(launch.command, launch.args, { + env: childEnv, + stdio: "inherit", + windowsVerbatimArguments: !!launch.windowsVerbatimArguments + }); + const code = await waitForChild(child); + process.exitCode = code; +} + +function parseArgs(args: string[]): CliOptions { + if (args[0] === "start") { + return parseWebArgs(args.slice(1), "start"); + } + if (args[0] === "ui") { + return parseWebArgs(args.slice(1), "ui", true); + } + if (args[0] === "stop") { + return parseStopArgs(args.slice(1)); + } + if (args[0] === "serve" || args[0] === "web") { + return parseWebArgs(args.slice(1), "web"); + } + + const options: ProfileCliOptions = { + agentArgs: [], + command: "profile", + help: false, + profileRef: "" + }; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--") { + options.agentArgs.push(...args.slice(index + 1)); + break; + } + if (arg === "--help" || arg === "-h") { + options.help = true; + continue; + } + if (arg === "--app") { + options.surface = "app"; + continue; + } + if (arg === "--cli") { + options.surface = "cli"; + continue; + } + if (options.profileRef && !options.surface && (arg === "cli" || arg === "app")) { + options.surface = arg; + continue; + } + if (!options.profileRef) { + options.profileRef = arg; + continue; + } + options.agentArgs.push(arg); + } + return options; +} + +function profileAppName(profile: Pick): string { + if (profile.agent === "claude-code") { + return "Claude App"; + } + if (profile.agent === "zcode") { + return "ZCode App"; + } + return "Codex App"; +} + +function parseStopArgs(args: string[]): StopCliOptions { + const options: StopCliOptions = { + command: "stop", + help: false + }; + for (const arg of args) { + if (arg === "--help" || arg === "-h") { + options.help = true; + continue; + } + throw new Error(`Unknown stop option: ${arg}`); + } + return options; +} + +function parseWebArgs(args: string[], command: WebCliOptions["command"], defaultOpen = false): WebCliOptions { + const options: WebCliOptions = { + command, + daemonChild: false, + help: false, + open: defaultOpen, + startGateway: true + }; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--help" || arg === "-h") { + options.help = true; + continue; + } + if (arg === "--open") { + options.open = true; + continue; + } + if (arg === "--no-open") { + options.open = false; + continue; + } + if (arg === "--gateway") { + options.startGateway = true; + continue; + } + if (arg === "--no-gateway") { + options.startGateway = false; + continue; + } + if (arg === "--daemon-child") { + options.daemonChild = true; + continue; + } + if (arg === "--host") { + index += 1; + options.host = requiredArg(args[index], "--host"); + continue; + } + if (arg.startsWith("--host=")) { + options.host = requiredArg(arg.slice("--host=".length), "--host"); + continue; + } + if (arg === "--port") { + index += 1; + options.port = parsePort(requiredArg(args[index], "--port")); + continue; + } + if (arg.startsWith("--port=")) { + options.port = parsePort(requiredArg(arg.slice("--port=".length), "--port")); + continue; + } + throw new Error(`Unknown web option: ${arg}`); + } + return options; +} + +async function startService(options: WebCliOptions): Promise { + const current = readServiceState(); + const currentVerification = current ? await verifyServiceState(current) : undefined; + if (current && currentVerification?.ok) { + process.stdout.write(`CCR service is already running at ${current.url} (pid ${current.pid}).\n`); + if (options.open) { + await openManagementUrl(current.url); + } + return; + } + if (current) { + clearServiceState(current.pid); + } + + const serviceToken = generateServiceToken(); + const childArgs = [ + currentCliScript(), + "serve", + "--daemon-child", + ...(options.host ? ["--host", options.host] : []), + ...(options.port ? ["--port", String(options.port)] : []), + "--no-open", + ...(options.startGateway ? [] : ["--no-gateway"]) + ]; + const child = spawn(process.execPath, childArgs, { + detached: true, + env: serviceChildEnv(serviceToken), + stdio: "ignore", + windowsHide: true + }); + const spawnError = await waitForImmediateSpawnError(child, 1000); + if (spawnError) { + throw new Error(`Failed to start CCR service: ${spawnError}`); + } + child.unref(); + + const state = await waitForServiceState(child.pid, serviceStartTimeoutMs); + if (!state) { + throw new Error(`CCR service did not report ready within ${serviceStartTimeoutMs}ms.`); + } + process.stdout.write(`CCR service started at ${state.url} (pid ${state.pid}).\n`); + if (options.open) { + await openManagementUrl(state.url); + } +} + +async function openManagementUi(options: WebCliOptions): Promise { + await startService({ + ...options, + command: "start" + }); +} + +async function openManagementUrl(url: string): Promise { + try { + await openSystemExternal(url); + process.stdout.write(`Opened CCR management UI at ${url}\n`); + } catch (error) { + process.stderr.write(`Failed to open browser: ${formatError(error)}\n`); + process.stdout.write(`CCR management UI is available at ${url}\n`); + } +} + +function serviceChildEnv(serviceToken: string): NodeJS.ProcessEnv { + const env = { ...process.env }; + env[serviceInstanceTokenEnv] = serviceToken; + if (process.versions.electron) { + env.ELECTRON_RUN_AS_NODE = "1"; + } else { + delete env.ELECTRON_RUN_AS_NODE; + } + return env; +} + +async function runWebServer(options: WebCliOptions): Promise { + const runtime = await startWebManagementServer({ + host: options.host, + open: options.open, + port: options.port, + startGateway: options.startGateway + }); + if (options.daemonChild) { + const serviceToken = process.env[serviceInstanceTokenEnv]?.trim() || undefined; + writeServiceState({ + host: options.host, + pid: process.pid, + ...(serviceToken ? { serviceToken } : {}), + startedAt: new Date().toISOString(), + startGateway: options.startGateway, + url: runtime.url + }); + } + process.stdout.write(`CCR web management is running at ${runtime.url}\n`); + + let closing = false; + const shutdown = (signal: NodeJS.Signals) => { + if (closing) { + return; + } + closing = true; + void runtime.close().finally(() => { + if (options.daemonChild) { + clearServiceState(process.pid); + } + process.exit(signal === "SIGINT" ? 130 : 143); + }); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + await new Promise(() => undefined); +} + +async function stopService(): Promise { + const state = readServiceState(); + if (!state) { + process.stdout.write("CCR service is not running.\n"); + return; + } + const verification = await verifyServiceState(state); + if (!verification.ok) { + clearServiceState(state.pid); + process.stdout.write("CCR service is not running.\n"); + return; + } + + await callServiceRpc(state, "quitApp"); + const stopped = verification.trustedPid + ? await waitForProcessExit(state.pid, serviceStopTimeoutMs) + : await waitForServiceUnavailable(state, serviceStopTimeoutMs); + if (!stopped) { + throw new Error(`CCR service did not stop within ${serviceStopTimeoutMs}ms.`); + } + clearServiceState(state.pid); + process.stdout.write("CCR service stopped.\n"); +} + +function printHelp(exitCode: number): void { + const command = cliCommandName(); + const output = [ + "Usage:", + ` ${command} start [--host ] [--port ] [--open] [--no-gateway]`, + ` ${command} ui [--host ] [--port ] [--no-gateway]`, + ` ${command} stop`, + ` ${command} [cli|app] [-- ]`, + "", + "Examples:", + ` ${command} start`, + ` ${command} ui`, + ` ${command} stop`, + ` ${command} Codex`, + ` ${command} default-codex -- --model gpt-5-codex`, + ` ${command} default-codex app` + ].join("\n"); + const stream = exitCode === 0 ? process.stdout : process.stderr; + stream.write(`${output}\n`); + process.exitCode = exitCode; +} + +function printStartHelp(exitCode: number): void { + const command = cliCommandName(); + const output = [ + "Usage:", + ` ${command} start [--host ] [--port ] [--open] [--no-gateway]`, + "", + "Options:", + " --host Management server host. Defaults to 127.0.0.1.", + " --port Management server port. Defaults to 3458.", + " --open Open the management page in the default browser.", + " --no-open Do not open the management page.", + " --no-gateway Start only the web management server.", + "", + "Environment:", + " CCR_WEB_AUTH_TOKEN Use this token for management UI and RPC authentication." + ].join("\n"); + const stream = exitCode === 0 ? process.stdout : process.stderr; + stream.write(`${output}\n`); + process.exitCode = exitCode; +} + +function printUiHelp(exitCode: number): void { + const command = cliCommandName(); + const output = [ + "Usage:", + ` ${command} ui [--host ] [--port ] [--no-gateway]`, + "", + "Starts the background CCR service if needed and opens the management UI in the default browser.", + "", + "Options:", + " --host Management server host. Defaults to 127.0.0.1.", + " --port Management server port. Defaults to 3458.", + " --no-open Start or find the service and print the management URL without opening a browser.", + " --no-gateway Start only the web management server when the service is not already running.", + "", + "Environment:", + " CCR_WEB_AUTH_TOKEN Use this token for management UI and RPC authentication." + ].join("\n"); + const stream = exitCode === 0 ? process.stdout : process.stderr; + stream.write(`${output}\n`); + process.exitCode = exitCode; +} + +function printStopHelp(exitCode: number): void { + const command = cliCommandName(); + const output = [ + "Usage:", + ` ${command} stop`, + "", + `Stops the background CCR service started by \`${command} start\`.` + ].join("\n"); + const stream = exitCode === 0 ? process.stdout : process.stderr; + stream.write(`${output}\n`); + process.exitCode = exitCode; +} + +function printWebHelp(exitCode: number): void { + const command = cliCommandName(); + const output = [ + "Usage:", + ` ${command} serve [--host ] [--port ] [--open] [--no-gateway]`, + "", + "Options:", + " --host Management server host. Defaults to 127.0.0.1.", + " --port Management server port. Defaults to 3458.", + " --open Open the management page in the default browser.", + " --no-gateway Start only the web management server.", + "", + "Environment:", + " CCR_WEB_AUTH_TOKEN Use this token for management UI and RPC authentication." + ].join("\n"); + const stream = exitCode === 0 ? process.stdout : process.stderr; + stream.write(`${output}\n`); + process.exitCode = exitCode; +} + +function cliCommandName(): string { + const configured = process.env.CCR_CLI_COMMAND_NAME?.trim(); + return configured && /^[A-Za-z0-9._-]+$/.test(configured) + ? configured + : defaultCliCommandName; +} + +function readServiceState(): ServiceState | undefined { + const file = serviceStateFile(); + if (!existsSync(file)) { + return undefined; + } + try { + const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial; + const pid = Number(parsed.pid); + if (!Number.isInteger(pid) || pid <= 0 || typeof parsed.url !== "string") { + return undefined; + } + return { + host: parsed.host, + pid, + serviceToken: typeof parsed.serviceToken === "string" && parsed.serviceToken.trim() ? parsed.serviceToken.trim() : undefined, + startedAt: parsed.startedAt || "", + startGateway: parsed.startGateway !== false, + url: parsed.url + }; + } catch { + return undefined; + } +} + +function writeServiceState(state: ServiceState): void { + const file = serviceStateFile(); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); +} + +function clearServiceState(pid?: number): void { + const state = readServiceState(); + if (pid !== undefined && state && state.pid !== pid) { + return; + } + try { + unlinkSync(serviceStateFile()); + } catch { + // Stale state cleanup is best effort. + } +} + +function serviceStateFile(): string { + return path.join(CONFIGDIR, serviceStateFileName); +} + +function currentCliScript(): string { + return __filename; +} + +async function waitForServiceState(pid: number | undefined, timeoutMs: number): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const state = readServiceState(); + if (state && (!pid || state.pid === pid) && (await verifyServiceState(state)).ok) { + return state; + } + await delay(150); + } + return undefined; +} + +async function waitForProcessExit(pid: number, timeoutMs: number): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (!isProcessRunning(pid)) { + return true; + } + await delay(150); + } + return !isProcessRunning(pid); +} + +function isProcessRunning(pid: number | undefined): boolean { + if (!pid || pid <= 0) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = typeof error === "object" && error !== null && "code" in error ? (error as { code?: unknown }).code : undefined; + return code === "EPERM"; + } +} + +type ServiceStateVerification = + | { ok: true; trustedPid: boolean } + | { ok: false }; + +type ServiceIdentity = { + pid?: unknown; + serviceTokenConfigured?: unknown; + serviceTokenMatches?: unknown; +}; + +async function verifyServiceState(state: ServiceState): Promise { + if (!isProcessRunning(state.pid)) { + return { ok: false }; + } + + if (state.serviceToken) { + const identity = await callServiceRpc(state, "getServiceIdentity", [state.serviceToken]).catch(() => undefined); + if (identity?.serviceTokenMatches === true && Number(identity.pid) === state.pid) { + return { ok: true, trustedPid: true }; + } + return { ok: false }; + } + + const appInfo = await callServiceRpc<{ name?: unknown }>(state, "getAppInfo").catch(() => undefined); + return appInfo?.name === "Claude Code Router" + ? { ok: true, trustedPid: false } + : { ok: false }; +} + +async function waitForServiceUnavailable(state: ServiceState, timeoutMs: number): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const appInfo = await callServiceRpc<{ name?: unknown }>(state, "getAppInfo").catch(() => undefined); + if (appInfo?.name !== "Claude Code Router") { + return true; + } + await delay(150); + } + const appInfo = await callServiceRpc<{ name?: unknown }>(state, "getAppInfo").catch(() => undefined); + return appInfo?.name !== "Claude Code Router"; +} + +async function callServiceRpc(state: ServiceState, method: string, args: unknown[] = []): Promise { + const endpoint = serviceRpcEndpoint(state.url); + const authToken = serviceAuthToken(state.url); + if (!endpoint || !authToken) { + throw new Error("CCR service state does not include a usable management URL."); + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), serviceRpcTimeoutMs); + try { + const response = await fetch(endpoint, { + body: JSON.stringify({ args, method }), + headers: { + "content-type": "application/json", + [webAuthHeader]: authToken + }, + method: "POST", + signal: controller.signal + }); + const payload = await response.json().catch(() => undefined) as { ok?: boolean; value?: T } | undefined; + if (!response.ok || !payload?.ok) { + throw new Error(`CCR service RPC ${method} failed with HTTP ${response.status}`); + } + return payload.value as T; + } finally { + clearTimeout(timer); + } +} + +function serviceRpcEndpoint(url: string): string | undefined { + try { + const parsed = new URL(url); + return `${parsed.origin}/api/ccr/rpc`; + } catch { + return undefined; + } +} + +function serviceAuthToken(url: string): string { + try { + return new URL(url).searchParams.get(webAuthQueryParam)?.trim() ?? ""; + } catch { + return ""; + } +} + +function generateServiceToken(): string { + return randomBytes(32).toString("base64url"); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function requiredArg(value: string | undefined, option: string): string { + if (!value?.trim()) { + throw new Error(`${option} requires a value.`); + } + return value.trim(); +} + +function parsePort(value: string): number { + const port = Number(value); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`Invalid port: ${value}`); + } + return port; +} + +function waitForChild(child: ReturnType): Promise { + return new Promise((resolve) => { + child.on("exit", (code, signal) => resolve(code ?? (signal === "SIGINT" ? 130 : 1))); + child.on("error", (error) => { + process.stderr.write(`${formatError(error)}\n`); + resolve(1); + }); + }); +} + +function waitForImmediateSpawnError(child: ReturnType, timeoutMs: number): Promise { + return new Promise((resolve) => { + let settled = false; + let timer: NodeJS.Timeout | undefined; + const finish = (message: string | undefined) => { + if (settled) { + return; + } + settled = true; + if (timer) { + clearTimeout(timer); + } + child.off("error", onError); + child.off("spawn", onSpawn); + resolve(message); + }; + const onError = (error: Error) => finish(formatError(error)); + const onSpawn = () => finish(undefined); + child.once("error", onError); + child.once("spawn", onSpawn); + timer = setTimeout(() => finish(undefined), timeoutMs); + timer.unref?.(); + }); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +main().catch((error) => { + process.stderr.write(`${formatError(error)}\n`); + process.exitCode = 1; +}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..1f70006 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src" + }, + "include": ["src/**/*.ts", "src/**/*.d.ts"] +} diff --git a/models.json b/packages/core/models.json similarity index 100% rename from models.json rename to packages/core/models.json diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..ce6ce8d --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,21 @@ +{ + "name": "@claude-code-router/core", + "version": "3.0.2", + "private": true, + "description": "Claude Code Router core gateway, routing, provider, and storage services.", + "main": "dist/main/server.js", + "bin": { + "ccr-core-server": "dist/main/server.js" + }, + "engines": { + "node": ">=22" + }, + "dependencies": { + "@the-next-ai/ai-gateway": "^1.0.4", + "@the-next-ai/bot-gateway-sdk": "^0.1.0", + "better-sqlite3": "^12.11.1", + "node-forge": "^1.4.0", + "pm2": "^6.0.13", + "undici": "^7.27.2" + } +} diff --git a/src/main/bot-gateway-env.ts b/packages/core/src/agents/bot-gateway/env.ts similarity index 92% rename from src/main/bot-gateway-env.ts rename to packages/core/src/agents/bot-gateway/env.ts index 524945b..1dd8e60 100644 --- a/src/main/bot-gateway-env.ts +++ b/packages/core/src/agents/bot-gateway/env.ts @@ -1,8 +1,9 @@ import os from "node:os"; import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; import path from "node:path"; -import type { AppConfig, BotGatewayRuntimeConfig, ProfileConfig, ProfileOpenSurface } from "../shared/app"; -import { CONFIGDIR } from "./constants"; +import type { AppConfig, BotGatewayRuntimeConfig, ProfileConfig, ProfileOpenSurface } from "@ccr/core/contracts/app"; +import { CONFIGDIR } from "@ccr/core/config/constants"; const requireFromHere = createRequire(__filename); @@ -126,6 +127,11 @@ function botGatewaySdkEnv(): Record { } function resolveBotGatewaySdkModule(): string { + const bundled = resolveBundledBotGatewaySdkModule(); + if (bundled) { + return bundled; + } + try { return path.join(path.dirname(requireFromHere.resolve("@the-next-ai/bot-gateway-sdk/package.json")), "dist", "index.js"); } catch { @@ -133,6 +139,20 @@ function resolveBotGatewaySdkModule(): string { } } +function resolveBundledBotGatewaySdkModule(): string { + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + const candidates = [ + path.join(__dirname, "bot-gateway-sdk", "dist", "index.js"), + ...(resourcesPath + ? [ + path.join(resourcesPath, "app.asar", "dist", "main", "bot-gateway-sdk", "dist", "index.js"), + path.join(resourcesPath, "app", "dist", "main", "bot-gateway-sdk", "dist", "index.js") + ] + : []) + ]; + return candidates.find((candidate) => existsSync(candidate)) ?? ""; +} + function normalizeBotGatewayForWebSocket(bot: BotGatewayRuntimeConfig): BotGatewayRuntimeConfig { const platform = normalizeBotGatewayPlatform(bot.platform); return { diff --git a/src/main/bot-handoff-scan-service.ts b/packages/core/src/agents/bot-gateway/handoff-scan-service.ts similarity index 98% rename from src/main/bot-handoff-scan-service.ts rename to packages/core/src/agents/bot-gateway/handoff-scan-service.ts index 8522eb0..59712eb 100644 --- a/src/main/bot-handoff-scan-service.ts +++ b/packages/core/src/agents/bot-gateway/handoff-scan-service.ts @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import type { BotHandoffScanTarget } from "../shared/app"; -import { windowsSystemCommand } from "./windows-system"; +import type { BotHandoffScanTarget } from "@ccr/core/contracts/app"; +import { windowsSystemCommand } from "@ccr/core/platform/windows-system"; const execFileAsync = promisify(execFile); diff --git a/src/main/bot-gateway-qr-login-service.ts b/packages/core/src/agents/bot-gateway/qr-login-service.ts similarity index 95% rename from src/main/bot-gateway-qr-login-service.ts rename to packages/core/src/agents/bot-gateway/qr-login-service.ts index b44edca..cf1f665 100644 --- a/src/main/bot-gateway-qr-login-service.ts +++ b/packages/core/src/agents/bot-gateway/qr-login-service.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { CONFIGDIR } from "./constants"; +import { CONFIGDIR } from "@ccr/core/config/constants"; import type { BotGatewayQrLoginCancelRequest, BotGatewayQrLoginCancelResult, @@ -11,7 +11,7 @@ import type { BotGatewayQrLoginWaitRequest, BotGatewayQrLoginWaitResult, BotGatewayRuntimeConfig -} from "../shared/app"; +} from "@ccr/core/contracts/app"; type BotGatewayClientWithRequest = { close?: () => Promise | void; @@ -244,6 +244,7 @@ async function loadBotGatewaySdk(): Promise { async function importBotGatewaySdk(): Promise { const candidates = [ process.env.CCR_BOT_GATEWAY_SDK_MODULE, + resolveBundledBotGatewaySdkModule(), "@the-next-ai/bot-gateway-sdk" ].filter((value): value is string => Boolean(value?.trim())); const errors: string[] = []; @@ -261,6 +262,20 @@ async function importBotGatewaySdk(): Promise { throw new Error(`Unable to load @the-next-ai/bot-gateway-sdk. ${errors.join("; ")}`); } +function resolveBundledBotGatewaySdkModule(): string { + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + const candidates = [ + path.join(__dirname, "bot-gateway-sdk", "dist", "index.js"), + ...(resourcesPath + ? [ + path.join(resourcesPath, "app.asar", "dist", "main", "bot-gateway-sdk", "dist", "index.js"), + path.join(resourcesPath, "app", "dist", "main", "bot-gateway-sdk", "dist", "index.js") + ] + : []) + ]; + return candidates.find((candidate) => existsSync(candidate)) ?? ""; +} + function botGatewaySdkImportSpecifier(value: string): string { const trimmed = value.trim(); if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) { diff --git a/src/main/claude-app-cdp.ts b/packages/core/src/agents/claude-app/cdp.ts similarity index 98% rename from src/main/claude-app-cdp.ts rename to packages/core/src/agents/claude-app/cdp.ts index df2ca2f..9ced811 100644 --- a/src/main/claude-app-cdp.ts +++ b/packages/core/src/agents/claude-app/cdp.ts @@ -46,12 +46,9 @@ const claudeAppDesignCdpConnectTimeoutMs = 15_000; const claudeAppDesignCdpKeepAliveMs = 45_000; const claudeAppDesignCdpPollIntervalMs = 250; -export function shouldEnableClaudeAppDesignCdp(enabledByConfig = false): boolean { +export function shouldEnableClaudeAppDesignCdp(_enabledByConfig = false): boolean { const configured = process.env.CCR_CLAUDE_APP_DESIGN_CDP?.trim().toLowerCase(); - if (configured === "false" || configured === "0" || configured === "off") { - return false; - } - return enabledByConfig || configured === "true" || configured === "1" || configured === "on"; + return configured === "true" || configured === "1" || configured === "on"; } export async function reserveClaudeAppCdpPort(logger: ClaudeAppCdpLogger = console, enabledByConfig = false): Promise { diff --git a/src/shared/claude-app-gateway.ts b/packages/core/src/agents/claude-app/gateway-routes.ts similarity index 95% rename from src/shared/claude-app-gateway.ts rename to packages/core/src/agents/claude-app/gateway-routes.ts index fff1a9c..3ccda7e 100644 --- a/src/shared/claude-app-gateway.ts +++ b/packages/core/src/agents/claude-app/gateway-routes.ts @@ -1,5 +1,5 @@ -import type { AppConfig } from "./app"; -import { normalizeProfileScopeValue } from "./app"; +import type { AppConfig } from "@ccr/core/contracts/app"; +import { normalizeProfileScopeValue } from "@ccr/core/contracts/app"; export const CLAUDE_APP_FALLBACK_MODEL = "claude-sonnet-4-5"; export const CLAUDE_APP_ONE_MILLION_CONTEXT_SUFFIX = "[1m]"; @@ -25,14 +25,13 @@ export type ClaudeAppGatewayInferenceModel = { supports1m?: true; }; -export function inferClaudeAppGatewayTargetModel(config: Pick): string { - return config.Router.default?.trim() || - inferGlobalClaudeProfileModel(config) || +export function inferClaudeAppGatewayTargetModel(config: Pick): string { + return inferGlobalClaudeProfileModel(config) || CLAUDE_APP_FALLBACK_MODEL; } export function buildClaudeAppGatewayModelRoutes( - config: Pick, + config: Pick, options: ClaudeAppGatewayModelRouteOptions = {} ): ClaudeAppGatewayModelRoute[] { const targetModels = claudeAppGatewayTargetModels(config); @@ -74,7 +73,7 @@ export function buildClaudeAppGatewayModelRoutes( export function resolveClaudeAppGatewayRouteModel( model: string, - config: Pick, + config: Pick, options: ClaudeAppGatewayModelRouteOptions = {} ): string | undefined { const normalized = model.trim().toLowerCase(); @@ -99,7 +98,7 @@ export function resolveClaudeAppGatewayRouteModel( } export function buildClaudeAppGatewayInferenceModels( - config: Pick, + config: Pick, options: ClaudeAppGatewayModelRouteOptions = {} ): ClaudeAppGatewayInferenceModel[] { const routes = buildClaudeAppGatewayModelRoutes(config, options); @@ -129,7 +128,7 @@ function inferGlobalClaudeProfileModel(config: Pick): stri )?.model.trim() ?? ""; } -function claudeAppGatewayTargetModels(config: Pick): string[] { +function claudeAppGatewayTargetModels(config: Pick): string[] { const baseEntries = config.Providers.flatMap((provider) => { const providerName = provider.name?.trim(); if (!providerName || !Array.isArray(provider.models)) { diff --git a/src/main/claude-app-gateway-service.ts b/packages/core/src/agents/claude-app/gateway-service.ts similarity index 84% rename from src/main/claude-app-gateway-service.ts rename to packages/core/src/agents/claude-app/gateway-service.ts index c9494a8..58c7eff 100644 --- a/src/main/claude-app-gateway-service.ts +++ b/packages/core/src/agents/claude-app/gateway-service.ts @@ -1,17 +1,17 @@ -import { app } from "electron"; import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { createHash, randomBytes, randomUUID } from "node:crypto"; -import { saveAppConfig } from "./config"; -import { CONFIGDIR } from "./constants"; +import { resolveRuntimeAppPath } from "@ccr/core/runtime/app-paths"; +import { saveAppConfig } from "@ccr/core/config/config"; +import { CONFIGDIR } from "@ccr/core/config/constants"; import { buildClaudeAppGatewayInferenceModels, type ClaudeAppGatewayInferenceModel, type ClaudeAppGatewayModelRouteOptions -} from "../shared/claude-app-gateway"; -import type { ApiKeyConfig, AppConfig, ClaudeAppGatewayApplyResult } from "../shared/app"; -import { findModelCatalogEntry } from "../server/gateway/model-catalog"; +} from "@ccr/core/agents/claude-app/gateway-routes"; +import { NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, hasAvailableGatewayModels, type ApiKeyConfig, type AppConfig, type ClaudeAppGatewayApplyResult } from "@ccr/core/contracts/app"; +import { findModelCatalogEntry } from "@ccr/core/gateway/model-catalog"; const CLAUDE_APP_CONFIG_ID = "8f69f2f1-3275-4ad8-9317-4aa7e972f311"; const CLAUDE_APP_CONFIG_NAME = "Claude Code Router"; @@ -77,6 +77,14 @@ export type ClaudeAppGatewaySyncResult = { }; export async function syncClaudeAppGatewayConfig(config: AppConfig): Promise { + if (!hasAvailableGatewayModels(config)) { + return { + config, + configChanged: false, + result: skippedClaudeAppGatewayResult(config) + }; + } + const applied = applyClaudeAppGatewayConfig(config); if (applied.config === config) { return { @@ -93,7 +101,25 @@ export async function syncClaudeAppGatewayConfig(config: AppConfig): Promise(); + const result: string[] = []; + for (const value of values) { + const normalized = value.trim(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + result.push(normalized); + } + return result; +} + function ensureClaudeAppGatewayState(config: AppConfig): ClaudeAppApplyState { const currentApiKey = findReusableApiKey(config); const gatewayEnabledConfig = config.gateway.enabled @@ -236,13 +289,17 @@ function getClaudeAppGatewayPaths(dataDir = getClaudeApp3pDataDir()): ClaudeAppG function getClaudeApp3pDataDir(): string { if (process.platform === "darwin") { - return path.join(app.getPath("home"), "Library", "Application Support", "Claude-3p"); + return path.join(appPath("home"), "Library", "Application Support", "Claude-3p"); } if (process.platform === "win32") { - const localAppData = process.env.LOCALAPPDATA || path.join(app.getPath("appData"), "..", "Local"); + const localAppData = process.env.LOCALAPPDATA || path.join(appPath("appData"), "..", "Local"); return path.join(localAppData, "Claude-3p"); } - return path.join(app.getPath("appData") || os.homedir(), "Claude-3p"); + return path.join(appPath("appData") || os.homedir(), "Claude-3p"); +} + +function appPath(name: "appData" | "home"): string { + return resolveRuntimeAppPath(name); } function backupClaudeAppGatewayConfig(paths: ClaudeAppGatewayPaths): void { diff --git a/src/main/claude-app-launch.ts b/packages/core/src/agents/claude-app/launch.ts similarity index 76% rename from src/main/claude-app-launch.ts rename to packages/core/src/agents/claude-app/launch.ts index fa75457..cdfa57d 100644 --- a/src/main/claude-app-launch.ts +++ b/packages/core/src/agents/claude-app/launch.ts @@ -2,11 +2,12 @@ import { spawn, type ChildProcess } from "node:child_process"; import { mkdirSync, readdirSync, readFileSync, statSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { AppConfig, ProfileConfig } from "../shared/app"; -import { botGatewayProfileEnv } from "./bot-gateway-env"; -import { prepareClaudeAppCdpUserDataDir, reserveClaudeAppCdpPort, scheduleClaudeAppDesignCdp } from "./claude-app-cdp"; -import { resolveClaudeCodeSettingsFile } from "./profile-launch-core"; -import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "./windows-app-discovery"; +import type { AppConfig, ProfileConfig } from "@ccr/core/contracts/app"; +import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env"; +import { prepareClaudeAppCdpUserDataDir, reserveClaudeAppCdpPort, scheduleClaudeAppDesignCdp } from "@ccr/core/agents/claude-app/cdp"; +import { claudeCodeUtcTimezoneEnvOverride } from "@ccr/core/agents/claude-code/environment"; +import { resolveClaudeCodeSettingsFile } from "@ccr/core/profiles/launch-core"; +import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "@ccr/core/platform/windows-app-discovery"; type ClaudeAppLookupResult = { checked: string[]; @@ -18,6 +19,7 @@ export type ClaudeAppLaunchResult = { command: string; cdpPort?: number; claudeDesignProxy?: boolean; + pidIsLauncher?: boolean; pid?: number; userDataDir: string; }; @@ -51,21 +53,27 @@ export async function launchClaudeAppProfile(configDir: string, profile: Profile const shouldOpenDesign = shouldOpenClaudeAppDesign(config); const cdpPort = await reserveClaudeAppCdpPort(console, shouldOpenDesign); - const env: NodeJS.ProcessEnv = { - ...process.env, + const appEnv: Record = { ...profileEnv(profile), + ...claudeCodeModelEnv(profile), ...(config ? botGatewayProfileEnv(config, profile, "app") : {}), CLAUDE_CONFIG_DIR: settingsDir, CLAUDE_USER_DATA_DIR: userDataDir, CCR_CLAUDE_APP_USER_DATA_PATH: userDataDir, CCR_PROFILE_SURFACE: "app", - ELECTRON_ENABLE_LOGGING: "1" + ELECTRON_ENABLE_LOGGING: "1", + ...claudeCodeUtcTimezoneEnvOverride() + }; + const env: NodeJS.ProcessEnv = { + ...process.env, + ...appEnv }; delete env.ELECTRON_RUN_AS_NODE; const designUrl = claudeAppDesignUrl(config); const proxyUrl = claudeAppProxyUrl(config); - const child = spawn(lookup.executable, claudeElectronArgs(userDataDir, cdpPort, proxyUrl), { + const launch = claudeAppLaunchCommand(lookup.executable, userDataDir, cdpPort, proxyUrl, appEnv); + const child = spawn(launch.command, launch.args, { detached: true, env, stdio: "ignore" @@ -81,8 +89,9 @@ export async function launchClaudeAppProfile(configDir: string, profile: Profile return { child, claudeDesignProxy: Boolean(proxyUrl), - command: lookup.executable, + command: launch.command, ...(cdpPort ? { cdpPort } : {}), + ...(launch.pidIsLauncher ? { pidIsLauncher: launch.pidIsLauncher } : {}), pid: child.pid, userDataDir }; @@ -160,6 +169,52 @@ function claudeElectronArgs(userDataDir: string, cdpPort?: number, proxyUrl?: st ]; } +export function claudeAppLaunchCommand( + executable: string, + userDataDir: string, + cdpPort: number | undefined, + proxyUrl: string | undefined, + env: Record +): { args: string[]; command: string; pidIsLauncher?: boolean } { + const args = claudeElectronArgs(userDataDir, cdpPort, proxyUrl); + const appBundle = process.platform === "darwin" ? macAppBundleFromExecutable(executable) : undefined; + if (appBundle) { + return { + args: [ + "-W", + "-n", + ...macOpenEnvArgs(env), + appBundle, + "--args", + ...args + ], + command: "/usr/bin/open", + pidIsLauncher: true + }; + } + return { + args, + command: executable + }; +} + +function macAppBundleFromExecutable(executable: string): string | undefined { + const normalized = path.normalize(executable); + const marker = `${path.sep}Contents${path.sep}MacOS${path.sep}`; + const markerIndex = normalized.lastIndexOf(marker); + if (markerIndex <= 0) { + return undefined; + } + const appBundle = normalized.slice(0, markerIndex); + return appBundle.endsWith(".app") && isDirectory(appBundle) ? appBundle : undefined; +} + +function macOpenEnvArgs(env: Record): string[] { + return Object.entries(env) + .filter(([key, value]) => isEnvName(key) && typeof value === "string") + .flatMap(([key, value]) => ["--env", `${key}=${value}`]); +} + function claudeElectronUserDataDir(settingsDir: string, profile: ProfileConfig): string { return path.join( settingsDir, @@ -312,6 +367,35 @@ function profileEnv(profile: ProfileConfig): Record { }, {}); } +function claudeCodeModelEnv(profile: ProfileConfig): Record { + const env: Record = {}; + const model = normalizeClientModel(profile.model); + if (model) { + env.ANTHROPIC_MODEL = model; + env.CCR_CLAUDE_CODE_MODEL = model; + env.CODEXL_CLAUDE_CODE_MODEL = model; + } + const smallFastModel = normalizeClientModel(profile.smallFastModel); + if (smallFastModel) { + env.ANTHROPIC_SMALL_FAST_MODEL = smallFastModel; + } + return env; +} + +function normalizeClientModel(value: string | undefined): string { + const trimmed = value?.trim() || ""; + if (!trimmed) { + return ""; + } + const commaIndex = trimmed.indexOf(","); + if (commaIndex > 0 && commaIndex < trimmed.length - 1) { + const provider = trimmed.slice(0, commaIndex).trim(); + const model = trimmed.slice(commaIndex + 1).trim(); + return provider && model ? `${provider}/${model}` : ""; + } + return trimmed; +} + function isEnvName(value: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value); } diff --git a/packages/core/src/agents/claude-code/environment.ts b/packages/core/src/agents/claude-code/environment.ts new file mode 100644 index 0000000..33ed526 --- /dev/null +++ b/packages/core/src/agents/claude-code/environment.ts @@ -0,0 +1,39 @@ +export const CLAUDE_CODE_MCP_CONFIG_ENV = "CCR_CLAUDE_CODE_MCP_CONFIG"; +export const CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV = "CODEXL_CLAUDE_CODE_MCP_CONFIG"; + +const chinaTimeZones = new Set([ + "asia/chongqing", + "asia/chungking", + "asia/harbin", + "asia/kashgar", + "asia/shanghai", + "asia/urumqi", + "china standard time", + "prc" +]); + +export function claudeCodeMcpConfigEnv(configFile: string | undefined): Record { + return configFile + ? { + [CLAUDE_CODE_MCP_CONFIG_ENV]: configFile, + [CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV]: configFile + } + : {}; +} + +export function claudeCodeUtcTimezoneEnvOverride(timeZone = currentTimeZone()): Record { + return isChinaTimeZone(timeZone) ? { TZ: "UTC" } : {}; +} + +export function currentTimeZone(): string | undefined { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return undefined; + } +} + +export function isChinaTimeZone(timeZone: string | undefined): boolean { + const normalized = timeZone?.trim().toLowerCase(); + return Boolean(normalized && chinaTimeZones.has(normalized)); +} diff --git a/src/main/codex-app-launch.ts b/packages/core/src/agents/codex/app-launch.ts similarity index 92% rename from src/main/codex-app-launch.ts rename to packages/core/src/agents/codex/app-launch.ts index e75a8db..566770d 100644 --- a/src/main/codex-app-launch.ts +++ b/packages/core/src/agents/codex/app-launch.ts @@ -2,12 +2,12 @@ import { spawn, type ChildProcess } from "node:child_process"; import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { AppConfig, ProfileConfig } from "../shared/app"; -import { botGatewayProfileEnv } from "./bot-gateway-env"; -import { codexModelCatalogJson } from "./codex-model-catalog"; -import { buildProfileLaunchPlan, resolveCodexConfigFile } from "./profile-launch-core"; -import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "./windows-app-discovery"; -import { writeZcodeGatewayConfig, zcodeHomeFromConfigFile } from "./zcode-profile-config"; +import type { AppConfig, ProfileConfig } from "@ccr/core/contracts/app"; +import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env"; +import { codexModelCatalogJson } from "@ccr/core/agents/codex/model-catalog"; +import { buildProfileLaunchPlan, resolveCodexConfigFile } from "@ccr/core/profiles/launch-core"; +import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "@ccr/core/platform/windows-app-discovery"; +import { writeZcodeGatewayConfig, zcodeHomeFromConfigFile } from "@ccr/core/agents/zcode/profile-config"; type CodexAppLookupResult = { checked: string[]; @@ -41,6 +41,12 @@ export type CodexAppLaunchResult = { userDataDir: string; }; +export type CodexCompatibleAppModelCatalogWriteResult = { + changed: boolean; + file: string; + userDataDir: string; +}; + const codexAppSpec: CodexCompatibleAppSpec = { bundledCliNames: ["codex", "Codex", "OpenAI Codex"], defaultCliCommand: "codex", @@ -137,18 +143,36 @@ export function refreshCodexCompatibleAppProfileFiles( configDir: string, profile: ProfileConfig, config?: AppConfig -): { modelCatalogFile: string; userDataDir: string } { +): { modelCatalogChanged: boolean; modelCatalogFile: string; userDataDir: string } { const spec = profile.agent === "zcode" ? zcodeAppSpec : codexAppSpec; - const configFile = resolveCodexConfigFile(configDir, profile); if (spec.kind === "zcode" && config?.APIKEY) { writeZcodeGatewayConfig(config, profile, config.APIKEY, { backup: false }); } + const modelCatalog = writeCodexCompatibleAppModelCatalog(configDir, profile, config); + return { + modelCatalogChanged: modelCatalog.changed, + modelCatalogFile: modelCatalog.file, + userDataDir: modelCatalog.userDataDir + }; +} + +export function writeCodexCompatibleAppModelCatalog( + configDir: string, + profile: ProfileConfig, + config?: AppConfig +): CodexCompatibleAppModelCatalogWriteResult { + const spec = profile.agent === "zcode" ? zcodeAppSpec : codexAppSpec; + const configFile = resolveCodexConfigFile(configDir, profile); const codexHome = codexCompatibleHomeFromConfigFile(spec, configFile); const userDataDir = codexElectronUserDataDir(codexHome, profile, spec); mkdirSync(userDataDir, { recursive: true }); - const modelCatalogFile = codexAppModelCatalogFile(userDataDir, spec); - writeFileSync(modelCatalogFile, codexModelCatalogJson(config, profile.model), "utf8"); - return { modelCatalogFile, userDataDir }; + const file = codexAppModelCatalogFile(userDataDir, spec); + const content = codexModelCatalogJson(config, profile.model); + const previous = existsSync(file) ? readFileSync(file, "utf8") : undefined; + if (previous !== content) { + writeFileSync(file, content, "utf8"); + } + return { changed: previous !== content, file, userDataDir }; } function launchCodexCompatibleAppProfile( diff --git a/src/main/codex-cli-middleware-runtime.ts b/packages/core/src/agents/codex/cli-middleware-runtime.ts similarity index 89% rename from src/main/codex-cli-middleware-runtime.ts rename to packages/core/src/agents/codex/cli-middleware-runtime.ts index 6791ffb..0c87695 100644 --- a/src/main/codex-cli-middleware-runtime.ts +++ b/packages/core/src/agents/codex/cli-middleware-runtime.ts @@ -18,15 +18,47 @@ const REQUEST_TIMEOUT_MS = numberEnv("CCR_CODEX_APP_REQUEST_TIMEOUT_MS", 10 * 60 const TURN_IDLE_TIMEOUT_MS = numberEnv("CCR_CODEX_CLAUDE_TURN_IDLE_TIMEOUT_MS", 10 * 60 * 1000); const CONFIG_DIR = resolveConfigDir(); const LOG_PATH = process.env.CCR_CODEX_CLI_MIDDLEWARE_LOG || ""; +const CLAUDE_CODE_MCP_CONFIG_ENV = "CCR_CLAUDE_CODE_MCP_CONFIG"; +const CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV = "CODEXL_CLAUDE_CODE_MCP_CONFIG"; +const CLAUDE_CODE_CHINA_TIME_ZONES = new Set([ + "asia/chongqing", + "asia/chungking", + "asia/harbin", + "asia/kashgar", + "asia/shanghai", + "asia/urumqi", + "china standard time", + "prc" +]); let BOT_BRIDGE_INSTANCE = null; +function claudeCodeUtcTimezoneEnvOverride() { + return isClaudeCodeChinaTimeZone(currentTimeZone()) ? { TZ: "UTC" } : {}; +} + +function currentTimeZone() { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return ""; + } +} + +function isClaudeCodeChinaTimeZone(timeZone) { + const normalized = String(timeZone || "").trim().toLowerCase(); + return Boolean(normalized && CLAUDE_CODE_CHINA_TIME_ZONES.has(normalized)); +} + function resolveConfigDir() { const configured = nonEmptyEnv("CODEXL_HOME") || nonEmptyEnv("CCR_CONFIG_DIR"); if (configured) { return expandHome(configured); } if (process.platform === "win32") { - return path.join(process.env.APPDATA || process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Roaming"), "Claude Code Router"); + const appData = process.env.APPDATA || process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Roaming"); + const current = path.join(appData, "claude-code-router"); + const legacy = path.join(appData, "Claude Code Router"); + return fs.existsSync(current) || !fs.existsSync(legacy) ? current : legacy; } return path.join(os.homedir(), ".claude-code-router"); } @@ -57,32 +89,181 @@ async function main() { async function runClaudeCodeCliWrapper(args) { const realCli = expandHome(nonEmptyEnv("CCR_REAL_CLAUDE_CODE_BIN") || nonEmptyEnv("CCR_CLAUDE_CODE_BIN") || nonEmptyEnv("CODEXL_CLAUDE_CODE_BIN") || "claude"); - log("claude_code_wrapper_start", { realCli, args }); - const child = childProcess.spawn(realCli, args, { - env: withoutKeys(process.env, ["CCR_CLAUDE_CODE_WRAPPER", "CCR_REAL_CLAUDE_CODE_BIN"]), - stdio: ["inherit", "pipe", "inherit"] + const realArgs = claudeCodeCliWrapperArgs(args); + log("claude_code_wrapper_start", { realCli, args, realArgs }); + const captureStdout = shouldCaptureClaudeCodeCliStdout(args); + const remoteSync = createRemoteSyncClient({ + args, + cwd: process.cwd(), + mode: "claude-cli", + title: nonEmptyEnv("CCR_REMOTE_SYNC_PROFILE_NAME") || "Claude Code" + }); + const injectRemoteStdin = boolEnv("CCR_REMOTE_SYNC_INJECT_STDIN"); + const child = childProcess.spawn(realCli, realArgs, { + env: { + ...withoutKeys(process.env, ["CCR_CLAUDE_CODE_WRAPPER", "CCR_REAL_CLAUDE_CODE_BIN"]), + ...claudeCodeUtcTimezoneEnvOverride() + }, + stdio: [injectRemoteStdin ? "pipe" : "inherit", captureStdout ? "pipe" : "inherit", "inherit"] + }); + if (injectRemoteStdin && child.stdin) { + process.stdin.pipe(child.stdin); + } + remoteSync.start((event) => { + const text = remoteEventText(event); + if (!text) return; + if (injectRemoteStdin && child.stdin && !child.killed) { + child.stdin.write(text + "\n"); + return; + } + if (boolEnv("CCR_REMOTE_SYNC_NOTIFY_INBOUND") || !process.env.CCR_REMOTE_SYNC_NOTIFY_INBOUND) { + process.stdout.write("\n[CCR remote] " + text + "\n"); + } }); child.on("error", (error) => { log("claude_code_wrapper_spawn_error", { error: formatError(error) }); + remoteSync.postEvent("claude.spawn.error", { error: formatError(error) }, { direction: "system" }); }); let pending = ""; - child.stdout.on("data", (chunk) => { - process.stdout.write(chunk); - pending += chunk.toString("utf8"); - const lines = pending.split(/\r?\n/g); - pending = lines.pop() || ""; - for (const line of lines) { - botBridge().handleClaudeCliLine(line); - } - }); - const code = await waitForChild(child); - if (pending.trim()) { - botBridge().handleClaudeCliLine(pending); + if (captureStdout && child.stdout) { + child.stdout.on("data", (chunk) => { + process.stdout.write(chunk); + pending += chunk.toString("utf8"); + const lines = pending.split(/\r?\n/g); + pending = lines.pop() || ""; + for (const line of lines) { + botBridge().handleClaudeCliLine(line); + remoteSync.postEvent("claude.stdout", { line }, { text: line }); + } + }); } + const code = await waitForChild(child); + if (captureStdout && pending.trim()) { + botBridge().handleClaudeCliLine(pending); + remoteSync.postEvent("claude.stdout", { line: pending }, { text: pending }); + } + await remoteSync.postEvent("claude.exit", { code }, { direction: "system" }); + remoteSync.stop(); log("claude_code_wrapper_exit", { code }); process.exitCode = code; } +function claudeCodeCliWrapperArgs(args) { + const modelArgs = claudeCodeArgsWithModel(args); + return claudeCodeArgsWithMcpConfig(modelArgs, process.env); +} + +function claudeCodeArgsWithModel(args) { + const model = nonEmptyEnv("CCR_CLAUDE_CODE_MODEL") || nonEmptyEnv("CODEXL_CLAUDE_CODE_MODEL") || nonEmptyEnv("ANTHROPIC_MODEL"); + if (!model || claudeCodeArgsHaveModel(args) || claudeCodeArgsShouldSkipModelInjection(args)) { + return args; + } + return ["--model", model, ...args]; +} + +function claudeCodeArgsWithMcpConfig(args, env) { + const mcpConfig = nonEmptyEnvFrom(env, CLAUDE_CODE_MCP_CONFIG_ENV) || nonEmptyEnvFrom(env, CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV); + if (!mcpConfig || claudeCodeArgsHaveMcpConfig(args) || claudeCodeArgsShouldSkipModelInjection(args)) { + return args; + } + return ["--mcp-config", mcpConfig, ...args]; +} + +function claudeCodeArgsHaveModel(args) { + for (const arg of args) { + if (arg === "--model" || arg === "-m" || arg.startsWith("--model=")) { + return true; + } + } + return false; +} + +function claudeCodeArgsHaveMcpConfig(args) { + for (const arg of args) { + if (arg === "--mcp-config" || arg.startsWith("--mcp-config=")) { + return true; + } + } + return false; +} + +function claudeCodeArgsShouldSkipModelInjection(args) { + if (args.some((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-v")) { + return true; + } + const command = firstClaudeCodePositionalArg(args); + return Boolean(command && new Set([ + "config", + "doctor", + "help", + "install", + "login", + "logout", + "mcp", + "update", + "upgrade", + "version" + ]).has(command.toLowerCase())); +} + +function firstClaudeCodePositionalArg(args) { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--") { + return undefined; + } + if (!arg.startsWith("-")) { + return arg; + } + if (claudeCodeOptionTakesValue(arg) && !arg.includes("=")) { + index += 1; + } + } + return undefined; +} + +function claudeCodeOptionTakesValue(arg) { + return new Set([ + "--add-dir", + "--append-system-prompt", + "--config", + "--continue", + "--debug-to", + "--fallback-model", + "--model", + "--mcp-config", + "--output-format", + "--permission-mode", + "--resume", + "--settings", + "--system-prompt", + "-c", + "-m", + "-p", + "-r" + ]).has(arg); +} + +function shouldCaptureClaudeCodeCliStdout(args) { + if (boolEnv("CCR_CLAUDE_CODE_CAPTURE_STDOUT") || boolEnv("CODEXL_CLAUDE_CODE_CAPTURE_STDOUT")) { + return true; + } + if (botGatewayCliCaptureEnabled()) { + return true; + } + return claudeCodeArgsUsePrintMode(args); +} + +function botGatewayCliCaptureEnabled() { + const enabled = boolEnv("CCR_BOT_GATEWAY_ENABLED") || boolEnv("CODEXL_BOT_GATEWAY_ENABLED"); + const platform = normalizeBotGatewayPlatform(nonEmptyEnv("CCR_BOT_GATEWAY_PLATFORM") || nonEmptyEnv("CODEXL_BOT_GATEWAY_PLATFORM") || "none"); + return enabled && platform !== "none"; +} + +function claudeCodeArgsUsePrintMode(args) { + return args.some((arg) => arg === "--print" || arg === "-p"); +} + function defaultCodexArgs() { return normalizeProfileSurface(nonEmptyEnv("CCR_PROFILE_SURFACE") || nonEmptyEnv("CODEXL_PROFILE_SURFACE")) === "cli" ? [] @@ -654,7 +835,14 @@ async function runClaudeCodeBotWorker(args) { try { const server = new ClaudeCodeAppServer(options); server.ensureBotBridgeRegistered(); - log("claude_bot_worker_start", { workspaceName: options.workspaceName, pid: process.pid, lockPath: lock.path }); + log("claude_bot_worker_start", { + workspaceName: options.workspaceName, + pid: process.pid, + lockPath: lock.path, + claudeConfigDir: nonEmptyEnv("CLAUDE_CONFIG_DIR"), + claudeUserDataDir: currentClaudeAppUserDataDir(), + model: nonEmptyEnv("CCR_CLAUDE_CODE_MODEL") || nonEmptyEnv("CODEXL_CLAUDE_CODE_MODEL") || agentEnv(codexRuntimeAgent(), "MODEL") || "" + }); await waitForTerminationSignal(); await botBridge().stop(); log("claude_bot_worker_stop", { pid: process.pid }); @@ -1213,13 +1401,23 @@ class ClaudeCodeAppServer { return null; } if (!entry.claudeSessionId && !entry.claudeAppSessionId) return null; + const appSession = readClaudeAppLocalAgentSession(entry.claudeAppSessionFile || ""); + if (!botSessionEntryMatchesCurrentProfile(entry, appSession)) { + log("bot_gateway_session_scope_skip", { + conversationKeyPrefix: key.slice(0, 80), + threadId: entry.threadId || "", + claudeConfigDir: entry.claudeConfigDir || appSession.claudeConfigDir || "", + claudeAppSessionFile: entry.claudeAppSessionFile || "", + expectedUserDataDir: currentClaudeAppUserDataDir() + }); + return null; + } const thread = this.createThread({ cwd: entry.cwd || process.cwd(), model: entry.model || undefined, workspaceKind: "local", claudeConfigDir: entry.claudeConfigDir || null }); - const appSession = readClaudeAppLocalAgentSession(entry.claudeAppSessionFile || ""); this.replaceThreadId(thread, entry.threadId || thread.id); thread.sessionId = entry.sessionId || thread.id; thread.claudeSessionId = entry.claudeSessionId || appSession.cliSessionId || null; @@ -1455,7 +1653,15 @@ class ClaudeCodeAppServer { if (!thread || !turn) return; const started = Date.now(); const command = claudeCommand(work); - log("claude_turn_spawn", { threadId: work.threadId, turnId: work.turnId, command: command.command, args: command.args }); + log("claude_turn_spawn", { + threadId: work.threadId, + turnId: work.turnId, + command: command.command, + args: command.args, + cwd: work.cwd, + claudeConfigDir: work.claudeConfigDir || "", + expectedUserDataDir: currentClaudeAppUserDataDir() + }); const child = childProcess.spawn(command.command, command.args, { cwd: work.cwd, env: command.env, @@ -1763,6 +1969,7 @@ function claudeCommand(work) { const env = withoutKeys({ ...process.env, ...settingsEnv, + ...claudeCodeUtcTimezoneEnvOverride(), CODEX_SESSION_ID: work.threadId, CODEX_THREAD_ID: work.threadId, CODEX_TURN_ID: work.turnId @@ -1772,7 +1979,7 @@ function claudeCommand(work) { } return { command, - args, + args: claudeCodeArgsWithMcpConfig(args, env), env }; } @@ -2173,8 +2380,12 @@ function configRead(params, values) { model: agentEnv(runtimeAgent, "MODEL") || DEFAULT_MODEL, model_catalog_json: JSON.stringify(modelCatalogConfigValue()), model_provider: agentEnv(runtimeAgent, "MODEL_PROVIDER") || "claude-code", - approval_policy: "default", - sandbox_mode: "workspace-write" + approval_policy: "default" + // sandbox_mode intentionally omitted: let Codex read it from its own + // config.toml (e.g. [windows] sandbox) instead of forcing workspace-write. + // Forcing workspace-write triggers codex-windows-sandbox-setup.exe on every + // command, which fails on systems where the COM+ catalog is unavailable + // (see openai/codex#29332), surfacing as repeated error dialogs. } }; } @@ -2211,13 +2422,8 @@ function modelCatalogConfigItem(model, priority) { slug: model, display_name: model, description: "CCR gateway model " + model, - default_reasoning_level: "medium", - supported_reasoning_levels: [ - { effort: "low", description: "Low reasoning" }, - { effort: "medium", description: "Medium reasoning" }, - { effort: "high", description: "High reasoning" }, - { effort: "xhigh", description: "Extra high reasoning" } - ], + default_reasoning_level: null, + supported_reasoning_levels: [], shell_type: "shell_command", visibility: "list", supported_in_api: true, @@ -2227,21 +2433,21 @@ function modelCatalogConfigItem(model, priority) { availability_nux: null, upgrade: null, base_instructions: "You are Codex, a coding agent.", - supports_reasoning_summaries: true, + supports_reasoning_summaries: false, default_reasoning_summary: "none", support_verbosity: true, default_verbosity: "low", - apply_patch_tool_type: "freeform", - web_search_tool_type: "text_and_image", + apply_patch_tool_type: null, + web_search_tool_type: "text", truncation_policy: { mode: "tokens", limit: 10000 }, - supports_parallel_tool_calls: true, - supports_image_detail_original: true, + supports_parallel_tool_calls: false, + supports_image_detail_original: false, context_window: 128000, max_context_window: 128000, effective_context_window_percent: 95, experimental_supported_tools: [], - input_modalities: ["text", "image"], - supports_search_tool: true + input_modalities: ["text"], + supports_search_tool: false }; } @@ -2365,6 +2571,196 @@ function writeLine(stream, value) { stream.write(JSON.stringify(value) + "\n"); } +function createRemoteSyncClient(options) { + const endpoint = normalizeRemoteSyncEndpoint(nonEmptyEnv("CCR_REMOTE_SYNC_ENDPOINT")); + const enabled = endpoint && !["0", "false", "no", "off"].includes(String(process.env.CCR_REMOTE_SYNC_ENABLED || "1").trim().toLowerCase()); + if (!enabled || typeof fetch !== "function") { + return { + postEvent: async () => {}, + start() {}, + stop() {} + }; + } + return new RemoteSyncClient({ + args: options.args || [], + cwd: options.cwd || process.cwd(), + endpoint, + mode: options.mode || "agent", + title: options.title || "CCR Remote", + profileId: nonEmptyEnv("CCR_REMOTE_SYNC_PROFILE_ID"), + profileName: nonEmptyEnv("CCR_REMOTE_SYNC_PROFILE_NAME") + }); +} + +class RemoteSyncClient { + constructor(options) { + this.options = options; + this.active = false; + this.apiKey = ""; + this.lastInboundSeq = 0; + this.pollTimer = null; + this.ready = null; + this.seenInbound = new Set(); + this.sessionId = nonEmptyEnv("CCR_REMOTE_SYNC_SESSION_ID") || "ccr-" + safePathSegment(options.profileId || options.profileName || options.mode) + "-" + uuid(); + } + + start(onInbound) { + if (this.active) return; + this.active = true; + this.ready = this.open().catch((error) => { + this.active = false; + log("remote_sync_start_failed", { error: formatError(error) }); + }); + this.ready.then(() => { + if (this.active) this.pollInbound(onInbound); + }).catch(() => {}); + } + + stop() { + this.active = false; + if (this.pollTimer) { + clearTimeout(this.pollTimer); + this.pollTimer = null; + } + } + + async open() { + this.apiKey = await readRemoteSyncApiKey(); + const response = await this.request("POST", "/sessions", { + id: this.sessionId, + title: this.options.title, + metadata: { + args: this.options.args, + cwd: this.options.cwd, + mode: this.options.mode, + pid: process.pid, + profileId: this.options.profileId, + profileName: this.options.profileName, + startedAt: new Date().toISOString() + } + }); + const session = response && response.session; + if (session && session.id) this.sessionId = session.id; + log("remote_sync_started", { sessionId: this.sessionId, endpoint: this.options.endpoint }); + } + + postEvent(type, payload, options) { + if (!this.active) return Promise.resolve(); + const eventOptions = options || {}; + return Promise.resolve(this.ready) + .then(() => { + if (!this.active || !this.sessionId) return undefined; + return this.request("POST", "/sessions/" + encodeURIComponent(this.sessionId) + "/events", { + direction: eventOptions.direction || "local", + payload: payload || {}, + source: "ccr-claude-wrapper", + text: eventOptions.text, + type + }); + }) + .catch((error) => { + log("remote_sync_event_failed", { type, error: formatError(error) }); + }); + } + + pollInbound(onInbound) { + if (!this.active) return; + this.request("GET", "/sessions/" + encodeURIComponent(this.sessionId) + "/inbound?after=" + this.lastInboundSeq) + .then((response) => { + const events = Array.isArray(response && response.events) ? response.events : []; + for (const event of events) { + if (!event || !Number.isFinite(event.seq)) continue; + this.lastInboundSeq = Math.max(this.lastInboundSeq, event.seq); + const key = event.id || event.dedupeKey || String(event.seq); + if (this.seenInbound.has(key)) continue; + this.seenInbound.add(key); + while (this.seenInbound.size > 500) { + const oldest = this.seenInbound.values().next().value; + if (!oldest) break; + this.seenInbound.delete(oldest); + } + try { + onInbound(event); + } catch (error) { + log("remote_sync_inbound_handler_failed", { error: formatError(error) }); + } + } + }) + .catch((error) => { + log("remote_sync_poll_failed", { error: formatError(error) }); + }) + .finally(() => { + if (!this.active) return; + this.pollTimer = setTimeout(() => this.pollInbound(onInbound), numberEnv("CCR_REMOTE_SYNC_POLL_INTERVAL_MS", 2000)); + }); + } + + async request(method, suffix, body) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), numberEnv("CCR_REMOTE_SYNC_REQUEST_TIMEOUT_MS", 5000)); + const headers = { "accept": "application/json" }; + if (body !== undefined) headers["content-type"] = "application/json"; + if (this.apiKey) headers.authorization = "Bearer " + this.apiKey; + try { + const response = await fetch(remoteSyncUrl(this.options.endpoint, suffix), { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal + }); + if (!response.ok) { + throw new Error("HTTP " + response.status + " from CCR remote sync"); + } + return await response.json(); + } finally { + clearTimeout(timeout); + } + } +} + +async function readRemoteSyncApiKey() { + const direct = nonEmptyEnv("CCR_REMOTE_SYNC_API_KEY"); + if (direct) return direct; + const helper = nonEmptyEnv("CCR_REMOTE_SYNC_API_KEY_HELPER"); + if (!helper) return ""; + return new Promise((resolve) => { + childProcess.execFile(expandHome(helper), { + shell: process.platform === "win32", + timeout: 3000, + windowsHide: true + }, (error, stdout) => { + if (error) { + log("remote_sync_api_key_helper_failed", { error: formatError(error) }); + resolve(""); + return; + } + resolve(String(stdout || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean) || ""); + }); + }); +} + +function normalizeRemoteSyncEndpoint(value) { + const trimmed = String(value || "").trim().replace(/\/+$/, ""); + return trimmed || ""; +} + +function remoteSyncUrl(endpoint, suffix) { + return endpoint + (String(suffix || "").startsWith("/") ? suffix : "/" + suffix); +} + +function remoteEventText(event) { + if (!event || typeof event !== "object") return ""; + if (typeof event.text === "string" && event.text.trim()) return event.text.trim(); + const payload = event.payload; + if (typeof payload === "string" && payload.trim()) return payload.trim(); + if (payload && typeof payload === "object") { + if (typeof payload.text === "string" && payload.text.trim()) return payload.text.trim(); + if (typeof payload.content === "string" && payload.content.trim()) return payload.content.trim(); + if (typeof payload.message === "string" && payload.message.trim()) return payload.message.trim(); + } + return ""; +} + function createBotGatewayBridge() { const config = readBotGatewayBridgeConfig(); if (!config.enabled) { @@ -2879,6 +3275,10 @@ async function importBotGatewaySdk() { if (configured) { candidates.push(configured); } + const bundled = bundledBotGatewaySdkModule(); + if (bundled) { + candidates.push(bundled); + } candidates.push("@the-next-ai/bot-gateway-sdk"); const errors = []; for (const candidate of candidates) { @@ -2895,6 +3295,20 @@ async function importBotGatewaySdk() { throw new Error("Unable to load @the-next-ai/bot-gateway-sdk. " + errors.join("; ")); } +function bundledBotGatewaySdkModule() { + const resourcesPath = process["resourcesPath"]; + const candidates = [ + path.join(__dirname, "bot-gateway-sdk", "dist", "index.js"), + ...(resourcesPath + ? [ + path.join(resourcesPath, "app.asar", "dist", "main", "bot-gateway-sdk", "dist", "index.js"), + path.join(resourcesPath, "app", "dist", "main", "bot-gateway-sdk", "dist", "index.js") + ] + : []) + ]; + return candidates.find((candidate) => fs.existsSync(candidate)) || ""; +} + function botGatewaySdkImportSpecifier(value) { const trimmed = String(value || "").trim(); if (!trimmed) return "@the-next-ai/bot-gateway-sdk"; @@ -3146,9 +3560,9 @@ function latestClaudeAppLocalAgentSession() { } function claudeAppLocalAgentSessions() { - const baseDir = nonEmptyEnv("CCR_CLAUDE_APP_USER_DATA_PATH") || nonEmptyEnv("CLAUDE_USER_DATA_DIR"); + const baseDir = currentClaudeAppUserDataDir(); if (!baseDir) return []; - const root = path.join(expandHome(baseDir), "local-agent-mode-sessions"); + const root = path.join(baseDir, "local-agent-mode-sessions"); const files = listClaudeAppSessionFiles(root, 6); const sessions = []; for (const file of files) { @@ -3181,6 +3595,37 @@ function claudeAppLocalAgentSessions() { return sessions; } +function currentClaudeAppUserDataDir() { + return expandHome(nonEmptyEnv("CCR_CLAUDE_APP_USER_DATA_PATH") || nonEmptyEnv("CLAUDE_USER_DATA_DIR") || ""); +} + +function botSessionEntryMatchesCurrentProfile(entry, appSession) { + const expectedUserDataDir = currentClaudeAppUserDataDir(); + if (!expectedUserDataDir) return true; + const candidates = [ + entry && entry.claudeConfigDir, + entry && entry.claudeAppSessionFile, + entry && entry.cwd, + appSession && appSession.claudeConfigDir + ]; + return candidates.some((candidate) => pathIsInside(candidate, expectedUserDataDir)); +} + +function pathIsInside(candidate, parentDir) { + const child = expandHome(String(candidate || "")); + const parent = expandHome(String(parentDir || "")); + if (!child || !parent) return false; + const childPath = normalizeComparablePath(path.resolve(child)); + const parentPath = normalizeComparablePath(path.resolve(parent)); + if (childPath === parentPath) return true; + const relative = path.relative(parentPath, childPath); + return Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative); +} + +function normalizeComparablePath(value) { + return process.platform === "win32" ? value.toLowerCase() : value; +} + function resolveClaudeAppLocalAgentSession(selector) { const query = String(selector || "").trim(); if (!query) return null; @@ -3830,6 +4275,11 @@ function nonEmptyEnv(name) { return typeof value === "string" && value.trim() ? value.trim() : ""; } +function nonEmptyEnvFrom(env, name) { + const value = env?.[name]; + return typeof value === "string" && value.trim() ? value.trim() : ""; +} + function codexRuntimeAgent() { return nonEmptyEnv("CCR_ZCODE_PROFILE") || nonEmptyEnv("CODEXL_ZCODE_PROFILE") || diff --git a/packages/core/src/agents/codex/model-catalog.ts b/packages/core/src/agents/codex/model-catalog.ts new file mode 100644 index 0000000..dd51f76 --- /dev/null +++ b/packages/core/src/agents/codex/model-catalog.ts @@ -0,0 +1,518 @@ +import { BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME } from "@ccr/core/contracts/app"; +import type { AppConfig, GatewayProviderConfig, GatewayProviderProtocol, VirtualModelProfileConfig } from "@ccr/core/contracts/app"; +import { + findModelCatalogEntry, + modelCatalogMaxInputTokens, + readCatalogCapability, + type ModelCatalogEntry +} from "@ccr/core/gateway/model-catalog"; + +const fusionModelProviderName = "Fusion"; +const codexDefaultContextWindow = 128_000; +const codexEffectiveContextWindowPercent = 95; + +export type CodexModelCatalog = { + models: CodexModelCatalogItem[]; +}; + +export type CodexModelCatalogItem = { + additional_speed_tiers: unknown[]; + apply_patch_tool_type: string | null; + availability_nux: null; + base_instructions: string; + context_window: number; + default_reasoning_level: string | null; + default_reasoning_summary: string; + description: string; + display_name: string; + effective_context_window_percent: number; + experimental_supported_tools: unknown[]; + input_modalities: string[]; + max_context_window: number; + priority: number; + service_tiers: unknown[]; + shell_type: string; + slug: string; + support_verbosity: boolean; + supported_in_api: boolean; + supported_reasoning_levels: Array<{ description: string; effort: string }>; + supports_image_detail_original: boolean; + supports_parallel_tool_calls: boolean; + supports_reasoning_summaries: boolean; + supports_search_tool: boolean; + truncation_policy: { limit: number; mode: string }; + upgrade: null; + visibility: string; + web_search_tool_type: string; +}; + +export function buildCodexModelCatalog(config?: Partial>, selectedModel?: string): CodexModelCatalog { + return { + models: buildCodexModelCatalogIds(config, selectedModel).map((model, index) => codexModelCatalogItem(model, index, config)) + }; +} + +export function buildCodexModelCatalogIds(config?: Partial>, selectedModel?: string): string[] { + const ids: string[] = []; + pushUniqueModel(ids, normalizeModelSelector(selectedModel)); + + const baseEntries: Array<{ modelName: string; providerName: string }> = []; + for (const provider of config?.Providers ?? []) { + const providerName = provider.name?.trim(); + if (!providerName || !Array.isArray(provider.models)) { + continue; + } + for (const rawModel of provider.models) { + const modelName = rawModel.trim(); + if (!modelName) { + continue; + } + baseEntries.push({ modelName, providerName }); + pushUniqueModel(ids, `${providerName}/${modelName}`); + } + } + + for (const profile of config?.virtualModelProfiles ?? []) { + if (!virtualModelIsCatalogVisible(profile)) { + continue; + } + for (const entry of baseEntries) { + for (const prefix of profile.match?.prefixes ?? []) { + const normalizedPrefix = prefix.trim(); + if (normalizedPrefix) { + pushUniqueModel(ids, `${entry.providerName}/${normalizedPrefix}${entry.modelName}`); + } + } + for (const suffix of profile.match?.suffixes ?? []) { + const normalizedSuffix = suffix.trim(); + if (normalizedSuffix) { + pushUniqueModel(ids, `${entry.providerName}/${entry.modelName}${normalizedSuffix}`); + } + } + } + for (const alias of virtualModelRawCatalogNames(profile)) { + pushUniqueModel(ids, fusionModelSelector(alias)); + } + } + + return ids; +} + +export function codexModelCatalogJson(config?: Partial>, selectedModel?: string): string { + return `${JSON.stringify(buildCodexModelCatalog(config, selectedModel), null, 2)}\n`; +} + +export function codexModelCatalogBase64(config?: Partial>, selectedModel?: string): string { + const catalog = buildCodexModelCatalog(config, selectedModel); + return Buffer.from(JSON.stringify(catalog), "utf8").toString("base64"); +} + +function codexModelCatalogItem( + model: string, + priority: number, + config?: Partial> +): CodexModelCatalogItem { + const profile = codexModelCapabilityProfile(model, config); + const contextWindow = codexModelContextWindow(model, profile.catalogEntry); + return { + additional_speed_tiers: [], + apply_patch_tool_type: profile.applyPatchToolType, + availability_nux: null, + base_instructions: "You are Codex, a coding agent.", + context_window: contextWindow, + default_reasoning_level: profile.supportsReasoning ? "medium" : null, + default_reasoning_summary: "none", + description: `CCR gateway model ${model}`, + display_name: model, + effective_context_window_percent: codexEffectiveContextWindowPercent, + experimental_supported_tools: [], + input_modalities: profile.inputModalities, + max_context_window: contextWindow, + priority, + service_tiers: [], + shell_type: "shell_command", + slug: model, + support_verbosity: true, + supported_in_api: true, + supported_reasoning_levels: profile.supportedReasoningLevels, + supports_image_detail_original: profile.supportsImageInput, + supports_parallel_tool_calls: profile.supportsParallelToolCalls, + supports_reasoning_summaries: profile.supportsReasoning, + supports_search_tool: profile.supportsSearchTool, + truncation_policy: { mode: "tokens", limit: 10_000 }, + upgrade: null, + visibility: "list", + web_search_tool_type: profile.supportsSearchTool && profile.supportsImageInput ? "text_and_image" : "text" + }; +} + +type CodexCapabilityProfile = { + applyPatchToolType: string | null; + catalogEntry?: ModelCatalogEntry; + inputModalities: string[]; + supportedReasoningLevels: Array<{ description: string; effort: string }>; + supportsImageInput: boolean; + supportsParallelToolCalls: boolean; + supportsReasoning: boolean; + supportsSearchTool: boolean; +}; + +function codexModelCapabilityProfile( + model: string, + config?: Partial> +): CodexCapabilityProfile { + const selector = parseModelSelector(model); + const provider = selector?.provider ? findConfiguredProvider(config, selector.provider) : undefined; + const catalogEntry = findModelCatalogEntry(model); + const capabilities = catalogEntry?.capabilities ?? {}; + const providerProtocol = provider ? codexProviderProtocol(provider) : undefined; + const providerSupportsResponses = provider ? codexProviderSupportsResponses(provider) : false; + const supportsFusionWebSearch = codexVirtualModelSupportsFusionWebSearch(model, config); + const supportsReasoning = readCatalogCapability(capabilities, "reasoning"); + const supportsImageInput = catalogEntrySupportsImageInput(catalogEntry); + const supportsParallelToolCalls = readCatalogCapability(capabilities, "parallelFunctionCalling"); + const applyPatchToolType = providerSupportsResponses || catalogModelLooksLikeGpt(model, catalogEntry) || codexPatchBridgeApplies(model, catalogEntry, config) + ? "freeform" + : null; + const supportsSearchTool = + supportsFusionWebSearch || + ( + readCatalogCapability(capabilities, "webSearch") && + ( + providerProtocol === "openai_responses" || + providerProtocol === "anthropic_messages" || + providerProtocol === "gemini_interactions" + ) + ); + + return { + applyPatchToolType, + catalogEntry, + inputModalities: supportsImageInput ? ["text", "image"] : ["text"], + supportedReasoningLevels: supportsReasoning ? supportedReasoningLevels(capabilities) : [], + supportsImageInput, + supportsParallelToolCalls, + supportsReasoning, + supportsSearchTool + }; +} + +function codexModelContextWindow(model: string, entry = findModelCatalogEntry(model)): number { + return modelCatalogMaxInputTokens(entry) || codexDefaultContextWindow; +} + +function catalogEntrySupportsImageInput(entry: ModelCatalogEntry | undefined): boolean { + const capabilities = entry?.capabilities ?? {}; + const modalities = new Set((entry?.modalities?.input ?? []).map((item) => item.toLowerCase())); + return modalities.has("image") || + readCatalogCapability(capabilities, "imageInput") || + readCatalogCapability(capabilities, "vision") || + readCatalogCapability(capabilities, "multimodal"); +} + +function supportedReasoningLevels(capabilities: Record): Array<{ description: string; effort: string }> { + const levels = [ + { effort: "low", description: "Low reasoning" }, + { effort: "medium", description: "Medium reasoning" }, + { effort: "high", description: "High reasoning" } + ]; + if (readCatalogCapability(capabilities, "xhighReasoningEffort") || readCatalogCapability(capabilities, "maxReasoningEffort")) { + levels.push({ effort: "xhigh", description: "Extra high reasoning" }); + } + return levels; +} + +function findConfiguredProvider( + config: Partial> | undefined, + providerName: string +): GatewayProviderConfig | undefined { + const normalized = providerName.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + return (config?.Providers ?? []).find((provider) => provider.name.trim().toLowerCase() === normalized); +} + +function codexProviderProtocol(provider: GatewayProviderConfig): GatewayProviderProtocol | undefined { + const capabilityProtocols = uniqueProviderProtocols((provider.capabilities ?? []).map((capability) => normalizeProviderProtocol(capability.type))); + for (const protocol of ["openai_responses", "openai_chat_completions", "anthropic_messages", "gemini_generate_content", "gemini_interactions"] as GatewayProviderProtocol[]) { + if (capabilityProtocols.includes(protocol)) { + return protocol; + } + } + + return normalizeProviderProtocol(provider.type) ?? normalizeProviderProtocol(provider.provider) ?? inferProviderProtocol(provider); +} + +function codexProviderSupportsResponses(provider: GatewayProviderConfig): boolean { + return uniqueProviderProtocols((provider.capabilities ?? []).map((capability) => normalizeProviderProtocol(capability.type))).includes("openai_responses") || + normalizeProviderProtocol(provider.type) === "openai_responses" || + normalizeProviderProtocol(provider.provider) === "openai_responses" || + providerEndpointLooksLikeResponses(provider); +} + +function inferProviderProtocol(provider: GatewayProviderConfig): GatewayProviderProtocol { + const url = (provider.baseUrl || provider.baseurl || provider.api_base_url || "").toLowerCase(); + const transformer = JSON.stringify(provider.transformer ?? "").toLowerCase(); + if (providerEndpointLooksLikeResponses(provider)) { + return "openai_responses"; + } + if (url.includes("/interactions") || transformer.includes("gemini_interactions")) { + return "gemini_interactions"; + } + if (url.includes("generativelanguage.googleapis.com") || transformer.includes("gemini")) { + return "gemini_generate_content"; + } + if (url.includes("anthropic") || transformer.includes("anthropic")) { + return "anthropic_messages"; + } + return "openai_chat_completions"; +} + +function providerEndpointLooksLikeResponses(provider: GatewayProviderConfig): boolean { + const url = (provider.baseUrl || provider.baseurl || provider.api_base_url || "").toLowerCase(); + return url.endsWith("/responses") || url.includes("/responses?"); +} + +function catalogModelLooksLikeGpt(model: string, entry: ModelCatalogEntry | undefined): boolean { + return [ + model, + entry?.id, + entry?.model + ].some((value) => typeof value === "string" && value.toLowerCase().includes("gpt")); +} + +function codexPatchBridgeApplies( + model: string, + entry: ModelCatalogEntry | undefined, + config?: Partial> +): boolean { + const codexRule = config?.Router?.builtInRules?.codex; + if (!codexRule || codexRule.enabled === false) { + return false; + } + return !catalogModelLooksLikeGpt(modelNameForPatchBridge(model), entry); +} + +function modelNameForPatchBridge(model: string): string { + return parseModelSelector(model)?.model ?? model; +} + +function normalizeProviderProtocol(value: unknown): GatewayProviderProtocol | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim().toLowerCase(); + if (normalized === "openai" || normalized === "openai_responses") { + return "openai_responses"; + } + if (normalized === "openai_chat" || normalized === "openai_chat_completions") { + return "openai_chat_completions"; + } + if (normalized === "anthropic" || normalized === "anthropic_messages") { + return "anthropic_messages"; + } + if (normalized === "gemini" || normalized === "gemini_generate_content") { + return "gemini_generate_content"; + } + if ( + normalized === "gemini_interactions" || + normalized === "gemini-interactions" || + normalized === "google_interactions" || + normalized === "google-interactions" || + normalized === "interactions" || + normalized === "interaction" + ) { + return "gemini_interactions"; + } + return undefined; +} + +function uniqueProviderProtocols(values: Array): GatewayProviderProtocol[] { + const seen = new Set(); + const output: GatewayProviderProtocol[] = []; + for (const value of values) { + if (!value || seen.has(value)) { + continue; + } + seen.add(value); + output.push(value); + } + return output; +} + +function parseModelSelector(model: string): { model: string; provider: string } | undefined { + const normalized = normalizeModelSelector(model); + const slashIndex = normalized.indexOf("/"); + if (slashIndex <= 0 || slashIndex >= normalized.length - 1) { + return undefined; + } + return { + provider: normalized.slice(0, slashIndex), + model: normalized.slice(slashIndex + 1) + }; +} + +function codexVirtualModelSupportsFusionWebSearch( + model: string, + config?: Partial> +): boolean { + return (config?.virtualModelProfiles ?? []).some((profile) => + virtualModelIsCatalogVisible(profile) && + virtualModelMatchesCatalogModel(profile, model, config) && + virtualModelProfileSupportsFusionWebSearch(profile) + ); +} + +function virtualModelMatchesCatalogModel( + profile: VirtualModelProfileConfig, + model: string, + config?: Partial> +): boolean { + const normalizedModel = normalizeModelSelector(model); + const normalizedModelLower = normalizedModel.toLowerCase(); + if (!normalizedModelLower) { + return false; + } + + for (const alias of virtualModelRawCatalogNames(profile)) { + const normalizedAlias = alias.trim().toLowerCase(); + if (normalizedAlias && (normalizedModelLower === normalizedAlias || normalizedModelLower === fusionModelSelector(alias).toLowerCase())) { + return true; + } + } + + const selector = parseModelSelector(normalizedModel); + if (!selector) { + return false; + } + const provider = findConfiguredProvider(config, selector.provider); + if (!provider) { + return false; + } + const configuredModels = new Set(provider.models.map((item) => item.trim().toLowerCase()).filter(Boolean)); + const selectedModel = selector.model.trim(); + const selectedModelLower = selectedModel.toLowerCase(); + + for (const prefix of profile.match?.prefixes ?? []) { + const normalizedPrefix = prefix.trim(); + if (!normalizedPrefix || !selectedModelLower.startsWith(normalizedPrefix.toLowerCase())) { + continue; + } + const baseModel = selectedModel.slice(normalizedPrefix.length).trim().toLowerCase(); + if (configuredModels.has(baseModel)) { + return true; + } + } + + for (const suffix of profile.match?.suffixes ?? []) { + const normalizedSuffix = suffix.trim(); + if (!normalizedSuffix || !selectedModelLower.endsWith(normalizedSuffix.toLowerCase())) { + continue; + } + const baseModel = selectedModel.slice(0, selectedModel.length - normalizedSuffix.length).trim().toLowerCase(); + if (configuredModels.has(baseModel)) { + return true; + } + } + + return false; +} + +function virtualModelProfileSupportsFusionWebSearch(profile: VirtualModelProfileConfig): boolean { + const metadata = recordValue(profile.metadata); + const fusionWebSearch = recordValue(metadata?.fusionWebSearch); + if (stringRecordValue(fusionWebSearch, "toolName")) { + return true; + } + + if (recordValue(profile.execution)?.matchWebSearch === true) { + return true; + } + + return (profile.tools ?? []).some((tool) => { + const name = tool.name.trim(); + return fusionWebSearchToolNameMatches(name); + }); +} + +function fusionWebSearchToolNameMatches(name: string): boolean { + const normalized = name.toLowerCase().replace(/[-.]/g, "_"); + return normalized === BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME || + normalized.startsWith(`${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}_`) || + normalized.endsWith(`_${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`) || + normalized.includes("search_web"); +} + +function recordValue(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : undefined; +} + +function stringRecordValue(record: Record | undefined, key: string): string { + const value = record?.[key]; + return typeof value === "string" ? value.trim() : ""; +} + +function virtualModelIsCatalogVisible(profile: VirtualModelProfileConfig): boolean { + return profile.enabled !== false && + profile.materialization?.enabled !== false && + profile.materialization?.includeInGatewayModels !== false; +} + +function virtualModelRawCatalogNames(profile: VirtualModelProfileConfig): string[] { + const exactAliases = uniqueStrings(profile.match?.exactAliases ?? []); + if (exactAliases.length > 0) { + return exactAliases; + } + return [profile.key || profile.displayName].filter(Boolean); +} + +function fusionModelSelector(model: string): string { + const normalized = fusionModelNameFromSelector(model); + return normalized ? `${fusionModelProviderName}/${normalized}` : ""; +} + +function fusionModelNameFromSelector(model: string): string { + const trimmed = model.trim(); + const prefix = `${fusionModelProviderName}/`; + return trimmed.toLowerCase().startsWith(prefix.toLowerCase()) + ? trimmed.slice(prefix.length).trim() + : trimmed; +} + +function normalizeModelSelector(value: string | undefined): string { + const trimmed = value?.trim(); + if (!trimmed) { + return ""; + } + const commaIndex = trimmed.indexOf(","); + if (commaIndex > 0 && commaIndex < trimmed.length - 1) { + const provider = trimmed.slice(0, commaIndex).trim(); + const model = trimmed.slice(commaIndex + 1).trim(); + return provider && model ? `${provider}/${model}` : ""; + } + return trimmed; +} + +function pushUniqueModel(models: string[], model: string | undefined): void { + const normalized = model?.trim(); + if (normalized && !models.includes(normalized)) { + models.push(normalized); + } +} + +function uniqueStrings(values: string[]): string[] { + const seen = new Set(); + const output: string[] = []; + for (const value of values) { + const normalized = value.trim(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + output.push(normalized); + } + return output; +} diff --git a/src/main/local-agent-providers/claude-code.ts b/packages/core/src/agents/local-providers/claude-code.ts similarity index 76% rename from src/main/local-agent-providers/claude-code.ts rename to packages/core/src/agents/local-providers/claude-code.ts index ae1ad43..bdae4e2 100644 --- a/src/main/local-agent-providers/claude-code.ts +++ b/packages/core/src/agents/local-providers/claude-code.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; import type { @@ -5,10 +6,11 @@ import type { LocalAgentProviderImportResult, ProviderAccountConfig, ProviderAccountMappingConfig -} from "../../shared/app"; +} from "@ccr/core/contracts/app"; import { bearerAuthPlugin, findOauthTokenSet, + isRecord, missingCandidate, providerInternalNamePlaceholder, providerPayload, @@ -16,9 +18,10 @@ import { uniqueProviderName, uniqueStrings, type OAuthTokenSet -} from "./shared"; +} from "@ccr/core/agents/local-providers/shared"; -const claudeDefaultModels = ["claude-sonnet-4-20250514"]; +const claudeDefaultModels = ["claude-sonnet-5"]; +const claudeCodeKeychainService = "Claude Code-credentials"; const percentLimitMapping = (id: string, label: string, path: string, window: string) => ({ id, @@ -148,6 +151,19 @@ function readClaudeCodeOauth(): OAuthTokenSet | undefined { sourceFile }; } + + const keychainRecord = readClaudeCodeKeychainRecord(); + if (keychainRecord) { + const credential = findOauthTokenSet(keychainRecord); + if (credential) { + return { + accessToken: credential.accessToken, + refreshToken: credential.refreshToken, + sourceFile: `keychain:${claudeCodeKeychainService}` + }; + } + } + return undefined; } @@ -158,3 +174,24 @@ function claudeCredentialFiles(): string[] { path.join(os.homedir(), ".config", "claude", "credentials.json") ]); } + +// Newer macOS builds of the Claude Code CLI store credentials in the +// Keychain instead of ~/.claude/.credentials.json. Reading it triggers the +// standard macOS keychain access prompt (Allow / Always Allow); the user +// declining or the item not existing both surface as a non-zero exit here. +function readClaudeCodeKeychainRecord(): Record | undefined { + if (process.platform !== "darwin") { + return undefined; + } + try { + const output = execFileSync( + "security", + ["find-generic-password", "-s", claudeCodeKeychainService, "-w"], + { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] } + ); + const parsed = JSON.parse(output.trim()) as unknown; + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} diff --git a/packages/core/src/agents/local-providers/codex.ts b/packages/core/src/agents/local-providers/codex.ts new file mode 100644 index 0000000..2a16a11 --- /dev/null +++ b/packages/core/src/agents/local-providers/codex.ts @@ -0,0 +1,642 @@ +import os from "node:os"; +import path from "node:path"; +import type { + LocalAgentProviderCandidate, + LocalAgentProviderImportResult, + GatewayProviderConfig, + ProviderAccountConfig, + ProviderAccountConnectorConfig, + ProviderAccountMappingConfig, + ProviderAccountMeter, + ProviderAccountMeterDetail +} from "@ccr/core/contracts/app"; +import { normalizeProviderBaseUrl } from "@ccr/core/providers/url"; +import { + isRecord, + localAgentProviderApiKey, + missingCandidate, + modelDisplayNamesForModels, + providerInternalNamePlaceholder, + providerNamePlaceholder, + providerNameSlugPlaceholder, + providerPayload, + readBoolean, + readJsonRecord, + readString, + uniqueProviderName, + uniqueStrings, + type OAuthTokenSet +} from "@ccr/core/agents/local-providers/shared"; + +export const codexDefaultBaseUrl = "https://chatgpt.com/backend-api/codex"; + +const codexAccountBaseUrl = "https://chatgpt.com/backend-api"; +const codexDefaultModels = ["gpt-5-codex"]; + +type LocalAgentModelCatalog = { + modelDisplayNames?: Record; + models: string[]; +}; + +const codexAccountRateLimitMapping: ProviderAccountMappingConfig = { + meters: [ + { + id: "codex_primary_quota", + kind: "quota", + label: "Primary quota", + limit: 100, + remaining: [ + "100 - $.rate_limit.primary_window.used_percent", + "100 - $.rate_limits.primary.used_percent" + ], + resetAt: [ + "$.rate_limit.primary_window.reset_at", + "$.rate_limit.primary_window.resets_at", + "$.rate_limits.primary.resets_at" + ], + unit: "%", + used: [ + "$.rate_limit.primary_window.used_percent", + "$.rate_limits.primary.used_percent" + ], + window: "primary" + }, + { + id: "codex_manual_resets", + kind: "requests", + label: "Manual resets", + remaining: [ + "$.resetsAvailable", + "$.availableRateLimitResetCount", + "$.rate_limit_reset_credits.available_count", + "$.rate_limit.resetsAvailable", + "$.rate_limits.resetsAvailable", + "$.rate_limit.manual_resets.remaining", + "$.rate_limit.manual_resets.resetsAvailable", + "$.rate_limit.manual_reset.remaining", + "$.rate_limit.manual_reset.resetsAvailable", + "$.rate_limits.manual_resets.remaining", + "$.rate_limits.manual_resets.resetsAvailable", + "$.rate_limits.manual_reset.remaining", + "$.rate_limits.manual_reset.resetsAvailable", + "$.manual_resets.remaining", + "$.manual_resets.resetsAvailable", + "$.manual_reset.remaining", + "$.manual_reset.resetsAvailable", + "$.resets.remaining", + "$.resets.resetsAvailable", + "$.rate_limit.manual_resets.available", + "$.rate_limit.manual_reset.available", + "$.rate_limit.resets.available", + "$.rate_limits.resets.available", + "$.manual_resets.available", + "$.manual_reset.available", + "$.resets.available", + 0 + ], + resetAt: [ + "$.resetExpires", + "$.expires_at", + "$.resets_at", + "$.rate_limit.manual_resets.expires_at", + "$.rate_limit.manual_resets.expire_at", + "$.rate_limit.manual_resets.reset_at", + "$.rate_limit.manual_resets.resets_at", + "$.rate_limit.manual_reset.expires_at", + "$.rate_limit.manual_reset.expire_at", + "$.rate_limit.manual_reset.reset_at", + "$.rate_limit.manual_reset.resets_at", + "$.rate_limits.manual_resets.expires_at", + "$.rate_limits.manual_resets.expire_at", + "$.rate_limits.manual_resets.reset_at", + "$.rate_limits.manual_resets.resets_at", + "$.rate_limits.manual_reset.expires_at", + "$.rate_limits.manual_reset.expire_at", + "$.rate_limits.manual_reset.reset_at", + "$.rate_limits.manual_reset.resets_at", + "$.rate_limit.resets.expires_at", + "$.rate_limit.resets.expire_at", + "$.rate_limit.resets.reset_at", + "$.rate_limit.resets.resets_at", + "$.rate_limits.resets.expires_at", + "$.rate_limits.resets.expire_at", + "$.rate_limits.resets.reset_at", + "$.rate_limits.resets.resets_at", + "$.manual_resets.expires_at", + "$.manual_resets.expire_at", + "$.manual_resets.reset_at", + "$.manual_resets.resets_at", + "$.manual_reset.expires_at", + "$.manual_reset.expire_at", + "$.manual_reset.reset_at", + "$.manual_reset.resets_at", + "$.resets.expires_at", + "$.resets.expire_at", + "$.resets.reset_at", + "$.resets.resets_at" + ], + unit: "resets", + used: [ + "$.rate_limit.manual_resets.used", + "$.rate_limit.manual_reset.used", + "$.rate_limits.manual_resets.used", + "$.manual_resets.used", + "$.manual_reset.used", + "$.resets.used" + ], + window: "manual-reset" + }, + { + id: "codex_secondary_quota", + kind: "quota", + label: "Secondary quota", + limit: 100, + remaining: [ + "100 - $.rate_limit.secondary_window.used_percent", + "100 - $.rate_limits.secondary.used_percent" + ], + resetAt: [ + "$.rate_limit.secondary_window.reset_at", + "$.rate_limit.secondary_window.resets_at", + "$.rate_limits.secondary.resets_at" + ], + unit: "%", + used: [ + "$.rate_limit.secondary_window.used_percent", + "$.rate_limits.secondary.used_percent" + ], + window: "secondary" + }, + { + id: "codex_individual_limit", + kind: "quota", + label: "Individual limit", + limit: "$.spend_control.individual_limit.limit", + remaining: "$.spend_control.individual_limit.remaining", + resetAt: "$.spend_control.individual_limit.reset_at", + unit: "credits", + used: "$.spend_control.individual_limit.used", + window: "monthly" + }, + { + id: "codex_credit_balance", + kind: "balance", + label: "Credit balance", + remaining: "$.credits.balance", + unit: "credits" + } + ] +}; + +const codexAccountRateLimitResetCreditsMapping: ProviderAccountMappingConfig = { + meters: [ + { + id: "codex_manual_resets", + kind: "requests", + label: "Manual resets", + remaining: [ + "$.available_count", + "$.rate_limit_reset_credits.available_count", + 0 + ], + resetAt: [ + "$.expires_at", + "$.expiresAt", + "$.reset_at", + "$.resetAt" + ], + unit: "resets", + window: "manual-reset" + } + ] +}; + +const codexAccountTokenUsageMapping: ProviderAccountMappingConfig = { + meters: [ + { + id: "codex_lifetime_tokens", + kind: "tokens", + label: "Lifetime tokens", + unit: "tokens", + used: "$.stats.lifetime_tokens" + }, + { + id: "codex_peak_daily_tokens", + kind: "tokens", + label: "Peak daily tokens", + unit: "tokens", + used: "$.stats.peak_daily_tokens", + window: "daily" + } + ] +}; + +export function codexCandidate(): LocalAgentProviderCandidate { + const auth = readCodexAuth(); + const catalog = readCodexModelCatalog(); + if (auth?.refreshToken || auth?.accessToken) { + return { + detail: "ChatGPT login detected. Click Import to add it as a gateway provider.", + id: "codex-api", + importable: true, + kind: "codex", + modelDisplayNames: catalog.modelDisplayNames, + models: catalog.models, + name: "Codex API", + protocol: "openai_responses", + sourceFile: auth.sourceFile, + status: "available" + }; + } + return missingCandidate("codex", "codex-api", "Codex API", "openai_responses", catalog.models, catalog.modelDisplayNames); +} + +export function importCodexProvider(candidate: LocalAgentProviderCandidate, providerNames: string[]): LocalAgentProviderImportResult { + const auth = readCodexAuth(); + if (!auth?.refreshToken && !auth?.accessToken) { + throw new Error("Codex login token was not found."); + } + const provider = providerPayload(candidate, uniqueProviderName(providerNames, "Codex API"), codexDefaultBaseUrl, codexProviderAccountConfig()); + return { + candidate, + provider, + providerPlugins: [ + codexOauthPlugin("codex-oauth"), + codexOauthPlugin("codex-oauth-internal", providerInternalNamePlaceholder) + ].map((plugin) => ({ + ...plugin, + ...(auth.isFedrampAccount ? { auth: { headers: { "X-OpenAI-Fedramp": "true" } } } : {}), + codexOauth: { + accessToken: auth.accessToken, + ...(auth.accountId ? { accountId: auth.accountId } : {}), + refreshIfMissingAccessToken: true, + refreshToken: auth.refreshToken, + required: true + } + })) + }; +} + +export function readCodexAuth(): OAuthTokenSet | undefined { + const sourceFile = path.join(os.homedir(), ".codex", "auth.json"); + const record = readJsonRecord(sourceFile); + if (!record) { + return undefined; + } + const tokens = isRecord(record.tokens) ? record.tokens : {}; + const idToken = readString(tokens.id_token) || readString(tokens.idToken); + const idTokenClaims = readCodexIdTokenClaims(idToken); + return { + accountId: + readString(tokens.account_id) || + readString(tokens.accountId) || + idTokenClaims.accountId, + accessToken: readString(tokens.access_token) || readString(tokens.accessToken), + isFedrampAccount: idTokenClaims.isFedrampAccount, + refreshToken: readString(tokens.refresh_token) || readString(tokens.refreshToken), + sourceFile + }; +} + +export function codexProviderAccountConfig(): ProviderAccountConfig { + return { + connectors: [ + { + auth: "provider-api-key", + endpoint: `${codexAccountBaseUrl}/wham/usage`, + headers: { + "User-Agent": "codex-cli" + }, + mapping: codexAccountRateLimitMapping, + type: "http-json" + }, + { + auth: "provider-api-key", + endpoint: `${codexAccountBaseUrl}/wham/rate-limit-reset-credits`, + headers: { + "User-Agent": "codex-cli" + }, + mapping: codexAccountRateLimitResetCreditsMapping, + type: "http-json" + }, + { + auth: "provider-api-key", + endpoint: `${codexAccountBaseUrl}/wham/profiles/me`, + headers: { + "User-Agent": "codex-cli" + }, + mapping: codexAccountTokenUsageMapping, + type: "http-json" + } + ], + enabled: true + }; +} + +export function attachCodexRateLimitResetCreditDetails(meters: ProviderAccountMeter[], payload: unknown): ProviderAccountMeter[] { + const details = codexRateLimitResetCreditDetails(payload); + if (details.length === 0) { + return meters; + } + const resetAt = firstCodexResetCreditExpiry(details); + return meters.map((meter) => { + if (!isCodexManualResetMeter(meter)) { + return meter; + } + return { + ...meter, + details, + resetAt: meter.resetAt ?? resetAt + }; + }); +} + +export function codexRateLimitResetCreditDetails(payload: unknown): ProviderAccountMeterDetail[] { + const records = codexRateLimitResetCreditRecords(payload); + if (records.length === 0) { + return []; + } + const availableRecords = records.filter(isAvailableCodexResetCreditRecord); + const sourceRecords = availableRecords.length > 0 ? availableRecords : records; + return sourceRecords + .map(codexRateLimitResetCreditDetail) + .filter((detail): detail is ProviderAccountMeterDetail => Boolean(detail)) + .sort(compareCodexResetCreditDetails); +} + +export function normalizeCodexProviderAccountConfig(provider: GatewayProviderConfig): GatewayProviderConfig { + if (!isLocalCodexProvider(provider) || !shouldUseCurrentCodexAccountConfig(provider.account)) { + return provider; + } + const account = codexProviderAccountConfig(); + return { + ...provider, + account: { + ...account, + refreshIntervalMs: provider.account?.refreshIntervalMs ?? account.refreshIntervalMs + } + }; +} + +function isLocalCodexProvider(provider: GatewayProviderConfig): boolean { + return ( + providerApiKey(provider) === localAgentProviderApiKey && + normalizeProviderBaseUrl(providerBaseUrl(provider)) === normalizeProviderBaseUrl(codexDefaultBaseUrl) + ); +} + +function shouldUseCurrentCodexAccountConfig(account: ProviderAccountConfig | undefined): boolean { + if (account?.enabled === false) { + return false; + } + const connectors = account?.connectors ?? []; + if (connectors.length === 0) { + return true; + } + return connectors.every(isCodexAccountConnector); +} + +function isCodexAccountConnector(connector: ProviderAccountConnectorConfig): boolean { + if (connector.type === "standard") { + return !connector.endpoint?.trim() && !connector.endpoints?.length && !connector.headers && !connector.id; + } + if (connector.type !== "http-json") { + return false; + } + return /^https:\/\/chatgpt\.com\/backend-api\/wham\//i.test(connector.endpoint.trim()); +} + +function codexRateLimitResetCreditRecords(payload: unknown): Record[] { + if (!isRecord(payload)) { + return []; + } + const containers = [ + payload.rate_limit_reset_credits, + payload.rateLimitResetCredits, + payload + ].filter(isRecord); + for (const container of containers) { + const candidates = [ + container.credits, + container.items, + container.data, + container.available, + container.available_credits, + container.availableCredits, + container.reset_credits, + container.resetCredits + ]; + for (const candidate of candidates) { + const records = readCodexResetCreditRecordArray(candidate); + if (records.length > 0) { + return records; + } + } + } + return []; +} + +function readCodexResetCreditRecordArray(value: unknown): Record[] { + if (Array.isArray(value)) { + return value.filter(isRecord); + } + if (!isRecord(value)) { + return []; + } + const nested = [value.credits, value.items, value.data]; + for (const candidate of nested) { + if (Array.isArray(candidate)) { + return candidate.filter(isRecord); + } + } + return []; +} + +function isAvailableCodexResetCreditRecord(record: Record): boolean { + const status = readCodexStringFromKeys(record, ["status", "state"])?.toLowerCase(); + return !status || status === "available" || status === "active"; +} + +function codexRateLimitResetCreditDetail(record: Record, index: number): ProviderAccountMeterDetail | undefined { + const id = readCodexStringFromKeys(record, ["id", "credit_id", "creditId"]); + const status = readCodexStringFromKeys(record, ["status", "state"]); + const effectiveAt = readCodexDateFromKeys(record, [ + "effective_at", + "effectiveAt", + "start_date", + "startDate", + "valid_from", + "validFrom", + "starts_at", + "startsAt", + "start_at", + "startAt", + "available_at", + "availableAt", + "granted_at", + "grantedAt", + "created_at", + "createdAt" + ]); + const expiresAt = readCodexDateFromKeys(record, [ + "expires_at", + "expiresAt", + "expire_at", + "expireAt", + "expiration_at", + "expirationAt", + "valid_until", + "validUntil", + "end_date", + "endDate", + "ends_at", + "endsAt", + "end_at", + "endAt" + ]); + if (!effectiveAt && !expiresAt) { + return undefined; + } + return { + description: readCodexStringFromKeys(record, ["description", "message"]), + effectiveAt, + expiresAt, + id: id ?? `codex-reset-credit-${index + 1}`, + label: readCodexStringFromKeys(record, ["label", "name", "title"]), + redeemable: Boolean(id) && isAvailableCodexResetCreditRecord(record), + status + }; +} + +function readCodexStringFromKeys(record: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = readString(record[key]); + if (value) { + return value; + } + } + return undefined; +} + +function readCodexDateFromKeys(record: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = codexDateString(record[key]); + if (value) { + return value; + } + } + return undefined; +} + +function codexDateString(value: unknown): string | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + const timestamp = value > 1_000_000_000_000 ? value : value * 1000; + return new Date(timestamp).toISOString(); + } + const text = readString(value); + if (!text) { + return undefined; + } + const timestamp = new Date(text).getTime(); + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : text; +} + +function compareCodexResetCreditDetails(a: ProviderAccountMeterDetail, b: ProviderAccountMeterDetail): number { + return codexDetailTimestamp(a.expiresAt) - codexDetailTimestamp(b.expiresAt) + || codexDetailTimestamp(a.effectiveAt) - codexDetailTimestamp(b.effectiveAt); +} + +function codexDetailTimestamp(value: string | undefined): number { + if (!value) { + return Number.MAX_SAFE_INTEGER; + } + const timestamp = new Date(value).getTime(); + return Number.isFinite(timestamp) ? timestamp : Number.MAX_SAFE_INTEGER; +} + +function firstCodexResetCreditExpiry(details: ProviderAccountMeterDetail[]): string | undefined { + return [...details] + .sort(compareCodexResetCreditDetails) + .find((detail) => detail.expiresAt) + ?.expiresAt; +} + +function isCodexManualResetMeter(meter: ProviderAccountMeter): boolean { + const text = `${meter.id} ${meter.label} ${meter.window ?? ""}`.toLowerCase(); + return text.includes("manual_reset") || text.includes("manual reset") || text.includes("manual-reset"); +} + +function providerBaseUrl(provider: GatewayProviderConfig): string { + return provider.api_base_url || provider.baseUrl || provider.baseurl || ""; +} + +function providerApiKey(provider: GatewayProviderConfig): string { + return provider.api_key || provider.apiKey || provider.apikey || ""; +} + +function codexOauthPlugin(suffix: string, providerName = providerNamePlaceholder): Record { + return { + key: `ccr-local-agent-${providerNameSlugPlaceholder}-${suffix}`, + providerName, + request: codexBackendRequestTransform() + }; +} + +function codexBackendRequestTransform(): Record { + return { + bodyRemove: ["max_output_tokens"] + }; +} + +function readCodexModelCatalog(): LocalAgentModelCatalog { + const modelsFile = path.join(os.homedir(), ".codex", "models_cache.json"); + const record = readJsonRecord(modelsFile); + const models: string[] = []; + const modelDisplayNames: Record = {}; + for (const item of Array.isArray(record?.models) ? record.models : []) { + const model = isRecord(item) + ? readString(item.slug) || readString(item.id) || readString(item.name) + : readString(item); + if (!model) { + continue; + } + models.push(model); + if (isRecord(item)) { + const displayName = readString(item.display_name) || readString(item.displayName) || readString(item.label) || readString(item.name); + if (displayName && displayName !== model) { + modelDisplayNames[model] = displayName; + } + } + } + const uniqueModels = uniqueStrings([...models, ...codexDefaultModels]); + return { + modelDisplayNames: modelDisplayNamesForModels(modelDisplayNames, uniqueModels), + models: uniqueModels + }; +} + +function readCodexIdTokenClaims(idToken: string | undefined): { accountId?: string; isFedrampAccount?: boolean } { + const payload = readJwtPayload(idToken); + const auth = isRecord(payload?.["https://api.openai.com/auth"]) + ? payload["https://api.openai.com/auth"] + : {}; + return { + accountId: readString(auth.chatgpt_account_id) || readString(auth.account_id) || readString(auth.accountId), + isFedrampAccount: readBoolean(auth.chatgpt_account_is_fedramp) + }; +} + +function readJwtPayload(jwt: string | undefined): Record | undefined { + const encoded = jwt?.split(".")[1]; + if (!encoded) { + return undefined; + } + try { + const padded = encoded.padEnd(encoded.length + ((4 - encoded.length % 4) % 4), "="); + const decoded = Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"); + const payload = JSON.parse(decoded) as unknown; + return isRecord(payload) ? payload : undefined; + } catch { + return undefined; + } +} diff --git a/src/main/local-agent-provider-service.ts b/packages/core/src/agents/local-providers/service.ts similarity index 66% rename from src/main/local-agent-provider-service.ts rename to packages/core/src/agents/local-providers/service.ts index 8ef2326..d86415f 100644 --- a/src/main/local-agent-provider-service.ts +++ b/packages/core/src/agents/local-providers/service.ts @@ -2,13 +2,13 @@ import type { LocalAgentProviderCandidate, LocalAgentProviderImportRequest, LocalAgentProviderImportResult -} from "../shared/app"; -import { claudeCodeCandidate, importClaudeCodeProvider } from "./local-agent-providers/claude-code"; -import { codexCandidate, importCodexProvider } from "./local-agent-providers/codex"; -import { importZcodeProvider, zcodeCandidate } from "./local-agent-providers/zcode"; +} from "@ccr/core/contracts/app"; +import { claudeCodeCandidate, importClaudeCodeProvider } from "@ccr/core/agents/local-providers/claude-code"; +import { codexCandidate, importCodexProvider } from "@ccr/core/agents/local-providers/codex"; +import { importZcodeProvider, zcodeCandidate } from "@ccr/core/agents/local-providers/zcode"; -export { codexDefaultBaseUrl, readCodexAuth } from "./local-agent-providers/codex"; -export { localAgentProviderApiKey, type OAuthTokenSet } from "./local-agent-providers/shared"; +export { codexDefaultBaseUrl, readCodexAuth } from "@ccr/core/agents/local-providers/codex"; +export { localAgentProviderApiKey, type OAuthTokenSet } from "@ccr/core/agents/local-providers/shared"; export function getLocalAgentProviderCandidates(): LocalAgentProviderCandidate[] { return [ diff --git a/src/main/local-agent-providers/shared.ts b/packages/core/src/agents/local-providers/shared.ts similarity index 86% rename from src/main/local-agent-providers/shared.ts rename to packages/core/src/agents/local-providers/shared.ts index bc1d4e4..f70c238 100644 --- a/src/main/local-agent-providers/shared.ts +++ b/packages/core/src/agents/local-providers/shared.ts @@ -5,7 +5,7 @@ import type { LocalAgentProviderKind, ProviderAccountConfig, ProviderDeepLinkPayload -} from "../../shared/app"; +} from "@ccr/core/contracts/app"; export type OAuthTokenSet = { accountId?: string; @@ -30,13 +30,15 @@ export function missingCandidate( id: string, name: string, protocol: GatewayProviderProtocol, - models: string[] + models: string[], + modelDisplayNames?: Record ): LocalAgentProviderCandidate { return { detail: "No local login state was found for this agent.", id, importable: false, kind, + modelDisplayNames: modelDisplayNamesForModels(modelDisplayNames, models), models, name, protocol, @@ -50,16 +52,29 @@ export function providerPayload( baseUrl: string, account?: ProviderAccountConfig ): ProviderDeepLinkPayload { + const models = uniqueStrings(candidate.models).slice(0, 24); return { account, apiKey: localAgentProviderApiKey, baseUrl, - models: uniqueStrings(candidate.models).slice(0, 24), + modelDisplayNames: modelDisplayNamesForModels(candidate.modelDisplayNames, models), + models, name, protocol: candidate.protocol }; } +export function modelDisplayNamesForModels( + value: Record | undefined, + models: string[] +): Record | undefined { + const modelIds = new Set(models); + const entries = Object.entries(value ?? {}) + .map(([rawModel, rawDisplayName]) => [rawModel.trim(), rawDisplayName.trim()] as const) + .filter(([model, displayName]) => model && displayName && model !== displayName && modelIds.has(model)); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + export function bearerAuthPlugin( suffix: string, token: string, diff --git a/src/main/local-agent-providers/zcode.ts b/packages/core/src/agents/local-providers/zcode.ts similarity index 72% rename from src/main/local-agent-providers/zcode.ts rename to packages/core/src/agents/local-providers/zcode.ts index a853737..f32f3c0 100644 --- a/src/main/local-agent-providers/zcode.ts +++ b/packages/core/src/agents/local-providers/zcode.ts @@ -4,8 +4,8 @@ import type { LocalAgentProviderCandidate, LocalAgentProviderImportResult, ProviderAccountConfig -} from "../../shared/app"; -import { findProviderPresetByBaseUrl } from "../presets"; +} from "@ccr/core/contracts/app"; +import { findProviderPresetByBaseUrl } from "@ccr/core/providers/presets/index"; import { apiKeyAuthPlugin, cloneProviderAccountConfig, @@ -13,6 +13,7 @@ import { isLoopbackUrl, isRecord, missingCandidate, + modelDisplayNamesForModels, providerInternalNamePlaceholder, providerPayload, readJsonRecord, @@ -20,17 +21,23 @@ import { uniqueProviderName, uniqueStrings, type ApiTokenSet -} from "./shared"; +} from "@ccr/core/agents/local-providers/shared"; type ZcodeConfiguredProvider = { apiKey: string; baseUrl: string; + modelDisplayNames?: Record; models: string[]; name: string; providerId: string; sourceFile: string; }; +type LocalAgentModelCatalog = { + modelDisplayNames?: Record; + models: string[]; +}; + const zcodeDefaultModels = ["GLM-5.2", "GLM-5-Turbo"]; const zcodeDefaultBaseUrl = "https://zcode.z.ai/api/v1/zcode-plan/anthropic"; @@ -40,12 +47,16 @@ export function zcodeCandidate(): LocalAgentProviderCandidate { const models = configuredProvider?.models.length ? configuredProvider.models : zcodeRuntime.models.length > 0 ? zcodeRuntime.models : zcodeDefaultModels; + const modelDisplayNames = configuredProvider?.models.length + ? configuredProvider.modelDisplayNames + : zcodeRuntime.modelDisplayNames; if (configuredProvider) { return { detail: "ZCode provider API key detected in local ZCode config. Click Import to add it as a gateway provider.", id: "zcode-api", importable: true, kind: "zcode", + modelDisplayNames, models, name: "ZCode API", protocol: "anthropic_messages", @@ -61,6 +72,7 @@ export function zcodeCandidate(): LocalAgentProviderCandidate { id: "zcode-api", importable: false, kind: "zcode", + modelDisplayNames, models, name: "ZCode API", protocol: "anthropic_messages", @@ -68,7 +80,7 @@ export function zcodeCandidate(): LocalAgentProviderCandidate { status: "locked" }; } - return missingCandidate("zcode", "zcode-api", "ZCode API", "anthropic_messages", models); + return missingCandidate("zcode", "zcode-api", "ZCode API", "anthropic_messages", models, modelDisplayNames); } export function importZcodeProvider(candidate: LocalAgentProviderCandidate, providerNames: string[]): LocalAgentProviderImportResult { @@ -77,7 +89,11 @@ export function importZcodeProvider(candidate: LocalAgentProviderCandidate, prov throw new Error("ZCode provider API key was not found in ZCode config."); } const provider = providerPayload( - { ...candidate, models: configuredProvider.models.length > 0 ? configuredProvider.models : candidate.models }, + { + ...candidate, + modelDisplayNames: configuredProvider.models.length > 0 ? configuredProvider.modelDisplayNames : candidate.modelDisplayNames, + models: configuredProvider.models.length > 0 ? configuredProvider.models : candidate.models + }, uniqueProviderName(providerNames, "ZCode API"), configuredProvider.baseUrl, zcodeProviderAccountConfig(configuredProvider.baseUrl) @@ -152,7 +168,7 @@ function readZcodeConfiguredProviders(sourceFile: string): ZcodeConfiguredProvid return [{ apiKey, baseUrl, - models: zcodeProviderModels(value), + ...zcodeProviderModelCatalog(value), name: readString(value.name) || providerId, providerId, sourceFile @@ -160,7 +176,7 @@ function readZcodeConfiguredProviders(sourceFile: string): ZcodeConfiguredProvid }); } -function readZcodeRuntime(): { baseUrl: string; models: string[] } { +function readZcodeRuntime(): { baseUrl: string } & LocalAgentModelCatalog { const cache = readJsonRecord(path.join(os.homedir(), ".zcode", "v2", "bots-model-cache.v2.json")); const providers = Array.isArray(cache?.providers) ? cache.providers.filter((provider): provider is Record => isRecord(provider)) @@ -174,23 +190,63 @@ function readZcodeRuntime(): { baseUrl: string; models: string[] } { return text.includes("zcode") || text.includes("z.ai") || text.includes("bigmodel"); }); const baseUrl = readString(isRecord(provider?.endpoints) ? provider?.endpoints.baseURL : undefined) || zcodeDefaultBaseUrl; - const models = Array.isArray(provider?.models) - ? provider.models.map((model) => isRecord(model) ? readString(model.id) || readString(model.name) : readString(model)) - : []; + const catalog = zcodeProviderModelCatalog(provider ?? {}); + const models = uniqueStrings([...catalog.models, ...zcodeDefaultModels]); return { baseUrl, - models: uniqueStrings([...models, ...zcodeDefaultModels]) + modelDisplayNames: modelDisplayNamesForModels(catalog.modelDisplayNames, models), + models }; } -function zcodeProviderModels(provider: Record): string[] { +function zcodeProviderModelCatalog(provider: Record): LocalAgentModelCatalog { + const models: string[] = []; + const modelDisplayNames: Record = {}; if (Array.isArray(provider.models)) { - return uniqueStrings(provider.models.map((model) => isRecord(model) ? readString(model.id) || readString(model.name) : readString(model))); + for (const item of provider.models) { + const model = isRecord(item) + ? readString(item.id) || readString(item.name) + : readString(item); + if (!model) { + continue; + } + models.push(model); + if (isRecord(item)) { + const displayName = readString(item.displayName) || readString(item.display_name) || readString(item.label) || readString(item.name); + if (displayName && displayName !== model) { + modelDisplayNames[model] = displayName; + } + } + } + const uniqueModels = uniqueStrings(models); + return { + modelDisplayNames: modelDisplayNamesForModels(modelDisplayNames, uniqueModels), + models: uniqueModels + }; } if (isRecord(provider.models)) { - return uniqueStrings(Object.entries(provider.models).map(([key, value]) => isRecord(value) ? readString(value.id) || key : key)); + for (const [key, value] of Object.entries(provider.models)) { + const model = isRecord(value) ? readString(value.id) || key : key; + if (!model) { + continue; + } + models.push(model); + if (isRecord(value)) { + const displayName = readString(value.displayName) || readString(value.display_name) || readString(value.label) || readString(value.name); + if (displayName && displayName !== model) { + modelDisplayNames[model] = displayName; + } + } + } + const uniqueModels = uniqueStrings(models); + return { + modelDisplayNames: modelDisplayNamesForModels(modelDisplayNames, uniqueModels), + models: uniqueModels + }; } - return []; + return { + models: [] + }; } function isZcodeModelProvider(providerId: string, provider: Record): boolean { diff --git a/src/main/zcode-profile-config.ts b/packages/core/src/agents/zcode/profile-config.ts similarity index 92% rename from src/main/zcode-profile-config.ts rename to packages/core/src/agents/zcode/profile-config.ts index 073eba2..c8ebb35 100644 --- a/src/main/zcode-profile-config.ts +++ b/packages/core/src/agents/zcode/profile-config.ts @@ -1,9 +1,9 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { AppConfig, ProfileConfig } from "../shared/app"; -import { normalizeRouteSelector } from "../server/gateway/claude-code-router-plugin"; -import { buildCodexModelCatalogIds } from "./codex-model-catalog"; +import type { AppConfig, ProfileConfig } from "@ccr/core/contracts/app"; +import { normalizeRouteSelector } from "@ccr/core/gateway/claude-code-router-plugin"; +import { buildCodexModelCatalogIds } from "@ccr/core/agents/codex/model-catalog"; export type ZcodeProfileConfigWriteResult = { backupFile?: string; @@ -23,6 +23,8 @@ type ZcodeGatewayConfigValues = { const legacyZcodeTomlConfigFile = "~/.zcode/config.toml"; const defaultZcodeConfigFile = "~/.zcode/cli/config.json"; +const originalBackupSuffix = ".ccr-original"; +const originalMissingSuffix = ".ccr-original-missing"; export function resolveZcodeConfigFile(profile: Pick): string { const configured = profile.configFile?.trim(); @@ -227,6 +229,9 @@ function writeJsonFile(file: string, value: Record, options: { if (previous === content) { return { changed: false, file }; } + if (options.backup !== false) { + ensureOriginalSnapshot(file, previous); + } const backupFile = options.backup === false || previous === undefined ? undefined : backupFilePath(file); if (backupFile) { copyFileSync(file, backupFile); @@ -252,6 +257,19 @@ function backupFilePath(file: string): string { return `${file}.ccr-backup-${timestamp}`; } +function ensureOriginalSnapshot(file: string, previous: string | undefined): void { + const originalBackup = `${file}${originalBackupSuffix}`; + const originalMissing = `${file}${originalMissingSuffix}`; + if (existsSync(originalBackup) || existsSync(originalMissing)) { + return; + } + if (previous === undefined) { + writeFileSync(originalMissing, "", "utf8"); + return; + } + copyFileSync(file, originalBackup); +} + function isLegacyZcodeTomlConfigFile(value: string): boolean { return value === legacyZcodeTomlConfigFile || resolveUserPath(value) === path.join(os.homedir(), ".zcode", "config.toml"); @@ -264,10 +282,6 @@ function gatewayEndpoint(config: AppConfig): string { } function defaultClientModel(config: AppConfig): string { - const configuredDefault = normalizeClientModel(config.Router.default); - if (configuredDefault) { - return configuredDefault; - } const preferred = config.Providers.find((provider) => provider.name === config.preferredProvider) ?? config.Providers[0]; if (preferred?.name && preferred.models[0]) { return `${preferred.name}/${preferred.models[0]}`; diff --git a/src/main/api-key-store.ts b/packages/core/src/config/api-key-store.ts similarity index 88% rename from src/main/api-key-store.ts rename to packages/core/src/config/api-key-store.ts index 216db36..76fca9c 100644 --- a/src/main/api-key-store.ts +++ b/packages/core/src/config/api-key-store.ts @@ -1,8 +1,8 @@ import { chmodSync, existsSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; -import { API_KEYS_DB_FILE } from "./constants"; -import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "./sqlite-native"; -import type { ApiKeyConfig, ApiKeyLimitConfig } from "../shared/app"; +import { API_KEYS_DB_FILE, LEGACY_API_KEYS_DB_FILES } from "@ccr/core/config/constants"; +import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "@ccr/core/storage/sqlite-native"; +import type { ApiKeyConfig, ApiKeyLimitConfig } from "@ccr/core/contracts/app"; type SqlDatabase = BetterSqliteDatabase; type SqlValue = bigint | Buffer | number | string | null; @@ -130,6 +130,12 @@ class ApiKeyStore { export const apiKeyStore = new ApiKeyStore(API_KEYS_DB_FILE); export async function loadPersistedApiKeys(): Promise { + if (!existsSync(API_KEYS_DB_FILE)) { + const legacyApiKeys = await readLegacyApiKeys(); + if (legacyApiKeys.length > 0) { + return legacyApiKeys; + } + } return apiKeyStore.list(); } @@ -137,6 +143,24 @@ export async function replacePersistedApiKeys(apiKeys: ApiKeyConfig[]): Promise< return apiKeyStore.replace(apiKeys); } +async function readLegacyApiKeys(): Promise { + for (const dbFile of LEGACY_API_KEYS_DB_FILES) { + if (!existsSync(dbFile) || samePath(dbFile, API_KEYS_DB_FILE)) { + continue; + } + try { + const store = new ApiKeyStore(dbFile); + const apiKeys = await store.list(); + if (apiKeys.length > 0) { + return apiKeys; + } + } catch (error) { + console.warn(`[config] Failed to read legacy API key database ${dbFile}: ${formatError(error)}`); + } + } + return []; +} + function toApiKeyConfig(row: Record): ApiKeyConfig | undefined { const stored = toStoredApiKeyRow(row); if (!stored) { @@ -290,6 +314,14 @@ function readString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } +function samePath(left: string, right: string): boolean { + return left.toLowerCase() === right.toLowerCase(); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/main/app-config-store.ts b/packages/core/src/config/app-config-store.ts similarity index 75% rename from src/main/app-config-store.ts rename to packages/core/src/config/app-config-store.ts index 2dcb94f..13e1209 100644 --- a/src/main/app-config-store.ts +++ b/packages/core/src/config/app-config-store.ts @@ -1,7 +1,7 @@ import { chmodSync, existsSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; -import { APP_CONFIG_DB_FILE } from "./constants"; -import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "./sqlite-native"; +import { APP_CONFIG_DB_FILE, LEGACY_APP_CONFIG_DB_FILES } from "@ccr/core/config/constants"; +import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "@ccr/core/storage/sqlite-native"; type SqlDatabase = BetterSqliteDatabase; type SqlValue = bigint | Buffer | number | string | null; @@ -79,10 +79,22 @@ class AppConfigStore { export const appConfigStore = new AppConfigStore(APP_CONFIG_DB_FILE); export async function loadPersistedAppConfig(): Promise { + if (!existsSync(APP_CONFIG_DB_FILE)) { + const legacyValue = await readLegacyAppConfigKey(appConfigKey); + if (legacyValue !== undefined) { + return legacyValue; + } + } return appConfigStore.read(); } export async function loadPersistedAppSetting(key: string): Promise { + if (!existsSync(APP_CONFIG_DB_FILE)) { + const legacyValue = await readLegacyAppConfigKey(key); + if (legacyValue !== undefined) { + return legacyValue; + } + } return appConfigStore.readKey(key); } @@ -94,6 +106,24 @@ export async function replacePersistedAppSetting(key: string, value: unknown): P await appConfigStore.replaceKey(key, value); } +async function readLegacyAppConfigKey(key: string): Promise { + for (const dbFile of LEGACY_APP_CONFIG_DB_FILES) { + if (!existsSync(dbFile) || samePath(dbFile, APP_CONFIG_DB_FILE)) { + continue; + } + try { + const store = new AppConfigStore(dbFile); + const value = await store.readKey(key); + if (value !== undefined) { + return value; + } + } catch (error) { + console.warn(`[config] Failed to read legacy app config database ${dbFile}: ${formatError(error)}`); + } + } + return undefined; +} + function configureSqliteDatabase(database: SqlDatabase): void { database.pragma("journal_mode = WAL"); database.pragma("synchronous = NORMAL"); @@ -124,3 +154,11 @@ function securePathPermissions(file: string, mode: number): void { // Best effort for filesystems that do not support chmod. } } + +function samePath(left: string, right: string): boolean { + return left.toLowerCase() === right.toLowerCase(); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/config.ts b/packages/core/src/config/config.ts similarity index 88% rename from src/main/config.ts rename to packages/core/src/config/config.ts index 8fd494e..0cf78ab 100644 --- a/src/main/config.ts +++ b/packages/core/src/config/config.ts @@ -1,9 +1,12 @@ +import { createHash, randomBytes } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; -import { loadPersistedAppConfig, replacePersistedAppConfig } from "./app-config-store"; -import { loadPersistedApiKeys, replacePersistedApiKeys } from "./api-key-store"; -import { CONFIG_FILE, GATEWAY_CONFIG_FILE, LEGACY_CONFIG_FILE } from "./constants"; -import { CLAUDE_CODE_DEFAULT_ENV, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, OVERVIEW_WIDGET_SIZE_VALUES, ROUTER_FALLBACK_MAX_RETRY_COUNT, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS, enforceSingleEnabledGlobalProfilePerAgent } from "../shared/app"; -import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "./presets"; +import { loadPersistedAppConfig, replacePersistedAppConfig } from "@ccr/core/config/app-config-store"; +import { loadPersistedApiKeys, replacePersistedApiKeys } from "@ccr/core/config/api-key-store"; +import { CONFIG_FILE, GATEWAY_CONFIG_FILE, LEGACY_CONFIG_FILE, LEGACY_WINDOWS_CONFIG_FILE } from "@ccr/core/config/constants"; +import { normalizeCodexProviderAccountConfig } from "@ccr/core/agents/local-providers/codex"; +import { CLAUDE_CODE_DEFAULT_ENV, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, OVERVIEW_WIDGET_SIZE_VALUES, ROUTER_FALLBACK_MAX_RETRY_COUNT, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS, enforceSingleEnabledGlobalProfilePerAgent } from "@ccr/core/contracts/app"; +import { createDefaultAppConfig } from "@ccr/core/config/default-config"; +import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "@ccr/core/providers/presets/index"; import type { AppConfig, ApiKeyConfig, @@ -34,6 +37,7 @@ import type { ProfileRuntimeConfig, ProxyRouteTarget, ProxyRuntimeConfig, + RouterBuiltInRulesConfig, RouterConfig, RouterFallbackConfig, RouterFallbackMode, @@ -46,11 +50,12 @@ import type { TrayBalanceProgressConfig, TrayComponentVariants, TrayIconPreference, + ToolHubConfig, TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant, TrayWindowModuleId -} from "../shared/app"; +} from "@ccr/core/contracts/app"; type LoadedProfileConfig = Partial> & { claudeCode?: Partial; @@ -62,7 +67,7 @@ type LoadedBotGatewayConfig = Partial> handoff?: Partial; }; -type LoadedAppConfig = Partial> & { +type LoadedAppConfig = Partial> & { Router?: Partial; agent?: Partial; botConfigs?: BotGatewaySavedConfig[]; @@ -71,24 +76,16 @@ type LoadedAppConfig = Partial; profile?: LoadedProfileConfig; proxy?: Partial; + toolHub?: Partial; }; -type RawAppConfigSource = "default" | "legacy-json" | "sqlite"; +export type RawAppConfigSource = "default" | "legacy-json" | "sqlite"; type RawAppConfigLoadResult = { source: RawAppConfigSource; value: Partial; }; -const DEFAULT_PROXY_TARGETS: ProxyRouteTarget[] = [ - { host: "api.anthropic.com", paths: ["/v1/messages", "/v1/messages/count_tokens"] }, - { host: "api.openai.com", paths: ["/v1/chat/completions", "/v1/responses", "/v1/models"] }, - { host: "generativelanguage.googleapis.com", paths: ["/v1beta/models", "/v1/models"] }, - { host: "openrouter.ai", paths: ["/api/v1/chat/completions", "/api/v1/responses", "/api/v1/models"] }, - { host: "api.deepseek.com", paths: ["/chat/completions", "/v1/chat/completions", "/models", "/v1/models"] }, - { host: "api.mistral.ai", paths: ["/v1/chat/completions", "/v1/models"] } -]; - const REMOVED_LEGACY_ROUTER_RULE_IDS = new Set([ "legacy-subagent", "legacy-background", @@ -97,144 +94,12 @@ const REMOVED_LEGACY_ROUTER_RULE_IDS = new Set([ "legacy-image" ]); const INTERNAL_GATEWAY_CORE_HOST = "127.0.0.1"; +const GENERATED_GATEWAY_API_KEY_ID = "local-gateway"; -const DEFAULT_CONFIG: AppConfig = { - APIKEY: "", - APIKEYS: [], - API_TIMEOUT_MS: 600000, - CUSTOM_ROUTER_PATH: "", - HOST: "127.0.0.1", - PORT: 3456, - Providers: [], - Router: { - fallback: { - mode: "off", - models: [], - retryCount: 1 - }, - longContextThreshold: 200000, - rules: [] - }, - agent: { - mcpServers: [] - }, - autoStart: false, - botConfigs: [], - botGateway: { - acknowledgeEvents: false, - args: [], - authType: "", - autoStartIntegration: true, - command: "", - createIntegration: false, - credentials: {}, - cwd: "", - enabled: false, - forwardAllAgentMessages: true, - handoff: { - enabled: false, - idleSeconds: 30, - phoneBluetoothTargets: [], - phoneWifiTargets: [], - screenLock: true, - userIdle: true - }, - integrationConfig: {}, - integrationId: "", - platform: "none", - pollIntervalMs: 2000, - requestTimeoutMs: 600000, - sourceDir: "", - startupTimeoutMs: 10000, - stateDir: "", - tenantId: "ccr" - }, - gateway: { - coreHost: INTERNAL_GATEWAY_CORE_HOST, - corePort: 3457, - enabled: true, - generatedConfigFile: GATEWAY_CONFIG_FILE, - host: "127.0.0.1", - port: 3456 - }, - observability: { - agentAnalysis: false, - requestLogs: false - }, - preferredProvider: "", - plugins: [], - overviewWidgets: DEFAULT_OVERVIEW_WIDGETS, - profile: { - claudeCode: { - enabled: true, - model: "", - settingsFile: "~/.claude/settings.json", - smallFastModel: "" - }, - codex: { - cliMiddleware: true, - codexCliPath: "", - codexHome: "", - configFormat: "separate_profile_files", - configFile: "~/.codex/config.toml", - enabled: true, - model: "", - providerId: "claude-code-router", - providerName: "Claude Code Router", - showAllSessions: false - }, - enabled: true, - profiles: [ - { - agent: "claude-code", - enabled: true, - env: { ...CLAUDE_CODE_DEFAULT_ENV }, - id: "default-claude-code", - model: "", - name: "Claude Code", - scope: "global", - settingsFile: "~/.claude/settings.json", - smallFastModel: "", - surface: "auto" - }, - { - agent: "codex", - cliMiddleware: true, - codexCliPath: "", - codexHome: "", - configFormat: "separate_profile_files", - configFile: "~/.codex/config.toml", - enabled: true, - env: {}, - id: "default-codex", - model: "", - name: "Codex", - providerId: "claude-code-router", - providerName: "Claude Code Router", - showAllSessions: false, - scope: "global", - surface: "auto" - } - ] - }, - proxy: { - browserMode: true, - captureNetwork: false, - enabled: false, - host: "127.0.0.1", - mode: "gateway", - port: 7890, - systemProxy: false, - targets: DEFAULT_PROXY_TARGETS - }, - routerEndpoint: "http://127.0.0.1:3456", - theme: "system", - trayComponentVariants: DEFAULT_TRAY_COMPONENT_VARIANTS, - trayIcon: "random", - trayProgressTargetTokens: 100000, - trayWidgets: DEFAULT_TRAY_WIDGETS, - trayWindowModules: DEFAULT_TRAY_WINDOW_MODULES -}; +const DEFAULT_CONFIG: AppConfig = createDefaultAppConfig({ + coreHost: INTERNAL_GATEWAY_CORE_HOST, + generatedConfigFile: GATEWAY_CONFIG_FILE +}); function completeBotGatewayConfig(config: LoadedBotGatewayConfig | undefined): BotGatewayRuntimeConfig { const platform = normalizeBotGatewayPlatform(config?.platform ?? DEFAULT_CONFIG.botGateway.platform); @@ -346,7 +211,7 @@ export async function loadAppConfig(): Promise { try { const loadedRawConfig = await loadRawAppConfig(); const rawValue = loadedRawConfig.value; - const value = interpolateEnvVars(rawValue) as Partial; + const value = interpolateRawAppConfigEnvVars(rawValue, loadedRawConfig.source) as Partial; const picked = pickConfig(value); const providers = picked.Providers ?? DEFAULT_CONFIG.Providers; const port = picked.PORT ?? endpointPort(picked.routerEndpoint) ?? DEFAULT_CONFIG.PORT; @@ -355,8 +220,9 @@ export async function loadAppConfig(): Promise { const gatewayConfig = picked.gateway ?? {}; const corePort = gatewayConfig.corePort ?? nextPort(port); const configFileApiKeys = normalizeApiKeys(picked.APIKEYS, picked.APIKEY).filter((apiKey) => !isDefaultSeedApiKey(apiKey)); - const persistedApiKeys = await loadPersistedApiKeys(); - const apiKeys = uniqueApiKeyConfigs([...persistedApiKeys, ...configFileApiKeys]); + const persistedApiKeys = (await loadPersistedApiKeys()).filter((apiKey) => !isDefaultSeedApiKey(apiKey)); + const loadedApiKeys = uniqueApiKeyConfigs([...persistedApiKeys, ...configFileApiKeys]); + const apiKeys = ensureGatewayApiKeys(loadedApiKeys); const config: AppConfig = withSingleEnabledGlobalProfiles({ ...DEFAULT_CONFIG, ...picked, @@ -410,13 +276,22 @@ export async function loadAppConfig(): Promise { ...(picked.proxy ?? {}), targets: picked.proxy?.targets?.length ? picked.proxy.targets : DEFAULT_CONFIG.proxy.targets }, - routerEndpoint: endpoint + routerEndpoint: endpoint, + toolHub: { + ...DEFAULT_CONFIG.toolHub, + ...(picked.toolHub ?? {}), + llm: { + ...DEFAULT_CONFIG.toolHub.llm, + ...(picked.toolHub?.llm ?? {}) + }, + mcpServers: picked.toolHub?.mcpServers ?? DEFAULT_CONFIG.toolHub.mcpServers + } }); - const shouldMigrateApiKeys = hasConfigFileApiKeys(rawValue) || configFileApiKeys.length > 0; - if (shouldMigrateApiKeys) { + const shouldPersistApiKeys = loadedApiKeys.length === 0 || hasConfigFileApiKeys(rawValue) || configFileApiKeys.length > 0; + if (shouldPersistApiKeys) { await replacePersistedApiKeys(apiKeys); } - if (loadedRawConfig.source !== "sqlite" || shouldMigrateApiKeys) { + if (loadedRawConfig.source !== "sqlite" || shouldPersistApiKeys) { await writeSanitizedConfig(config); } return config; @@ -426,10 +301,16 @@ export async function loadAppConfig(): Promise { console.warn(`[config] Failed to load API keys: ${formatError(storeError)}`); return [] as ApiKeyConfig[]; }); + const apiKeys = ensureGatewayApiKeys(persistedApiKeys.filter((apiKey) => !isDefaultSeedApiKey(apiKey))); + if (persistedApiKeys.length === 0) { + await replacePersistedApiKeys(apiKeys).catch((storeError) => { + console.warn(`[config] Failed to persist generated API key: ${formatError(storeError)}`); + }); + } return { ...DEFAULT_CONFIG, - APIKEY: persistedApiKeys[0]?.key ?? "", - APIKEYS: persistedApiKeys + APIKEY: apiKeys[0]?.key ?? "", + APIKEYS: apiKeys }; } } @@ -437,7 +318,7 @@ export async function loadAppConfig(): Promise { export async function saveAppConfig(config: AppConfig): Promise { const normalizedConfig = withSingleEnabledGlobalProfiles(config); assertProviderApiKeysAreSafe(normalizedConfig); - const apiKeys = normalizeApiKeys(normalizedConfig.APIKEYS, normalizedConfig.APIKEY).filter((apiKey) => !isDefaultSeedApiKey(apiKey)); + const apiKeys = ensureGatewayApiKeys(normalizeApiKeys(normalizedConfig.APIKEYS, normalizedConfig.APIKEY).filter((apiKey) => !isDefaultSeedApiKey(apiKey))); await replacePersistedApiKeys(apiKeys); await writeSanitizedConfig({ ...normalizedConfig, @@ -563,7 +444,7 @@ function providerCredentialApiKey(credential: ProviderCredentialConfig): string } export async function saveApiKeysConfig(apiKeys: ApiKeyConfig[]): Promise { - const normalized = normalizeApiKeys(apiKeys, undefined).filter((apiKey) => !isDefaultSeedApiKey(apiKey)); + const normalized = ensureGatewayApiKeys(normalizeApiKeys(apiKeys, undefined).filter((apiKey) => !isDefaultSeedApiKey(apiKey))); await replacePersistedApiKeys(normalized); return loadAppConfig(); } @@ -592,7 +473,7 @@ async function loadRawAppConfig(): Promise { } function readLegacyJsonConfig(): Partial | undefined { - const files = uniqueStrings([CONFIG_FILE, LEGACY_CONFIG_FILE]); + const files = uniqueStrings([CONFIG_FILE, LEGACY_WINDOWS_CONFIG_FILE, LEGACY_CONFIG_FILE]); for (const file of files) { if (!existsSync(file)) { continue; @@ -623,6 +504,7 @@ function sanitizeConfigForDisk(config: AppConfig): AppConfig { ...config.gateway, coreHost: INTERNAL_GATEWAY_CORE_HOST }, + Providers: withProviderIds(config.Providers), profile: sanitizeProfileConfigForDisk(config.profile) }; } @@ -709,6 +591,10 @@ function pickConfig(value: Partial): LoadedAppConfig { if (typeof value.autoStart === "boolean") { config.autoStart = value.autoStart; } + const launchAtLogin = (value as Record).launchAtLogin; + if (typeof launchAtLogin === "boolean") { + config.launchAtLogin = launchAtLogin; + } if (isObject(value.gateway)) { const gateway = value.gateway as Record; const gatewayConfig: Partial = {}; @@ -740,6 +626,10 @@ function pickConfig(value: Partial): LoadedAppConfig { if (observability) { config.observability = observability; } + const toolHub = parseToolHub((value as Record).toolHub ?? (value as Record).tool_hub); + if (toolHub) { + config.toolHub = toolHub; + } if (typeof value.preferredProvider === "string" && value.preferredProvider.trim()) { config.preferredProvider = value.preferredProvider.trim(); } @@ -801,6 +691,53 @@ function parseObservability(value: unknown): Partial | unde return Object.keys(observability).length ? observability : undefined; } +function parseToolHub(value: unknown): Partial | undefined { + if (!isObject(value)) { + return undefined; + } + + const toolHub: Partial = {}; + if (typeof value.enabled === "boolean") { + toolHub.enabled = value.enabled; + } + const browserAutomation = value.browserAutomation ?? value.browser_automation; + if (typeof browserAutomation === "boolean") { + toolHub.browserAutomation = browserAutomation; + } + const maxTools = readNumber(value.maxTools ?? value.max_tools); + if (maxTools !== undefined) { + toolHub.maxTools = clampNumber(maxTools, 1, 20); + } + const requestTimeoutMs = readNumber(value.requestTimeoutMs ?? value.request_timeout_ms); + if (requestTimeoutMs !== undefined) { + toolHub.requestTimeoutMs = clampNumber(requestTimeoutMs, 8000, 300000); + } + const mcpServers = parseMcpServers(value.mcpServers ?? value.mcp_servers); + if (mcpServers) { + toolHub.mcpServers = mcpServers; + } + + const rawLlm = isObject(value.llm) ? value.llm : value; + const llm: Partial = {}; + const apiKey = readString(rawLlm.apiKey) || readString(rawLlm.api_key); + if (apiKey !== undefined) { + llm.apiKey = apiKey; + } + const baseUrl = readString(rawLlm.baseUrl) || readString(rawLlm.base_url); + if (baseUrl !== undefined) { + llm.baseUrl = baseUrl; + } + const model = readString(rawLlm.model); + if (model !== undefined) { + llm.model = model; + } + if (Object.keys(llm).length > 0) { + toolHub.llm = llm as ToolHubConfig["llm"]; + } + + return Object.keys(toolHub).length ? toolHub : undefined; +} + function parseOverviewWidgets(value: unknown): OverviewWidgetConfig[] | undefined { if (!Array.isArray(value)) { return undefined; @@ -833,7 +770,7 @@ function parseOverviewWidget(value: unknown): OverviewWidgetConfig | undefined { } function parseOverviewWidgetType(value: unknown): OverviewWidgetType | undefined { - return parseEnumValue(value, ["account-balance", "client-analysis", "metric", "model-distribution", "provider-analysis", "system-status", "token-activity", "token-mix", "usage-trend"], undefined); + return parseEnumValue(value, ["account-balance", "client-analysis", "metric", "model-distribution", "provider-analysis", "share-fuel-cockpit", "share-model-leaderboard", "share-route-map", "share-spend-receipt", "share-token-calendar", "share-usage-wrapped", "system-status", "token-activity", "token-mix", "usage-trend"], undefined); } function parseOverviewWidgetSize(value: unknown, type: OverviewWidgetType): OverviewWidgetSize | undefined { @@ -886,6 +823,9 @@ function defaultOverviewWidgetSize(type: OverviewWidgetType): OverviewWidgetSize if (type === "system-status") { return "4:1"; } + if (isShareOverviewWidgetType(type)) { + return "1:4"; + } return "4:2"; } @@ -911,6 +851,9 @@ function defaultOverviewWidgetVariant(type: OverviewWidgetType): OverviewWidgetV if (type === "system-status") { return "timeline"; } + if (isShareOverviewWidgetType(type)) { + return "card"; + } return "table"; } @@ -918,6 +861,15 @@ function overviewWidgetId(type: OverviewWidgetType, metric?: OverviewMetricKind) return type === "metric" ? `metric-${metric ?? "requests"}` : type; } +function isShareOverviewWidgetType(type: OverviewWidgetType): boolean { + return type === "share-fuel-cockpit" || + type === "share-model-leaderboard" || + type === "share-route-map" || + type === "share-spend-receipt" || + type === "share-token-calendar" || + type === "share-usage-wrapped"; +} + function parseTrayIconPreference(value: unknown): TrayIconPreference | undefined { if (value === "random" || value === "violet" || value === "orange" || value === "cyan" || value === "progress") { return value; @@ -1081,6 +1033,8 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined { const models = Array.isArray(item.models) ? item.models.map((model) => readString(model)).filter((model): model is string => Boolean(model)) : []; + const modelDescriptions = parseModelDescriptions(item.modelDescriptions ?? item.model_descriptions, models); + const modelDisplayNames = parseModelDisplayNames(item.modelDisplayNames ?? item.model_display_names, models); if (!name) { return undefined; @@ -1100,17 +1054,92 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined { extraBody: item.extraBody, extraHeaders: item.extraHeaders, icon: readString(item.icon), + id: readString(item.id), + modelDescriptions, + modelDisplayNames, models, name, provider: readString(item.provider), transformer: item.transformer, type: readString(item.type) }; - return provider; + return normalizeCodexProviderAccountConfig(provider); }) .filter((item): item is GatewayProviderConfig => Boolean(item)); - return providers; + return withProviderIds(providers); +} + +function parseModelDescriptions(value: unknown, models: string[]): Record | undefined { + if (!isObject(value)) { + return undefined; + } + + const modelIds = new Set(models); + const entries = Object.entries(value) + .map(([rawModel, rawDescription]) => [rawModel.trim(), readString(rawDescription)] as const) + .filter((entry): entry is [string, string] => { + const [model, description] = entry; + return Boolean(model && description && modelIds.has(model)); + }); + + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +function parseModelDisplayNames(value: unknown, models: string[]): Record | undefined { + if (!isObject(value)) { + return undefined; + } + + const modelIds = new Set(models); + const entries = Object.entries(value) + .map(([rawModel, rawDisplayName]) => [rawModel.trim(), readString(rawDisplayName)] as const) + .filter((entry): entry is [string, string] => { + const [model, displayName] = entry; + return Boolean(model && displayName && modelIds.has(model) && model !== displayName); + }); + + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +function withProviderIds(providers: GatewayProviderConfig[]): GatewayProviderConfig[] { + const counts = new Map(); + return providers.map((provider) => { + const baseId = providerRuntimeIdCandidate(provider); + const nextCount = (counts.get(baseId) ?? 0) + 1; + counts.set(baseId, nextCount); + return { + ...provider, + id: nextCount === 1 ? baseId : `${baseId}-${nextCount}` + }; + }); +} + +function providerRuntimeIdCandidate(provider: GatewayProviderConfig): string { + const explicit = sanitizeProviderId(provider.id); + if (explicit) { + return explicit; + } + const slug = sanitizeProviderId(provider.name) || "provider"; + const source = [ + provider.name, + provider.provider ?? "", + providerBaseUrl(provider), + provider.type ?? "" + ].join("\n"); + const hash = createHash("sha256").update(source).digest("hex").slice(0, 10); + return `provider-${slug.slice(0, 48)}-${hash}`; +} + +function sanitizeProviderId(value: string | undefined): string | undefined { + const normalized = value + ?.normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 72); + return normalized || undefined; } function parseProviderCredentials(value: unknown): ProviderCredentialConfig[] | undefined { @@ -1219,6 +1248,16 @@ function parseProviderCapabilityProtocol(value: string | undefined): GatewayProv if (normalized === "gemini" || normalized === "gemini_generate_content") { return "gemini_generate_content"; } + if ( + normalized === "gemini_interactions" || + normalized === "gemini-interactions" || + normalized === "google_interactions" || + normalized === "google-interactions" || + normalized === "interactions" || + normalized === "interaction" + ) { + return "gemini_interactions"; + } return undefined; } @@ -1228,15 +1267,9 @@ function parseRouter(value: unknown): Partial | undefined { } const router: Partial = {}; - for (const key of ["background", "default", "image", "longContext", "think", "webSearch"] as const) { - const route = readString(value[key]); - if (route) { - router[key] = route; - } - } - const threshold = readNumber(value.longContextThreshold); - if (threshold !== undefined && threshold > 0) { - router.longContextThreshold = threshold; + const builtInRules = parseRouterBuiltInRules(value.builtInRules ?? value.builtinRules ?? value.agentRules); + if (builtInRules) { + router.builtInRules = builtInRules; } const rules = parseRouterRules(value.rules); if (rules) { @@ -1251,6 +1284,29 @@ function parseRouter(value: unknown): Partial | undefined { return router; } +function parseRouterBuiltInRules(value: unknown): RouterBuiltInRulesConfig | undefined { + if (!isObject(value)) { + return undefined; + } + + return { + "claude-code": parseRouterBuiltInAgentRule(value["claude-code"] ?? value.claudeCode ?? value.claude), + codex: parseRouterBuiltInAgentRule(value.codex) + }; +} + +function parseRouterBuiltInAgentRule(value: unknown): { enabled: boolean } { + if (typeof value === "boolean") { + return { enabled: value }; + } + if (!isObject(value)) { + return { enabled: true }; + } + return { + enabled: typeof value.enabled === "boolean" ? value.enabled : true + }; +} + function parseRouterFallback(value: unknown): RouterFallbackConfig | undefined { if (!isObject(value)) { return undefined; @@ -1353,8 +1409,7 @@ function parseRouterRules(value: unknown): RouterRule[] | undefined { const pattern = readString(item.pattern); const threshold = readNumber(item.threshold); const condition = parseRouterRuleCondition(item.condition ?? item) ?? routerRuleConditionFromLegacy(type, { - pattern, - threshold: threshold !== undefined && threshold > 0 ? threshold : undefined + pattern }); const rewrites = parseRouterRuleRewrites(item); const fallback = parseRouterFallback(item.fallback ?? item.failureFallback ?? item.fallbackStrategy); @@ -1370,7 +1425,7 @@ function parseRouterRules(value: unknown): RouterRule[] | undefined { ...(rewrites.length > 0 ? { rewrites } : {}), ...(target ? { target } : {}), ...(threshold !== undefined && threshold > 0 ? { threshold } : {}), - type: condition && type !== "subagent" ? "condition" : type + type: condition ? "condition" : type }; }) .filter((item): item is RouterRule => Boolean(item)); @@ -1384,12 +1439,7 @@ function parseRouterRuleType(value: unknown): RouterRuleType | undefined { const normalized = value.trim().toLowerCase(); if ( normalized === "condition" || - normalized === "image" || - normalized === "long-context" || - normalized === "model-prefix" || - normalized === "subagent" || - normalized === "thinking" || - normalized === "web-search" + normalized === "model-prefix" ) { return normalized; } @@ -1438,15 +1488,8 @@ function parseRouterRuleOperator(value: unknown): RouterRuleOperator | undefined function routerRuleConditionFromLegacy( type: RouterRuleType, - input: { pattern?: string; threshold?: number } + input: { pattern?: string } ): RouterRuleCondition | undefined { - if (type === "long-context") { - return { - left: "request.tokenCount", - operator: ">", - right: String(input.threshold ?? "200000") - }; - } if (type === "model-prefix" && input.pattern) { return { left: "request.body.model", @@ -1454,27 +1497,6 @@ function routerRuleConditionFromLegacy( right: input.pattern }; } - if (type === "thinking") { - return { - left: "request.body.thinking", - operator: "==", - right: "true" - }; - } - if (type === "web-search") { - return { - left: "request.body.tools", - operator: "contains-deep", - right: "web_search" - }; - } - if (type === "image") { - return { - left: "request.body.messages", - operator: "contains-deep", - right: "image" - }; - } return undefined; } @@ -1777,7 +1799,7 @@ function parseMcpServers(value: unknown): GatewayMcpServerConfig[] | undefined { return undefined; } - const transport = parseMcpServerTransport(item.transport); + const transport = parseMcpServerTransport(item.transport ?? item.type); const name = readString(item.name) || (transport !== "stdio" ? readString(item.url) : readString(item.command)) || `mcp-${index + 1}`; const protocolVersion = readString(item.protocolVersion) || "2024-11-05"; const startupTimeoutMs = clampNumber(readNumber(item.startupTimeoutMs) ?? 600000, 100, 600000); @@ -1832,7 +1854,7 @@ function parseMcpServerTransport(value: unknown): GatewayMcpServerTransport { if (normalized === "sse") { return "sse"; } - if (normalized === "streamable-http" || normalized === "streamble-http" || normalized === "websocket") { + if (normalized === "http" || normalized === "streamable-http" || normalized === "streamablehttp" || normalized === "streamble-http" || normalized === "websocket") { return "streamable-http"; } return "stdio"; @@ -2455,6 +2477,19 @@ function normalizeApiKeys(value: ApiKeyConfig[] | undefined, legacyKey: string | return uniqueApiKeyConfigs([...(value ?? []), ...(legacyKey ? [createApiKeyConfig(legacyKey, value?.length ?? 0)] : [])]); } +function ensureGatewayApiKeys(apiKeys: ApiKeyConfig[]): ApiKeyConfig[] { + return apiKeys.length ? apiKeys : [createGeneratedGatewayApiKey()]; +} + +function createGeneratedGatewayApiKey(): ApiKeyConfig { + return { + createdAt: new Date().toISOString(), + id: GENERATED_GATEWAY_API_KEY_ID, + key: `sk-ccr-${randomBytes(32).toString("base64url")}`, + name: "Local Gateway" + }; +} + function createApiKeyConfig(key: string, index: number): ApiKeyConfig { return { createdAt: new Date(0).toISOString(), @@ -2516,6 +2551,10 @@ function isDefaultSeedApiKey(apiKey: ApiKeyConfig): boolean { ); } +export function interpolateRawAppConfigEnvVars(value: unknown, source: RawAppConfigSource): unknown { + return source === "legacy-json" ? interpolateEnvVars(value) : value; +} + function interpolateEnvVars(value: unknown): unknown { if (typeof value === "string") { return value.replace(/\$\{([^}]+)\}|\$([A-Z_][A-Z0-9_]*)/g, (match, braced, unbraced) => { diff --git a/packages/core/src/config/constants.ts b/packages/core/src/config/constants.ts new file mode 100644 index 0000000..b283061 --- /dev/null +++ b/packages/core/src/config/constants.ts @@ -0,0 +1,32 @@ +import path from "node:path"; +import { APP_NAME, APP_STORAGE_NAME, LEGACY_CONFIGDIR, resolveRuntimeAppPath, resolveRuntimeConfigDir, resolveRuntimeDataDir } from "@ccr/core/runtime/app-paths"; +import { copyMissingDirectoryContents } from "@ccr/core/storage/migration"; + +export { IPC_CHANNELS } from "@ccr/core/contracts/ipc-channels"; +export const LEGACY_CONFIG_FILE = path.join(LEGACY_CONFIGDIR, "config.json"); + +export { APP_NAME, APP_STORAGE_NAME, LEGACY_CONFIGDIR }; + +export const CONFIGDIR = resolveRuntimeConfigDir(); +export const LEGACY_WINDOWS_CONFIGDIR = path.join(resolveRuntimeAppPath("appData"), APP_NAME); +export const LEGACY_WINDOWS_CONFIG_FILE = path.join(LEGACY_WINDOWS_CONFIGDIR, "config.json"); +export const CONFIG_FILE = path.join(CONFIGDIR, "config.json"); +export const ONBOARDING_FINISHED_FILE = path.join(CONFIGDIR, ".onboard_finished"); +export const DATADIR = resolveRuntimeDataDir(); +export const APP_CONFIG_DB_FILE = path.join(CONFIGDIR, "config.sqlite"); +export const API_KEYS_DB_FILE = path.join(DATADIR, "api-keys.sqlite"); +export const LEGACY_APP_CONFIG_DB_FILES = process.platform === "win32" ? [path.join(LEGACY_WINDOWS_CONFIGDIR, "config.sqlite")] : []; +export const LEGACY_API_KEYS_DB_FILES = process.platform === "win32" ? [path.join(LEGACY_WINDOWS_CONFIGDIR, "api-keys.sqlite")] : []; +export const CERTDIR = path.join(DATADIR, "certs"); +export const PROVIDER_ICON_CACHE_DIR = path.join(DATADIR, "provider-icons"); +export const PROXY_CA_CERT_FILE = path.join(CERTDIR, "ca.pem"); +export const PROXY_CA_CERT_DER_FILE = path.join(CERTDIR, "ca.cer"); +export const PROXY_CA_KEY_FILE = path.join(CERTDIR, "key.pem"); +export const GATEWAY_CONFIG_FILE = path.join(CONFIGDIR, "gateway.config.json"); +export const REQUEST_LOGS_DB_FILE = path.join(DATADIR, "request-logs.sqlite"); +export const RAW_TRACE_SPOOL_DIR = path.join(DATADIR, "raw-trace-spool"); +export const USAGE_DB_FILE = path.join(DATADIR, "usage.sqlite"); + +if (process.platform === "win32") { + copyMissingDirectoryContents(LEGACY_WINDOWS_CONFIGDIR, CONFIGDIR, "Windows app data directory"); +} diff --git a/packages/core/src/config/default-config.ts b/packages/core/src/config/default-config.ts new file mode 100644 index 0000000..58a3868 --- /dev/null +++ b/packages/core/src/config/default-config.ts @@ -0,0 +1,186 @@ +import { + CLAUDE_CODE_DEFAULT_ENV, + DEFAULT_OVERVIEW_WIDGETS, + DEFAULT_TRAY_COMPONENT_VARIANTS, + DEFAULT_TRAY_WIDGETS, + DEFAULT_TRAY_WINDOW_MODULES, + type AppConfig, + type ProxyRouteTarget +} from "@ccr/core/contracts/app"; + +export const DEFAULT_PROXY_TARGETS: ProxyRouteTarget[] = [ + { host: "api.anthropic.com", paths: ["/v1/messages", "/v1/messages/count_tokens"] }, + { host: "api.openai.com", paths: ["/v1/chat/completions", "/v1/responses", "/v1/models"] }, + { host: "generativelanguage.googleapis.com", paths: ["/v1beta/models", "/v1/models"] }, + { host: "openrouter.ai", paths: ["/api/v1/chat/completions", "/api/v1/responses", "/api/v1/models"] }, + { host: "api.deepseek.com", paths: ["/chat/completions", "/v1/chat/completions", "/models", "/v1/models"] }, + { host: "api.mistral.ai", paths: ["/v1/chat/completions", "/v1/models"] } +]; + +export type DefaultAppConfigOptions = { + coreHost?: string; + generatedConfigFile: string; +}; + +export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppConfig { + const coreHost = options.coreHost ?? "127.0.0.1"; + return { + APIKEY: "", + APIKEYS: [], + API_TIMEOUT_MS: 600000, + CUSTOM_ROUTER_PATH: "", + HOST: "127.0.0.1", + PORT: 3456, + Providers: [], + Router: { + builtInRules: { + "claude-code": { + enabled: true + }, + codex: { + enabled: true + } + }, + fallback: { + mode: "off", + models: [], + retryCount: 1 + }, + rules: [] + }, + agent: { + mcpServers: [] + }, + autoStart: false, + botConfigs: [], + botGateway: { + acknowledgeEvents: false, + args: [], + authType: "", + autoStartIntegration: true, + command: "", + createIntegration: false, + credentials: {}, + cwd: "", + enabled: false, + forwardAllAgentMessages: true, + handoff: { + enabled: false, + idleSeconds: 30, + phoneBluetoothTargets: [], + phoneWifiTargets: [], + screenLock: true, + userIdle: true + }, + integrationConfig: {}, + integrationId: "", + platform: "none", + pollIntervalMs: 2000, + requestTimeoutMs: 600000, + sourceDir: "", + startupTimeoutMs: 10000, + stateDir: "", + tenantId: "ccr" + }, + gateway: { + coreHost, + corePort: 3457, + enabled: true, + generatedConfigFile: options.generatedConfigFile, + host: "127.0.0.1", + port: 3456 + }, + launchAtLogin: false, + observability: { + agentAnalysis: false, + requestLogs: false + }, + preferredProvider: "", + plugins: [], + profile: { + claudeCode: { + enabled: true, + model: "", + settingsFile: "~/.claude/settings.json", + smallFastModel: "" + }, + codex: { + cliMiddleware: true, + codexCliPath: "", + codexHome: "", + configFormat: "separate_profile_files", + configFile: "~/.codex/config.toml", + enabled: true, + model: "", + providerId: "claude-code-router", + providerName: "Claude Code Router", + showAllSessions: false + }, + enabled: true, + profiles: [ + { + agent: "claude-code", + enabled: true, + env: { ...CLAUDE_CODE_DEFAULT_ENV }, + id: "default-claude-code", + model: "", + name: "Claude Code", + scope: "global", + settingsFile: "~/.claude/settings.json", + smallFastModel: "", + surface: "auto" + }, + { + agent: "codex", + cliMiddleware: true, + codexCliPath: "", + codexHome: "", + configFormat: "separate_profile_files", + configFile: "~/.codex/config.toml", + enabled: true, + env: {}, + id: "default-codex", + model: "", + name: "Codex", + providerId: "claude-code-router", + providerName: "Claude Code Router", + showAllSessions: false, + scope: "global", + surface: "auto" + } + ] + }, + proxy: { + browserMode: true, + captureNetwork: false, + enabled: false, + host: "127.0.0.1", + mode: "gateway", + port: 7890, + systemProxy: false, + targets: DEFAULT_PROXY_TARGETS + }, + providerPlugins: [], + overviewWidgets: DEFAULT_OVERVIEW_WIDGETS, + routerEndpoint: "http://127.0.0.1:3456", + theme: "system", + trayComponentVariants: DEFAULT_TRAY_COMPONENT_VARIANTS, + trayIcon: "random", + trayProgressTargetTokens: 100000, + trayWidgets: DEFAULT_TRAY_WIDGETS, + trayWindowModules: DEFAULT_TRAY_WINDOW_MODULES, + toolHub: { + browserAutomation: false, + enabled: false, + llm: { + apiKey: "", + baseUrl: "https://api.openai.com/v1", + model: "" + }, + mcpServers: [], + maxTools: 10, + requestTimeoutMs: 60000 + }, + virtualModelProfiles: [] + }; +} diff --git a/src/shared/app.ts b/packages/core/src/contracts/app.ts similarity index 83% rename from src/shared/app.ts rename to packages/core/src/contracts/app.ts index f201679..60e389e 100644 --- a/src/shared/app.ts +++ b/packages/core/src/contracts/app.ts @@ -5,6 +5,7 @@ export type AppInfo = { configFile: string; dataDir: string; gatewayConfigFile: string; + launchAtLoginSupported: boolean; requestLogsDbFile: string; name: string; platform: string; @@ -18,6 +19,57 @@ export type AppDataExportResult = { file?: string; }; +export type AppCaptureElementPngRequest = { + borderRadius?: number; + exportId?: string; + fileName: string; + output?: { + height: number; + width: number; + }; + rect: { + height: number; + width: number; + x: number; + y: number; + }; +}; + +export type AppCaptureElementPngResult = { + canceled: boolean; + file?: string; +}; + +export type AppImageExportTargetRequest = { + fileName: string; +}; + +export type AppImageExportTargetResult = { + canceled: boolean; + exportId?: string; + file?: string; +}; + +export type AppRenderHtmlPngRequest = { + borderRadius?: number; + exportId?: string; + fileName: string; + html: string; + output?: { + height: number; + width: number; + }; + size: { + height: number; + width: number; + }; +}; + +export type AppRenderHtmlPngResult = { + canceled: boolean; + file?: string; +}; + export type AppUpdateState = | "idle" | "checking" @@ -61,7 +113,8 @@ export type GatewayProviderProtocol = | "openai_responses" | "openai_chat_completions" | "anthropic_messages" - | "gemini_generate_content"; + | "gemini_generate_content" + | "gemini_interactions"; export type GatewayProviderConfig = { account?: ProviderAccountConfig; @@ -77,6 +130,9 @@ export type GatewayProviderConfig = { extraBody?: unknown; extraHeaders?: unknown; icon?: string; + id?: string; + modelDescriptions?: Record; + modelDisplayNames?: Record; models: string[]; name: string; provider?: string; @@ -104,6 +160,7 @@ export type ProviderAccountStatus = "ok" | "warning" | "critical" | "error" | "u export type ProviderAccountMeterKind = "balance" | "subscription" | "quota" | "time_window" | "tokens" | "requests"; export type ProviderAccountMeterUnit = "USD" | "CNY" | "hours" | "minutes" | "tokens" | "requests" | string; export type ProviderAccountMeterWindow = "5h" | "daily" | "weekly" | "monthly" | string; +export type ProviderAccountHttpJsonParser = "kimi-code-usages" | "new-api-key-usage" | "new-api-user-self"; export type ProviderAccountConfig = { connectors?: ProviderAccountConnectorConfig[]; @@ -137,6 +194,7 @@ export type ProviderAccountHttpJsonConnectorConfig = ProviderAccountConnectorBas headers?: Record; mapping: ProviderAccountMappingConfig; method?: "GET" | "POST"; + parser?: ProviderAccountHttpJsonParser; type: "http-json"; }; @@ -166,19 +224,33 @@ export type ProviderAccountMappingConfig = { status?: string; }; +export type ProviderAccountMappedNumberExpression = number | string | Array; +export type ProviderAccountMappedStringExpression = string | string[]; + export type ProviderAccountMappedMeterConfig = { id: string; kind?: ProviderAccountMeterKind; label: string; - limit?: number | string; - remaining?: number | string; - resetAt?: string; + limit?: ProviderAccountMappedNumberExpression; + remaining?: ProviderAccountMappedNumberExpression; + resetAt?: ProviderAccountMappedStringExpression; unit?: ProviderAccountMeterUnit; - used?: number | string; + used?: ProviderAccountMappedNumberExpression; window?: ProviderAccountMeterWindow; }; +export type ProviderAccountMeterDetail = { + description?: string; + effectiveAt?: string; + expiresAt?: string; + id?: string; + label?: string; + redeemable?: boolean; + status?: string; +}; + export type ProviderAccountMeter = { + details?: ProviderAccountMeterDetail[]; id: string; kind: ProviderAccountMeterKind; label: string; @@ -219,6 +291,8 @@ export type ProviderDeepLinkPayload = { apiKey?: string; baseUrl: string; icon?: string; + modelDescriptions?: Record; + modelDisplayNames?: Record; models: string[]; name?: string; protocol?: GatewayProviderProtocol; @@ -248,6 +322,7 @@ export type LocalAgentProviderCandidate = { id: string; importable: boolean; kind: LocalAgentProviderKind; + modelDisplayNames?: Record; models: string[]; name: string; protocol: GatewayProviderProtocol; @@ -276,6 +351,7 @@ export type ProviderCatalogModelsRequest = { export type ProviderCatalogModelsResult = { loadedFrom?: string; matchedBy?: "base-url" | "provider-id" | "provider-name"; + modelDisplayNames?: Record; models: string[]; provider?: string; providerName?: string; @@ -302,6 +378,18 @@ export type ProviderAccountTestResult = { status?: ProviderAccountStatus; }; +export type ProviderAccountResetRequest = { + credentialId?: string; + creditId: string; + provider: string; +}; + +export type ProviderAccountResetResult = { + code?: string; + creditId: string; + ok: boolean; +}; + export type ProviderDeepLinkRequest = { error?: string; id: string; @@ -318,6 +406,8 @@ export type GatewayProviderCapability = { type: GatewayProviderProtocol; }; +export type GatewayProviderDetectedProvider = "new-api"; + export type GatewayProviderProbeRequest = { apiKey?: string; baseUrl: string; @@ -330,6 +420,7 @@ export type GatewayProviderProbeRequest = { export type GatewayProviderProbeCandidate = { baseUrl: string; + declaredProtocols?: GatewayProviderProtocol[]; label?: string; protocols: GatewayProviderProtocol[]; source: "custom" | "preset"; @@ -358,6 +449,7 @@ export type ProviderIconDetectionResult = { export type GatewayProviderProbeProtocolResult = { baseUrl?: string; + detectedProvider?: GatewayProviderDetectedProvider; endpoint: string; message: string; protocol: GatewayProviderProtocol; @@ -366,8 +458,11 @@ export type GatewayProviderProbeProtocolResult = { }; export type GatewayProviderProbeResult = { + account?: ProviderAccountConfig; capabilities?: GatewayProviderCapability[]; + detectedProvider?: GatewayProviderDetectedProvider; detectedProtocol?: GatewayProviderProtocol; + modelDisplayNames?: Record; modelSource?: "anthropic" | "gemini" | "openai"; models: string[]; normalizedBaseUrl: string; @@ -403,12 +498,7 @@ export type GatewayProviderConnectivityCheckReport = { export type RouterRuleType = | "condition" - | "image" - | "long-context" - | "model-prefix" - | "subagent" - | "thinking" - | "web-search"; + | "model-prefix"; export type RouterRuleOperator = | "==" @@ -467,16 +557,18 @@ export type RouterFallbackConfig = { retryCount: number; }; +export type RouterBuiltInAgentRuleId = "claude-code" | "codex"; + +export type RouterBuiltInAgentRuleConfig = { + enabled: boolean; +}; + +export type RouterBuiltInRulesConfig = Record; + export type RouterConfig = { - background?: string; - default?: string; + builtInRules: RouterBuiltInRulesConfig; fallback: RouterFallbackConfig; - image?: string; - longContext?: string; - longContextThreshold: number; rules: RouterRule[]; - think?: string; - webSearch?: string; }; export type GatewayRuntimeConfig = { @@ -550,6 +642,21 @@ export type GatewayAgentConfig = { mcpServers: GatewayMcpServerConfig[]; }; +export type ToolHubLlmConfig = { + apiKey: string; + baseUrl: string; + model: string; +}; + +export type ToolHubConfig = { + browserAutomation: boolean; + enabled: boolean; + llm: ToolHubLlmConfig; + mcpServers: GatewayMcpServerConfig[]; + maxTools: number; + requestTimeoutMs: number; +}; + export const CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV = "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"; export const CLAUDE_CODE_DEFAULT_ENV: Record = { [CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV]: "1" @@ -618,6 +725,7 @@ export type VirtualModelFusionVisionConfig = { }; export type VirtualModelFusionWebSearchProvider = + | "browser" | "brave" | "bing" | "google_cse" @@ -655,6 +763,88 @@ export type VirtualModelProfileConfig = { tools: VirtualModelToolConfig[]; }; +export const NO_AVAILABLE_GATEWAY_MODELS_MESSAGE = + "No available models. Configure at least one provider with a model before starting CCR Gateway or opening an agent through CCR."; + +export function assertAvailableGatewayModels(config: Pick): void { + if (!hasAvailableGatewayModels(config)) { + throw new Error(NO_AVAILABLE_GATEWAY_MODELS_MESSAGE); + } +} + +export function hasAvailableGatewayModels(config: Pick): boolean { + return availableGatewayModelIds(config).length > 0; +} + +export function availableGatewayModelIds(config: Pick): string[] { + const baseEntries = availableGatewayBaseModelEntries(config.Providers); + const ids = baseEntries.map((entry) => `${entry.providerName}/${entry.modelName}`); + + for (const profile of config.virtualModelProfiles ?? []) { + if (!isGatewayModelVisibleVirtualProfile(profile)) { + continue; + } + + for (const entry of baseEntries) { + for (const prefix of profile.match?.prefixes ?? []) { + const normalizedPrefix = prefix.trim(); + if (normalizedPrefix) { + ids.push(`${entry.providerName}/${normalizedPrefix}${entry.modelName}`); + } + } + for (const suffix of profile.match?.suffixes ?? []) { + const normalizedSuffix = suffix.trim(); + if (normalizedSuffix) { + ids.push(`${entry.providerName}/${entry.modelName}${normalizedSuffix}`); + } + } + } + + for (const alias of profile.match?.exactAliases ?? []) { + const normalizedAlias = alias.trim(); + if (normalizedAlias && baseEntries.length > 0) { + ids.push(normalizedAlias.toLowerCase().startsWith("fusion/") ? normalizedAlias : `Fusion/${normalizedAlias}`); + } + } + } + + return uniqueGatewayModelIds(ids); +} + +function availableGatewayBaseModelEntries(providers: GatewayProviderConfig[]): Array<{ modelName: string; providerName: string }> { + return providers.flatMap((provider) => { + const providerName = provider.name?.trim(); + if (!providerName || !Array.isArray(provider.models)) { + return []; + } + return provider.models.flatMap((rawModel) => { + const modelName = rawModel.trim(); + return modelName ? [{ modelName, providerName }] : []; + }); + }); +} + +function isGatewayModelVisibleVirtualProfile(profile: VirtualModelProfileConfig): boolean { + return profile.enabled !== false && + profile.materialization?.enabled !== false && + profile.materialization?.includeInGatewayModels !== false; +} + +function uniqueGatewayModelIds(values: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const normalized = value.trim(); + const key = normalized.toLowerCase(); + if (!normalized || seen.has(key)) { + continue; + } + seen.add(key); + result.push(normalized); + } + return result; +} + export type InstalledBrowserApp = GatewayPluginAppConfig & { id: string; pluginId: string; @@ -762,6 +952,12 @@ export type OverviewWidgetType = | "metric" | "model-distribution" | "provider-analysis" + | "share-fuel-cockpit" + | "share-model-leaderboard" + | "share-route-map" + | "share-spend-receipt" + | "share-token-calendar" + | "share-usage-wrapped" | "system-status" | "token-activity" | "token-mix" @@ -1206,6 +1402,7 @@ export type AppConfig = { botConfigs: BotGatewaySavedConfig[]; botGateway: BotGatewayRuntimeConfig; gateway: GatewayRuntimeConfig; + launchAtLogin: boolean; observability: ObservabilityConfig; preferredProvider: string; plugins: GatewayPluginConfig[]; @@ -1221,6 +1418,7 @@ export type AppConfig = { trayIcon: TrayIconPreference; trayWidgets: TrayWidgetConfig[]; trayWindowModules: TrayWindowModuleId[]; + toolHub: ToolHubConfig; virtualModelProfiles?: VirtualModelProfileConfig[]; }; @@ -1278,12 +1476,91 @@ export type BuiltInBrowserTabState = { url: string; }; +export type BuiltInBrowserAutomationHandoffKind = + | "blocked" + | "human_verification" + | "login_required" + | "other" + | "verification_code"; + +export type BuiltInBrowserAutomationHandoff = { + id: string; + kind: BuiltInBrowserAutomationHandoffKind; + message: string; + reason?: string; + requestedAt: number; + sessionId?: string; + status: "pending"; + tabId?: string; +}; + export type BuiltInBrowserState = { activeTabId?: string; apps: InstalledBrowserApp[]; + automationHandoff?: BuiltInBrowserAutomationHandoff; tabs: BuiltInBrowserTabState[]; }; +export type ChromeLoginImportTarget = "browser" | "browser-and-web-search"; + +export type ChromeLoginImportStatus = + | "completed" + | "expired" + | "failed" + | "pending"; + +export type ChromeLoginImportRequest = { + domains: string[]; + openConfirmationPage?: boolean; + target?: ChromeLoginImportTarget; +}; + +export type ChromeLoginImportResult = { + completedAt: number; + cookieImported: number; + cookieSkipped: number; + domains: string[]; + errors?: string[]; + imported: number; + localStorageImported: number; + localStorageSkipped: number; + partitions: string[]; + skipped: number; +}; + +export type ChromeLoginImportJob = { + confirmUrl: string; + createdAt: number; + domains: string[]; + endpointUrl: string; + expiresAt: number; + id: string; + importUrl: string; + result?: ChromeLoginImportResult; + status: ChromeLoginImportStatus; + target: ChromeLoginImportTarget; +}; + +export type ChromeLoginImportCookie = { + domain: string; + expirationDate?: number; + hostOnly?: boolean; + httpOnly?: boolean; + name: string; + partitionKey?: unknown; + path?: string; + sameSite?: "lax" | "no_restriction" | "strict" | "unspecified"; + secure?: boolean; + session?: boolean; + storeId?: string; + value: string; +}; + +export type ChromeLoginImportLocalStorage = { + items: Record; + origin: string; +}; + export type ProxyCertificateInstallResult = { caCertFile: string; manualCommand?: string; @@ -1347,6 +1624,10 @@ export type RequestLogListFilter = { status?: RequestLogStatusFilter; }; +export type RequestLogDetailRequest = { + id: number; +}; + export type RequestLogBody = ProxyNetworkBody; export type RequestLogRetryAttempt = { diff --git a/src/shared/deep-link.ts b/packages/core/src/contracts/deep-link.ts similarity index 68% rename from src/shared/deep-link.ts rename to packages/core/src/contracts/deep-link.ts index 2fb8cba..b8bb98a 100644 --- a/src/shared/deep-link.ts +++ b/packages/core/src/contracts/deep-link.ts @@ -6,8 +6,8 @@ import type { ProviderDeepLinkPayload, ProviderDeepLinkRequest, ProviderManifestDeepLinkPayload -} from "./app"; -import { providerUrlWithDefaultScheme } from "./provider-url"; +} from "@ccr/core/contracts/app"; +import { providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; export const appDeepLinkProtocol = "ccr"; export const providerDeepLinkHost = "provider"; @@ -20,24 +20,16 @@ const maxIconLength = 8_192; const maxManifestUrlLength = 2_048; const maxSourceLength = 2_048; const maxModelLength = 256; +const maxModelDescriptionLength = 1_000; const maxModels = 300; -const providerLinkApiKeyError = "Provider links cannot include API keys. Add the key manually after verifying the endpoint."; -const protocolAliases: Record = { - anthropic: "anthropic_messages", - anthropic_messages: "anthropic_messages", - claude: "anthropic_messages", - gemini: "gemini_generate_content", - gemini_generate: "gemini_generate_content", - gemini_generate_content: "gemini_generate_content", - google: "gemini_generate_content", - openai: "openai_chat_completions", - openai_chat: "openai_chat_completions", - openai_chat_completions: "openai_chat_completions", - openai_response: "openai_responses", - openai_responses: "openai_responses", - responses: "openai_responses" -}; +const providerProtocols = new Set([ + "anthropic_messages", + "gemini_generate_content", + "gemini_interactions", + "openai_chat_completions", + "openai_responses" +]); export function isAppDeepLinkUrl(value: string): boolean { return value.trim().toLowerCase().startsWith(`${appDeepLinkProtocol}://`); @@ -91,8 +83,8 @@ export function parseProviderManifestDeepLinkPayload(rawUrl: string): ProviderMa const payload = readPayloadRecord(url.searchParams); const manifestUrl = boundedString( - firstStringParam(url.searchParams, ["manifest_url", "manifestUrl", "manifest"]) ?? - firstPayloadString(payload, ["manifest_url", "manifestUrl", "manifest"]), + firstStringParam(url.searchParams, ["manifest"]) ?? + firstPayloadString(payload, ["manifest"]), maxManifestUrlLength, "Manifest URL" ); @@ -125,14 +117,14 @@ export function parseProviderDeepLinkPayload(rawUrl: string): ProviderDeepLinkPa const params = url.searchParams; const payload = readPayloadRecord(params); const name = boundedString( - firstStringParam(params, ["name", "provider_name", "providerName", "title"]) ?? - firstPayloadString(payload, ["name", "provider_name", "providerName", "title"]), + firstStringParam(params, ["name"]) ?? + firstPayloadString(payload, ["name"]), maxNameLength, "Provider name" ); const baseUrl = boundedString( - firstStringParam(params, ["base_url", "baseUrl", "api_base_url", "apiBaseUrl", "url", "endpoint"]) ?? - firstPayloadString(payload, ["base_url", "baseUrl", "api_base_url", "apiBaseUrl", "url", "endpoint"]), + firstStringParam(params, ["base_url"]) ?? + firstPayloadString(payload, ["base_url"]), maxBaseUrlLength, "Base URL" ); @@ -142,35 +134,37 @@ export function parseProviderDeepLinkPayload(rawUrl: string): ProviderDeepLinkPa validateProviderBaseUrl(baseUrl); const apiKey = boundedString( - firstStringParam(params, ["api_key", "apiKey", "apikey", "key", "token"]) ?? - firstPayloadString(payload, ["api_key", "apiKey", "apikey", "key", "token"]), + firstStringParam(params, ["api_key"]) ?? + firstPayloadString(payload, ["api_key"]), maxApiKeyLength, "API key" ); - if (apiKey) { - throw new Error(providerLinkApiKeyError); - } const icon = boundedString( - firstStringParam(params, ["icon", "icon_url", "iconUrl"]) ?? - firstPayloadString(payload, ["icon", "icon_url", "iconUrl"]), + firstStringParam(params, ["icon"]) ?? + firstPayloadString(payload, ["icon"]), maxIconLength, "Provider icon" ); const protocol = normalizeProviderProtocol( - firstStringParam(params, ["protocol", "type"]) ?? firstPayloadString(payload, ["protocol", "type"]) + firstStringParam(params, ["protocol"]) ?? firstPayloadString(payload, ["protocol"]) ); const models = readDeepLinkModels(params, payload); + const modelDescriptions = readDeepLinkModelDescriptions(params, payload, models); + const modelDisplayNames = readDeepLinkModelDisplayNames(params, payload, models); const account = readDeepLinkAccount(params, payload); const source = boundedString( - firstStringParam(params, ["source", "source_url", "sourceUrl"]) ?? - firstPayloadString(payload, ["source", "source_url", "sourceUrl"]), + firstStringParam(params, ["source"]) ?? + firstPayloadString(payload, ["source"]), maxSourceLength, "Source URL" ); return { ...(account ? { account } : {}), + ...(apiKey ? { apiKey } : {}), baseUrl, ...(icon ? { icon } : {}), + ...(modelDescriptions ? { modelDescriptions } : {}), + ...(modelDisplayNames ? { modelDisplayNames } : {}), models, ...(name ? { name } : {}), ...(protocol ? { protocol } : {}), @@ -196,14 +190,14 @@ function parseProviderPayloadFields( sourceFallback?: string ): ProviderDeepLinkPayload { const name = boundedString( - firstStringParam(params, ["name", "provider_name", "providerName", "title"]) ?? - firstPayloadString(payload, ["name", "provider_name", "providerName", "title"]), + firstStringParam(params, ["name"]) ?? + firstPayloadString(payload, ["name"]), maxNameLength, "Provider name" ); const baseUrl = boundedString( - firstStringParam(params, ["base_url", "baseUrl", "api_base_url", "apiBaseUrl", "url", "endpoint"]) ?? - firstPayloadString(payload, ["base_url", "baseUrl", "api_base_url", "apiBaseUrl", "url", "endpoint"]), + firstStringParam(params, ["base_url"]) ?? + firstPayloadString(payload, ["base_url"]), maxBaseUrlLength, "Base URL" ); @@ -213,25 +207,27 @@ function parseProviderPayloadFields( validateProviderBaseUrl(baseUrl); const apiKey = boundedString( - firstStringParam(params, ["api_key", "apiKey", "apikey", "key", "token"]) ?? - firstPayloadString(payload, ["api_key", "apiKey", "apikey", "key", "token"]), + firstStringParam(params, ["api_key"]) ?? + firstPayloadString(payload, ["api_key"]), maxApiKeyLength, "API key" ); const icon = boundedString( - firstStringParam(params, ["icon", "icon_url", "iconUrl"]) ?? - firstPayloadString(payload, ["icon", "icon_url", "iconUrl"]), + firstStringParam(params, ["icon"]) ?? + firstPayloadString(payload, ["icon"]), maxIconLength, "Provider icon" ); const protocol = normalizeProviderProtocol( - firstStringParam(params, ["protocol", "type"]) ?? firstPayloadString(payload, ["protocol", "type"]) + firstStringParam(params, ["protocol"]) ?? firstPayloadString(payload, ["protocol"]) ); const models = readDeepLinkModels(params, payload); + const modelDescriptions = readDeepLinkModelDescriptions(params, payload, models); + const modelDisplayNames = readDeepLinkModelDisplayNames(params, payload, models); const account = readDeepLinkAccount(params, payload); const source = boundedString( - firstStringParam(params, ["source", "source_url", "sourceUrl"]) ?? - firstPayloadString(payload, ["source", "source_url", "sourceUrl"]) ?? + firstStringParam(params, ["source"]) ?? + firstPayloadString(payload, ["source"]) ?? sourceFallback, maxSourceLength, "Source URL" @@ -242,6 +238,8 @@ function parseProviderPayloadFields( ...(apiKey ? { apiKey } : {}), baseUrl, ...(icon ? { icon } : {}), + ...(modelDescriptions ? { modelDescriptions } : {}), + ...(modelDisplayNames ? { modelDisplayNames } : {}), models, ...(name ? { name } : {}), ...(protocol ? { protocol } : {}), @@ -251,25 +249,20 @@ function parseProviderPayloadFields( function readDeepLinkAccount(params: URLSearchParams, payload: Record | undefined): ProviderAccountConfig | undefined { const fetchUsage = readDeepLinkBoolean(params, payload, [ - "fetch_usage", - "fetchUsage", - "usage_enabled", - "usageEnabled", - "account_enabled", - "accountEnabled" + "fetch_usage" ]); if (fetchUsage === false) { return { enabled: false }; } - const payloadAccount = normalizeProviderAccountConfig(payload?.account ?? payload?.usage); + const payloadAccount = normalizeProviderAccountConfig(payload?.account); if (payloadAccount) { return payloadAccount; } const endpoint = boundedString( - firstStringParam(params, ["usage_url", "usageUrl", "account_url", "accountUrl"]) ?? - firstPayloadString(payload, ["usage_url", "usageUrl", "account_url", "accountUrl"]), + firstStringParam(params, ["usage_url"]) ?? + firstPayloadString(payload, ["usage_url"]), maxBaseUrlLength, "Usage URL" ); @@ -279,23 +272,23 @@ function readDeepLinkAccount(params: URLSearchParams, payload: Record | undefined { - const value = firstStringParam(params, ["payload", "config", "data"]); + const value = firstStringParam(params, ["payload"]); if (!value) { return undefined; } @@ -492,19 +485,16 @@ function normalizeProviderProtocol(value: string | undefined): GatewayProviderPr if (!value) { return undefined; } - const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_"); - const protocol = protocolAliases[normalized]; - if (!protocol) { + const protocol = value.trim(); + if (!providerProtocols.has(protocol as GatewayProviderProtocol)) { throw new Error(`Unsupported provider protocol: ${value}`); } - return protocol; + return protocol as GatewayProviderProtocol; } function readDeepLinkModels(params: URLSearchParams, payload: Record | undefined): string[] { const values = [ - ...params.getAll("model"), ...params.getAll("models"), - ...params.getAll("models[]"), ...payloadModels(payload) ]; const seen = new Set(); @@ -533,13 +523,100 @@ function payloadModels(payload: Record | undefined): string[] { if (!payload) { return []; } - const value = payload.models ?? payload.model; + const value = payload.models; if (Array.isArray(value)) { - return value.filter((item): item is string => typeof item === "string"); + return value + .map((item) => typeof item === "string" ? item : readPayloadModelId(item)) + .filter((item): item is string => Boolean(item)); } return typeof value === "string" ? [value] : []; } +function readDeepLinkModelDisplayNames( + params: URLSearchParams, + payload: Record | undefined, + models: string[] +): Record | undefined { + const modelIds = new Set(models); + const displayNames: Record = {}; + const addDisplayName = (rawModel: unknown, rawDisplayName: unknown) => { + const model = typeof rawModel === "string" ? rawModel.trim() : ""; + const displayName = typeof rawDisplayName === "string" ? rawDisplayName.trim() : ""; + if (!model || !displayName || model === displayName || !modelIds.has(model)) { + return; + } + if (displayName.length > maxModelLength) { + throw new Error("Model display name is too long."); + } + displayNames[model] = displayName; + }; + + const explicit = parseJsonValueParam(params, payload, ["modelDisplayNames", "model_display_names"]); + if (isRecord(explicit)) { + for (const [model, displayName] of Object.entries(explicit)) { + addDisplayName(model, displayName); + } + } + + const payloadModelList = Array.isArray(payload?.models) ? payload.models : []; + for (const item of payloadModelList) { + if (!isRecord(item)) { + continue; + } + addDisplayName(readPayloadModelId(item), readPayloadModelDisplayName(item)); + } + + return Object.keys(displayNames).length > 0 ? displayNames : undefined; +} + +function readDeepLinkModelDescriptions( + params: URLSearchParams, + payload: Record | undefined, + models: string[] +): Record | undefined { + const modelIds = new Set(models); + const descriptions: Record = {}; + const addDescription = (rawModel: unknown, rawDescription: unknown) => { + const model = typeof rawModel === "string" ? rawModel.trim() : ""; + const description = typeof rawDescription === "string" ? rawDescription.trim() : ""; + if (!model || !description || !modelIds.has(model)) { + return; + } + if (description.length > maxModelDescriptionLength) { + throw new Error("Model description is too long."); + } + descriptions[model] = description; + }; + + const explicit = parseJsonValueParam(params, payload, ["modelDescriptions", "model_descriptions"]); + if (isRecord(explicit)) { + for (const [model, description] of Object.entries(explicit)) { + addDescription(model, description); + } + } + + const payloadModelList = Array.isArray(payload?.models) ? payload.models : []; + for (const item of payloadModelList) { + if (!isRecord(item)) { + continue; + } + addDescription(readPayloadModelId(item), firstPayloadString(item, ["description", "desc", "summary"])); + } + + return Object.keys(descriptions).length > 0 ? descriptions : undefined; +} + +function readPayloadModelId(value: unknown): string | undefined { + if (!isRecord(value)) { + return undefined; + } + return firstPayloadString(value, ["id", "slug", "model", "name"]); +} + +function readPayloadModelDisplayName(value: Record): string | undefined { + return firstPayloadString(value, ["display_name", "displayName", "label", "name"]); +} + function splitModelValue(value: string): string[] { return value .split(/[\n,]+/) diff --git a/src/shared/i18n.ts b/packages/core/src/contracts/i18n.ts similarity index 98% rename from src/shared/i18n.ts rename to packages/core/src/contracts/i18n.ts index 7b5a8af..0b5b927 100644 --- a/src/shared/i18n.ts +++ b/packages/core/src/contracts/i18n.ts @@ -32,6 +32,7 @@ const zhExactErrorMessages: Record = { "Model name is too long.": "模型名称过长。", "Network capture MCP is disabled.": "网络捕获 MCP 已禁用。", "No available models": "没有可用模型", + "No available models. Configure at least one provider with a model before starting CCR Gateway or opening an agent through CCR.": "没有可用模型。请先配置至少一个包含模型的供应商,再启动 CCR 网关或通过 CCR 打开 Agent。", "No Bot Gateway conversationRef is available for inbound bot response.": "没有可用于入站 Bot 响应的 Bot Gateway conversationRef。", "No Bot Gateway conversationRef is configured and no inbound bot event context is available.": "未配置 Bot Gateway conversationRef,且没有可用的入站 Bot 事件上下文。", "No endpoint candidates available.": "没有可用的端点候选项。", diff --git a/src/shared/ipc-channels.ts b/packages/core/src/contracts/ipc-channels.ts similarity index 87% rename from src/shared/ipc-channels.ts rename to packages/core/src/contracts/ipc-channels.ts index e380e8e..f24d040 100644 --- a/src/shared/ipc-channels.ts +++ b/packages/core/src/contracts/ipc-channels.ts @@ -1,5 +1,6 @@ export const IPC_CHANNELS = { appBeforeQuit: "ccr:app:before-quit", + appCaptureElementPng: "ccr:app:capture-element-png", appCloseTray: "ccr:app:close-tray", appDetectProviderIcon: "ccr:app:detect-provider-icon", appExportData: "ccr:app:export-data", @@ -19,6 +20,7 @@ export const IPC_CHANNELS = { appGetProxyCertificateStatus: "ccr:app:get-proxy-certificate-status", appGetProxyNetworkCaptures: "ccr:app:get-proxy-network-captures", appGetProxyStatus: "ccr:app:get-proxy-status", + appGetRequestLogDetail: "ccr:app:get-request-log-detail", appGetRequestLogs: "ccr:app:get-request-logs", appGetUpdateStatus: "ccr:app:get-update-status", appGetUsageStats: "ccr:app:get-usage-stats", @@ -29,6 +31,7 @@ export const IPC_CHANNELS = { appOpenBuiltInBrowser: "ccr:app:open-built-in-browser", appOpenExternal: "ccr:app:open-external", appOpenSettings: "ccr:app:open-settings", + appOpenUpdate: "ccr:app:open-update", appOpenProfile: "ccr:app:open-profile", appApplyClaudeAppGateway: "ccr:app:apply-claude-app-gateway", appApplyProfile: "ccr:app:apply-profile", @@ -44,10 +47,13 @@ export const IPC_CHANNELS = { appProbeProviderCandidates: "ccr:app:probe-provider-candidates", appProviderDeepLink: "ccr:app:provider-deep-link", appGetPluginMarketplace: "ccr:app:get-plugin-marketplace", + appPrepareImageExportTarget: "ccr:app:prepare-image-export-target", appQuit: "ccr:app:quit", appRevealProxyCertificate: "ccr:app:reveal-proxy-certificate", + appRenderHtmlPng: "ccr:app:render-html-png", appRestartProxy: "ccr:app:restart-proxy", appRestartGateway: "ccr:app:restart-gateway", + appResetCodexRateLimitCredit: "ccr:app:reset-codex-rate-limit-credit", appSaveApiKeys: "ccr:app:save-api-keys", appClearProxyNetworkCaptures: "ccr:app:clear-proxy-network-captures", appStartGateway: "ccr:app:start-gateway", @@ -67,10 +73,13 @@ export const IPC_CHANNELS = { browserBack: "ccr:browser:back", browserCloseTab: "ccr:browser:close-tab", browserForward: "ccr:browser:forward", + browserGetChromeLoginImport: "ccr:browser:get-chrome-login-import", browserGetState: "ccr:browser:get-state", browserNavigate: "ccr:browser:navigate", browserNewTab: "ccr:browser:new-tab", browserReload: "ccr:browser:reload", + browserResolveAutomationHandoff: "ccr:browser:resolve-automation-handoff", browserSelectTab: "ccr:browser:select-tab", + browserStartChromeLoginImport: "ccr:browser:start-chrome-login-import", browserStateChanged: "ccr:browser:state-changed" } as const; diff --git a/packages/core/src/entrypoints/server.ts b/packages/core/src/entrypoints/server.ts new file mode 100644 index 0000000..2059d8a --- /dev/null +++ b/packages/core/src/entrypoints/server.ts @@ -0,0 +1,129 @@ +#!/usr/bin/env node +import { startWebManagementServer } from "@ccr/core/web/management-server"; + +type CoreServerOptions = { + help: boolean; + host?: string; + open: boolean; + port?: number; + startGateway: boolean; +}; + +export async function runCoreServer(args = process.argv.slice(2)): Promise { + const options = parseCoreServerArgs(args); + if (options.help) { + printHelp(); + return; + } + + const runtime = await startWebManagementServer({ + host: options.host, + open: options.open, + port: options.port, + startGateway: options.startGateway + }); + process.stdout.write(`CCR core server is running at ${runtime.url}\n`); + + let closing = false; + const shutdown = (signal: NodeJS.Signals) => { + if (closing) { + return; + } + closing = true; + void runtime.close().finally(() => { + process.exit(signal === "SIGINT" ? 130 : 143); + }); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + await new Promise(() => undefined); +} + +function parseCoreServerArgs(args: string[]): CoreServerOptions { + const options: CoreServerOptions = { + help: false, + open: false, + startGateway: true + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--help" || arg === "-h") { + options.help = true; + continue; + } + if (arg === "--open") { + options.open = true; + continue; + } + if (arg === "--no-open") { + options.open = false; + continue; + } + if (arg === "--gateway") { + options.startGateway = true; + continue; + } + if (arg === "--no-gateway") { + options.startGateway = false; + continue; + } + if (arg === "--host") { + index += 1; + options.host = requiredArg(args[index], "--host"); + continue; + } + if (arg.startsWith("--host=")) { + options.host = requiredArg(arg.slice("--host=".length), "--host"); + continue; + } + if (arg === "--port") { + index += 1; + options.port = parsePort(requiredArg(args[index], "--port")); + continue; + } + if (arg.startsWith("--port=")) { + options.port = parsePort(requiredArg(arg.slice("--port=".length), "--port")); + continue; + } + throw new Error(`Unknown core server option: ${arg}`); + } + + return options; +} + +function printHelp(): void { + process.stdout.write([ + "Usage:", + " ccr-core-server [--host ] [--port ] [--no-gateway]", + "", + "Options:", + " --host Management server host. Defaults to CCR_WEB_HOST or 127.0.0.1.", + " --port Management server port. Defaults to CCR_WEB_PORT or 3458.", + " --no-gateway Start only the web management server.", + "", + "Environment:", + " CCR_WEB_AUTH_TOKEN Use this token for management UI and RPC authentication." + ].join("\n") + "\n"); +} + +function requiredArg(value: string | undefined, flag: string): string { + if (!value) { + throw new Error(`Missing value for ${flag}`); + } + return value; +} + +function parsePort(value: string): number { + const port = Number(value); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`Invalid port: ${value}`); + } + return port; +} + +runCoreServer().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; +}); diff --git a/packages/core/src/gateway/claude-code-router-plugin.ts b/packages/core/src/gateway/claude-code-router-plugin.ts new file mode 100644 index 0000000..579ea5d --- /dev/null +++ b/packages/core/src/gateway/claude-code-router-plugin.ts @@ -0,0 +1,1310 @@ +import { createRequire } from "node:module"; +import { EventEmitter } from "node:events"; +import os from "node:os"; +import path from "node:path"; +import { availableGatewayModelIds, type AppConfig, type RouterBuiltInAgentRuleId, type RouterConfig, type RouterFallbackConfig, type RouterRule, type RouterRuleCondition, type RouterRuleRewrite } from "@ccr/core/contracts/app"; +import { CONFIGDIR } from "@ccr/core/config/constants"; + +type HeaderValue = string | string[] | undefined; + +export type MutableRequestLike = { + builtInSubagentModel?: string; + body: Record; + headers: Record; + log: Pick; + method: string; + sessionId?: string; + tokenCount?: number; + url: string; +}; + +export type ClaudeCodeRouteDecision = { + fallback?: RouterFallbackConfig; + model?: string; + reason: string; + sessionId?: string; + tokenCount: number; +}; + +type ConfiguredRouteDecision = { + fallback?: RouterFallbackConfig; + model?: string; + reason: string; + rewrite?: RouterRuleRewrite; + rewrites?: RouterRuleRewrite[]; +}; + +const requireFromHere = createRequire(__filename); + +export class ClaudeCodeRouterPlugin { + private readonly event = new EventEmitter(); + + constructor(private readonly config: AppConfig) {} + + async routeRequest(input: { + body: Record; + headers: Record; + method: string; + url: string; + }): Promise<{ body: Record; decision: ClaudeCodeRouteDecision }> { + const body = cloneRecord(input.body); + const request: MutableRequestLike = { + body, + headers: input.headers, + log: console, + method: input.method, + url: input.url + }; + if (builtInAgentRouteMatches(request, this.config, "claude-code")) { + injectClaudeCodeAgentToolDescription(body, this.config); + injectClaudeCodeToolHubInstructions(body, this.config); + removeClaudeCodeBillingSystemHeader(body); + request.builtInSubagentModel = extractAndRemoveClaudeCodeSubagentModelTag(body); + } + const sessionId = resolveSessionId(body, input.headers); + const tokenCount = calculateTokenCount(body.messages, body.system, body.tools); + request.sessionId = sessionId; + request.tokenCount = tokenCount; + + const customModel = await this.resolveCustomRoute(request); + const configuredDecision = resolveConfiguredRouteDecision(request, this.config); + if (customModel) { + body.model = customModel; + } else { + if (configuredDecision.rewrites?.length) { + for (const rewrite of configuredDecision.rewrites) { + applyRouterRewrite(rewrite, request); + } + } else if (configuredDecision.rewrite) { + applyRouterRewrite(configuredDecision.rewrite, request); + } + if (configuredDecision.model) { + body.model = configuredDecision.model; + } + } + const routedModel = customModel ?? configuredDecision.model ?? readString(body.model); + + return { + body, + decision: { + fallback: customModel ? this.config.Router.fallback : configuredDecision.fallback, + model: routedModel, + reason: customModel ? "custom-router" : configuredDecision.reason, + sessionId, + tokenCount + } + }; + } + + countTokens(body: Record) { + return { + input_tokens: calculateTokenCount(body.messages, body.system, body.tools) + }; + } + + private async resolveCustomRoute(request: MutableRequestLike): Promise { + const routerPath = this.config.CUSTOM_ROUTER_PATH; + if (!routerPath) { + return undefined; + } + + try { + const resolvedRouterPath = resolveCustomRouterModule(routerPath); + delete requireFromHere.cache[resolvedRouterPath]; + const loaded = requireFromHere(resolvedRouterPath) as unknown; + const customRouter = typeof loaded === "function" ? loaded : readDefaultFunction(loaded); + if (!customRouter) { + request.log.warn(`Custom router does not export a function: ${routerPath}`); + return undefined; + } + const result = await customRouter(request, this.config, { event: this.event }); + return normalizeRouteSelector(typeof result === "string" ? result : undefined); + } catch (error) { + request.log.error(`Failed to load custom router "${routerPath}": ${formatError(error)}`); + return undefined; + } + } +} + +function resolveCustomRouterModule(routerPath: string): string { + const resolved = requireFromHere.resolve(resolveLocalModulePath(routerPath, "Custom router")); + assertJavaScriptModulePath(resolved, "Custom router"); + return resolved; +} + +function resolveLocalModulePath(value: string, label: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`${label} path is required.`); + } + + const expanded = expandHome(trimmed); + if (path.isAbsolute(expanded)) { + return expanded; + } + if (isProtocolSpecifier(expanded)) { + throw new Error(`${label} must be a local JavaScript file path, not a URL or protocol specifier.`); + } + if (!expanded.startsWith(".")) { + throw new Error(`${label} must be an explicit local JavaScript path. Package specifiers are not loaded from configuration.`); + } + + const resolved = path.resolve(CONFIGDIR, expanded); + if (!isPathInside(resolved, CONFIGDIR)) { + throw new Error(`${label} relative paths must stay inside the CCR config directory.`); + } + return resolved; +} + +function assertJavaScriptModulePath(resolved: string, label: string): void { + const extension = path.extname(resolved).toLowerCase(); + if (![".cjs", ".js", ".mjs"].includes(extension)) { + throw new Error(`${label} must resolve to a JavaScript module file.`); + } +} + +function expandHome(value: string): string { + if (value === "~") { + return os.homedir(); + } + if (value.startsWith("~/")) { + return path.join(os.homedir(), value.slice(2)); + } + return value; +} + +function isProtocolSpecifier(value: string): boolean { + return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value); +} + +function isPathInside(file: string, root: string): boolean { + const relative = path.relative(root, file); + return relative === "" || (Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function resolveConfiguredRouteDecision( + request: MutableRequestLike, + config: AppConfig +): ConfiguredRouteDecision { + const builtInSubagentDecision = resolveBuiltInClaudeCodeSubagentRouteDecision(request, config); + if (builtInSubagentDecision) { + return builtInSubagentDecision; + } + + const requestedModel = readString(request.body.model); + const explicitModel = normalizeRouteSelector(requestedModel); + if (explicitModel && isKnownInlineRoute(explicitModel, config)) { + return { fallback: config.Router.fallback, model: explicitModel, reason: "inline-model" }; + } + + const router = config.Router; + const builtInDecision = resolveBuiltInAgentRouteDecision(request, config); + const rules = router.rules ?? []; + for (const rule of rules) { + const decision = resolveRouterRule(rule, request, router); + if (decision) { + return builtInDecision ? mergeConfiguredRouteDecisions(builtInDecision, decision) : decision; + } + } + + if (builtInDecision) { + return builtInDecision; + } + + return { fallback: router.fallback, model: explicitModel, reason: "default" }; +} + +function mergeConfiguredRouteDecisions( + base: ConfiguredRouteDecision, + override: ConfiguredRouteDecision +): ConfiguredRouteDecision { + const rewrites = [ + ...configuredRouteDecisionRewrites(base), + ...configuredRouteDecisionRewrites(override) + ]; + return { + fallback: override.fallback ?? base.fallback, + model: override.model ?? base.model, + reason: override.reason, + ...(rewrites.length === 1 ? { rewrite: rewrites[0] } : {}), + ...(rewrites.length > 0 ? { rewrites } : {}) + }; +} + +function configuredRouteDecisionRewrites(decision: ConfiguredRouteDecision): RouterRuleRewrite[] { + if (decision.rewrites?.length) { + return decision.rewrites; + } + return decision.rewrite ? [decision.rewrite] : []; +} + +function resolveBuiltInClaudeCodeSubagentRouteDecision( + request: MutableRequestLike, + config: AppConfig +): ConfiguredRouteDecision | undefined { + if (!builtInAgentRouteMatches(request, config, "claude-code")) { + return undefined; + } + const target = normalizeRouteSelector(request.builtInSubagentModel); + if (!target || isSubagentModelPlaceholder(target) || !isKnownInlineRoute(target, config)) { + return undefined; + } + return { + fallback: config.Router.fallback, + model: target, + reason: "builtin:claude-code-subagent", + rewrite: { + key: "request.body.model", + operation: "set", + value: target + } + }; +} + +function resolveBuiltInAgentRouteDecision( + request: MutableRequestLike, + config: AppConfig +): ConfiguredRouteDecision | undefined { + for (const agent of builtInAgentRuleIds) { + if (!builtInAgentRouteMatches(request, config, agent)) { + continue; + } + const target = resolveBuiltInAgentRouteTarget(config, agent); + if (!target) { + continue; + } + return { + fallback: config.Router.fallback, + model: target, + reason: `builtin:${agent}`, + rewrite: { + key: "request.body.model", + operation: "set", + value: target + } + }; + } + return undefined; +} + +const builtInAgentRuleIds: RouterBuiltInAgentRuleId[] = ["claude-code", "codex"]; + +function builtInAgentRouteMatches( + request: MutableRequestLike, + config: AppConfig, + agent: RouterBuiltInAgentRuleId +): boolean { + if (config.Router.builtInRules?.[agent]?.enabled === false) { + return false; + } + if (!resolveBuiltInAgentProfile(config, agent)) { + return false; + } + const userAgent = readRequestHeader(request.headers, "user-agent")?.toLowerCase() ?? ""; + return userAgent.includes(builtInAgentUserAgentNeedle(agent)); +} + +function resolveBuiltInAgentProfile(config: AppConfig, agent: RouterBuiltInAgentRuleId) { + if (config.profile.enabled === false) { + return undefined; + } + return config.profile.profiles.find((profile) => profile.enabled && profile.agent === agent); +} + +function resolveBuiltInAgentRouteTarget(config: AppConfig, agent: RouterBuiltInAgentRuleId): string | undefined { + return normalizeRouteSelector(resolveBuiltInAgentProfile(config, agent)?.model); +} + +function builtInAgentUserAgentNeedle(agent: RouterBuiltInAgentRuleId): string { + return agent === "claude-code" ? "claude" : "codex"; +} + +const ccrSubagentModelOpenTag = ""; +const ccrSubagentModelCloseTag = ""; +const ccrSubagentModelTagExample = `${ccrSubagentModelOpenTag}Provider/model${ccrSubagentModelCloseTag}`; +const ccrSubagentModelPlaceholder = "provider/model"; +const claudeCodeBillingSystemHeaderPrefix = "x-anthropic-billing-header"; +const ccrSubagentToolModelInstruction = + `CCR subagent routing is enabled. When calling this tool, the prompt parameter MUST start with ` + + `${ccrSubagentModelTagExample} on its own first line, replacing Provider/model with the best configured CCR gateway model. ` + + `CCR consumes the tag, removes it from the prompt, and routes the spawned agent request to that model. ` + + `Do not omit the tag, do not put it in description or subagent_type, and do not rely on the tool model enum for CCR-specific models. ` + + `中文要求:调用该工具时,prompt 参数第一行必须写入 ${ccrSubagentModelTagExample},并将 Provider/model 替换成最适合的 CCR 模型。`; +const ccrWorkflowSubagentModelInstruction = + `CCR workflow subagent routing is enabled. When this workflow creates Agent/Task subagents, each spawned agent prompt MUST start with ` + + `${ccrSubagentModelTagExample} on its own first line, replacing Provider/model with the best configured CCR gateway model. ` + + `Put the tag inside the Agent/Task prompt created by the workflow, not in the workflow description or subagent_type. ` + + `CCR consumes the tag from the spawned agent request, removes it, and routes that request to the selected model. ` + + `Do not rely on the Agent/Task model enum for CCR-specific models. ` + + `中文要求:Workflow 中创建 Agent/Task 时,每个被创建 agent 的 prompt 第一行必须写入 ${ccrSubagentModelTagExample},并替换成最适合的 CCR 模型。`; +const ccrSubagentPromptFieldInstruction = + `CCR subagent routing is enabled. This prompt string MUST start with ${ccrSubagentModelTagExample} on its own first line, ` + + `with Provider/model replaced by one configured CCR gateway model. Put the subagent task after that line; CCR removes the tag before the subagent runs. ` + + `中文要求:这个 prompt 字符串第一行必须是替换后的模型标签,后面再写 subagent 任务正文。`; +type ClaudeCodeSubagentToolKind = "subagent" | "workflow"; +const claudeCodeAgentToolNames = new Set(["agent", "task"]); +const claudeCodeWorkflowToolNames = new Set(["workflow"]); +const ccrToolHubSystemInstructionMarker = "CCR ToolHub tool resolution is enabled."; + +function claudeCodeToolName(tool: Record): string | undefined { + const functionSpec = isRecord(tool.function) ? tool.function : undefined; + return readString(tool.name) ?? readString(functionSpec?.name); +} + +function normalizeClaudeCodeToolName(toolName: string | undefined): string { + return toolName?.toLowerCase().replace(/[-._]/g, "") ?? ""; +} + +function injectClaudeCodeToolHubInstructions(body: Record, config: AppConfig): void { + if (!config.toolHub?.enabled || !Array.isArray(body.tools)) { + return; + } + const toolNames = claudeCodeToolHubToolNames(body.tools); + if (!toolNames.resolve) { + return; + } + const invokeName = toolNames.invoke ?? "tool_hub.invoke"; + appendSystemInstruction(body, [ + ccrToolHubSystemInstructionMarker, + `The ToolHub search/resolution tool is ${toolNames.resolve}; call this actual tool, do not merely mention its name in text.`, + `You MUST call the ToolHub search/resolution tool ${toolNames.resolve} before answering any request that asks about external services, installed MCP capabilities, business APIs, orders, coupons, stores, accounts, available tools, or capabilities that are not already obvious from the eager tools.`, + `Do this even if the user did not mention ToolHub or ${toolNames.resolve}. Only skip the ToolHub search/resolution tool when the request is clearly local code/file/shell work or simple conversation that does not need an external or MCP capability.`, + `If ${toolNames.resolve} returns selected tools, call the ToolHub invocation tool ${invokeName} to run the selected tool instead of telling the user that no such capability is available.`, + "When the ToolHub resolve result includes executionPlanJs or workflowSketch, treat that JavaScript as the invocation dependency plan: await means serial order, and only callTool calls grouped inside the same Promise.all may be issued in parallel.", + "Use the user's request as the task and include concise context when resolving. Do not ask the user to name the MCP tool unless the task is genuinely ambiguous after resolution." + ].join("\n")); +} + +function claudeCodeToolHubToolNames(tools: unknown[]): { invoke?: string; resolve?: string } { + const names: { invoke?: string; resolve?: string } = {}; + for (const tool of tools) { + if (!isRecord(tool)) { + continue; + } + const name = claudeCodeToolName(tool); + const normalized = normalizeClaudeCodeToolName(name); + if (normalized.endsWith("toolhubresolve") && shouldUseClaudeCodeToolHubName(names.resolve, name)) { + names.resolve = name; + } + if (normalized.endsWith("toolhubinvoke") && shouldUseClaudeCodeToolHubName(names.invoke, name)) { + names.invoke = name; + } + } + return names; +} + +function shouldUseClaudeCodeToolHubName(current: string | undefined, candidate: string | undefined): boolean { + if (!candidate) { + return false; + } + if (!current) { + return true; + } + return claudeCodeToolHubNameScore(candidate) > claudeCodeToolHubNameScore(current); +} + +function claudeCodeToolHubNameScore(name: string): number { + const normalized = name.toLowerCase(); + if (normalized.startsWith("mcp__ccr-toolhub__") || normalized.startsWith("mcp__ccr_toolhub__")) { + return 3; + } + if (normalized.startsWith("mcp__") && normalized.includes("toolhub")) { + return 2; + } + if (normalized.startsWith("mcp__")) { + return 1; + } + return 0; +} + +function appendSystemInstruction(body: Record, instruction: string): void { + if (systemContainsInstruction(body.system, ccrToolHubSystemInstructionMarker)) { + return; + } + if (typeof body.system === "string") { + body.system = body.system.trim() ? `${body.system}\n\n${instruction}` : instruction; + return; + } + if (Array.isArray(body.system)) { + body.system.push({ text: instruction, type: "text" }); + return; + } + if (body.system === undefined) { + body.system = [{ text: instruction, type: "text" }]; + } +} + +function systemContainsInstruction(system: unknown, marker: string): boolean { + if (typeof system === "string") { + return system.includes(marker); + } + if (!Array.isArray(system)) { + return false; + } + return system.some((block) => typeof block === "string" + ? block.includes(marker) + : isRecord(block) && typeof block.text === "string" && block.text.includes(marker)); +} + +function injectClaudeCodeAgentToolDescription(body: Record, config: AppConfig): void { + if (!Array.isArray(body.tools)) { + return; + } + + const instructions = claudeCodeAgentToolInstructions(config); + if (!instructions) { + return; + } + for (const tool of body.tools) { + if (!isRecord(tool)) { + continue; + } + const toolKind = claudeCodeSubagentToolKind(tool); + if (!toolKind) { + continue; + } + appendToolDescriptionInstruction(tool, toolKind === "workflow" ? instructions.workflow : instructions.tool); + if (toolKind === "subagent") { + appendPromptSchemaDescriptionInstruction(tool, instructions.prompt); + } + } +} + +function claudeCodeSubagentToolKind(tool: Record): ClaudeCodeSubagentToolKind | undefined { + const functionSpec = isRecord(tool.function) ? tool.function : undefined; + const name = readString(tool.name)?.toLowerCase() ?? readString(functionSpec?.name)?.toLowerCase(); + if (!name) { + return undefined; + } + if (claudeCodeAgentToolNames.has(name)) { + return "subagent"; + } + if (claudeCodeWorkflowToolNames.has(name)) { + return "workflow"; + } + return undefined; +} + +function appendToolDescriptionInstruction(tool: Record, instruction: string): void { + if (isRecord(tool.function)) { + tool.function.description = appendDescriptionInstruction(readOptionalString(tool.function.description), instruction); + return; + } + tool.description = appendDescriptionInstruction(readOptionalString(tool.description), instruction); +} + +function appendPromptSchemaDescriptionInstruction(tool: Record, instruction: string): void { + const functionSpec = isRecord(tool.function) ? tool.function : undefined; + const schema = isRecord(tool.input_schema) + ? tool.input_schema + : isRecord(tool.inputSchema) + ? tool.inputSchema + : isRecord(functionSpec?.parameters) + ? functionSpec.parameters + : undefined; + const properties = isRecord(schema?.properties) ? schema.properties : undefined; + const prompt = isRecord(properties?.prompt) ? properties.prompt : undefined; + if (!prompt) { + return; + } + prompt.description = appendDescriptionInstruction(readOptionalString(prompt.description), instruction); +} + +function appendDescriptionInstruction(description: string | undefined, instruction: string): string { + const existing = description?.trim() ?? ""; + if (existing.includes(ccrSubagentModelOpenTag)) { + return existing; + } + return existing ? `${existing}\n\n${instruction}` : instruction; +} + +function claudeCodeAgentToolInstructions(config: AppConfig): { prompt: string; tool: string; workflow: string } | undefined { + const modelRows = configuredSubagentModelDescriptionRows(config); + if (modelRows.length === 0) { + return undefined; + } + const modelList = [ + "Configured CCR gateway models:", + ...modelRows + ].join("\n"); + return { + prompt: [ + ccrSubagentPromptFieldInstruction, + "", + modelList + ].join("\n"), + tool: [ + ccrSubagentToolModelInstruction, + "", + modelList + ].join("\n"), + workflow: [ + ccrWorkflowSubagentModelInstruction, + "", + modelList + ].join("\n") + }; +} + +function configuredSubagentModelDescriptionRows(config: AppConfig): string[] { + const candidates: Array<{ key: string; row: string; selector: string }> = []; + for (const provider of config.Providers) { + const providerName = provider.name?.trim(); + if (!providerName || !Array.isArray(provider.models)) { + continue; + } + for (const rawModel of provider.models) { + const model = rawModel.trim(); + const description = provider.modelDescriptions?.[model]?.trim(); + if (!model || !description) { + continue; + } + const selector = `${providerName}/${model}`; + const key = selector.toLowerCase(); + const displayName = provider.modelDisplayNames?.[model]?.trim(); + const label = displayName && displayName !== model ? `${selector} (${displayName})` : selector; + candidates.push({ + key, + row: `- ${label}: ${singleLineText(description, 320)}`, + selector + }); + } + } + candidates.sort(compareSubagentModelDescriptionRows); + + const rows: string[] = []; + const seen = new Set(); + for (const candidate of candidates) { + if (seen.has(candidate.key)) { + continue; + } + seen.add(candidate.key); + rows.push(candidate.row); + } + return rows; +} + +function compareSubagentModelDescriptionRows( + left: { key: string; row: string; selector: string }, + right: { key: string; row: string; selector: string } +): number { + return compareCodeUnitStrings(left.key, right.key) || + compareCodeUnitStrings(left.selector, right.selector) || + compareCodeUnitStrings(left.row, right.row); +} + +function compareCodeUnitStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function removeClaudeCodeBillingSystemHeader(body: Record): void { + const system = body.system; + if (!Array.isArray(system) || system.length === 0) { + return; + } + const firstBlock = system[0]; + const firstText = typeof firstBlock === "string" + ? firstBlock + : isRecord(firstBlock) && firstBlock.type === "text" && typeof firstBlock.text === "string" + ? firstBlock.text + : undefined; + if (!firstText?.startsWith(claudeCodeBillingSystemHeaderPrefix)) { + return; + } + system.shift(); + if (system.length === 0) { + delete body.system; + } +} + +function extractAndRemoveClaudeCodeSubagentModelTag(body: Record): string | undefined { + const systemModel = extractAndRemoveSystemSubagentModelTag(body); + if (systemModel) { + return systemModel; + } + return extractAndRemoveMessageSubagentModelTag(body); +} + +function extractAndRemoveSystemSubagentModelTag(body: Record): string | undefined { + const system = body.system; + if (typeof system === "string") { + return extractAndRemoveSubagentModelTagFromText(system, (text) => { + body.system = text; + }); + } + if (!Array.isArray(system)) { + return undefined; + } + for (let index = 0; index < system.length; index += 1) { + const block = system[index]; + const model = extractAndRemoveSubagentModelTagFromContentBlock(block, (text) => { + if (typeof block === "string") { + system[index] = text; + } else if (isRecord(block)) { + block.text = text; + } + }); + if (model) { + return model; + } + } + return undefined; +} + +function extractAndRemoveMessageSubagentModelTag(body: Record): string | undefined { + if (!Array.isArray(body.messages)) { + return undefined; + } + const limit = Math.min(body.messages.length, 2); + for (let index = 0; index < limit; index += 1) { + const message = body.messages[index]; + if (!isRecord(message) || message.role !== "user") { + continue; + } + const model = extractAndRemoveSubagentModelTagFromMessage(message); + if (model) { + return model; + } + } + return undefined; +} + +function extractAndRemoveSubagentModelTagFromMessage(message: Record): string | undefined { + if (typeof message.content === "string") { + return extractAndRemoveSubagentModelTagFromText(message.content, (text) => { + message.content = text; + }); + } + if (!Array.isArray(message.content)) { + return undefined; + } + const content = message.content; + for (let index = 0; index < content.length; index += 1) { + const block = content[index]; + const model = extractAndRemoveSubagentModelTagFromContentBlock(block, (text) => { + if (typeof block === "string") { + content[index] = text; + } else if (isRecord(block)) { + block.text = text; + } + }); + if (model) { + return model; + } + } + return undefined; +} + +function extractAndRemoveSubagentModelTagFromContentBlock( + block: unknown, + replace: (text: string) => void +): string | undefined { + if (typeof block === "string") { + return extractAndRemoveSubagentModelTagFromText(block, replace); + } + if (!isRecord(block) || typeof block.text !== "string") { + return undefined; + } + return extractAndRemoveSubagentModelTagFromText(block.text, replace); +} + +function extractAndRemoveSubagentModelTagFromText( + text: string, + replace: (text: string) => void +): string | undefined { + const openIndex = text.indexOf(ccrSubagentModelOpenTag); + if (openIndex < 0) { + return undefined; + } + const modelStart = openIndex + ccrSubagentModelOpenTag.length; + const closeIndex = text.indexOf(ccrSubagentModelCloseTag, modelStart); + if (closeIndex < 0) { + return undefined; + } + const model = normalizeRouteSelector(text.slice(modelStart, closeIndex)); + if (!model) { + return undefined; + } + const nextText = `${text.slice(0, openIndex)}${text.slice(closeIndex + ccrSubagentModelCloseTag.length)}`; + replace(nextText); + return model; +} + +function resolveRouterRule( + rule: RouterRule, + request: MutableRequestLike, + router: RouterConfig +): ConfiguredRouteDecision | undefined { + if (!rule.enabled) { + return undefined; + } + const fallback = rule.fallback ?? router.fallback; + + const rewrites = routerRuleRewritesFromRule(rule); + if (rewrites.length === 0) { + return undefined; + } + + if (rule.type === "condition") { + return rule.condition && routerRuleConditionMatches(rule.condition, request) + ? routerRuleRewriteDecision(rule, rewrites, fallback) + : undefined; + } + + if (rule.type === "model-prefix") { + const pattern = readString(rule.pattern); + const requestedModel = readString(request.body.model); + return pattern && requestedModel?.startsWith(pattern) + ? routerRuleRewriteDecision(rule, rewrites, fallback) + : undefined; + } + + return undefined; +} + +function routerRuleRewriteDecision( + rule: RouterRule, + rewrites: RouterRuleRewrite[], + fallback: RouterFallbackConfig +): ConfiguredRouteDecision { + const modelRewrite = rewrites.find((rewrite) => (rewrite.operation ?? "set") === "set" && rewrite.key === "request.body.model"); + return { + fallback, + model: modelRewrite?.value ? normalizeRouteSelector(modelRewrite.value) : undefined, + reason: routerRuleReason(rule), + rewrite: rewrites[0], + rewrites + }; +} + +function routerRuleRewritesFromRule(rule: RouterRule): RouterRuleRewrite[] { + if (rule.rewrites?.length) { + return rule.rewrites; + } + if (rule.rewrite) { + return [rule.rewrite]; + } + return rule.target + ? [{ key: "request.body.model", operation: "set", value: rule.target }] + : []; +} + +function applyRouterRewrite(rewrite: RouterRuleRewrite, request: MutableRequestLike): void { + const parts = rewrite.key + .split(".") + .map((part) => part.trim()) + .filter(Boolean); + const [scope, section, ...rest] = parts; + if (scope !== "request") { + return; + } + + if (section === "header" || section === "headers") { + const name = rest.join(".").trim().toLowerCase(); + if (!name) { + return; + } + if ((rewrite.operation ?? "set") === "delete") { + delete request.headers[name]; + } else if (rewrite.value !== undefined) { + request.headers[name] = rewrite.value; + } + return; + } + + if (section === "body") { + applyBodyRewrite(request.body, rest, rewrite); + } +} + +function applyBodyRewrite(body: Record, path: string[], rewrite: RouterRuleRewrite): void { + const operation = rewrite.operation ?? "set"; + if (operation === "delete") { + deletePathValue(body, path); + return; + } + + const value = rewrite.key === "request.body.model" && rewrite.value !== undefined + ? normalizeRouteSelector(rewrite.value) ?? rewrite.value + : rewrite.value !== undefined + ? parseRewriteLiteral(rewrite.value) + : undefined; + + if (operation === "set") { + setPathValue(body, path, value); + return; + } + + const current = readPathValue(body, path); + const array = Array.isArray(current) ? [...current] : []; + if (operation === "array-append") { + array.push(value); + setPathValue(body, path, array); + return; + } + if (operation === "array-prepend") { + array.unshift(value); + setPathValue(body, path, array); + return; + } + if (operation === "array-remove") { + setPathValue(body, path, array.filter((item) => !arrayElementMatches(item, value))); + return; + } + if (operation === "array-replace" && rewrite.match !== undefined) { + const match = parseRewriteLiteral(rewrite.match); + setPathValue(body, path, array.map((item) => arrayElementMatches(item, match) ? value : item)); + } +} + +function setPathValue(target: Record, path: string[], value: unknown): void { + if (path.length === 0) { + return; + } + + let current: unknown = target; + for (let index = 0; index < path.length - 1; index += 1) { + const key = path[index]; + const nextKey = path[index + 1]; + if (Array.isArray(current)) { + const arrayIndex = Number(key); + if (!Number.isInteger(arrayIndex)) { + return; + } + if (!isRecord(current[arrayIndex]) && !Array.isArray(current[arrayIndex])) { + current[arrayIndex] = numericPathSegment(nextKey) ? [] : {}; + } + current = current[arrayIndex]; + continue; + } + if (!isRecord(current)) { + return; + } + if (!isRecord(current[key]) && !Array.isArray(current[key])) { + current[key] = numericPathSegment(nextKey) ? [] : {}; + } + current = current[key]; + } + + const lastKey = path[path.length - 1]; + if (Array.isArray(current)) { + const arrayIndex = Number(lastKey); + if (Number.isInteger(arrayIndex)) { + current[arrayIndex] = value; + } + return; + } + if (isRecord(current)) { + current[lastKey] = value; + } +} + +function deletePathValue(target: Record, path: string[]): void { + if (path.length === 0) { + return; + } + const parent = readPathValue(target, path.slice(0, -1)); + const key = path[path.length - 1]; + if (Array.isArray(parent)) { + const index = Number(key); + if (Number.isInteger(index)) { + parent.splice(index, 1); + } + return; + } + if (isRecord(parent)) { + delete parent[key]; + } +} + +function numericPathSegment(value: string): boolean { + return /^\d+$/.test(value); +} + +function parseRewriteLiteral(value: string): unknown { + const trimmed = value.trim(); + if (trimmed === "true") return true; + if (trimmed === "false") return false; + if (trimmed === "null") return null; + const json = parseJsonLiteral(trimmed); + if (json.ok) return json.value; + if ( + (trimmed.startsWith("\"") && trimmed.endsWith("\"")) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + const parsedNumber = Number(trimmed); + return trimmed && Number.isFinite(parsedNumber) ? parsedNumber : trimmed; +} + +function routerRuleConditionMatches(condition: RouterRuleCondition, request: MutableRequestLike): boolean { + if (condition.left.trim().startsWith("response.")) { + return false; + } + const actual = resolveRouterConditionValue(condition.left, request); + const expected = parseConditionLiteral(condition.right); + + if (condition.operator === "starts-with") { + const actualText = conditionComparableText(actual); + const expectedText = conditionComparableText(expected); + return actualText !== undefined && expectedText !== undefined && actualText.startsWith(expectedText); + } + + if (condition.operator === "contains" || condition.operator === "not-contains" || condition.operator === "contains-deep") { + const matched = condition.operator === "contains-deep" + ? valueContainsDeep(actual, expected) + : valueContains(actual, expected); + return condition.operator === "not-contains" ? !matched : matched; + } + + if (condition.operator === "==" || condition.operator === "!=") { + const matched = valuesEqual(actual, expected); + return condition.operator === "==" ? matched : !matched; + } + + const actualNumber = numberValue(actual); + const expectedNumber = numberValue(expected); + if (actualNumber !== undefined && expectedNumber !== undefined) { + if (condition.operator === ">") return actualNumber > expectedNumber; + if (condition.operator === ">=") return actualNumber >= expectedNumber; + if (condition.operator === "<") return actualNumber < expectedNumber; + if (condition.operator === "<=") return actualNumber <= expectedNumber; + } + + const actualText = conditionComparableText(actual); + const expectedText = conditionComparableText(expected); + if (actualText === undefined || expectedText === undefined) { + return false; + } + if (condition.operator === ">") return actualText > expectedText; + if (condition.operator === ">=") return actualText >= expectedText; + if (condition.operator === "<") return actualText < expectedText; + if (condition.operator === "<=") return actualText <= expectedText; + return false; +} + +function resolveRouterConditionValue(path: string, request: MutableRequestLike): unknown { + const parts = path + .split(".") + .map((part) => part.trim()) + .filter(Boolean); + if (parts.length === 0) { + return undefined; + } + + const [scope, section, ...rest] = parts; + if (scope === "response") { + return undefined; + } + if (scope !== "request") { + return undefined; + } + + if (section === "header" || section === "headers") { + return readRequestHeader(request.headers, rest.join(".")); + } + if (section === "body") { + return readPathValue(request.body, rest); + } + if (section === "method") { + return request.method; + } + if (section === "url") { + return request.url; + } + if (section === "tokenCount" || section === "token_count") { + return request.tokenCount; + } + if (section === "sessionId" || section === "session_id") { + return request.sessionId; + } + + return readPathValue(request.body, [section, ...rest].filter(Boolean)); +} + +function readRequestHeader(headers: Record, name: string): string | undefined { + const normalized = name.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + const direct = readHeader(headers[normalized]); + if (direct !== undefined) { + return direct; + } + const matchedKey = Object.keys(headers).find((key) => key.toLowerCase() === normalized); + return matchedKey ? readHeader(headers[matchedKey]) : undefined; +} + +function readPathValue(value: unknown, path: string[]): unknown { + return path.reduce((current, part) => { + if (Array.isArray(current)) { + const index = Number(part); + return Number.isInteger(index) ? current[index] : undefined; + } + return isRecord(current) ? current[part] : undefined; + }, value); +} + +function parseConditionLiteral(value: string): unknown { + const trimmed = value.trim(); + if (trimmed === "true") return true; + if (trimmed === "false") return false; + if (trimmed === "null") return null; + if (trimmed === "undefined") return undefined; + const json = parseJsonLiteral(trimmed); + if (json.ok) return json.value; + if ( + (trimmed.startsWith("\"") && trimmed.endsWith("\"")) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + const parsedNumber = Number(trimmed); + return trimmed && Number.isFinite(parsedNumber) ? parsedNumber : trimmed; +} + +function valuesEqual(actual: unknown, expected: unknown): boolean { + if (actual === expected) { + return true; + } + const actualNumber = numberValue(actual); + const expectedNumber = numberValue(expected); + if (actualNumber !== undefined && expectedNumber !== undefined) { + return actualNumber === expectedNumber; + } + return conditionComparableText(actual) === conditionComparableText(expected); +} + +function valueContains(actual: unknown, expected: unknown): boolean { + if (Array.isArray(actual)) { + return actual.some((item) => arrayElementMatches(item, expected)); + } + if (typeof actual === "string") { + const expectedText = conditionComparableText(expected); + return expectedText !== undefined && actual.includes(expectedText); + } + return false; +} + +function valueContainsDeep(actual: unknown, expected: unknown): boolean { + if (valueContains(actual, expected) || valuesEqual(actual, expected)) { + return true; + } + if (Array.isArray(actual)) { + return actual.some((item) => valueContainsDeep(item, expected)); + } + if (isRecord(actual)) { + return Object.values(actual).some((item) => valueContainsDeep(item, expected)); + } + const actualText = conditionComparableText(actual); + const expectedText = conditionComparableText(expected); + return actualText !== undefined && expectedText !== undefined && actualText.includes(expectedText); +} + +function arrayElementMatches(actual: unknown, expected: unknown): boolean { + if (isRecord(expected) && isRecord(actual)) { + return Object.entries(expected).every(([key, expectedValue]) => arrayElementMatches(actual[key], expectedValue)); + } + if (Array.isArray(expected) && Array.isArray(actual)) { + return expected.length === actual.length && expected.every((item, index) => arrayElementMatches(actual[index], item)); + } + return valuesEqual(actual, expected); +} + +function parseJsonLiteral(value: string): { ok: true; value: unknown } | { ok: false } { + if (!value || (!value.startsWith("{") && !value.startsWith("["))) { + return { ok: false }; + } + try { + return { ok: true, value: JSON.parse(value) as unknown }; + } catch { + return { ok: false }; + } +} + +function numberValue(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value !== "string" || !value.trim()) { + return undefined; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function conditionComparableText(value: unknown): string | undefined { + if (value === undefined) { + return undefined; + } + if (value === null) { + return "null"; + } + if (Array.isArray(value)) { + return value.map((item) => conditionComparableText(item)).filter((item): item is string => item !== undefined).join(","); + } + if (typeof value === "object") { + return JSON.stringify(value); + } + return String(value); +} + +function singleLineText(value: string, maxLength: number): string { + const normalized = value.replace(/\s+/g, " ").trim(); + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}...`; +} + +function routerRuleReason(rule: RouterRule): string { + if (rule.id.startsWith("legacy-")) { + return rule.id.replace(/^legacy-/, ""); + } + return `rule:${rule.id}`; +} + +export function normalizeRouteSelector(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + + const commaIndex = trimmed.indexOf(","); + if (commaIndex > 0 && commaIndex < trimmed.length - 1) { + const provider = trimmed.slice(0, commaIndex).trim(); + const model = trimmed.slice(commaIndex + 1).trim(); + return provider && model ? `${provider}/${model}` : undefined; + } + + return trimmed; +} + +function isKnownInlineRoute(model: string | undefined, config: AppConfig): boolean { + const normalizedModel = normalizeRouteSelector(model); + if (!normalizedModel) { + return false; + } + + const normalizedModelLower = normalizedModel.toLowerCase(); + if (availableGatewayModelIds(config).some((id) => id.toLowerCase() === normalizedModelLower)) { + return true; + } + + const separator = normalizedModel.indexOf("/"); + if (separator <= 0) { + return false; + } + + const providerName = normalizedModel.slice(0, separator).trim().toLowerCase(); + return config.Providers.some((provider) => provider.name.trim().toLowerCase() === providerName); +} + +function isSubagentModelPlaceholder(model: string): boolean { + return model.trim().toLowerCase() === ccrSubagentModelPlaceholder; +} + +function calculateTokenCount(messages: unknown, system: unknown, tools: unknown): number { + return countMessageTokens(messages) + countSystemTokens(system) + countToolTokens(tools); +} + +function countMessageTokens(messages: unknown): number { + if (!Array.isArray(messages)) { + return 0; + } + return messages.reduce((total, message) => total + countUnknownTokens(message), 0); +} + +function countSystemTokens(system: unknown): number { + return countUnknownTokens(system); +} + +function countToolTokens(tools: unknown): number { + if (!Array.isArray(tools)) { + return 0; + } + return tools.reduce((total, tool) => total + countUnknownTokens(tool), 0); +} + +function countUnknownTokens(value: unknown): number { + if (typeof value === "string") { + return estimateTextTokens(value); + } + + if (typeof value === "number" || typeof value === "boolean") { + return 1; + } + + if (Array.isArray(value)) { + return value.reduce((total, item) => total + countUnknownTokens(item), 0); + } + + if (!isRecord(value)) { + return 0; + } + + let total = 0; + for (const [key, item] of Object.entries(value)) { + total += estimateTextTokens(key); + total += countUnknownTokens(item); + } + return total; +} + +function estimateTextTokens(text: string): number { + const asciiWords = text.match(/[A-Za-z0-9_]+|[^\sA-Za-z0-9_]/g)?.length ?? 0; + const cjkChars = text.match(/[\u3400-\u9fff]/g)?.length ?? 0; + return Math.max(1, Math.ceil((asciiWords + cjkChars) * 1.15)); +} + +function resolveSessionId(body: Record, headers: Record): string | undefined { + const fromHeader = readHeader(headers["x-claude-code-session-id"]) || readHeader(headers["x-claude-session-id"]); + if (fromHeader) { + return fromHeader; + } + + const metadata = body.metadata; + if (isRecord(metadata) && typeof metadata.user_id === "string") { + const parts = metadata.user_id.split("_session_"); + if (parts.length > 1) { + return parts.at(-1); + } + } + + return undefined; +} + +function cloneRecord(value: Record): Record { + return JSON.parse(JSON.stringify(value)) as Record; +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readDefaultFunction(value: unknown): ((...args: unknown[]) => unknown) | undefined { + if (isRecord(value) && typeof value.default === "function") { + return value.default as (...args: unknown[]) => unknown; + } + return undefined; +} + +function readHeader(value: HeaderValue): string | undefined { + if (Array.isArray(value)) { + return value[0]?.trim(); + } + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readOptionalString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} diff --git a/src/server/gateway/model-catalog.ts b/packages/core/src/gateway/model-catalog.ts similarity index 89% rename from src/server/gateway/model-catalog.ts rename to packages/core/src/gateway/model-catalog.ts index a176913..b32e2e6 100644 --- a/src/server/gateway/model-catalog.ts +++ b/packages/core/src/gateway/model-catalog.ts @@ -1,5 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; -import { resolve as pathResolve } from "node:path"; +import { loadModelCatalogPayload } from "@ccr/core/models/catalog-file"; const claudeCodeDefaultContextTokens = 200_000; let modelCatalogIndex: ModelCatalogIndex | undefined; @@ -94,17 +93,14 @@ function loadModelCatalogIndex(): ModelCatalogIndex { return modelCatalogIndex; } - for (const candidate of modelCatalogPathCandidates()) { - if (!existsSync(candidate)) { - continue; - } - try { - const parsed = JSON.parse(readFileSync(candidate, "utf8")) as unknown; - modelCatalogIndex = buildModelCatalogIndex(parsed, candidate); + try { + const loaded = loadModelCatalogPayload(); + if (loaded) { + modelCatalogIndex = buildModelCatalogIndex(loaded.payload, loaded.loadedFrom); return modelCatalogIndex; - } catch (error) { - console.warn(`Failed to load model catalog from ${candidate}:`, error); } + } catch (error) { + console.warn("Failed to load model catalog:", error); } modelCatalogIndex = { @@ -114,17 +110,6 @@ function loadModelCatalogIndex(): ModelCatalogIndex { return modelCatalogIndex; } -function modelCatalogPathCandidates(): string[] { - return uniqueStrings([ - process.env.CCR_MODEL_CATALOG_PATH?.trim() || "", - process.env.CCR_MODELS_JSON_PATH?.trim() || "", - pathResolve(process.cwd(), "models.json"), - pathResolve(__dirname, "..", "models.json"), - pathResolve(__dirname, "..", "assets", "models.json"), - pathResolve(__dirname, "..", "..", "..", "models.json") - ]); -} - function buildModelCatalogIndex(payload: unknown, loadedFrom: string): ModelCatalogIndex { const byKey = new Map(); const byModelKey = new Map(); diff --git a/packages/core/src/gateway/remote-control-service.ts b/packages/core/src/gateway/remote-control-service.ts new file mode 100644 index 0000000..b3a7e70 --- /dev/null +++ b/packages/core/src/gateway/remote-control-service.ts @@ -0,0 +1,627 @@ +import { randomUUID } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; + +export const ccrRemoteControlPathPrefix = "/__ccr/remote"; + +type RemoteDirection = "inbound" | "local" | "remote" | "system"; + +export type CcrRemoteControlRequestContext = { + endpoint: string; + path: string; + readBody: (request: IncomingMessage) => Promise; + request: IncomingMessage; + response: ServerResponse; + sendJson: (response: ServerResponse, statusCode: number, payload: unknown) => void; +}; + +type RemoteSession = { + archivedAt?: string; + createdAt: string; + events: RemoteEvent[]; + id: string; + inboundEvents: RemoteEvent[]; + lastSeq: number; + metadata: Record; + presence: Record; + seenDedupeKeys: Map; + subscribers: Set; + title: string; + updatedAt: string; +}; + +type RemoteEvent = { + createdAt: string; + dedupeKey?: string; + direction: RemoteDirection; + id: string; + payload: unknown; + role?: string; + seq: number; + sessionId: string; + source?: string; + text?: string; + type: string; +}; + +type RemotePresence = { + lastSeenAt: string; + metadata: Record; + name: string; + role: string; +}; + +type RemoteSubscriber = { + close: () => void; + id: string; + kind: "events" | "inbound"; + response: ServerResponse; +}; + +const maxSessions = 100; +const maxEventsPerSession = 2_000; +const maxInboundEventsPerSession = 500; +const sseHeartbeatMs = 15_000; + +class CcrRemoteControlService { + private readonly sessions = new Map(); + + async handleRequest(context: CcrRemoteControlRequestContext): Promise { + const segments = remotePathSegments(context.path); + const [root, sessionId, resource] = segments; + + if (segments.length === 0 || context.path === ccrRemoteControlPathPrefix) { + this.sendCapabilities(context); + return; + } + + if (root === "capabilities") { + this.sendCapabilities(context); + return; + } + + if (root !== "sessions") { + context.sendJson(context.response, 404, { error: { message: "Remote control endpoint not found." } }); + return; + } + + if (!sessionId) { + await this.handleSessionsRequest(context); + return; + } + + if (!resource) { + await this.handleSessionRequest(context, sessionId); + return; + } + + if (resource === "events") { + await this.handleEventsRequest(context, sessionId); + return; + } + + if (resource === "inbound") { + await this.handleInboundRequest(context, sessionId); + return; + } + + if (resource === "presence") { + await this.handlePresenceRequest(context, sessionId); + return; + } + + context.sendJson(context.response, 404, { error: { message: "Remote control session endpoint not found." } }); + } + + private sendCapabilities(context: CcrRemoteControlRequestContext): void { + context.sendJson(context.response, 200, { + endpoints: { + createSession: `${context.endpoint}${ccrRemoteControlPathPrefix}/sessions`, + inbound: `${context.endpoint}${ccrRemoteControlPathPrefix}/sessions/{sessionId}/inbound`, + sessionEvents: `${context.endpoint}${ccrRemoteControlPathPrefix}/sessions/{sessionId}/events` + }, + name: "ccr-remote-control", + protocol: "ccr.remote.v1", + transport: ["json", "sse"], + capabilities: { + catchupReplay: true, + fanout: true, + inboundQueue: true, + presence: true + } + }); + } + + private async handleSessionsRequest(context: CcrRemoteControlRequestContext): Promise { + if (context.request.method === "GET") { + context.sendJson(context.response, 200, { + sessions: [...this.sessions.values()].map((session) => this.sessionSummary(session, context.endpoint)) + }); + return; + } + + if (context.request.method !== "POST") { + context.sendJson(context.response, 405, { error: { message: "Method not allowed." } }); + return; + } + + const body = await this.readJsonBody(context); + if (!body) { + return; + } + const id = sanitizeSessionId(readString(body.id) || readString(body.sessionId)) || randomUUID(); + const title = readString(body.title) || readString(body.name) || `CCR Remote ${id.slice(0, 8)}`; + const metadata = readRecord(body.metadata) ?? {}; + const session = this.ensureSession(id, title, metadata); + this.appendEvent(session, { + direction: "system", + payload: { title }, + source: "ccr-gateway", + type: "session.created" + }); + + context.sendJson(context.response, 201, { + session: this.sessionSnapshot(session, context.endpoint) + }); + } + + private async handleSessionRequest(context: CcrRemoteControlRequestContext, sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + context.sendJson(context.response, 404, { error: { message: "Remote session not found." } }); + return; + } + + if (context.request.method === "GET") { + context.sendJson(context.response, 200, { session: this.sessionSnapshot(session, context.endpoint) }); + return; + } + + if (context.request.method === "PATCH") { + const body = await this.readJsonBody(context); + if (!body) { + return; + } + const title = readString(body.title) || readString(body.name); + if (title) { + session.title = title; + } + const metadata = readRecord(body.metadata); + if (metadata) { + session.metadata = { ...session.metadata, ...metadata }; + } + session.updatedAt = new Date().toISOString(); + this.appendEvent(session, { + direction: "system", + payload: { metadata: metadata ?? {}, title: title ?? session.title }, + source: "ccr-gateway", + type: "session.updated" + }); + context.sendJson(context.response, 200, { session: this.sessionSnapshot(session, context.endpoint) }); + return; + } + + if (context.request.method === "DELETE") { + session.archivedAt = new Date().toISOString(); + session.updatedAt = session.archivedAt; + this.appendEvent(session, { + direction: "system", + payload: { archivedAt: session.archivedAt }, + source: "ccr-gateway", + type: "session.archived" + }); + context.sendJson(context.response, 200, { archived: true, session: this.sessionSummary(session, context.endpoint) }); + return; + } + + context.sendJson(context.response, 405, { error: { message: "Method not allowed." } }); + } + + private async handleEventsRequest(context: CcrRemoteControlRequestContext, sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + context.sendJson(context.response, 404, { error: { message: "Remote session not found." } }); + return; + } + + if (context.request.method === "GET") { + const after = remoteAfterSeq(context.request); + if (wantsSse(context.request)) { + this.openSse(context, session, "events", after); + return; + } + context.sendJson(context.response, 200, { + events: session.events.filter((event) => event.seq > after), + session: this.sessionSummary(session, context.endpoint) + }); + return; + } + + if (context.request.method !== "POST") { + context.sendJson(context.response, 405, { error: { message: "Method not allowed." } }); + return; + } + + const body = await this.readJsonBody(context); + if (!body) { + return; + } + const events = normalizeEventInputs(body).map((event) => + this.appendEvent(session, { + ...event, + direction: event.direction ?? "local", + type: event.type || "message" + }) + ); + context.sendJson(context.response, 202, { + accepted: events.length, + events, + session: this.sessionSummary(session, context.endpoint) + }); + } + + private async handleInboundRequest(context: CcrRemoteControlRequestContext, sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + context.sendJson(context.response, 404, { error: { message: "Remote session not found." } }); + return; + } + + if (context.request.method === "GET") { + const after = remoteAfterSeq(context.request); + if (wantsSse(context.request)) { + this.openSse(context, session, "inbound", after); + return; + } + context.sendJson(context.response, 200, { + events: session.inboundEvents.filter((event) => event.seq > after), + session: this.sessionSummary(session, context.endpoint) + }); + return; + } + + if (context.request.method !== "POST") { + context.sendJson(context.response, 405, { error: { message: "Method not allowed." } }); + return; + } + + const body = await this.readJsonBody(context); + if (!body) { + return; + } + const events = normalizeEventInputs(body).map((event) => + this.appendEvent(session, { + ...event, + direction: "remote", + type: event.type || "user.message" + }, true) + ); + context.sendJson(context.response, 202, { + accepted: events.length, + events, + session: this.sessionSummary(session, context.endpoint) + }); + } + + private async handlePresenceRequest(context: CcrRemoteControlRequestContext, sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + context.sendJson(context.response, 404, { error: { message: "Remote session not found." } }); + return; + } + + if (context.request.method === "GET") { + context.sendJson(context.response, 200, { presence: session.presence }); + return; + } + + if (context.request.method !== "POST") { + context.sendJson(context.response, 405, { error: { message: "Method not allowed." } }); + return; + } + + const body = await this.readJsonBody(context); + if (!body) { + return; + } + const clientId = sanitizeSessionId(readString(body.clientId) || readString(body.id)) || randomUUID(); + const presence: RemotePresence = { + lastSeenAt: new Date().toISOString(), + metadata: readRecord(body.metadata) ?? {}, + name: readString(body.name) || clientId, + role: readString(body.role) || "client" + }; + session.presence[clientId] = presence; + const event = this.appendEvent(session, { + direction: "system", + payload: { clientId, presence }, + source: "ccr-gateway", + type: "presence.updated" + }); + context.sendJson(context.response, 202, { event, presence: session.presence }); + } + + private ensureSession(id: string, title: string, metadata: Record): RemoteSession { + const existing = this.sessions.get(id); + if (existing) { + existing.metadata = { ...existing.metadata, ...metadata }; + existing.title = title || existing.title; + existing.updatedAt = new Date().toISOString(); + return existing; + } + + this.pruneSessions(); + const now = new Date().toISOString(); + const session: RemoteSession = { + createdAt: now, + events: [], + id, + inboundEvents: [], + lastSeq: 0, + metadata, + presence: {}, + seenDedupeKeys: new Map(), + subscribers: new Set(), + title, + updatedAt: now + }; + this.sessions.set(id, session); + return session; + } + + private appendEvent( + session: RemoteSession, + input: RemoteEventInput, + inbound = false + ): RemoteEvent { + const dedupeKey = input.dedupeKey || input.id; + if (dedupeKey) { + const duplicate = session.seenDedupeKeys.get(dedupeKey); + if (duplicate) { + return duplicate; + } + } + + const event: RemoteEvent = { + createdAt: new Date().toISOString(), + ...(dedupeKey ? { dedupeKey } : {}), + direction: input.direction ?? "local", + id: input.id || randomUUID(), + payload: input.payload ?? {}, + ...(input.role ? { role: input.role } : {}), + seq: ++session.lastSeq, + sessionId: session.id, + ...(input.source ? { source: input.source } : {}), + ...(input.text ? { text: input.text } : {}), + type: input.type || "message" + }; + + session.events.push(event); + trimArray(session.events, maxEventsPerSession); + if (inbound || event.direction === "remote" || event.direction === "inbound") { + session.inboundEvents.push(event); + trimArray(session.inboundEvents, maxInboundEventsPerSession); + } + if (dedupeKey) { + session.seenDedupeKeys.set(dedupeKey, event); + while (session.seenDedupeKeys.size > maxEventsPerSession) { + const oldest = session.seenDedupeKeys.keys().next().value; + if (!oldest) { + break; + } + session.seenDedupeKeys.delete(oldest); + } + } + session.updatedAt = event.createdAt; + this.broadcast(session, event); + return event; + } + + private openSse( + context: CcrRemoteControlRequestContext, + session: RemoteSession, + kind: RemoteSubscriber["kind"], + after: number + ): void { + context.response.writeHead(200, { + "cache-control": "no-cache, no-transform", + "connection": "keep-alive", + "content-type": "text/event-stream; charset=utf-8", + "x-accel-buffering": "no" + }); + context.response.write(": connected\n\n"); + const source = kind === "events" ? session.events : session.inboundEvents; + for (const event of source.filter((item) => item.seq > after)) { + writeSseEvent(context.response, event); + } + + const heartbeat = setInterval(() => { + if (!context.response.destroyed) { + context.response.write(": keepalive\n\n"); + } + }, sseHeartbeatMs); + const subscriber: RemoteSubscriber = { + close: () => clearInterval(heartbeat), + id: randomUUID(), + kind, + response: context.response + }; + session.subscribers.add(subscriber); + context.request.once("close", () => { + subscriber.close(); + session.subscribers.delete(subscriber); + }); + } + + private broadcast(session: RemoteSession, event: RemoteEvent): void { + for (const subscriber of session.subscribers) { + if (subscriber.kind === "inbound" && !(event.direction === "remote" || event.direction === "inbound")) { + continue; + } + if (subscriber.response.destroyed) { + subscriber.close(); + session.subscribers.delete(subscriber); + continue; + } + writeSseEvent(subscriber.response, event); + } + } + + private sessionSnapshot(session: RemoteSession, endpoint: string) { + return { + ...this.sessionSummary(session, endpoint), + events: session.events, + inboundEvents: session.inboundEvents, + metadata: session.metadata, + presence: session.presence + }; + } + + private sessionSummary(session: RemoteSession, endpoint: string) { + return { + ...(session.archivedAt ? { archivedAt: session.archivedAt } : {}), + createdAt: session.createdAt, + endpoints: { + events: `${endpoint}${ccrRemoteControlPathPrefix}/sessions/${encodeURIComponent(session.id)}/events`, + inbound: `${endpoint}${ccrRemoteControlPathPrefix}/sessions/${encodeURIComponent(session.id)}/inbound`, + presence: `${endpoint}${ccrRemoteControlPathPrefix}/sessions/${encodeURIComponent(session.id)}/presence` + }, + eventCount: session.events.length, + id: session.id, + inboundCount: session.inboundEvents.length, + lastSeq: session.lastSeq, + subscriberCount: session.subscribers.size, + title: session.title, + updatedAt: session.updatedAt + }; + } + + private pruneSessions(): void { + while (this.sessions.size >= maxSessions) { + const oldest = [...this.sessions.values()] + .sort((left, right) => Date.parse(left.updatedAt) - Date.parse(right.updatedAt))[0]; + if (!oldest) { + return; + } + for (const subscriber of oldest.subscribers) { + subscriber.close(); + subscriber.response.end(); + } + this.sessions.delete(oldest.id); + } + } + + private async readJsonBody(context: CcrRemoteControlRequestContext): Promise | undefined> { + const body = await context.readBody(context.request); + if (body.length === 0) { + return {}; + } + try { + const parsed = JSON.parse(body.toString("utf8")) as unknown; + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record; + } + context.sendJson(context.response, 400, { error: { message: "Request body must be a JSON object." } }); + return undefined; + } catch { + context.sendJson(context.response, 400, { error: { message: "Request body must be valid JSON." } }); + return undefined; + } + } +} + +type RemoteEventInput = { + dedupeKey?: string; + direction?: RemoteDirection; + id?: string; + payload?: unknown; + role?: string; + source?: string; + text?: string; + type?: string; +}; + +export const ccrRemoteControlService = new CcrRemoteControlService(); + +function normalizeEventInputs(body: Record): RemoteEventInput[] { + const rawEvents = Array.isArray(body.events) ? body.events : [body]; + return rawEvents + .filter((event): event is Record => typeof event === "object" && event !== null && !Array.isArray(event)) + .map((event) => { + const payload = Object.prototype.hasOwnProperty.call(event, "payload") + ? event.payload + : Object.prototype.hasOwnProperty.call(event, "message") + ? event.message + : {}; + return { + dedupeKey: readString(event.dedupeKey) || readString(event.uuid) || readString(event.requestId), + direction: readDirection(event.direction), + id: readString(event.id), + payload, + role: readString(event.role), + source: readString(event.source), + text: readString(event.text) || readString(event.content), + type: readString(event.type) + }; + }); +} + +function writeSseEvent(response: ServerResponse, event: RemoteEvent): void { + response.write(`id: ${event.seq}\n`); + response.write(`event: ${event.type.replace(/[\r\n]+/g, "") || "message"}\n`); + response.write(`data: ${JSON.stringify(event)}\n\n`); +} + +function wantsSse(request: IncomingMessage): boolean { + const url = new URL(request.url || "/", "http://127.0.0.1"); + return url.searchParams.get("stream") === "1" || + url.searchParams.get("stream") === "true" || + readHeader(request.headers.accept)?.toLowerCase().includes("text/event-stream") === true; +} + +function remoteAfterSeq(request: IncomingMessage): number { + const url = new URL(request.url || "/", "http://127.0.0.1"); + const after = Number(url.searchParams.get("after") ?? readHeader(request.headers["last-event-id"]) ?? 0); + return Number.isFinite(after) && after > 0 ? Math.floor(after) : 0; +} + +function remotePathSegments(path: string): string[] { + const suffix = path.slice(ccrRemoteControlPathPrefix.length).replace(/^\/+|\/+$/g, ""); + if (!suffix) { + return []; + } + return suffix.split("/").map((segment) => decodeURIComponent(segment)).filter(Boolean); +} + +function readDirection(value: unknown): RemoteDirection | undefined { + return value === "inbound" || value === "local" || value === "remote" || value === "system" + ? value + : undefined; +} + +function readHeader(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) { + return value[0]?.trim(); + } + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : undefined; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function sanitizeSessionId(value: string | undefined): string | undefined { + const sanitized = value?.trim().replace(/[^a-zA-Z0-9:._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 128); + return sanitized || undefined; +} + +function trimArray(items: T[], maxLength: number): void { + if (items.length > maxLength) { + items.splice(0, items.length - maxLength); + } +} diff --git a/packages/core/src/gateway/runtime-change.ts b/packages/core/src/gateway/runtime-change.ts new file mode 100644 index 0000000..1097362 --- /dev/null +++ b/packages/core/src/gateway/runtime-change.ts @@ -0,0 +1,23 @@ +import type { AppConfig } from "@ccr/core/contracts/app"; + +export function shouldRestartGatewayForRuntimeConfigChange(previousConfig: AppConfig, nextConfig: AppConfig): boolean { + return ( + previousConfig.gateway.enabled !== nextConfig.gateway.enabled || + previousConfig.gateway.host !== nextConfig.gateway.host || + previousConfig.gateway.port !== nextConfig.gateway.port || + previousConfig.gateway.coreHost !== nextConfig.gateway.coreHost || + previousConfig.gateway.corePort !== nextConfig.gateway.corePort || + previousConfig.proxy.enabled !== nextConfig.proxy.enabled || + previousConfig.proxy.host !== nextConfig.proxy.host || + previousConfig.proxy.mode !== nextConfig.proxy.mode || + previousConfig.proxy.port !== nextConfig.proxy.port || + previousConfig.proxy.systemProxy !== nextConfig.proxy.systemProxy || + JSON.stringify(previousConfig.proxy.targets) !== JSON.stringify(nextConfig.proxy.targets) || + JSON.stringify(previousConfig.agent) !== JSON.stringify(nextConfig.agent) || + JSON.stringify(previousConfig.Providers) !== JSON.stringify(nextConfig.Providers) || + JSON.stringify(previousConfig.plugins) !== JSON.stringify(nextConfig.plugins) || + JSON.stringify(previousConfig.providerPlugins) !== JSON.stringify(nextConfig.providerPlugins) || + JSON.stringify(previousConfig.toolHub) !== JSON.stringify(nextConfig.toolHub) || + JSON.stringify(previousConfig.virtualModelProfiles) !== JSON.stringify(nextConfig.virtualModelProfiles) + ); +} diff --git a/packages/core/src/gateway/service.ts b/packages/core/src/gateway/service.ts new file mode 100644 index 0000000..3e34503 --- /dev/null +++ b/packages/core/src/gateway/service.ts @@ -0,0 +1,8461 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { createServer, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { createRequire } from "node:module"; +import { networkInterfaces } from "node:os"; +import { Readable, Transform } from "node:stream"; +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join as pathJoin, resolve as pathResolve, sep as pathSep } from "node:path"; +import type { + ApiKeyConfig, + ApiKeyLimitConfig, + AppConfig, + GatewayMcpServerConfig, + GatewayNetworkEndpoint, + GatewayProviderCapability, + GatewayProviderConfig, + GatewayProviderProtocol, + ProviderCredentialConfig, + GatewayStatus, + RouterFallbackConfig, + RouterFallbackMode, + VirtualModelFusionVisionConfig, + VirtualModelFusionWebSearchConfig, + VirtualModelFusionWebSearchProvider +} from "@ccr/core/contracts/app"; +import { + CLAUDE_APP_FALLBACK_MODEL, + buildClaudeAppGatewayModelRoutes, + inferClaudeAppGatewayTargetModel, + resolveClaudeAppGatewayRouteModel, + type ClaudeAppGatewayModelRouteOptions +} from "@ccr/core/agents/claude-app/gateway-routes"; +import { + BUILTIN_FUSION_VISION_TOOL_NAME, + BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, + NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, + ROUTER_FALLBACK_MAX_RETRY_COUNT, + hasAvailableGatewayModels +} from "@ccr/core/contracts/app"; +import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue } from "@ccr/core/providers/presets/index"; +import { normalizeProviderBaseUrl as normalizeProviderBaseUrlInput } from "@ccr/core/providers/url"; +import { backendService } from "@ccr/core/plugins/backend-service"; +import { RAW_TRACE_SPOOL_DIR } from "@ccr/core/config/constants"; +import { loadPersistedApiKeys } from "@ccr/core/config/api-key-store"; +import { codexDefaultBaseUrl, readCodexAuth } from "@ccr/core/agents/local-providers/service"; +import { fetchWithSystemProxy, getSystemProxyUrlForProtocol } from "@ccr/core/proxy/system-proxy-fetch"; +import { handleNetworkCaptureMcpRequest, isNetworkCaptureMcpPath } from "@ccr/core/mcp/network-capture-mcp"; +import { BROWSER_AUTOMATION_MCP_PATH, TOOL_HUB_MCP_SERVER_NAME, browserAutomationMcpEnabled, toolHubBuiltInBackendServers, toolHubMcpRuntimeConfig, toolHubRequestTimeoutMs } from "@ccr/core/mcp/toolhub-config"; +import { pluginService } from "@ccr/core/plugins/service"; +import { proxyService } from "@ccr/core/proxy/service"; +import { createSseErrorDetector, recordGatewayRequestLog, updateGatewayRequestLogFromRawTrace, type RequestLogRawTraceUpdateInput } from "@ccr/core/observability/request-log-store"; +import { recordGatewayUsageCapture } from "@ccr/core/usage/store"; +import { ClaudeCodeRouterPlugin, normalizeRouteSelector } from "@ccr/core/gateway/claude-code-router-plugin"; +import { ccrRemoteControlPathPrefix, ccrRemoteControlService } from "@ccr/core/gateway/remote-control-service"; +import { + claudeCodeEffectiveMaxInputTokens, + findModelCatalogEntry, + modelCatalogMaxInputTokens, + modelCatalogMaxOutputTokens, + readCatalogCapability, + type ModelCatalogCapabilities, + type ModelCatalogEntry +} from "@ccr/core/gateway/model-catalog"; + +type CoreGatewayProvider = { + apikey?: string; + baseurl?: string; + billing?: unknown; + extraBody?: unknown; + extraHeaders?: unknown; + models: string[]; + name: string; + type: GatewayProviderProtocol; +}; + +const defaultFusionWebSearchProvider: VirtualModelFusionWebSearchProvider = "brave"; +const fusionModelProviderName = "Fusion"; +const claudeCodeOneMillionContextSuffix = "[1m]"; +const claudeAppGatewayModelRouteOptions: ClaudeAppGatewayModelRouteOptions = { + displayName: (model) => findModelCatalogEntry(model)?.displayName, + supportsOneMillionContext: (model) => Boolean(findModelCatalogEntry(model)?.limits?.supports1MContext) +}; + +type ApiKeyAuthorizationResult = + | { ok: true; apiKey?: ApiKeyConfig } + | { ok: false }; + +type ApiKeyLimitUsage = { + imageCount: number; + totalTokens: number; +}; + +type ApiKeyLimitRule = { + limit: number; + metric: "images" | "requests" | "tokens"; + name: string; + requested: number; + windowMs: number; +}; + +type GatewayStopOptions = { + proxyRestoreTimeoutMs?: number; +}; + +type HostedWebSearchProtocolContext = { + maxUses?: number; + protocol: GatewayProviderProtocol; + queryHint?: string; + records?: BrowserWebSearchProtocolRecord[]; + requestId: string; + sinceMs: number; + toolName: string; +}; + +type AnthropicWebSearchProtocolContext = HostedWebSearchProtocolContext; + +type ClaudeCodeWebSearchContinuationContext = { + queryHint?: string; + sinceMs: number; + toolName: string; +}; + +export type BrowserWebSearchMcpRegistration = { + env?: Record; + name: string; + resultCount?: number; + timeoutMs?: number; + toolName: string; +}; + +export type BrowserWebSearchProtocolResult = { + content?: string; + diagnostics?: string[]; + snippet?: string; + title: string; + url: string; +}; + +export type BrowserWebSearchProtocolRecord = { + completedAtMs: number; + engine: string; + query: string; + results: BrowserWebSearchProtocolResult[]; + searchUrl: string; + toolName: string; +}; + +export type BrowserWebSearchMcpIntegration = { + registerBrowserWebSearchMcpServer: (options: BrowserWebSearchMcpRegistration) => Promise; + recentBrowserWebSearchResults?: (options: { sinceMs: number; toolName?: string }) => BrowserWebSearchProtocolRecord[]; + runBrowserWebSearch?: (options: { count?: number; prompt: string; timeoutMs?: number; toolName?: string }) => Promise; + stopBrowserWebSearchMcpServers: () => Promise; +}; + +export type BrowserAutomationMcpIntegration = { + handleBrowserAutomationMcpRequest: (request: IncomingMessage, response: ServerResponse) => Promise; + stopBrowserAutomationMcpServer: () => Promise; +}; + +type CoreGatewayHealth = { + runtimeId?: string; + status?: string; +}; + +type ManagedGatewayRuntimeMarker = { + generatedConfigFile?: unknown; + gatewayEntry?: unknown; + pid?: unknown; + runtimeId?: unknown; + startedAt?: unknown; +}; + +type ApiKeyWindowCounter = { + expiresAt: number; + value: number; + windowStart: number; +}; + +type PendingRawTraceUpdate = RequestLogRawTraceUpdateInput & { + receivedAt: number; +}; + +type RawTracePartText = { + contentType?: string; + text: string; +}; + +type CursorOpenAICompatContext = { + systemPrompt?: string; + toolChoice?: unknown; + tools: unknown[]; +}; + +type CursorOpenAICompatPreparation = { + body?: Buffer; + diagnostic: "fallback-injected" | "simplified-missing-context"; +}; + +type ClaudeCodeDiscoverableModel = { + id: string; + oneMillionContext: boolean; +}; + +type UpstreamAttempt = { + body?: Buffer; + credentialChain?: string[]; + credentialIds?: string[]; + credentialProtocol?: GatewayProviderProtocol; + headers?: Record; + index: number; + logicalProvider?: string; + model?: string; +}; + +type UpstreamFailedAttempt = { + credentialChain?: string[]; + credentialIds?: string[]; + delayMs?: number; + error?: string; + model?: string; + statusCode?: number; +}; + +type UpstreamFetchResult = { + attempt: UpstreamAttempt; + failedAttempts: UpstreamFailedAttempt[]; + response: Response; +}; + +class UpstreamRequestError extends Error { + readonly attempt?: UpstreamAttempt; + readonly failedAttempts: UpstreamFailedAttempt[]; + + constructor(message: string, options: { attempt?: UpstreamAttempt; cause?: unknown; failedAttempts: UpstreamFailedAttempt[] }) { + super(message); + this.name = "UpstreamRequestError"; + this.attempt = options.attempt; + this.cause = options.cause; + this.failedAttempts = options.failedAttempts; + } +} + +const requireFromHere = createRequire(__filename); +const coreGatewayAuthHeader = "x-ccr-core-auth"; +const coreGatewayAuthTokenEnv = "CCR_CORE_GATEWAY_AUTH_TOKEN"; +const clientClosedRequestStatusCode = 499; +const clientDisconnectMessage = "Client connection closed before response completed."; +const localObservabilityHeaderNames = new Set([ + "x-ccr-claude-app-model-rewrite", + "x-ccr-codex-patch-bridge", + "x-ccr-claude-model-discovery", + "x-ccr-cursor-openai-compat", + "x-ccr-logical-provider", + "x-ccr-provider-credential-chain", + "x-ccr-provider-credential-saturated" +]); +const proxyHeaderDenyList = new Set(["connection", coreGatewayAuthHeader, "host", "upgrade"]); +const responseHeaderDenyList = new Set(["connection", "content-encoding", "transfer-encoding"]); +const maxUsageCaptureBytes = 8 * 1024 * 1024; +const maxPendingRawTraceUpdates = 200; +const pendingRawTraceMaxAgeMs = 5 * 60 * 1000; +const apiKeyLimitCounterRetentionWindows = 2; +const gatewayRuntimeMarkerFile = "gateway-runtime.json"; +const rawTraceSyncHeader = "x-ccr-raw-trace-token"; +const virtualApplyPatchToolName = "virtual_apply_patch"; +let warnedMissingCursorOpenAICompatContext = false; +const rawTraceSyncPath = "/__ccr/raw-trace-sync"; +const gatewayEntryOverrideEnv = "CCR_GATEWAY_ENTRY"; +const gatewayPackageCandidates = ["@the-next-ai/ai-gateway", "gateway"]; +const codexPatchBridgeInstructionText = [ + "When modifying files, call virtual_apply_patch.", + "Do not use exec_command or write_stdin to edit files, including shell redirection, heredocs, cat >, tee, sed -i, perl -i, python, node scripts, or similar shell-based edits.", + "Use exec_command only for reading files, listing/searching, running builds/tests, starting servers, and other commands that are not manual file edits." +].join(" "); +const codexPatchBridgeShellToolGuidance = [ + "When virtual_apply_patch is available, do not use this tool to edit files.", + "Do not write files with shell redirection, heredocs, cat >, tee, sed -i, perl -i, python, node scripts, or similar commands.", + "Use virtual_apply_patch for manual file changes." +].join(" "); +const virtualApplyPatchLarkGrammar = [ + "start: begin_patch hunk+ end_patch", + "begin_patch: \"*** Begin Patch\" LF", + "end_patch: \"*** End Patch\" LF?", + "", + "hunk: add_hunk | delete_hunk | update_hunk", + "add_hunk: \"*** Add File: \" filename LF add_line+", + "delete_hunk: \"*** Delete File: \" filename LF", + "update_hunk: \"*** Update File: \" filename LF change_move? change?", + "", + "filename: /(.+)/", + "add_line: \"+\" /(.*)/ LF -> line", + "", + "change_move: \"*** Move to: \" filename LF", + "change: (change_context | change_line)+ eof_line?", + "change_context: (\"@@\" | \"@@ \" /(.+)/) LF", + "change_line: (\"+\" | \"-\" | \" \") /(.*)/ LF", + "eof_line: \"*** End of File\" LF", + "", + "%import common.LF" +].join("\n"); +const apiKeyLimitCounters = new Map(); +const providerCredentialCooldowns = new Map(); +const providerCredentialCooldownMs = 60_000; +const providerCredentialSpilloverThreshold = 0.8; +const upstreamRetryBackoffBaseMs = 1_000; +const upstreamRetryBackoffMaxMs = 30_000; +const upstreamRetryAfterMaxMs = 60_000; +const gatewayProviderProtocolFallbackOrder: GatewayProviderProtocol[] = [ + "anthropic_messages", + "openai_chat_completions", + "openai_responses", + "gemini_generate_content", + "gemini_interactions" +]; +const privateDirMode = 0o700; +const privateFileMode = 0o600; +const persistedApiKeyCacheTtlMs = 1000; +let persistedApiKeyCache: { loadedAt: number; values: ApiKeyConfig[] } | undefined; + +class GatewayService { + private browserAutomationMcpIntegration?: BrowserAutomationMcpIntegration; + private browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration; + private child?: ChildProcess; + private config?: AppConfig; + private coreAuthToken = ""; + private plugin?: ClaudeCodeRouterPlugin; + private readonly pendingRawTraceUpdates = new Map(); + private readonly rawTraceSyncToken = randomUUID(); + private server?: Server; + private status: GatewayStatus = { + coreEndpoint: "", + endpoint: "", + generatedConfigFile: "", + networkEndpoints: [], + state: "stopped" + }; + + setBrowserWebSearchMcpIntegration(integration: BrowserWebSearchMcpIntegration): void { + this.browserWebSearchMcpIntegration = integration; + } + + setBrowserAutomationMcpIntegration(integration: BrowserAutomationMcpIntegration): void { + this.browserAutomationMcpIntegration = integration; + } + + async start(config: AppConfig): Promise { + const coreHostError = loopbackCoreHostError(config.gateway.coreHost); + if (coreHostError) { + return { + ...this.getStatus(), + lastError: coreHostError, + state: "error" + }; + } + await this.stop(); + this.config = config; + this.coreAuthToken = generateCoreGatewayAuthToken(); + this.plugin = new ClaudeCodeRouterPlugin(config); + this.status = { + coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort), + endpoint: endpoint(config.gateway.host, config.gateway.port), + generatedConfigFile: config.gateway.generatedConfigFile, + networkEndpoints: gatewayNetworkEndpoints(config.gateway.host, config.gateway.port), + state: "starting" + }; + + try { + await pluginService.start(config); + const shouldRunServer = shouldRunUnifiedServer(config) || pluginService.hasGatewayRoutes(); + const shouldRunGateway = shouldRunGatewayRuntime(config); + if (shouldRunGateway && !hasAvailableGatewayModels(config)) { + throw new Error(NO_AVAILABLE_GATEWAY_MODELS_MESSAGE); + } + if (!shouldRunServer) { + await pluginService.stop(); + await backendService.stopAll(); + this.coreAuthToken = ""; + this.status = { + ...this.status, + state: "stopped" + }; + return this.status; + } + + await this.listen(config); + if (this.server) { + const proxyStatus = await proxyService.attach(config, this.server); + if (proxyStatus.state === "error" && !config.gateway.enabled) { + throw new Error(proxyStatus.lastError || "Proxy service failed to start."); + } + } + + if (shouldRunGateway) { + await writeCoreGatewayConfig(config, this.rawTraceSyncToken, this.coreAuthToken, this.browserWebSearchMcpIntegration); + await stopPreviousManagedCoreGateway(config, this.status.coreEndpoint); + if (await isCoreGatewayHealthy(this.status.coreEndpoint)) { + throw new Error(`Core gateway endpoint is already in use: ${this.status.coreEndpoint}`); + } + await proxyService.refreshUpstreamProxyFromCurrentSystem(); + const runtimeId = randomUUID(); + const upstreamProxyUrl = proxyService.getUpstreamProxyUrl("https") ?? await getSystemProxyUrlForProtocol("https"); + this.child = spawnGatewayProcess(config, upstreamProxyUrl, runtimeId, this.coreAuthToken); + const managedChild = this.child; + writeManagedCoreGatewayMarker(config, this.child, runtimeId); + this.child.stdout?.on("data", (chunk) => console.info(`[gateway] ${chunk.toString().trimEnd()}`)); + this.child.stderr?.on("data", (chunk) => console.warn(`[gateway] ${chunk.toString().trimEnd()}`)); + this.child.on("exit", (code, signal) => { + void this.handleCoreGatewayExit(managedChild, code, signal); + }); + } + + this.status = { + ...this.status, + coreManagedExternally: this.status.coreManagedExternally, + lastStartedAt: new Date().toISOString(), + pid: this.child?.pid, + state: "running" + }; + return this.status; + } catch (error) { + await this.stop(); + this.status = { + ...this.status, + lastError: formatError(error), + state: "error" + }; + return this.status; + } + } + + async stop(options: GatewayStopOptions = {}): Promise { + const child = this.child; + const config = this.config; + this.child = undefined; + this.coreAuthToken = ""; + if (child && !child.killed) { + child.kill(); + } + removeManagedCoreGatewayMarker(config); + + const server = this.server; + this.server = undefined; + if (server) { + await closeServer(server); + } + + await proxyService.stop(options.proxyRestoreTimeoutMs); + await pluginService.stop(); + await backendService.stopAll(); + await this.browserWebSearchMcpIntegration?.stopBrowserWebSearchMcpServers().catch((error) => { + console.warn(`[gateway] Failed to stop browser web search MCP: ${formatError(error)}`); + }); + await this.browserAutomationMcpIntegration?.stopBrowserAutomationMcpServer().catch((error) => { + console.warn(`[gateway] Failed to stop browser automation MCP: ${formatError(error)}`); + }); + + this.status = { + ...this.status, + coreManagedExternally: undefined, + pid: undefined, + state: "stopped" + }; + return this.getStatus(); + } + + getStatus(): GatewayStatus { + return { + ...this.status, + networkEndpoints: this.config + ? gatewayNetworkEndpoints(this.config.gateway.host, this.config.gateway.port) + : this.status.networkEndpoints + }; + } + + updateConfig(config: AppConfig): void { + assertLoopbackCoreHost(config.gateway.coreHost); + this.config = config; + this.plugin = new ClaudeCodeRouterPlugin(config); + proxyService.updateConfig(config); + this.status = { + ...this.status, + coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort), + endpoint: endpoint(config.gateway.host, config.gateway.port), + generatedConfigFile: config.gateway.generatedConfigFile, + networkEndpoints: gatewayNetworkEndpoints(config.gateway.host, config.gateway.port) + }; + } + + private async listen(config: AppConfig): Promise { + this.server = createServer((request, response) => { + if (proxyService.shouldHandleHttpRequest(request)) { + void proxyService.handleHttpRequest(request, response).catch((error) => { + response.writeHead(502, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { message: formatError(error) } })); + }); + return; + } + + void this.handleRequest(request, response).catch((error) => { + response.writeHead(502, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { message: formatError(error) } })); + }); + }); + + await new Promise((resolve, reject) => { + this.server?.once("error", reject); + this.server?.listen(config.gateway.port, config.gateway.host, () => { + this.server?.off("error", reject); + resolve(); + }); + }); + } + + private async handleCoreGatewayExit(child: ChildProcess, code: number | null, signal: NodeJS.Signals | null): Promise { + if (this.child !== child || this.status.state === "stopped") { + return; + } + removeManagedCoreGatewayMarker(this.config); + this.status = { + ...this.status, + coreManagedExternally: undefined, + lastError: `Core gateway exited with ${signal ?? code ?? "unknown status"}`, + pid: undefined, + state: "error" + }; + } + + private async handleRequest(request: IncomingMessage, response: ServerResponse): Promise { + applyCors(response, this.config); + + if (request.method === "OPTIONS") { + response.writeHead(204); + response.end(); + return; + } + + if (!this.config || !this.plugin) { + sendJson(response, 503, { error: { message: "Gateway service is not configured." } }); + return; + } + + const path = request.url ? new URL(request.url, this.status.endpoint || "http://127.0.0.1").pathname : "/"; + if (path === rawTraceSyncPath) { + if (!shouldRecordRequestLogs(this.config)) { + sendJson(response, 202, { applied: false, disabled: true, ok: true }); + return; + } + await this.handleRawTraceSync(request, response); + return; + } + + if (path === ccrRemoteControlPathPrefix || path.startsWith(`${ccrRemoteControlPathPrefix}/`)) { + const authorization = await authorize(request, response, this.config); + if (!authorization.ok) { + return; + } + await ccrRemoteControlService.handleRequest({ + endpoint: this.status.endpoint, + path, + readBody: readRequestBody, + request, + response, + sendJson + }); + return; + } + + if (path === BROWSER_AUTOMATION_MCP_PATH || path === `${BROWSER_AUTOMATION_MCP_PATH}/`) { + if (!browserAutomationMcpEnabled(this.config)) { + sendJson(response, 404, { + error: { + message: "CCR browser automation MCP is disabled." + } + }); + return; + } + const authorization = await authorize(request, response, this.config); + if (!authorization.ok) { + return; + } + if (!this.browserAutomationMcpIntegration) { + sendJson(response, 503, { + error: { + message: "CCR browser automation MCP is only available in the Electron desktop app." + } + }); + return; + } + await this.browserAutomationMcpIntegration.handleBrowserAutomationMcpRequest(request, response); + return; + } + + if (isNetworkCaptureMcpPath(path)) { + if (!this.config.proxy.captureNetwork) { + sendJson(response, 404, { error: { message: "Network capture MCP is disabled." } }); + return; + } + const authorization = await authorize(request, response, this.config); + if (!authorization.ok) { + return; + } + await handleNetworkCaptureMcpRequest(request, response); + return; + } + + const pluginRoute = pluginService.matchGatewayRoute(request.method, path); + if (pluginRoute) { + if (pluginRoute.auth !== "none") { + const authorization = await authorize(request, response, this.config); + if (!authorization.ok) { + return; + } + } + await pluginService.handleGatewayRoute(pluginRoute, request, response); + return; + } + + if (!shouldServeGatewayRequest(this.config, request)) { + sendJson(response, 503, { error: { message: "Gateway runtime is disabled." } }); + return; + } + + if (path === "/health") { + sendJson(response, 200, { + core: this.status.coreEndpoint, + coreManagedExternally: this.status.coreManagedExternally || undefined, + status: this.status.state, + timestamp: new Date().toISOString() + }); + return; + } + + if (path === "/") { + sendJson(response, 200, { + core: "next-ai-gateway", + endpoints: ["POST /mcp", "POST /v1/messages", "POST /v1/messages/count_tokens", "GET /v1/models"], + name: "claude-code-router", + plugin: "claude-code-router", + wrapperPlugins: this.config.plugins.filter((plugin) => plugin.enabled !== false).map((plugin) => plugin.id) + }); + return; + } + + const authorization = await authorize(request, response, this.config); + if (!authorization.ok) { + return; + } + + if (request.method === "POST" && path === "/v1/messages/count_tokens") { + const requestBody = await readRequestBody(request); + const body = parseJsonObject(requestBody); + if (!reserveApiKeyLimits(authorization.apiKey, request, response, requestBody)) { + return; + } + sendJson(response, 200, this.plugin.countTokens(body)); + return; + } + + await this.proxyRequest(request, response, path, authorization.apiKey); + } + + private async proxyRequest(request: IncomingMessage, response: ServerResponse, path: string, apiKey?: ApiKeyConfig): Promise { + if (!this.config || !this.plugin) { + sendJson(response, 503, { error: { message: "Gateway service is not configured." } }); + return; + } + + const headers = forwardHeaders(request.headers); + if (apiKey) { + stripLocalGatewayAuthHeaders(headers); + headers["x-auth-api-key-id"] = apiKey.id; + headers["x-auth-sub"] = apiKey.id; + } + const method = request.method ?? "GET"; + const requestBody = await readRequestBody(request); + const client = inferGatewayClient(apiKey, request.headers); + const cursorCompatPreparation = prepareCursorOpenAICompatChatBody(this.config, client, method, path, requestBody); + if (cursorCompatPreparation) { + headers["x-ccr-cursor-openai-compat"] = sanitizeHeaderValue(cursorCompatPreparation.diagnostic); + } + let bodyToForward: Buffer | undefined = cursorCompatPreparation?.body ?? requestBody; + let routeFallback = this.config.Router.fallback; + let routedModel: string | undefined; + let codexApplyPatchBridgeActive = false; + const claudeModelRewrite = prepareClaudeCodeDiscoveredModelRequest(this.config, request.headers, method, path, bodyToForward); + if (claudeModelRewrite) { + headers["x-ccr-claude-model-discovery"] = sanitizeHeaderValue(claudeModelRewrite.diagnostic); + bodyToForward = claudeModelRewrite.body; + } + const claudeAppModelRewrite = prepareClaudeAppFallbackModelRequest(this.config, method, path, bodyToForward); + if (claudeAppModelRewrite) { + headers["x-ccr-claude-app-model-rewrite"] = sanitizeHeaderValue(claudeAppModelRewrite.diagnostic); + bodyToForward = claudeAppModelRewrite.body; + routedModel = claudeAppModelRewrite.routedModel; + } + if (!reserveApiKeyLimits(apiKey, request, response, bodyToForward)) { + return; + } + const startedAt = Date.now(); + const startedAtIso = new Date(startedAt).toISOString(); + const requestId = randomUUID(); + headers["x-client-request-id"] = requestId; + const requestUrl = new URL(request.url || path, this.status.endpoint || "http://127.0.0.1").toString(); + const upstreamAbortController = new AbortController(); + let clientDisconnected = false; + let responseCompleted = false; + let onClientDisconnect: (() => void) | undefined; + let onResponseFinish: (() => void) | undefined; + const handleClientDisconnect = () => { + if (responseCompleted || response.writableEnded) { + return; + } + if (!clientDisconnected) { + clientDisconnected = true; + upstreamAbortController.abort(new Error(clientDisconnectMessage)); + } + onClientDisconnect?.(); + }; + + response.once("finish", () => { + responseCompleted = true; + onResponseFinish?.(); + }); + response.once("close", handleClientDisconnect); + response.on("error", () => { + // Client-side write failures (EPIPE / ECONNRESET when the client closes + // mid-stream, common during tool execution) must not crash the main + // process as an Uncaught Exception. Swallow them here; the close handler + // above already records the disconnect via writeStreamLog. + handleClientDisconnect(); + }); + + const writeRequestLog = ( + statusCode: number, + responseHeaders: Headers, + responseBodyText = "", + responseBodyTruncated = false, + error?: string + ) => { + const config = this.config; + if (!config || !shouldRecordRequestLogs(config)) { + return; + } + void (async () => { + await recordGatewayRequestLog({ + client, + completedAt: new Date().toISOString(), + durationMs: Date.now() - startedAt, + error, + fallbackModel: routedModel, + method, + path, + providerName: resolveProviderLogName(responseHeaders, config, routedModel), + providerProtocol: resolveResponseProviderProtocol(responseHeaders, this.config), + requestBody: shouldSendBody(method) ? bodyToForward ?? Buffer.alloc(0) : Buffer.alloc(0), + requestHeaders: headers, + requestId, + responseBodyText, + responseBodyTruncated, + responseHeaders, + startedAt: startedAtIso, + statusCode, + url: requestUrl + }); + const pendingRawTraceUpdate = this.takePendingRawTraceUpdate(requestId); + if (pendingRawTraceUpdate) { + await updateGatewayRequestLogFromRawTrace(pendingRawTraceUpdate); + } + })(); + }; + + const shouldCaptureUsage = shouldCaptureGatewayUsage(method, path); + if (shouldServeGatewayModelsResponse(method, path)) { + const responseText = `${JSON.stringify(createGatewayModelsResponse(this.config, request.headers, apiKey))}\n`; + const modelHeaders = new Headers({ + "cache-control": "no-store, max-age=0", + "content-length": String(Buffer.byteLength(responseText)), + "content-type": "application/json; charset=utf-8", + "expires": "0", + "pragma": "no-cache" + }); + response.writeHead(200, Object.fromEntries(filteredResponseHeaders(modelHeaders))); + response.end(responseText); + return; + } + + if (method === "POST" && path === "/v1/messages") { + const body = parseJsonObject(bodyToForward ?? requestBody); + const routed = await this.plugin.routeRequest({ + body, + headers: headers as Record, + method, + url: request.url ?? path + }); + const serialized = Buffer.from(`${JSON.stringify(routed.body)}\n`, "utf8"); + headers["content-type"] = "application/json"; + headers["x-ccr-route-reason"] = sanitizeHeaderValue(routed.decision.reason); + routeFallback = routed.decision.fallback ?? routeFallback; + if (routed.decision.model) { + headers["x-ccr-routed-model"] = sanitizeHeaderValue(routed.decision.model); + routedModel = routed.decision.model; + } + bodyToForward = serialized; + } + if (method === "POST" && requestProtocolForPath(path) === "openai_responses" && isCodexUserAgent(request.headers)) { + const body = parseJsonObject(bodyToForward ?? requestBody); + const routed = await this.plugin.routeRequest({ + body, + headers: headers as Record, + method, + url: request.url ?? path + }); + const serialized = Buffer.from(`${JSON.stringify(routed.body)}\n`, "utf8"); + headers["content-type"] = "application/json"; + headers["x-ccr-route-reason"] = sanitizeHeaderValue(routed.decision.reason); + routeFallback = routed.decision.fallback ?? routeFallback; + if (routed.decision.model) { + headers["x-ccr-routed-model"] = sanitizeHeaderValue(routed.decision.model); + routedModel = routed.decision.model; + } + bodyToForward = serialized; + } + + const codexApplyPatchBridgeRequest = prepareCodexApplyPatchBridgeRequest({ + body: bodyToForward, + config: this.config, + headers: request.headers, + method, + path, + routedModel + }); + if (codexApplyPatchBridgeRequest) { + bodyToForward = codexApplyPatchBridgeRequest.body; + codexApplyPatchBridgeActive = true; + headers["x-ccr-codex-patch-bridge"] = sanitizeHeaderValue(codexApplyPatchBridgeRequest.diagnostic); + headers["content-type"] = "application/json"; + } + + const providerCapabilityRouting = applyProviderCapabilityRouting({ + body: bodyToForward, + config: this.config, + fallback: routeFallback, + headers, + path, + routedModel + }); + bodyToForward = providerCapabilityRouting.body; + routeFallback = providerCapabilityRouting.fallback; + routedModel = providerCapabilityRouting.routedModel; + + const hostedWebSearchProtocolContext = createHostedWebSearchProtocolContext({ + body: bodyToForward, + config: this.config, + method, + path, + requestId, + routedModel, + sinceMs: startedAt - 1_000 + }); + + if (hostedWebSearchProtocolContext && !this.browserWebSearchMcpIntegration) { + const message = browserWebSearchUnavailableMessage(hostedWebSearchProtocolContext.toolName); + const responseHeaders = new Headers({ "content-type": "application/json; charset=utf-8" }); + const responseBody = JSON.stringify({ error: { message } }); + writeRequestLog(503, responseHeaders, responseBody, false, message); + sendJson(response, 503, { error: { message } }); + return; + } + + if (hostedWebSearchProtocolContext && this.browserWebSearchMcpIntegration) { + const records = await selectHostedWebSearchProtocolRecords( + hostedWebSearchProtocolContext, + this.browserWebSearchMcpIntegration + ).catch((error) => { + console.warn(`[gateway] Failed to prefetch hosted web search results: ${formatError(error)}`); + return [] as BrowserWebSearchProtocolRecord[]; + }); + if (records.length > 0) { + hostedWebSearchProtocolContext.records = records; + const webSearchContextBody = prepareHostedWebSearchProtocolRequestBody( + bodyToForward, + records, + hostedWebSearchProtocolContext + ); + if (webSearchContextBody) { + bodyToForward = webSearchContextBody; + headers["content-type"] = "application/json"; + headers["x-ccr-hosted-web-search-context"] = hostedWebSearchProtocolContext.protocol; + } + } + } + + const claudeCodeWebSearchContinuationContext = !hostedWebSearchProtocolContext && this.browserWebSearchMcpIntegration + ? createClaudeCodeWebSearchContinuationContext({ + body: bodyToForward, + config: this.config, + method, + path, + routedModel, + sinceMs: startedAt - 5 * 60_000 + }) + : undefined; + if (claudeCodeWebSearchContinuationContext && this.browserWebSearchMcpIntegration) { + const records = selectClaudeCodeWebSearchContinuationRecords( + claudeCodeWebSearchContinuationContext, + this.browserWebSearchMcpIntegration + ); + const webSearchContinuationBody = prepareClaudeCodeWebSearchContinuationRequestBody( + bodyToForward, + records, + claudeCodeWebSearchContinuationContext + ); + if (webSearchContinuationBody) { + bodyToForward = webSearchContinuationBody; + headers["content-type"] = "application/json"; + headers["x-ccr-claude-code-web-search-continuation"] = records.length > 0 ? "in-app-browser-evidence" : "tool-result-evidence"; + } + } + + delete headers["content-length"]; + const upstreamUrl = new URL(request.url || "/", this.status.coreEndpoint).toString(); + let upstreamResult: UpstreamFetchResult; + + try { + upstreamResult = await fetchUpstreamWithFallback({ + body: bodyToForward, + config: this.config, + fallback: routeFallback, + headers, + method, + path, + routedModel, + coreAuthToken: this.coreAuthToken, + signal: upstreamAbortController.signal, + upstreamUrl + }); + } catch (error) { + const message = formatError(error); + if (error instanceof UpstreamRequestError) { + bodyToForward = error.attempt?.body ?? bodyToForward; + routedModel = error.attempt?.model ?? routedModel; + } + if (clientDisconnected || upstreamAbortController.signal.aborted) { + writeRequestLog(clientClosedRequestStatusCode, new Headers(), "", false, clientDisconnectMessage); + return; + } + if (shouldCaptureUsage) { + void recordGatewayUsageCapture({ + bodyText: "", + client, + durationMs: Date.now() - startedAt, + fallbackModel: routedModel, + method, + path, + providerName: resolveProviderLogName(new Headers(), this.config, routedModel), + providerProtocol: resolveResponseProviderProtocol(new Headers(), this.config), + requestId, + responseHeaders: new Headers(), + statusCode: 502 + }); + } + writeRequestLog(502, new Headers(), "", false, message); + throw error; + } + + bodyToForward = upstreamResult.attempt.body ?? bodyToForward; + routedModel = upstreamResult.attempt.model ?? routedModel; + const responseHeaders = rewriteCapabilityResponseHeaders( + // Copy into a mutable Headers instance: upstream fetch Response.headers + // can be immutable (TypeError: immutable on .delete/.set), and + // mergeFallbackResponseHeaders returns the original object as-is when + // no fallback occurred. Codex apply_patch / web-search paths call + // .delete("content-length") below, which would otherwise throw and + // surface as a 502. + new Headers(mergeFallbackResponseHeaders(upstreamResponseHeaders(upstreamResult), upstreamResult)), + this.config + ); + const upstreamResponse = upstreamResult.response; + if (clientDisconnected || upstreamAbortController.signal.aborted) { + await cancelResponseBody(upstreamResponse); + writeRequestLog(clientClosedRequestStatusCode, responseHeaders, "", false, clientDisconnectMessage); + return; + } + if (codexApplyPatchBridgeActive) { + responseHeaders.delete("content-length"); + } + const hostedWebSearchResponseContentType = responseHeaders.get("content-type")?.toLowerCase() ?? ""; + if ( + hostedWebSearchProtocolContext && + (hostedWebSearchResponseContentType.includes("application/json") || + hostedWebSearchResponseContentType.includes("text/event-stream")) && + (this.browserWebSearchMcpIntegration?.recentBrowserWebSearchResults || this.browserWebSearchMcpIntegration?.runBrowserWebSearch) + ) { + responseHeaders.delete("content-length"); + } + recordProviderCredentialOutcome(this.config, method, upstreamResult.attempt, upstreamResponse.status, responseHeaders); + if (clientDisconnected || response.destroyed) { + await cancelResponseBody(upstreamResponse); + writeRequestLog(clientClosedRequestStatusCode, responseHeaders, "", false, clientDisconnectMessage); + return; + } + response.writeHead(upstreamResponse.status, Object.fromEntries(filteredResponseHeaders(responseHeaders))); + if (!upstreamResponse.body) { + if (shouldCaptureUsage) { + void recordGatewayUsageCapture({ + bodyText: "", + client, + durationMs: Date.now() - startedAt, + fallbackModel: routedModel, + method, + path, + providerName: resolveProviderLogName(responseHeaders, this.config, routedModel), + providerProtocol: resolveResponseProviderProtocol(responseHeaders, this.config), + requestId, + responseHeaders, + statusCode: upstreamResponse.status + }); + } + writeRequestLog(upstreamResponse.status, responseHeaders); + response.end(); + return; + } + + const upstreamBody = Readable.fromWeb(upstreamResponse.body as unknown as import("node:stream/web").ReadableStream); + const patchedResponseBody = codexApplyPatchBridgeActive + ? codexApplyPatchBridgeResponseStream(upstreamBody, responseHeaders) + : upstreamBody; + const responseBody = hostedWebSearchProtocolContext + ? hostedWebSearchProtocolResponseStream( + patchedResponseBody, + responseHeaders, + hostedWebSearchProtocolContext, + this.browserWebSearchMcpIntegration + ) + : patchedResponseBody; + const responseStreams = uniqueStreams([upstreamBody, patchedResponseBody, responseBody]); + const sampler = createBodySampler(); + const sseErrorDetector = createSseErrorDetector(responseHeaders.get("content-type") ?? undefined); + let streamDetectedError: string | undefined; + let upstreamStreamEnded = false; + let logRecorded = false; + const writeStreamLog = (error?: string) => { + if (logRecorded) { + return; + } + logRecorded = true; + writeRequestLog( + clientDisconnected ? clientClosedRequestStatusCode : upstreamResponse.status, + responseHeaders, + sampler.read(), + sampler.isTruncated(), + error ?? streamDetectedError + ); + }; + onClientDisconnect = () => { + writeStreamLog(clientDisconnectMessage); + responseBody.unpipe(response); + destroyResponseStreams(responseStreams); + }; + onResponseFinish = () => { + if (upstreamStreamEnded) { + writeStreamLog(); + } + }; + const onResponseStreamError = (error: Error) => { + streamDetectedError ??= sseErrorDetector.finish(); + writeStreamLog(clientDisconnected ? clientDisconnectMessage : formatError(error)); + }; + for (const stream of responseStreams) { + stream.on("error", onResponseStreamError); + } + responseBody.on("data", (chunk) => { + sampler.append(chunk); + streamDetectedError ??= sseErrorDetector.append(chunk); + }); + responseBody.once("end", () => { + upstreamStreamEnded = true; + streamDetectedError ??= sseErrorDetector.finish(); + if (responseCompleted || response.writableEnded) { + writeStreamLog(); + } + }); + if (shouldCaptureUsage) { + responseBody.once("end", () => { + void recordGatewayUsageCapture({ + bodyText: sampler.read(), + client, + durationMs: Date.now() - startedAt, + fallbackModel: routedModel, + method, + path, + providerName: resolveProviderLogName(responseHeaders, this.config, routedModel), + providerProtocol: resolveResponseProviderProtocol(responseHeaders, this.config), + requestId, + responseHeaders, + statusCode: upstreamResponse.status + }); + }); + } + if (clientDisconnected || response.destroyed) { + onClientDisconnect(); + return; + } + responseBody.pipe(response); + } + + private async handleRawTraceSync(request: IncomingMessage, response: ServerResponse): Promise { + if (request.method !== "POST") { + sendJson(response, 405, { error: { message: "Method not allowed." } }); + return; + } + if (readHeader(request.headers[rawTraceSyncHeader]) !== this.rawTraceSyncToken) { + sendJson(response, 401, { error: { message: "Unauthorized raw trace sync." } }); + return; + } + + const manifest = parseJsonObject(await readRequestBody(request)); + const update = readRawTraceRequestLogUpdate(manifest); + cleanupRawTraceBundle(manifest); + if (!update) { + sendJson(response, 202, { applied: false, ok: true }); + return; + } + + const applied = await updateGatewayRequestLogFromRawTrace(update); + if (!applied) { + this.storePendingRawTraceUpdate(update); + } + sendJson(response, 200, { applied, ok: true }); + } + + private storePendingRawTraceUpdate(update: RequestLogRawTraceUpdateInput): void { + this.prunePendingRawTraceUpdates(); + this.pendingRawTraceUpdates.set(update.requestId, { + ...update, + receivedAt: Date.now() + }); + while (this.pendingRawTraceUpdates.size > maxPendingRawTraceUpdates) { + const oldestKey = this.pendingRawTraceUpdates.keys().next().value; + if (!oldestKey) { + break; + } + this.pendingRawTraceUpdates.delete(oldestKey); + } + } + + private takePendingRawTraceUpdate(requestId: string): RequestLogRawTraceUpdateInput | undefined { + const update = this.pendingRawTraceUpdates.get(requestId); + if (!update) { + return undefined; + } + this.pendingRawTraceUpdates.delete(requestId); + const { receivedAt: _receivedAt, ...input } = update; + return input; + } + + private prunePendingRawTraceUpdates(): void { + const cutoff = Date.now() - pendingRawTraceMaxAgeMs; + for (const [requestId, update] of this.pendingRawTraceUpdates) { + if (update.receivedAt < cutoff) { + this.pendingRawTraceUpdates.delete(requestId); + } + } + } +} + +export const gatewayService = new GatewayService(); + +async function writeCoreGatewayConfig( + config: AppConfig, + rawTraceSyncToken: string, + coreAuthToken: string, + browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration +): Promise { + assertLoopbackCoreHost(config.gateway.coreHost); + mkdirSync(dirname(config.gateway.generatedConfigFile), { mode: privateDirMode, recursive: true }); + const pluginCoreGatewayConfig = pluginService.getCoreGatewayConfig(); + const providerPlugins = withCodexOauthRuntimeDefaults([ + ...(config.providerPlugins ?? []).filter(providerPluginEnabled), + ...pluginService.getCoreProviderPlugins().filter(providerPluginEnabled) + ]); + const codexOauthProviderNames = codexOauthLocalProviderNames(providerPlugins); + const virtualModelProfiles = normalizeCoreGatewayVirtualModelProfiles(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([ + ...(config.virtualModelProfiles ?? []), + ...pluginService.getVirtualModelProfiles() + ])), config); + const coreEndpoint = endpoint(config.gateway.coreHost, config.gateway.corePort); + const builtinToolArtifacts = await fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration); + const providers = [ + ...config.Providers + .flatMap((provider) => toCoreGatewayProviders(withCodexOauthProviderBaseUrl(provider, codexOauthProviderNames))) + .filter((provider): provider is CoreGatewayProvider => Boolean(provider)), + ...builtinToolArtifacts.providers + ]; + const pluginAgentConfig = isRecord(pluginCoreGatewayConfig.agent) ? pluginCoreGatewayConfig.agent : {}; + const pluginMcpServers = Array.isArray(pluginAgentConfig.mcpServers) ? pluginAgentConfig.mcpServers : []; + const externalMcpServers = [ + ...pluginMcpServers, + ...(config.agent?.mcpServers ?? []), + ...(config.toolHub?.mcpServers ?? []) + ]; + const toolHubServer = toolHubMcpServer(config, externalMcpServers); + const mcpServers = [ + ...builtinToolArtifacts.mcpServers, + ...(toolHubServer ? [toolHubServer] : externalMcpServers) + ]; + const fallbackMcpServer = fusionToolFallbackMcpServer(virtualModelProfiles, [ + ...builtinToolArtifacts.mcpServers, + ...externalMcpServers + ]); + if (fallbackMcpServer) { + mcpServers.push(fallbackMcpServer); + } + const payload = { + ...pluginCoreGatewayConfig, + auth: { + enabled: true, + mode: "static_api_key", + required: true, + staticApiKeys: { + keyBearerOnly: false, + keyEnv: coreGatewayAuthTokenEnv, + keyHeader: coreGatewayAuthHeader + } + }, + billing: { + enabled: true + }, + billingQueue: { + enabled: false + }, + billingWebhook: { + enabled: false + }, + bodyLimitBytes: 50 * 1024 * 1024, + host: config.gateway.coreHost, + mcpGateway: { + enabled: false + }, + port: config.gateway.corePort, + upstreamTimeoutMs: Number(config.API_TIMEOUT_MS) || 0, + agent: { + ...pluginAgentConfig, + mcpServers + }, + rawTrace: buildRawTraceConfig(config, rawTraceSyncToken), + providerPlugins, + providers, + virtualModelProfiles + }; + + writePrivateTextFile(config.gateway.generatedConfigFile, `${JSON.stringify(payload, null, 2)}\n`); +} + +function writePrivateTextFile(file: string, content: string): void { + writeFileSync(file, content, { encoding: "utf8", mode: privateFileMode }); + if (process.platform !== "win32") { + try { + chmodSync(file, privateFileMode); + } catch { + // Best effort for filesystems that do not support chmod. + } + } +} + +function providerPluginEnabled(plugin: unknown): boolean { + return !isRecord(plugin) || plugin.enabled !== false; +} + +export function normalizeCoreGatewayVirtualModelProfiles(profiles: unknown[], config: AppConfig): unknown[] { + return profiles.map((profile) => normalizeCoreGatewayVirtualModelProfile(profile, config)); +} + +function normalizeCoreGatewayVirtualModelProfile(profile: unknown, config: AppConfig): unknown { + if (!isRecord(profile)) { + return profile; + } + + let nextProfile: Record | undefined; + const baseModel = isRecord(profile.baseModel) ? profile.baseModel : undefined; + const fixedModel = stringValue(baseModel?.fixedModel); + const rewrittenFixedModel = fixedModel + ? rewriteModelSelectorForCoreGatewayProfile(fixedModel, config, "anthropic_messages") + : undefined; + if (baseModel && rewrittenFixedModel && rewrittenFixedModel !== fixedModel) { + nextProfile = { + ...profile, + baseModel: { + ...baseModel, + fixedModel: rewrittenFixedModel + } + }; + } + + const sourceProfile = nextProfile ?? profile; + const metadata = isRecord(sourceProfile.metadata) ? sourceProfile.metadata : undefined; + const fusionVision = isRecord(metadata?.fusionVision) ? metadata.fusionVision : undefined; + const visionBaseUrl = stringValue(fusionVision?.baseUrl); + const visionSelectorField = stringValue(fusionVision?.modelSelector) ? "modelSelector" : stringValue(fusionVision?.model) ? "model" : undefined; + const visionSelector = visionSelectorField ? stringValue(fusionVision?.[visionSelectorField]) : undefined; + const rewrittenVisionSelector = fusionVision && !visionBaseUrl && visionSelector + ? rewriteModelSelectorForCoreGatewayProfile(visionSelector, config, "openai_chat_completions") + : undefined; + + if (metadata && fusionVision && visionSelectorField && rewrittenVisionSelector && rewrittenVisionSelector !== visionSelector) { + nextProfile = { + ...sourceProfile, + metadata: { + ...metadata, + fusionVision: { + ...fusionVision, + [visionSelectorField]: rewrittenVisionSelector + } + } + }; + } + + const profileAfterVision = nextProfile ?? profile; + const profileAfterWebSearchToolName = normalizeFusionWebSearchProfileToolName(profileAfterVision) ?? profileAfterVision; + return withFusionWebSearchToolInstructions(profileAfterWebSearchToolName) ?? profileAfterWebSearchToolName; +} + +function rewriteModelSelectorForCoreGatewayProfile( + model: string, + config: AppConfig, + clientProtocol: GatewayProviderProtocol +): string | undefined { + const normalized = normalizeRouteSelector(model); + if (!normalized) { + return undefined; + } + + const publicModel = resolveGatewayPublicModelId(normalized, config) ?? normalized; + const selector = + resolveConfiguredProviderModelSelector(publicModel, config) ?? + resolveUniqueConfiguredProviderModelSelector(publicModel, config); + if (!selector) { + return publicModel; + } + + const providerName = coreGatewayProviderSelectorName(selector.provider, clientProtocol); + return providerName ? `${providerName}/${selector.model}` : publicModel; +} + +function coreGatewayProviderSelectorName( + provider: GatewayProviderConfig, + clientProtocol: GatewayProviderProtocol +): string | undefined { + const capability = providerCapabilityForClientProtocol(provider, clientProtocol); + const explicitCapabilities = normalizedProviderCapabilities(provider); + const protocol = capability?.type ?? (explicitCapabilities.length === 0 ? providerProtocolForClientProtocol(provider, clientProtocol) : undefined); + if (!protocol) { + return undefined; + } + + const credentials = sortProviderCredentialsForConfig(activeProviderCredentials(provider)); + if (credentials.length > 0) { + return providerCredentialInternalName(provider, protocol, credentials[0]); + } + + return capability ? providerCapabilityInternalName(provider, protocol) : providerRuntimeId(provider); +} + +function withCodexOauthRuntimeDefaults(providerPlugins: unknown[]): unknown[] { + const codexAuth = readCodexAuth(); + return providerPlugins.map((plugin) => { + if (!isLocalCodexOauthProviderPlugin(plugin)) { + return plugin; + } + + const codexOauth = plugin.codexOauth; + const nextCodexOauth = { + ...codexOauth, + ...(!hasOwn(codexOauth, "accountId") && !hasOwn(codexOauth, "account_id") && codexAuth?.accountId + ? { accountId: codexAuth.accountId } + : {}) + }; + const nextPlugin: Record = { + ...plugin, + codexOauth: nextCodexOauth, + request: withCodexBackendRequestTransform(plugin.request) + }; + + if (codexAuth?.isFedrampAccount) { + const currentAuth = isRecord(plugin.auth) ? plugin.auth : {}; + const currentHeaders = isRecord(currentAuth.headers) ? currentAuth.headers : {}; + nextPlugin.auth = { + ...currentAuth, + headers: { + ...currentHeaders, + "X-OpenAI-Fedramp": "true" + } + }; + } + + return nextPlugin; + }); +} + +function codexOauthLocalProviderNames(providerPlugins: unknown[]): Set { + const names = new Set(); + for (const plugin of providerPlugins) { + if (!isLocalCodexOauthProviderPlugin(plugin)) { + continue; + } + addProviderNameVariants(names, stringValue(plugin.providerName)); + } + return names; +} + +function withCodexOauthProviderBaseUrl( + provider: GatewayProviderConfig, + codexOauthProviderNames: Set +): GatewayProviderConfig { + if (!codexOauthProviderNames.has(provider.name)) { + return provider; + } + + const protocol = + normalizeProviderProtocol(provider.type) ?? + normalizeProviderProtocol(provider.provider) ?? + inferProtocol(provider); + if (protocol !== "openai_responses") { + return provider; + } + + const capabilities = Array.isArray(provider.capabilities) + ? provider.capabilities.map((capability) => { + const capabilityProtocol = normalizeProviderProtocol(capability.type); + if (capabilityProtocol !== "openai_responses") { + return capability; + } + return { + ...capability, + baseUrl: codexDefaultBaseUrl + }; + }) + : provider.capabilities; + + return { + ...provider, + api_base_url: codexDefaultBaseUrl, + baseUrl: codexDefaultBaseUrl, + baseurl: codexDefaultBaseUrl, + capabilities + }; +} + +function isLocalCodexOauthProviderPlugin(value: unknown): value is Record & { codexOauth: Record } { + if (!isRecord(value) || !isRecord(value.codexOauth)) { + return false; + } + const key = stringValue(value.key)?.toLowerCase() ?? ""; + return key.startsWith("ccr-local-agent-") && key.includes("codex-oauth"); +} + +function withCodexBackendRequestTransform(request: unknown): Record { + const currentRequest = isRecord(request) ? request : {}; + const bodyRemove = Array.isArray(currentRequest.bodyRemove) + ? currentRequest.bodyRemove.map((item) => stringValue(item)).filter((item): item is string => Boolean(item)) + : []; + return { + ...currentRequest, + bodyRemove: uniqueStrings([...bodyRemove, "max_output_tokens"]) + }; +} + +function addProviderNameVariants(names: Set, providerName: string | undefined): void { + if (!providerName) { + return; + } + names.add(providerName); + const capabilitySeparatorIndex = providerName.indexOf("::"); + if (capabilitySeparatorIndex > 0) { + names.add(providerName.slice(0, capabilitySeparatorIndex)); + } +} + +function hasOwn(value: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +async function fusionBuiltinToolArtifacts( + profiles: unknown[], + coreEndpoint: string, + coreAuthToken: string, + browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration +): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: CoreGatewayProvider[] }> { + const providers: CoreGatewayProvider[] = []; + const mcpServers: GatewayMcpServerConfig[] = []; + const toolServerKeys = new Set(); + const entry = bundledFusionBuiltinMcpEntryPath(); + + for (const [index, profile] of profiles.entries()) { + if (!isRecord(profile) || profile.enabled === false) { + continue; + } + const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; + const profileId = stringValue(profile.id) || stringValue(profile.key) || `fusion-${index + 1}`; + const sanitizedProfileId = sanitizeMcpServerName(profileId); + + const visionConfig = readFusionVisionConfig(metadata?.fusionVision) ?? legacyFusionVisionConfig(profile); + if (visionConfig?.toolName) { + const resolvedVision = resolveFusionVisionRuntime(visionConfig); + providers.push(...resolvedVision.providers); + const toolServerKey = `vision:${visionConfig.toolName}`; + if (!toolServerKeys.has(toolServerKey)) { + toolServerKeys.add(toolServerKey); + const useGatewayVisionRuntime = !visionConfig.baseUrl; + mcpServers.push(fusionBuiltinMcpServer({ + entry, + env: { + FUSION_BUILTIN_TOOL_KIND: "vision", + FUSION_TOOL_NAME: visionConfig.toolName, + ...(useGatewayVisionRuntime ? { VISION_GATEWAY_BASE_URL: `${coreEndpoint}/v1` } : { VISION_BASE_URL: visionConfig.baseUrl || "" }), + ...(useGatewayVisionRuntime && coreAuthToken ? { VISION_GATEWAY_API_KEY: coreAuthToken } : {}), + ...(resolvedVision.model ? { VISION_MODEL: resolvedVision.model } : {}), + ...(visionConfig.baseUrl && visionConfig.apiKey ? { VISION_API_KEY: visionConfig.apiKey } : {}), + ...(visionConfig.timeoutMs ? { VISION_TIMEOUT_MS: String(visionConfig.timeoutMs) } : {}) + }, + name: `fusion-vision-${sanitizedProfileId}` + })); + } + } + + const webSearchConfig = readFusionWebSearchConfig(metadata?.fusionWebSearch) ?? legacyFusionWebSearchConfig(profile); + if (webSearchConfig?.toolName) { + const toolServerKey = `web_search:${webSearchConfig.toolName}`; + if (!toolServerKeys.has(toolServerKey)) { + toolServerKeys.add(toolServerKey); + const provider = webSearchConfig.provider ?? defaultFusionWebSearchProvider; + if (provider === "browser") { + const browserMcpServer = await browserWebSearchMcpIntegration?.registerBrowserWebSearchMcpServer({ + env: webSearchConfig.env ?? {}, + name: `fusion-browser-web-search-${sanitizedProfileId}`, + resultCount: webSearchConfig.resultCount, + timeoutMs: webSearchConfig.timeoutMs, + toolName: webSearchConfig.toolName + }); + if (browserMcpServer) { + mcpServers.push(browserMcpServer); + } + } else { + mcpServers.push(fusionBuiltinMcpServer({ + entry, + env: { + FUSION_BUILTIN_TOOL_KIND: "web_search", + FUSION_TOOL_NAME: webSearchConfig.toolName, + SEARCH_PROVIDER: provider, + ...(webSearchConfig.resultCount ? { SEARCH_RESULT_COUNT: String(webSearchConfig.resultCount) } : {}), + ...(webSearchConfig.timeoutMs ? { SEARCH_TIMEOUT_MS: String(webSearchConfig.timeoutMs) } : {}), + ...(webSearchConfig.env ?? {}) + }, + name: `fusion-web-search-${sanitizedProfileId}` + })); + } + } + } + } + + return { mcpServers, providers }; +} + +export async function fusionBuiltinToolArtifactsForTest( + profiles: unknown[], + coreEndpoint: string, + coreAuthToken: string, + browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration +): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: unknown[] }> { + return fusionBuiltinToolArtifacts(profiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration); +} + +function fusionBuiltinMcpServer({ + entry, + env, + name +}: { + entry: string; + env: Record; + name: string; +}): GatewayMcpServerConfig { + return { + args: [entry], + command: process.execPath, + env: { + ELECTRON_RUN_AS_NODE: "1", + ...env + }, + name, + protocolVersion: "2024-11-05", + requestTimeoutMs: 600000, + startupTimeoutMs: 600000, + stdioMessageMode: "content-length", + transport: "stdio" + }; +} + +function bundledFusionBuiltinMcpEntryPath(): string { + return pathJoin(__dirname, "fusion-vision-mcp.js"); +} + +function fusionToolFallbackMcpServer( + profiles: unknown[], + existingServers: unknown[] +): GatewayMcpServerConfig | undefined { + const tools = fusionFallbackToolDefinitions(profiles, fusionToolNamesBackedByMcpServers(existingServers)); + if (tools.length === 0) { + return undefined; + } + + return { + args: [bundledFusionToolFallbackMcpEntryPath()], + command: process.execPath, + env: { + ELECTRON_RUN_AS_NODE: "1", + FUSION_FALLBACK_TOOLS_JSON: JSON.stringify(tools) + }, + name: uniqueMcpServerName("ccr-fusion-tool-fallback", existingServers), + protocolVersion: "2024-11-05", + requestTimeoutMs: 600000, + startupTimeoutMs: 600000, + stdioMessageMode: "content-length", + transport: "stdio" + }; +} + +function bundledFusionToolFallbackMcpEntryPath(): string { + return pathJoin(__dirname, "fusion-tool-fallback-mcp.js"); +} + +function toolHubMcpServer(config: AppConfig, backendServers: unknown[]): GatewayMcpServerConfig | undefined { + const toolHub = config.toolHub; + const runtimeBackendServers = [ + ...toolHubBuiltInBackendServers(config), + ...backendServers + ]; + const runtimeConfig = toolHubMcpRuntimeConfig(config, runtimeBackendServers); + if (!toolHub?.enabled || !runtimeConfig) { + return undefined; + } + + return { + ...runtimeConfig, + name: uniqueMcpServerName(TOOL_HUB_MCP_SERVER_NAME, runtimeBackendServers), + protocolVersion: "2024-11-05", + requestTimeoutMs: toolHubRequestTimeoutMs(config, runtimeBackendServers), + startupTimeoutMs: 600000, + stdioMessageMode: "content-length", + transport: "stdio" + }; +} + +export function fusionFallbackToolDefinitions( + profiles: unknown[], + backedToolNames: Set = new Set() +): FusionFallbackToolDefinition[] { + const byName = new Map(); + + for (const profile of profiles) { + if (!isRecord(profile) || profile.enabled === false) { + continue; + } + + if (Array.isArray(profile.tools)) { + for (const tool of profile.tools) { + if (!isRecord(tool)) { + continue; + } + const name = stringValue(tool.name); + if (!name) { + continue; + } + if (backedToolNames.has(name)) { + continue; + } + + const existing = byName.get(name); + const description = stringValue(tool.description); + const inputSchema = isRecord(tool.inputSchema) + ? tool.inputSchema + : isRecord(tool.input_schema) + ? tool.input_schema + : undefined; + const unavailableMessage = fusionFallbackToolUnavailableMessage(profile, name); + if (existing) { + if (!existing.description && description) { + existing.description = description; + } + if (!existing.inputSchema && inputSchema) { + existing.inputSchema = inputSchema; + } + if (!existing.unavailableMessage && unavailableMessage) { + existing.unavailableMessage = unavailableMessage; + } + continue; + } + + byName.set(name, { + ...(description ? { description } : {}), + ...(inputSchema ? { inputSchema } : {}), + ...(unavailableMessage ? { unavailableMessage } : {}), + name + }); + } + } + + const browserFallback = browserWebSearchFallbackToolDefinition(profile, backedToolNames); + if (browserFallback && !byName.has(browserFallback.name)) { + byName.set(browserFallback.name, browserFallback); + } + } + + return [...byName.values()]; +} + +type FusionFallbackToolDefinition = { + description?: string; + inputSchema?: Record; + name: string; + unavailableMessage?: string; +}; + +function fusionFallbackToolUnavailableMessage(profile: unknown, toolName: string): string | undefined { + if (!isRecord(profile)) { + return undefined; + } + const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; + const fusionWebSearch = isRecord(metadata?.fusionWebSearch) ? metadata.fusionWebSearch : undefined; + const webSearchConfig = readFusionWebSearchConfig(fusionWebSearch); + if (webSearchConfig?.provider !== "browser" || webSearchConfig.toolName !== toolName) { + return undefined; + } + return browserWebSearchUnavailableMessage(toolName); +} + +function browserWebSearchUnavailableMessage(toolName: string): string { + return [ + `Fusion MCP tool "${toolName}" is unavailable because In-app Browser web search requires CCR Desktop.`, + "This runtime did not register the Electron browser web search integration, so the hidden browser search tool cannot run here.", + "Run the profile in CCR Desktop or switch the Fusion web search provider to Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, or Exa." + ].join(" "); +} + +function browserWebSearchFallbackToolDefinition( + profile: Record, + backedToolNames: Set +): FusionFallbackToolDefinition | undefined { + const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; + const fusionWebSearch = isRecord(metadata?.fusionWebSearch) ? metadata.fusionWebSearch : undefined; + const webSearchConfig = readFusionWebSearchConfig(fusionWebSearch); + if (webSearchConfig?.provider !== "browser" || !webSearchConfig.toolName || backedToolNames.has(webSearchConfig.toolName)) { + return undefined; + } + return { + description: "Fallback registration for CCR In-app Browser web search when the Electron browser integration is unavailable.", + inputSchema: { + additionalProperties: true, + properties: { + count: { maximum: 20, minimum: 1, type: "number" }, + prompt: { type: "string" }, + query: { type: "string" } + }, + required: ["prompt"], + type: "object" + }, + name: webSearchConfig.toolName, + unavailableMessage: fusionFallbackToolUnavailableMessage(profile, webSearchConfig.toolName) + }; +} + +export function fusionToolNamesBackedByMcpServers(servers: unknown[]): Set { + const names = new Set(); + for (const server of servers) { + if (!isRecord(server)) { + continue; + } + const serverName = stringValue(server.name); + if (serverName) { + names.add(serverName); + } + + const env = isRecord(server.env) ? server.env : undefined; + const fusionToolName = stringValue(env?.FUSION_TOOL_NAME); + if (fusionToolName) { + names.add(fusionToolName); + } + } + return names; +} + +function uniqueMcpServerName(baseName: string, servers: unknown[]): string { + const used = new Set( + servers + .map((server) => isRecord(server) ? stringValue(server.name)?.toLowerCase() : undefined) + .filter((name): name is string => Boolean(name)) + ); + if (!used.has(baseName.toLowerCase())) { + return baseName; + } + for (let index = 2; ; index += 1) { + const candidate = `${baseName}-${index}`; + if (!used.has(candidate.toLowerCase())) { + return candidate; + } + } +} + +function withFusionVirtualModelAliases(profiles: unknown[]): unknown[] { + return profiles.map((profile) => { + if (!isRecord(profile)) { + return profile; + } + const match = isRecord(profile.match) ? profile.match : {}; + const exactAliases = stringListValue(match.exactAliases); + const catalogNames = exactAliases.length > 0 + ? exactAliases + : [stringValue(profile.key) || stringValue(profile.displayName)].filter((value): value is string => Boolean(value)); + const fusionAliases = catalogNames.flatMap(fusionModelSelectors).filter(Boolean); + if (fusionAliases.length === 0) { + return profile; + } + return { + ...profile, + match: { + ...match, + exactAliases: uniqueStrings([...exactAliases, ...fusionAliases]) + } + }; + }); +} + +function withCodexCompatibleVirtualModelProfiles(profiles: unknown[]): unknown[] { + return profiles.map((profile) => { + if (!isRecord(profile) || profile.enabled === false) { + return profile; + } + const materialization = isRecord(profile.materialization) ? profile.materialization : {}; + if (materialization.enabled === false || materialization.includeInGatewayModels === false) { + return profile; + } + const execution = isRecord(profile.execution) ? profile.execution : {}; + if (execution.clientToolsPolicy === "allow") { + return profile; + } + return { + ...profile, + execution: { + ...execution, + clientToolsPolicy: "allow" + } + }; + }); +} + +function fusionModelSelector(model: string): string { + const normalized = fusionModelNameFromSelector(model); + return normalized ? `${fusionModelProviderName}/${normalized}` : ""; +} + +function fusionModelSelectors(model: string): string[] { + const normalized = fusionModelNameFromSelector(model); + if (!normalized) { + return []; + } + const lowerModel = normalized.toLowerCase(); + return uniqueStrings([ + fusionModelSelector(normalized), + lowerModel, + `${fusionModelProviderName}/${lowerModel}`, + `${fusionModelProviderName.toLowerCase()}/${lowerModel}` + ]); +} + +function fusionModelNameFromSelector(model: string): string { + const trimmed = model.trim(); + const prefix = `${fusionModelProviderName}/`; + return trimmed.toLowerCase().startsWith(prefix.toLowerCase()) + ? trimmed.slice(prefix.length).trim() + : trimmed; +} + +function legacyFusionVisionConfig(profile: Record): VirtualModelFusionVisionConfig | undefined { + const toolName = legacyFusionBuiltinToolName(profile, BUILTIN_FUSION_VISION_TOOL_NAME, "matchMultimodal"); + return toolName ? { toolName } : undefined; +} + +function legacyFusionWebSearchConfig(profile: Record): VirtualModelFusionWebSearchConfig | undefined { + const toolName = legacyFusionBuiltinToolName(profile, BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, "matchWebSearch"); + return toolName ? { provider: defaultFusionWebSearchProvider, toolName } : undefined; +} + +function legacyFusionBuiltinToolName( + profile: Record, + baseToolName: string, + executionFlag: "matchMultimodal" | "matchWebSearch" +): string | undefined { + const tools = Array.isArray(profile.tools) ? profile.tools : []; + const toolName = tools + .map((tool) => isRecord(tool) ? stringValue(tool.name) ?? "" : "") + .find((name) => fusionBuiltinToolNameMatches(name, baseToolName)); + if (toolName) { + return toolName; + } + const execution = isRecord(profile.execution) ? profile.execution : {}; + return execution[executionFlag] === true ? baseToolName : undefined; +} + +function fusionBuiltinToolNameMatches(name: string, baseToolName: string): boolean { + if (name === baseToolName || name.startsWith(`${baseToolName}_`)) { + return true; + } + if (baseToolName !== BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME) { + return false; + } + return coreGatewayWebSearchToolNameMatches(name); +} + +function normalizeFusionWebSearchProfileToolName(profile: Record): Record | undefined { + const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; + const fusionWebSearch = isRecord(metadata?.fusionWebSearch) ? metadata.fusionWebSearch : undefined; + const configuredToolName = stringValue(fusionWebSearch?.toolName); + const legacyToolName = configuredToolName ? undefined : legacyFusionWebSearchConfig(profile)?.toolName; + const toolName = configuredToolName || legacyToolName; + if (!toolName) { + return undefined; + } + + const nextToolName = coreGatewayCompatibleWebSearchToolName(toolName, stringValue(profile.key) || stringValue(profile.id)); + if (nextToolName === toolName) { + return undefined; + } + + const tools = Array.isArray(profile.tools) + ? profile.tools.map((tool) => { + if (!isRecord(tool) || stringValue(tool.name) !== toolName) { + return tool; + } + return { + ...tool, + name: nextToolName + }; + }) + : profile.tools; + + return { + ...profile, + ...(metadata && fusionWebSearch + ? { + metadata: { + ...metadata, + fusionWebSearch: { + ...fusionWebSearch, + toolName: nextToolName + } + } + } + : {}), + ...(tools ? { tools } : {}) + }; +} + +function withFusionWebSearchToolInstructions(profile: Record): Record | undefined { + const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; + const fusionWebSearch = isRecord(metadata?.fusionWebSearch) ? metadata.fusionWebSearch : undefined; + const toolName = stringValue(fusionWebSearch?.toolName) || legacyFusionWebSearchConfig(profile)?.toolName; + if (!toolName) { + return undefined; + } + const execution = isRecord(profile.execution) ? profile.execution : {}; + if (execution.matchWebSearch !== true) { + return undefined; + } + + const instruction = [ + `When the client request includes a hosted web_search tool declaration, call the ${toolName} function tool before answering.`, + "Pass the user's search query in the prompt field.", + "Do not use provider-native web search or claim that web search is unavailable unless this function tool returns an error." + ].join(" "); + const instructions = isRecord(profile.instructions) ? profile.instructions : {}; + if ([instructions.prepend, instructions.append, instructions.replace].some((value) => stringValue(value)?.includes(instruction))) { + return undefined; + } + const replace = stringValue(instructions.replace); + const append = stringValue(instructions.append); + return { + ...profile, + instructions: { + ...instructions, + ...(replace + ? { replace: `${replace.trim()}\n\n${instruction}` } + : { append: [append, instruction].filter(Boolean).join("\n\n") }) + } + }; +} + +function coreGatewayCompatibleWebSearchToolName(toolName: string, fallbackName?: string): string { + if (coreGatewayWebSearchToolNameMatches(toolName)) { + return toolName; + } + + const normalized = sanitizeFusionToolName(toolName); + const prefix = `${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}_`; + if (normalized.startsWith(prefix) && normalized.length > prefix.length) { + return truncateFusionToolName(`${normalized.slice(prefix.length)}_${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`); + } + + const fallback = sanitizeFusionToolName(fallbackName || normalized || "fusion"); + return truncateFusionToolName(`${fallback}_${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`); +} + +function coreGatewayWebSearchToolNameMatches(name: string): boolean { + const normalized = name.toLowerCase().replace(/[-.]/g, "_"); + return normalized === BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME || + normalized.endsWith(`_${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`) || + normalized.includes("search_web"); +} + +function sanitizeFusionToolName(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9_]+/g, "_") + .replace(/^_+|_+$/g, "") || "fusion"; +} + +function truncateFusionToolName(value: string): string { + const maxToolNameLength = 64; + if (value.length <= maxToolNameLength) { + return value; + } + const suffix = `_${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`; + const available = Math.max(1, maxToolNameLength - suffix.length); + return `${value.slice(0, available).replace(/_+$/g, "")}${suffix}`; +} + +function readFusionVisionConfig(value: unknown): VirtualModelFusionVisionConfig | undefined { + if (!isRecord(value)) { + return undefined; + } + const toolName = stringValue(value.toolName); + if (!toolName) { + return undefined; + } + const config: VirtualModelFusionVisionConfig = { + toolName, + apiKey: stringValue(value.apiKey), + baseUrl: stringValue(value.baseUrl), + model: stringValue(value.model), + modelSelector: stringValue(value.modelSelector) + }; + const timeoutMs = numberValue(value.timeoutMs); + if (timeoutMs) { + config.timeoutMs = timeoutMs; + } + return config; +} + +function readFusionWebSearchConfig(value: unknown): VirtualModelFusionWebSearchConfig | undefined { + if (!isRecord(value)) { + return undefined; + } + const toolName = stringValue(value.toolName); + if (!toolName) { + return undefined; + } + const config: VirtualModelFusionWebSearchConfig = { + toolName, + env: isRecord(value.env) ? stringRecordFromUnknown(value.env) : undefined, + provider: parseFusionWebSearchProvider(value.provider) + }; + const resultCount = numberValue(value.resultCount); + if (resultCount) { + config.resultCount = resultCount; + } + const timeoutMs = numberValue(value.timeoutMs); + if (timeoutMs) { + config.timeoutMs = timeoutMs; + } + return config; +} + +function resolveFusionVisionRuntime( + config: VirtualModelFusionVisionConfig +): { model?: string; providers: CoreGatewayProvider[] } { + const selector = config.modelSelector || config.model; + if (config.baseUrl) { + return { + model: config.model || config.modelSelector, + providers: [] + }; + } + + const parsed = parseFusionModelSelector(selector); + if (!parsed) { + return { + model: selector ? normalizeGatewayModelSelector(selector) : undefined, + providers: [] + }; + } + + return { + model: `${parsed.providerName}/${parsed.model}`, + providers: [] + }; +} + +function parseFusionModelSelector(value: string | undefined): { model: string; providerName: string } | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + const commaIndex = trimmed.indexOf(","); + if (commaIndex > 0 && commaIndex < trimmed.length - 1) { + const providerName = trimmed.slice(0, commaIndex).trim(); + const model = trimmed.slice(commaIndex + 1).trim(); + return providerName && model ? { model, providerName } : undefined; + } + const slashIndex = trimmed.indexOf("/"); + if (slashIndex > 0 && slashIndex < trimmed.length - 1) { + const providerName = trimmed.slice(0, slashIndex).trim(); + const model = trimmed.slice(slashIndex + 1).trim(); + return providerName && model ? { model, providerName } : undefined; + } + return undefined; +} + +function normalizeGatewayModelSelector(value: string): string { + const parsed = parseFusionModelSelector(value); + return parsed ? `${parsed.providerName}/${parsed.model}` : value.trim(); +} + +function parseFusionWebSearchProvider(value: unknown): VirtualModelFusionWebSearchProvider | undefined { + const normalized = stringValue(value)?.toLowerCase(); + if ( + normalized === "brave" || + normalized === "bing" || + normalized === "google_cse" || + normalized === "serper" || + normalized === "serpapi" || + normalized === "tavily" || + normalized === "exa" || + normalized === "browser" + ) { + return normalized; + } + return undefined; +} + +function stringRecordFromUnknown(value: Record): Record | undefined { + const result: Record = {}; + for (const [key, rawValue] of Object.entries(value)) { + const normalizedKey = key.trim(); + const normalizedValue = stringValue(rawValue); + if (normalizedKey && normalizedValue) { + result[normalizedKey] = normalizedValue; + } + } + return Object.keys(result).length ? result : undefined; +} + +function sanitizeMcpServerName(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80) || "fusion"; +} + +function buildRawTraceConfig(config: AppConfig, rawTraceSyncToken: string): Record { + const enabled = rawTraceEnabledFromEnv() && shouldRecordRequestLogs(config); + return { + deleteLocalAfterUpload: false, + enabled, + maxPartBytes: maxUsageCaptureBytes, + mode: "wire_raw", + spoolDir: RAW_TRACE_SPOOL_DIR, + sync: { + enabled, + endpoint: `${endpoint(config.gateway.host, config.gateway.port)}${rawTraceSyncPath}`, + headers: { + [rawTraceSyncHeader]: rawTraceSyncToken + }, + timeoutMs: 5000 + } + }; +} + +function shouldRecordRequestLogs(config: AppConfig): boolean { + return Boolean(config.observability?.requestLogs || config.observability?.agentAnalysis); +} + +function rawTraceEnabledFromEnv(): boolean { + const value = (process.env.CCR_RAW_TRACE_ENABLED ?? process.env.CCR_RAW_TRACE ?? "").trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes" || value === "on"; +} + +function readRawTraceRequestLogUpdate(manifest: Record): RequestLogRawTraceUpdateInput | undefined { + const requestId = stringValue(manifest.turnKey); + const parts = Array.isArray(manifest.parts) + ? manifest.parts.filter((part): part is Record => isRecord(part)) + : []; + if (!requestId || parts.length === 0) { + return undefined; + } + + const upstreamRequestMetadata = readRawTraceJsonPart(parts, "upstream_request_metadata"); + const upstreamResponseMetadata = readRawTraceJsonPart(parts, "upstream_response_metadata"); + const upstreamRequestBody = readRawTraceTextPart(parts, "upstream_request"); + const upstreamResponseStream = readRawTraceTextPart(parts, "response_stream"); + const upstreamResponseBody = upstreamResponseStream ?? readRawTraceTextPart(parts, "upstream_response"); + const target = isRecord(manifest.target) ? manifest.target : {}; + const rawUrl = stringValue(upstreamRequestMetadata?.url); + const url = sanitizeUrlForLog(rawUrl); + + return { + method: stringValue(upstreamRequestMetadata?.method) || "POST", + model: stringValue(target.model), + path: pathFromUrl(url), + provider: stringValue(target.providerName) || stringValue(target.provider), + requestBodyContentType: upstreamRequestBody?.contentType, + requestBodyText: upstreamRequestBody?.text, + requestHeaders: headerRecordFromUnknown(upstreamRequestMetadata?.headers), + requestId, + isStream: upstreamResponseStream !== undefined, + responseBodyContentType: upstreamResponseBody?.contentType, + responseBodyText: upstreamResponseBody?.text, + responseHeaders: headerRecordFromUnknown(upstreamResponseMetadata?.headers), + statusCode: numberValue(upstreamResponseMetadata?.statusCode), + url + }; +} + +function readRawTraceJsonPart(parts: Record[], partType: string): Record | undefined { + const text = readRawTraceTextPart(parts, partType)?.text; + if (!text) { + return undefined; + } + try { + const parsed = JSON.parse(text) as unknown; + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function readRawTraceTextPart(parts: Record[], partType: string): RawTracePartText | undefined { + const part = parts.find((candidate) => stringValue(candidate.partType) === partType); + const filePath = stringValue(part?.filePath); + if (!filePath || !isRawTraceSpoolFile(filePath)) { + return undefined; + } + try { + return { + contentType: stringValue(part?.contentType), + text: readFileSync(filePath, "utf8") + }; + } catch (error) { + console.warn(`[gateway] Failed to read raw trace part ${partType}: ${formatError(error)}`); + return undefined; + } +} + +function cleanupRawTraceBundle(manifest: Record): void { + const parts = Array.isArray(manifest.parts) + ? manifest.parts.filter((part): part is Record => isRecord(part)) + : []; + const firstFilePath = parts.map((part) => stringValue(part.filePath)).find((value): value is string => Boolean(value)); + if (!firstFilePath || !isRawTraceSpoolFile(firstFilePath)) { + return; + } + try { + rmSync(dirname(firstFilePath), { force: true, recursive: true }); + } catch (error) { + console.warn(`[gateway] Failed to clean raw trace bundle: ${formatError(error)}`); + } +} + +function isRawTraceSpoolFile(filePath: string): boolean { + const spoolDir = pathResolve(RAW_TRACE_SPOOL_DIR); + const resolvedFile = pathResolve(filePath); + return dirname(resolvedFile) !== spoolDir && resolvedFile.startsWith(`${spoolDir}${pathSep}`); +} + +function headerRecordFromUnknown(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const headers: Record = {}; + for (const [key, headerValue] of Object.entries(value)) { + if (headerValue === undefined || headerValue === null) { + continue; + } + headers[key] = Array.isArray(headerValue) + ? headerValue.map((item) => String(item)).join(", ") + : String(headerValue); + } + return headers; +} + +function sanitizeUrlForLog(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + try { + const url = new URL(value); + for (const key of [...url.searchParams.keys()]) { + if (isSensitiveQueryParam(key)) { + url.searchParams.set(key, "[redacted]"); + } + } + return url.toString(); + } catch { + return value; + } +} + +function isSensitiveQueryParam(value: string): boolean { + const normalized = value.trim().toLowerCase(); + return normalized === "key" || normalized === "api_key" || normalized === "apikey" || normalized === "access_token"; +} + +function pathFromUrl(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + try { + return new URL(value).pathname || undefined; + } catch { + return undefined; + } +} + +function createBodySampler() { + const chunks: Buffer[] = []; + let totalBytes = 0; + let truncated = false; + + return { + append(chunk: Buffer | string) { + if (truncated) { + return; + } + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + if (totalBytes + buffer.byteLength > maxUsageCaptureBytes) { + const remaining = Math.max(0, maxUsageCaptureBytes - totalBytes); + if (remaining > 0) { + chunks.push(buffer.subarray(0, remaining)); + totalBytes += remaining; + } + truncated = true; + return; + } + chunks.push(buffer); + totalBytes += buffer.byteLength; + }, + isTruncated() { + return truncated; + }, + read() { + return Buffer.concat(chunks, totalBytes).toString("utf8"); + } + }; +} + +function applyProviderCapabilityRouting(input: { + body?: Buffer; + config: AppConfig; + fallback: RouterFallbackConfig; + headers: Record; + path: string; + routedModel?: string; +}): { body?: Buffer; fallback: RouterFallbackConfig; routedModel?: string } { + const protocol = requestProtocolForPath(input.path); + if (!protocol) { + return { + body: input.body, + fallback: input.fallback, + routedModel: input.routedModel + }; + } + + rewriteProviderHeader(input.headers, "x-target-provider", input.config, protocol); + rewriteProviderListHeader(input.headers, "x-target-providers", input.config, protocol); + rewriteProviderHeader(input.headers, "x-gateway-target-provider", input.config, protocol); + + const routedModel = rewriteModelSelectorForProtocol(input.routedModel, input.config, protocol); + const fallback = rewriteFallbackForProtocol(input.fallback, input.config, protocol); + const body = rewriteBodyModelForProtocol(input.body, input.config, protocol); + clearTargetProviderHeadersForModelSelector(input.headers, input.config, body, routedModel); + + return { + body, + fallback, + routedModel + }; +} + +export function prepareGatewayUpstreamAttemptForTest(input: { + body: Record; + config: AppConfig; + fallback?: RouterFallbackConfig; + headers: Record; + method: string; + path: string; + routedModel?: string; +}): { + body?: Record; + credentialChain?: string[]; + credentialIds?: string[]; + credentialProtocol?: GatewayProviderProtocol; + fallback: RouterFallbackConfig; + headers?: Record; + logicalProvider?: string; + model?: string; + routedModel?: string; +} { + const headers = { ...input.headers }; + const providerCapabilityRouting = applyProviderCapabilityRouting({ + body: Buffer.from(`${JSON.stringify(input.body)}\n`, "utf8"), + config: input.config, + fallback: input.fallback ?? input.config.Router.fallback, + headers, + path: input.path, + routedModel: input.routedModel + }); + const attempt = prepareUpstreamCredentialAttempt({ + attempt: { + body: providerCapabilityRouting.body, + index: 0, + model: normalizeRouteSelector(providerCapabilityRouting.routedModel) + }, + config: input.config, + headers, + method: input.method, + path: input.path + }); + return { + body: parseJsonObjectSafe(attempt.body), + credentialChain: attempt.credentialChain, + credentialIds: attempt.credentialIds, + credentialProtocol: attempt.credentialProtocol, + fallback: providerCapabilityRouting.fallback, + headers: attempt.headers, + logicalProvider: attempt.logicalProvider, + model: attempt.model, + routedModel: providerCapabilityRouting.routedModel + }; +} + +export function prepareCodexApplyPatchBridgeRequest(input: { + body?: Buffer; + config: AppConfig; + headers: IncomingHttpHeaders; + method: string; + path: string; + routedModel?: string; +}): { body: Buffer; diagnostic: string } | undefined { + if (!codexApplyPatchBridgeEnabled(input.config, input.headers, input.method, input.path)) { + return undefined; + } + const parsedBody = parseJsonObjectSafe(input.body); + if (!parsedBody) { + return undefined; + } + const model = input.routedModel || stringValue(parsedBody.model); + if (!codexPatchBridgeModelEligible(model)) { + return undefined; + } + const transformed = transformCodexApplyPatchBridgeRequestBody(parsedBody); + if (!transformed.changed) { + return undefined; + } + return { + body: Buffer.from(`${JSON.stringify(transformed.body)}\n`, "utf8"), + diagnostic: `${model ?? "unknown"}:${transformed.changedParts.join(",")}` + }; +} + +export function transformCodexApplyPatchBridgeRequestBody(body: Record): { + body: Record; + changed: boolean; + changedParts: string[]; +} { + const next = { ...body }; + const changedParts: string[] = []; + const tools = transformCodexApplyPatchBridgeTools(body.tools); + if (tools.changed) { + next.tools = tools.value; + changedParts.push("tools"); + const instructions = transformCodexApplyPatchBridgeInstructions(body.instructions); + if (instructions.changed) { + next.instructions = instructions.value; + changedParts.push("instructions"); + } + const input = transformCodexApplyPatchBridgeInput(body.input); + if (input.changed) { + next.input = input.value; + changedParts.push("input"); + } + } + return { + body: next, + changed: changedParts.length > 0, + changedParts + }; +} + +function transformCodexApplyPatchBridgeTools(value: unknown): { value: unknown; changed: boolean } { + if (!Array.isArray(value)) { + return { value, changed: false }; + } + const hasApplyPatchTool = value.some((tool) => isRecord(tool) && tool.type === "custom" && tool.name === "apply_patch"); + if (!hasApplyPatchTool) { + return { value, changed: false }; + } + let changed = false; + const tools = value.map((tool) => { + if (isRecord(tool) && tool.type === "custom" && tool.name === "apply_patch") { + changed = true; + return virtualApplyPatchToolSpec(); + } + const shellTool = transformCodexPatchBridgeShellTool(tool); + if (shellTool.changed) { + changed = true; + return shellTool.value; + } + return tool; + }); + return { value: tools, changed }; +} + +function transformCodexApplyPatchBridgeInstructions(value: unknown): { value: unknown; changed: boolean } { + const text = rawStringValue(value); + if (text === undefined) { + return value === undefined + ? { value: codexPatchBridgeInstructionText, changed: true } + : { value, changed: false }; + } + if (text.includes(codexPatchBridgeInstructionText)) { + return { value, changed: false }; + } + return { + value: `${text.trimEnd()}\n\n${codexPatchBridgeInstructionText}`, + changed: true + }; +} + +function transformCodexPatchBridgeShellTool(value: unknown): { value: unknown; changed: boolean } { + if (!isRecord(value) || value.type !== "function") { + return { value, changed: false }; + } + const name = stringValue(value.name); + if (name !== "exec_command" && name !== "write_stdin") { + return { value, changed: false }; + } + let changed = false; + const next: Record = { ...value }; + const description = rawStringValue(value.description) ?? ""; + if (!description.includes(codexPatchBridgeShellToolGuidance)) { + next.description = description + ? `${description} ${codexPatchBridgeShellToolGuidance}` + : codexPatchBridgeShellToolGuidance; + changed = true; + } + if (name === "exec_command") { + const parameters = transformCodexPatchBridgeExecCommandParameters(value.parameters); + if (parameters.changed) { + next.parameters = parameters.value; + changed = true; + } + } + return { value: changed ? next : value, changed }; +} + +function transformCodexPatchBridgeExecCommandParameters(value: unknown): { value: unknown; changed: boolean } { + if (!isRecord(value) || !isRecord(value.properties) || !isRecord(value.properties.cmd)) { + return { value, changed: false }; + } + const cmd = value.properties.cmd; + const description = rawStringValue(cmd.description) ?? ""; + if (description.includes(codexPatchBridgeShellToolGuidance)) { + return { value, changed: false }; + } + return { + value: { + ...value, + properties: { + ...value.properties, + cmd: { + ...cmd, + description: description + ? `${description} ${codexPatchBridgeShellToolGuidance}` + : codexPatchBridgeShellToolGuidance + } + } + }, + changed: true + }; +} + +function transformCodexApplyPatchBridgeInput(value: unknown): { value: unknown; changed: boolean } { + if (!Array.isArray(value)) { + return { value, changed: false }; + } + const applyPatchCallIds = new Set(); + for (const item of value) { + if (isRecord(item) && item.type === "custom_tool_call" && item.name === "apply_patch") { + const callId = stringValue(item.call_id); + if (callId) { + applyPatchCallIds.add(callId); + } + } + } + let changed = false; + const items = value.map((item) => { + const transformed = transformCodexApplyPatchBridgeInputItem(item, applyPatchCallIds); + changed ||= transformed.changed; + return transformed.value; + }); + return { value: items, changed }; +} + +function transformCodexApplyPatchBridgeInputItem(value: unknown, applyPatchCallIds: Set): { value: unknown; changed: boolean } { + if (!isRecord(value)) { + return { value, changed: false }; + } + if (value.type === "custom_tool_call" && value.name === "apply_patch") { + const { input: patchInput, name: _name, type: _type, ...rest } = value; + return { + value: { + ...rest, + type: "function_call", + name: virtualApplyPatchToolName, + arguments: JSON.stringify({ patch: rawStringValue(patchInput) ?? "" }) + }, + changed: true + }; + } + if ( + value.type === "custom_tool_call_output" && + (applyPatchCallIds.has(stringValue(value.call_id) ?? "") || value.name === "apply_patch") + ) { + const { name: _name, type: _type, ...rest } = value; + return { + value: { + ...rest, + type: "function_call_output" + }, + changed: true + }; + } + return { value, changed: false }; +} + +function virtualApplyPatchToolSpec(): Record { + return { + type: "function", + name: virtualApplyPatchToolName, + description: [ + "Edit files by returning exactly one complete apply_patch patch.", + "The patch field must be raw patch grammar text starting with *** Begin Patch and ending with *** End Patch.", + "Do not wrap the patch in JSON, markdown fences, shell commands, cat, sed, perl, or python.", + "The patch field must match this Lark grammar:", + virtualApplyPatchLarkGrammar + ].join("\n\n"), + strict: true, + parameters: { + type: "object", + additionalProperties: false, + required: ["patch"], + properties: { + patch: { + type: "string", + description: [ + "Raw apply_patch grammar text matching this Lark grammar:", + virtualApplyPatchLarkGrammar + ].join("\n\n") + } + } + } + }; +} + +function codexApplyPatchBridgeEnabled(config: AppConfig, headers: IncomingHttpHeaders, method: string, path: string): boolean { + const codexRule = config.Router.builtInRules?.codex; + return (method || "GET").toUpperCase() === "POST" && + requestProtocolForPath(path) === "openai_responses" && + isCodexUserAgent(headers) && + codexRule?.enabled !== false; +} + +function isCodexUserAgent(headers: IncomingHttpHeaders): boolean { + return readHeader(headers["user-agent"])?.toLowerCase().includes("codex") ?? false; +} + +function codexPatchBridgeModelEligible(model: string | undefined): boolean { + const modelName = modelNameForPatchBridge(model); + return Boolean(modelName) && !modelName.toLowerCase().includes("gpt"); +} + +function modelNameForPatchBridge(model: string | undefined): string { + const normalized = normalizeRouteSelector(model) ?? ""; + const slashIndex = normalized.lastIndexOf("/"); + return slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized; +} + +function codexApplyPatchBridgeResponseStream(input: Readable, headers: Headers): Readable { + const contentType = headers.get("content-type")?.toLowerCase() ?? ""; + if (contentType.includes("text/event-stream")) { + return input.pipe(new Transform({ + transform(chunk, _encoding, callback) { + transformSseChunk(this, chunk); + callback(); + }, + flush(callback) { + flushSseTransform(this); + callback(); + } + })); + } + if (contentType.includes("application/json")) { + const chunks: Buffer[] = []; + return input.pipe(new Transform({ + transform(chunk, _encoding, callback) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + callback(); + }, + flush(callback) { + const raw = Buffer.concat(chunks).toString("utf8"); + try { + const parsed = JSON.parse(raw); + const transformed = transformCodexApplyPatchBridgeResponseValue(parsed); + this.push(Buffer.from(`${JSON.stringify(transformed.value)}\n`, "utf8")); + } catch { + this.push(Buffer.from(raw, "utf8")); + } + callback(); + } + })); + } + return input; +} + +export function transformCodexApplyPatchBridgeResponseValue(value: unknown): { value: unknown; changed: boolean } { + if (!isRecord(value)) { + return { value, changed: false }; + } + let changed = false; + const next = { ...value }; + if (isRecord(value.item)) { + const item = transformVirtualApplyPatchFunctionCall(value.item, value.type === "response.output_item.added"); + if (item.changed) { + next.item = item.value; + changed = true; + } + } + if (Array.isArray(value.output)) { + const output = transformCodexApplyPatchBridgeResponseItems(value.output); + if (output.changed) { + next.output = output.value; + changed = true; + } + } + if (isRecord(value.response) && Array.isArray(value.response.output)) { + const output = transformCodexApplyPatchBridgeResponseItems(value.response.output); + if (output.changed) { + next.response = { + ...value.response, + output: output.value + }; + changed = true; + } + } + const item = transformVirtualApplyPatchFunctionCall(next, false); + if (item.changed) { + return item; + } + return { value: next, changed }; +} + +function transformCodexApplyPatchBridgeResponseItems(items: unknown[]): { value: unknown[]; changed: boolean } { + let changed = false; + const value = items.map((item) => { + const transformed = isRecord(item) + ? transformVirtualApplyPatchFunctionCall(item, false) + : { value: item, changed: false }; + changed ||= transformed.changed; + return transformed.value; + }); + return { value, changed }; +} + +function transformVirtualApplyPatchFunctionCall(item: Record, allowEmptyInput: boolean): { value: unknown; changed: boolean } { + if (item.type !== "function_call" || item.name !== virtualApplyPatchToolName) { + return { value: item, changed: false }; + } + const patch = patchInputFromVirtualApplyPatchArguments(item.arguments); + if (patch === undefined && !allowEmptyInput) { + return { value: item, changed: false }; + } + const { arguments: _arguments, name: _name, type: _type, ...rest } = item; + return { + value: { + ...rest, + type: "custom_tool_call", + name: "apply_patch", + input: patch ?? "" + }, + changed: true + }; +} + +function patchInputFromVirtualApplyPatchArguments(value: unknown): string | undefined { + if (isRecord(value)) { + return rawStringValue(value.patch); + } + const text = rawStringValue(value); + if (text === undefined) { + return undefined; + } + try { + const parsed = JSON.parse(text); + return isRecord(parsed) ? rawStringValue(parsed.patch) : undefined; + } catch { + return undefined; + } +} + +function transformSseChunk(stream: Transform, chunk: Buffer | string): void { + const state = stream as Transform & { __ccrCodexPatchBridgeSsePending?: string }; + state.__ccrCodexPatchBridgeSsePending = (state.__ccrCodexPatchBridgeSsePending ?? "") + chunk.toString(); + while (state.__ccrCodexPatchBridgeSsePending) { + const match = /\r?\n\r?\n/.exec(state.__ccrCodexPatchBridgeSsePending); + if (!match || match.index === undefined) { + break; + } + const block = state.__ccrCodexPatchBridgeSsePending.slice(0, match.index); + const delimiter = match[0]; + state.__ccrCodexPatchBridgeSsePending = state.__ccrCodexPatchBridgeSsePending.slice(match.index + delimiter.length); + stream.push(transformCodexApplyPatchBridgeSseEvent(block) + delimiter); + } +} + +function flushSseTransform(stream: Transform): void { + const state = stream as Transform & { __ccrCodexPatchBridgeSsePending?: string }; + if (state.__ccrCodexPatchBridgeSsePending) { + stream.push(transformCodexApplyPatchBridgeSseEvent(state.__ccrCodexPatchBridgeSsePending)); + state.__ccrCodexPatchBridgeSsePending = ""; + } +} + +export function transformCodexApplyPatchBridgeSseEvent(block: string): string { + const lines = block.split(/\r?\n/g); + const data = lines + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).replace(/^ /, "")) + .join("\n"); + if (!data || data === "[DONE]") { + return block; + } + try { + const parsed = JSON.parse(data); + const transformed = transformCodexApplyPatchBridgeResponseValue(parsed); + if (!transformed.changed) { + return block; + } + const event = stringValue((transformed.value as Record).type) || stringValue(parsed.type); + return [ + event ? `event: ${event}` : undefined, + `data: ${JSON.stringify(transformed.value)}` + ].filter(Boolean).join("\n"); + } catch { + return block; + } +} + +function createHostedWebSearchProtocolContext(input: { + body: Buffer | undefined; + config: AppConfig; + method: string; + path: string; + requestId: string; + routedModel?: string; + sinceMs: number; +}): HostedWebSearchProtocolContext | undefined { + const protocol = requestProtocolForPath(input.path); + if (input.method !== "POST" || !protocol) { + return undefined; + } + const body = parseJsonObjectSafe(input.body); + if (!body || !hasHostedWebSearchDeclaration(body, protocol)) { + return undefined; + } + const toolName = fusionWebSearchToolNameForRequest(input.config, stringValue(body.model) || input.routedModel); + if (!toolName) { + return undefined; + } + return { + maxUses: readHostedWebSearchMaxUses(body, protocol), + protocol, + queryHint: extractHostedWebSearchQueryHint(body, protocol), + requestId: input.requestId, + sinceMs: input.sinceMs, + toolName + }; +} + +function createAnthropicWebSearchProtocolContext(input: { + body: Buffer | undefined; + config: AppConfig; + method: string; + path: string; + requestId: string; + sinceMs: number; +}): AnthropicWebSearchProtocolContext | undefined { + const context = createHostedWebSearchProtocolContext(input); + return context?.protocol === "anthropic_messages" ? context : undefined; +} + +function createClaudeCodeWebSearchContinuationContext(input: { + body: Buffer | undefined; + config: AppConfig; + method: string; + path: string; + routedModel?: string; + sinceMs: number; +}): ClaudeCodeWebSearchContinuationContext | undefined { + if (input.method !== "POST" || requestProtocolForPath(input.path) !== "anthropic_messages") { + return undefined; + } + const body = parseJsonObjectSafe(input.body); + if (!body || claudeCodeWebSearchToolResultTexts(body).length === 0) { + return undefined; + } + const toolName = fusionWebSearchToolNameForRequest(input.config, stringValue(body.model) || input.routedModel); + if (!toolName) { + return undefined; + } + return { + queryHint: extractClaudeCodeWebSearchToolResultQuery(body) || extractAnthropicWebSearchQueryHint(body), + sinceMs: input.sinceMs, + toolName + }; +} + +export function prepareHostedWebSearchProtocolRequestBody( + body: Buffer | undefined, + records: BrowserWebSearchProtocolRecord[], + context: Pick +): Buffer | undefined { + if (context.protocol === "anthropic_messages") { + return prepareAnthropicWebSearchProtocolRequestBody(body, records, context); + } + const parsed = parseJsonObjectSafe(body); + if (!parsed || records.length === 0) { + return undefined; + } + const evidence = hostedWebSearchEvidenceText(records, context.queryHint); + if (!evidence) { + return undefined; + } + let next: Record | undefined; + if (context.protocol === "openai_chat_completions") { + next = prepareOpenAiChatHostedWebSearchRequestBody(parsed, evidence); + } else if (context.protocol === "openai_responses") { + next = prepareOpenAiResponsesHostedWebSearchRequestBody(parsed, evidence); + } else if (context.protocol === "gemini_generate_content") { + next = prepareGeminiHostedWebSearchRequestBody(parsed, evidence); + } + return next ? Buffer.from(`${JSON.stringify(next)}\n`, "utf8") : undefined; +} + +export function prepareAnthropicWebSearchProtocolRequestBody( + body: Buffer | undefined, + records: BrowserWebSearchProtocolRecord[], + context: Pick +): Buffer | undefined { + const parsed = parseJsonObjectSafe(body); + if (!parsed || records.length === 0) { + return undefined; + } + const evidence = hostedWebSearchEvidenceText(records, context.queryHint); + if (!evidence) { + return undefined; + } + const next = applyAnthropicWebSearchSynthesisControls(stripAnthropicHostedWebSearchTools({ + ...parsed, + system: appendAnthropicSystemText(parsed.system, evidence) + })); + return Buffer.from(`${JSON.stringify(next)}\n`, "utf8"); +} + +export function prepareClaudeCodeWebSearchContinuationRequestBody( + body: Buffer | undefined, + records: BrowserWebSearchProtocolRecord[], + context: Pick +): Buffer | undefined { + const parsed = parseJsonObjectSafe(body); + if (!parsed) { + return undefined; + } + const toolResultTexts = claudeCodeWebSearchToolResultTexts(parsed); + if (toolResultTexts.length === 0) { + return undefined; + } + const queryHint = context.queryHint || extractClaudeCodeWebSearchToolResultQuery(parsed) || extractAnthropicWebSearchQueryHint(parsed); + const evidence = claudeCodeWebSearchContinuationEvidenceText(records, queryHint, toolResultTexts); + if (!evidence) { + return undefined; + } + const next = applyAnthropicWebSearchSynthesisControls(stripClaudeCodeWebSearchContinuationTools({ + ...parsed, + system: appendAnthropicSystemText(parsed.system, evidence) + })); + return Buffer.from(`${JSON.stringify(next)}\n`, "utf8"); +} + +function applyAnthropicWebSearchSynthesisControls(body: Record): Record { + const next = { ...body }; + const outputConfig = isRecord(next.output_config) ? { ...next.output_config } : {}; + outputConfig.effort = "low"; + next.output_config = outputConfig; + delete next.thinking; + delete next.reasoning; + return next; +} + +function prepareOpenAiChatHostedWebSearchRequestBody(body: Record, evidence: string): Record { + const next = stripOpenAiHostedWebSearchTools({ + ...body, + messages: appendOpenAiChatSystemText(body.messages, evidence) + }); + return applyOpenAiHostedWebSearchSynthesisControls(next); +} + +function prepareOpenAiResponsesHostedWebSearchRequestBody(body: Record, evidence: string): Record { + const next = stripOpenAiHostedWebSearchTools({ + ...body, + instructions: appendStringInstruction(body.instructions, evidence) + }); + return applyOpenAiHostedWebSearchSynthesisControls(next); +} + +function prepareGeminiHostedWebSearchRequestBody(body: Record, evidence: string): Record { + return stripGeminiHostedWebSearchTools({ + ...body, + systemInstruction: appendGeminiSystemInstruction(body.systemInstruction, evidence) + }); +} + +function applyOpenAiHostedWebSearchSynthesisControls(body: Record): Record { + const next = { ...body }; + if (typeof next.reasoning_effort === "string") { + next.reasoning_effort = "low"; + } + if (isRecord(next.reasoning)) { + next.reasoning = { ...next.reasoning, effort: "low" }; + } + return next; +} + +function stripAnthropicHostedWebSearchTools(body: Record): Record { + if (!Array.isArray(body.tools)) { + return body; + } + const tools = body.tools.filter((tool) => !isAnthropicHostedWebSearchTool(tool)); + if (tools.length === body.tools.length) { + return body; + } + const next = { ...body }; + if (tools.length > 0) { + next.tools = tools; + } else { + delete next.tools; + } + const toolChoice = isRecord(next.tool_choice) ? next.tool_choice : undefined; + const toolChoiceName = stringValue(toolChoice?.name); + if (tools.length === 0 || toolChoiceName === "web_search") { + delete next.tool_choice; + } + return next; +} + +function stripClaudeCodeWebSearchContinuationTools(body: Record): Record { + if (!Array.isArray(body.tools)) { + return body; + } + const next = { ...body }; + delete next.tools; + delete next.tool_choice; + return next; +} + +function stripOpenAiHostedWebSearchTools(body: Record): Record { + const next = { ...body }; + let removedTools = false; + if (Array.isArray(body.tools)) { + const tools = body.tools.filter((tool) => !isOpenAiHostedWebSearchTool(tool)); + removedTools = tools.length !== body.tools.length; + if (tools.length > 0) { + next.tools = tools; + } else { + delete next.tools; + } + } + if (next.web_search_options !== undefined || next.webSearchOptions !== undefined) { + delete next.web_search_options; + delete next.webSearchOptions; + removedTools = true; + } + if (removedTools && (!Array.isArray(next.tools) || next.tools.length === 0 || openAiToolChoiceNamesWebSearch(next.tool_choice))) { + delete next.tool_choice; + delete next.parallel_tool_calls; + } + return next; +} + +function stripGeminiHostedWebSearchTools(body: Record): Record { + if (!Array.isArray(body.tools)) { + return body; + } + let changed = false; + const tools = body.tools.flatMap((tool) => { + const transformed = stripGeminiHostedWebSearchTool(tool); + changed ||= transformed.changed; + return transformed.value ? [transformed.value] : []; + }); + if (!changed) { + return body; + } + const next = { ...body }; + if (tools.length > 0) { + next.tools = tools; + } else { + delete next.tools; + } + return next; +} + +function stripGeminiHostedWebSearchTool(tool: unknown): { changed: boolean; value?: unknown } { + if (!isRecord(tool)) { + return { changed: false, value: tool }; + } + let changed = false; + const next: Record = { ...tool }; + for (const key of ["google_search", "googleSearch", "google_search_retrieval", "googleSearchRetrieval"]) { + if (key in next) { + delete next[key]; + changed = true; + } + } + return Object.keys(next).length === 0 ? { changed, value: undefined } : { changed, value: next }; +} + +function appendAnthropicSystemText(system: unknown, text: string): unknown { + if (typeof system === "string") { + return `${system.trimEnd()}\n\n${text}`; + } + const block = { text, type: "text" }; + if (Array.isArray(system)) { + return [...system, block]; + } + return [block]; +} + +function appendOpenAiChatSystemText(messages: unknown, text: string): unknown[] { + const message = { content: text, role: "system" }; + return Array.isArray(messages) ? [message, ...messages] : [message]; +} + +function appendStringInstruction(value: unknown, text: string): string { + const existing = rawStringValue(value); + return existing ? `${existing.trimEnd()}\n\n${text}` : text; +} + +function appendGeminiSystemInstruction(value: unknown, text: string): Record { + const part = { text }; + if (typeof value === "string") { + return { parts: [{ text: value }, part] }; + } + if (isRecord(value)) { + const parts = Array.isArray(value.parts) ? value.parts : []; + return { + ...value, + parts: [...parts, part] + }; + } + return { parts: [part] }; +} + +function hostedWebSearchEvidenceText(records: BrowserWebSearchProtocolRecord[], queryHint: string | undefined): string { + const sections = records.flatMap((record, recordIndex) => { + const resultLines = record.results.slice(0, 8).map((result, resultIndex) => { + const content = focusedWebSearchContent(result.content, queryHint); + const details = [ + result.snippet ? `Search snippet: ${result.snippet}` : "", + content ? `Extracted page content: ${content}` : "", + result.diagnostics?.length ? `Diagnostics: ${result.diagnostics.join("; ")}` : "" + ].filter(Boolean).join("\n"); + return [ + `${resultIndex + 1}. ${result.title}`, + `URL: ${result.url}`, + details + ].filter(Boolean).join("\n"); + }); + if (resultLines.length === 0) { + return []; + } + return [ + [ + `Search ${recordIndex + 1}`, + `Query: ${record.query}`, + `Engine: ${record.engine}`, + `Search URL: ${record.searchUrl}`, + ...resultLines + ].join("\n\n") + ]; + }); + if (sections.length === 0) { + return ""; + } + return [ + "A hidden in-app browser web search has already been performed for this request.", + "Use the evidence below to answer the user's question directly in the visible final response, within 5 concise sentences. Do not call another web search tool, do not merely list links, do not expose hidden reasoning, and do not ask the user to open links. If the evidence is insufficient for an exact value, say that clearly and summarize the most relevant findings with source names.", + queryHint ? `Original search intent: ${queryHint}` : "", + "Web search evidence:", + ...sections + ].filter(Boolean).join("\n\n").slice(0, 10_000); +} + +function claudeCodeWebSearchContinuationEvidenceText( + records: BrowserWebSearchProtocolRecord[], + queryHint: string | undefined, + toolResultTexts: string[] +): string { + const browserEvidence = records.length > 0 ? hostedWebSearchEvidenceText(records, queryHint) : ""; + const toolResultEvidence = toolResultTexts + .map((text) => text.trim()) + .filter(Boolean) + .join("\n\n---\n\n") + .slice(0, 12_000); + if (!browserEvidence && !toolResultEvidence) { + return ""; + } + return [ + "A Claude Code WebSearch tool result has already been returned for this turn.", + "Answer the user's search question directly in the visible final response. Do not call any tool. Do not merely list links or ask the user to open links. Include the sources you used as markdown links.", + queryHint ? `Original search intent: ${queryHint}` : "", + browserEvidence ? `In-app browser extracted evidence:\n\n${browserEvidence}` : "", + toolResultEvidence ? `Previous WebSearch tool result:\n\n${toolResultEvidence}` : "" + ].filter(Boolean).join("\n\n"); +} + +function focusedWebSearchContent(content: string | undefined, queryHint: string | undefined): string | undefined { + const text = content?.replace(/\s+/g, " ").trim(); + if (!text) { + return undefined; + } + const queryTerms = normalizeSearchComparisonText(queryHint ?? "") + .split(" ") + .filter((term) => term.length >= 2); + const weatherTerms = /天气|weather/i.test(queryHint ?? "") + ? ["天气", "气温", "温度", "体感", "空气质量", "湿度", "风", "降水", "℃", "晴", "多云", "阴", "雨"] + : []; + const terms = uniqueStrings([...queryTerms, ...weatherTerms]); + const indexes = terms.flatMap((term) => { + const index = text.toLowerCase().indexOf(term.toLowerCase()); + return index >= 0 ? [index] : []; + }); + if (indexes.length === 0) { + return text.slice(0, 1_000); + } + const center = Math.min(...indexes); + const start = Math.max(0, center - 300); + return `${start > 0 ? "..." : ""}${text.slice(start, start + 1_200)}${start + 1_200 < text.length ? "..." : ""}`; +} + +export function hostedWebSearchProtocolResponseStream( + input: Readable, + headers: Headers, + context: HostedWebSearchProtocolContext, + integration: BrowserWebSearchMcpIntegration | undefined +): Readable { + if (!integration?.recentBrowserWebSearchResults && !integration?.runBrowserWebSearch) { + return input; + } + const contentType = headers.get("content-type")?.toLowerCase() ?? ""; + if (contentType.includes("text/event-stream")) { + if (context.protocol === "anthropic_messages") { + return anthropicHostedWebSearchProtocolSseStream(input, context, integration); + } + return hostedWebSearchProtocolSseStream(input, context, integration); + } + if (!contentType.includes("application/json")) { + return input; + } + + const chunks: Buffer[] = []; + return input.pipe(new Transform({ + transform(chunk, _encoding, callback) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + callback(); + }, + flush(callback) { + const body = Buffer.concat(chunks).toString("utf8"); + void (async () => { + const records = await selectHostedWebSearchProtocolRecords(context, integration); + if (records.length === 0) { + this.push(body); + return; + } + + const parsed = JSON.parse(body) as unknown; + const transformed = transformHostedWebSearchProtocolResponseValue(parsed, records, context); + this.push(transformed.changed ? JSON.stringify(transformed.value) : body); + })().catch((error) => { + console.warn(`[gateway] Hosted web search protocol bridge failed: ${formatError(error)}`); + this.push(body); + }).finally(() => callback()); + } + })); +} + +function hostedWebSearchProtocolSseStream( + input: Readable, + context: HostedWebSearchProtocolContext, + integration: BrowserWebSearchMcpIntegration +): Readable { + const recordsPromise = selectHostedWebSearchProtocolRecords(context, integration); + let records: BrowserWebSearchProtocolRecord[] | undefined; + let pending = ""; + let passThrough = false; + const state: HostedWebSearchSseState = { + done: false, + maxOutputIndex: -1, + visibleText: false + }; + + async function ensureRecords() { + if (records || passThrough) { + return; + } + records = await recordsPromise; + passThrough = records.length === 0; + } + + return input.pipe(new Transform({ + transform(chunk, _encoding, callback) { + const text = chunk.toString(); + const rawText = pending + text; + void (async () => { + await ensureRecords(); + if (passThrough || !records) { + this.push(text); + return; + } + pending += text; + drainHostedWebSearchSseBlocks(this, pending, state, records, context, false); + pending = sseTrailingPartialBlock(pending); + })().catch((error) => { + console.warn(`[gateway] Hosted web search protocol bridge failed: ${formatError(error)}`); + this.push(rawText); + pending = ""; + passThrough = true; + }).finally(() => callback()); + }, + flush(callback) { + void (async () => { + await ensureRecords(); + if (passThrough || !records) { + if (pending) { + this.push(pending); + } + return; + } + drainHostedWebSearchSseBlocks(this, pending, state, records, context, true); + pending = ""; + })().catch((error) => { + console.warn(`[gateway] Hosted web search protocol bridge failed: ${formatError(error)}`); + if (pending) { + this.push(pending); + } + }).finally(() => callback()); + } + })); +} + +type HostedWebSearchSseState = { + done: boolean; + maxOutputIndex: number; + visibleText: boolean; +}; + +function drainHostedWebSearchSseBlocks( + stream: Transform, + text: string, + state: HostedWebSearchSseState, + records: BrowserWebSearchProtocolRecord[], + context: Pick, + flush: boolean +): void { + let cursor = 0; + for (const match of text.matchAll(/\r?\n\r?\n/g)) { + const index = match.index ?? 0; + const delimiter = match[0]; + const block = text.slice(cursor, index); + cursor = index + delimiter.length; + if (!block.trim()) { + stream.push(delimiter); + continue; + } + writeHostedWebSearchSseEvent(stream, parseSseEventBlock(block), delimiter, state, records, context); + } + if (flush) { + const block = text.slice(cursor); + if (block.trim()) { + writeHostedWebSearchSseEvent(stream, parseSseEventBlock(block), "", state, records, context); + } else if (block) { + stream.push(block); + } + writeHostedWebSearchSseFallback(stream, state, records, context); + } +} + +function writeHostedWebSearchSseEvent( + stream: Transform, + event: ParsedSseEvent, + delimiter: string, + state: HostedWebSearchSseState, + records: BrowserWebSearchProtocolRecord[], + context: Pick +): void { + updateHostedWebSearchSseState(event, state, context.protocol); + const isDone = sseEventIsDone(event); + const isOpenAiResponsesCompleted = context.protocol === "openai_responses" && + isRecord(event.data) && + stringValue(event.data.type) === "response.completed"; + if ((isDone || isOpenAiResponsesCompleted) && !state.done) { + writeHostedWebSearchSseFallback(stream, state, records, context); + } + const nextEvent = context.protocol === "openai_chat_completions" + ? updateOpenAiChatSseFinishReason(event) + : context.protocol === "openai_responses" + ? updateOpenAiResponsesCompletedStatus(event) + : event; + stream.push(`${serializeSseEvent(nextEvent)}${delimiter}`); +} + +function updateHostedWebSearchSseState( + event: ParsedSseEvent, + state: HostedWebSearchSseState, + protocol: GatewayProviderProtocol +): void { + if (protocol === "openai_chat_completions") { + state.visibleText ||= openAiChatSseContainsVisibleText([event]); + return; + } + if (protocol === "openai_responses") { + state.visibleText ||= openAiResponsesSseContainsVisibleText([event]); + if (isRecord(event.data)) { + const outputIndex = numberValue(event.data.output_index); + if (outputIndex !== undefined) { + state.maxOutputIndex = Math.max(state.maxOutputIndex, outputIndex); + } + } + return; + } + if (protocol === "gemini_generate_content") { + state.visibleText ||= geminiSseContainsVisibleText([event]); + } +} + +function writeHostedWebSearchSseFallback( + stream: Transform, + state: HostedWebSearchSseState, + records: BrowserWebSearchProtocolRecord[], + context: Pick +): void { + if (state.done || state.visibleText) { + return; + } + const answer = synthesizeWebSearchAnswer(records, context.queryHint); + if (!answer) { + state.done = true; + return; + } + for (const event of hostedWebSearchSseFallbackEvents(answer, state, context)) { + stream.push(`${serializeSseEvent(event)}\n\n`); + } + state.visibleText = true; + state.done = true; +} + +function hostedWebSearchSseFallbackEvents( + answer: string, + state: HostedWebSearchSseState, + context: Pick +): ParsedSseEvent[] { + if (context.protocol === "openai_chat_completions") { + return [sseEventFromValue({ + object: "chat.completion.chunk", + choices: [ + { + delta: { content: answer }, + finish_reason: null, + index: 0 + } + ] + })]; + } + if (context.protocol === "openai_responses") { + return openAiResponsesSseAnswerEvents(answer, context.requestId, state.maxOutputIndex + 1); + } + if (context.protocol === "gemini_generate_content") { + return [sseEventFromValue(geminiAnswerCandidateChunk(answer))]; + } + return []; +} + +function anthropicHostedWebSearchProtocolSseStream( + input: Readable, + context: HostedWebSearchProtocolContext, + integration: BrowserWebSearchMcpIntegration +): Readable { + const recordsPromise = selectHostedWebSearchProtocolRecords(context, integration); + let records: BrowserWebSearchProtocolRecord[] | undefined; + let pending = ""; + let passThrough = false; + const state: AnthropicHostedWebSearchSseState = { + answerInjected: false, + hasClientToolUse: false, + hasWebSearchBlocks: false, + injectedBlockCount: 0, + insertedSearchBlocks: false, + maxIndex: -1, + visibleText: false + }; + + async function ensureRecords() { + if (records || passThrough) { + return; + } + records = await recordsPromise; + passThrough = records.length === 0; + } + + return input.pipe(new Transform({ + transform(chunk, _encoding, callback) { + const text = chunk.toString(); + const rawText = pending + text; + void (async () => { + await ensureRecords(); + if (passThrough || !records) { + this.push(text); + return; + } + pending += text; + drainAnthropicHostedWebSearchSseBlocks(this, pending, state, records, context, false); + pending = sseTrailingPartialBlock(pending); + })().catch((error) => { + console.warn(`[gateway] Hosted web search protocol bridge failed: ${formatError(error)}`); + this.push(rawText); + pending = ""; + passThrough = true; + }).finally(() => callback()); + }, + flush(callback) { + void (async () => { + await ensureRecords(); + if (passThrough || !records) { + if (pending) { + this.push(pending); + } + return; + } + drainAnthropicHostedWebSearchSseBlocks(this, pending, state, records, context, true); + pending = ""; + })().catch((error) => { + console.warn(`[gateway] Hosted web search protocol bridge failed: ${formatError(error)}`); + if (pending) { + this.push(pending); + } + }).finally(() => callback()); + } + })); +} + +type AnthropicHostedWebSearchSseState = { + answerInjected: boolean; + hasClientToolUse: boolean; + hasWebSearchBlocks: boolean; + injectedBlockCount: number; + insertedSearchBlocks: boolean; + insertIndex?: number; + maxIndex: number; + visibleText: boolean; +}; + +function drainAnthropicHostedWebSearchSseBlocks( + stream: Transform, + text: string, + state: AnthropicHostedWebSearchSseState, + records: BrowserWebSearchProtocolRecord[], + context: Pick, + flush: boolean +): void { + let cursor = 0; + for (const match of text.matchAll(/\r?\n\r?\n/g)) { + const index = match.index ?? 0; + const delimiter = match[0]; + const block = text.slice(cursor, index); + cursor = index + delimiter.length; + if (!block.trim()) { + stream.push(delimiter); + continue; + } + writeAnthropicHostedWebSearchSseEvent( + stream, + parseSseEventBlock(block), + delimiter, + state, + records, + context + ); + } + if (flush) { + const block = text.slice(cursor); + if (block.trim()) { + writeAnthropicHostedWebSearchSseEvent( + stream, + parseSseEventBlock(block), + "", + state, + records, + context + ); + } else if (block) { + stream.push(block); + } + } +} + +function sseTrailingPartialBlock(text: string): string { + let cursor = 0; + for (const match of text.matchAll(/\r?\n\r?\n/g)) { + cursor = (match.index ?? 0) + match[0].length; + } + return text.slice(cursor); +} + +function writeAnthropicHostedWebSearchSseEvent( + stream: Transform, + event: ParsedSseEvent, + delimiter: string, + state: AnthropicHostedWebSearchSseState, + records: BrowserWebSearchProtocolRecord[], + context: Pick +): void { + updateAnthropicHostedWebSearchSseState(event, state); + const textStartIndex = anthropicSseTextBlockStartIndex(event); + const isMessageEnd = sseEventIsAnthropicMessageEnd(event); + const answer = !state.hasClientToolUse && !state.visibleText && !state.answerInjected && isMessageEnd + ? synthesizeWebSearchAnswer(records, context.queryHint) + : undefined; + + if (!state.insertedSearchBlocks && (textStartIndex !== undefined || isMessageEnd)) { + const insertIndex = textStartIndex ?? state.maxIndex + 1; + insertAnthropicHostedWebSearchSseBlocks(stream, state, records, context.requestId, insertIndex); + } + if (answer && isMessageEnd) { + const answerIndex = state.maxIndex + state.injectedBlockCount + 1; + insertAnthropicHostedWebSearchSseAnswer(stream, state, answer, answerIndex); + } + + const nextEvent = updateAnthropicWebSearchSseUsage( + shiftAnthropicHostedWebSearchSseEvent(event, state), + records.length, + Boolean(answer), + state.hasClientToolUse + ); + stream.push(`${serializeSseEvent(nextEvent)}${delimiter}`); +} + +function updateAnthropicHostedWebSearchSseState(event: ParsedSseEvent, state: AnthropicHostedWebSearchSseState): void { + if (isRecord(event.data) && Number.isFinite(event.data.index)) { + state.maxIndex = Math.max(state.maxIndex, Number(event.data.index)); + } + state.hasWebSearchBlocks ||= sseEventContainsAnthropicWebSearchBlock(event); + state.hasClientToolUse ||= sseEventContainsAnthropicClientToolUse(event); + state.visibleText ||= sseEventContainsVisibleText(event); +} + +function insertAnthropicHostedWebSearchSseBlocks( + stream: Transform, + state: AnthropicHostedWebSearchSseState, + records: BrowserWebSearchProtocolRecord[], + requestId: string, + insertIndex: number +): void { + state.insertedSearchBlocks = true; + state.insertIndex = insertIndex; + if (state.hasWebSearchBlocks) { + return; + } + const blocks = anthropicWebSearchProtocolBlocks(records, requestId); + for (const event of blocks.flatMap((block, offset) => anthropicWebSearchSseEventsForBlock(block, insertIndex + offset))) { + stream.push(`${serializeSseEvent(event)}\n\n`); + } + state.injectedBlockCount += blocks.length; +} + +function insertAnthropicHostedWebSearchSseAnswer( + stream: Transform, + state: AnthropicHostedWebSearchSseState, + answer: string, + answerIndex: number +): void { + state.answerInjected = true; + for (const event of anthropicWebSearchSseEventsForBlock({ text: answer, type: "text" }, answerIndex)) { + stream.push(`${serializeSseEvent(event)}\n\n`); + } +} + +function shiftAnthropicHostedWebSearchSseEvent( + event: ParsedSseEvent, + state: AnthropicHostedWebSearchSseState +): ParsedSseEvent { + if (!state.insertedSearchBlocks || state.insertIndex === undefined || state.injectedBlockCount === 0) { + return event; + } + return shiftSseContentBlockIndex(event, state.insertIndex, state.injectedBlockCount); +} + +function transformHostedWebSearchProtocolResponseValue( + value: unknown, + records: BrowserWebSearchProtocolRecord[], + context: Pick +): { changed: boolean; value: unknown } { + if (context.protocol === "anthropic_messages") { + return transformAnthropicWebSearchProtocolResponseValue(value, records, context.requestId, context.queryHint); + } + if (context.protocol === "openai_chat_completions") { + return transformOpenAiChatHostedWebSearchResponseValue(value, records, context.queryHint); + } + if (context.protocol === "openai_responses") { + return transformOpenAiResponsesHostedWebSearchResponseValue(value, records, context.requestId, context.queryHint); + } + if (context.protocol === "gemini_generate_content") { + return transformGeminiHostedWebSearchResponseValue(value, records, context.queryHint); + } + return { changed: false, value }; +} + +function transformHostedWebSearchProtocolSseText( + body: string, + records: BrowserWebSearchProtocolRecord[], + context: Pick +): string { + if (context.protocol === "anthropic_messages") { + return transformAnthropicWebSearchProtocolSseText(body, records, context.requestId, context.queryHint); + } + if (context.protocol === "openai_chat_completions") { + return transformOpenAiChatHostedWebSearchSseText(body, records, context.queryHint); + } + if (context.protocol === "openai_responses") { + return transformOpenAiResponsesHostedWebSearchSseText(body, records, context.requestId, context.queryHint); + } + if (context.protocol === "gemini_generate_content") { + return transformGeminiHostedWebSearchSseText(body, records, context.queryHint); + } + return body; +} + +export function transformAnthropicWebSearchProtocolResponseValue( + value: unknown, + records: BrowserWebSearchProtocolRecord[], + requestId: string, + queryHint?: string +): { changed: boolean; value: unknown } { + if (!isRecord(value) || !Array.isArray(value.content)) { + return { changed: false, value }; + } + const hasWebSearchBlocks = responseValueContainsAnthropicWebSearchBlocks(value); + const blocks = hasWebSearchBlocks ? [] : anthropicWebSearchProtocolBlocks(records, requestId); + const hasClientToolUse = responseValueContainsAnthropicClientToolUse(value); + const answer = hasClientToolUse || responseValueContainsVisibleText(value) + ? undefined + : synthesizeWebSearchAnswer(records, queryHint); + const injectedBlocks = [ + ...blocks, + ...(answer ? [{ text: answer, type: "text" }] : []) + ]; + const shouldUpdateUsage = hasWebSearchBlocks || blocks.length > 0; + if (injectedBlocks.length === 0 && !shouldUpdateUsage) { + return { changed: false, value }; + } + const insertAt = webSearchProtocolInsertIndex(value.content, hasWebSearchBlocks); + const nextValue = { + ...value, + ...(shouldUpdateUsage ? { usage: mergeAnthropicWebSearchUsage(value.usage, records.length) } : {}), + ...(shouldEndAnthropicHostedWebSearchTurn(value.stop_reason, Boolean(answer), hasClientToolUse) ? { stop_reason: "end_turn" } : {}), + content: injectedBlocks.length > 0 + ? [ + ...value.content.slice(0, insertAt), + ...injectedBlocks, + ...value.content.slice(insertAt) + ] + : value.content + }; + return { + changed: true, + value: nextValue + }; +} + +export function transformAnthropicWebSearchProtocolSseText( + body: string, + records: BrowserWebSearchProtocolRecord[], + requestId: string, + queryHint?: string +): string { + const events = parseSseEvents(body); + if (events.length === 0) { + return body; + } + const hasWebSearchBlocks = sseEventsContainAnthropicWebSearchBlocks(events); + const blocks = hasWebSearchBlocks ? [] : anthropicWebSearchProtocolBlocks(records, requestId); + const hasClientToolUse = sseEventsContainAnthropicClientToolUse(events); + const answer = hasClientToolUse || sseEventsContainVisibleText(events) + ? undefined + : synthesizeWebSearchAnswer(records, queryHint); + const injectedBlocks = [ + ...blocks, + ...(answer ? [{ text: answer, type: "text" }] : []) + ]; + const shouldUpdateUsage = hasWebSearchBlocks || blocks.length > 0; + if (injectedBlocks.length === 0 && !shouldUpdateUsage) { + return body; + } + + let maxIndex = -1; + for (const event of events) { + if (isRecord(event.data) && Number.isFinite(event.data.index)) { + maxIndex = Math.max(maxIndex, Number(event.data.index)); + } + } + + const firstTextIndex = events.findIndex((event) => { + const data = isRecord(event.data) ? event.data : undefined; + const contentBlock = isRecord(data?.content_block) ? data.content_block : undefined; + return data?.type === "content_block_start" && contentBlock?.type === "text"; + }); + const messageEndIndex = events.findIndex((event) => { + const type = isRecord(event.data) ? stringValue(event.data.type) : undefined; + return type === "message_delta" || type === "message_stop"; + }); + const insertPosition = firstTextIndex >= 0 + ? firstTextIndex + : messageEndIndex >= 0 + ? messageEndIndex + : events.length; + const insertIndex = firstTextIndex >= 0 && isRecord(events[firstTextIndex].data) && Number.isFinite(events[firstTextIndex].data.index) + ? Number(events[firstTextIndex].data.index) + : maxIndex + 1; + + const shiftedEvents = events + .map((event) => shiftSseContentBlockIndex(event, insertIndex, injectedBlocks.length)) + .map((event) => updateAnthropicWebSearchSseUsage(event, records.length, Boolean(answer), hasClientToolUse)); + const injectedEvents = injectedBlocks.flatMap((block, offset) => anthropicWebSearchSseEventsForBlock(block, insertIndex + offset)); + shiftedEvents.splice(insertPosition, 0, ...injectedEvents); + return `${shiftedEvents.map(serializeSseEvent).join("\n\n")}\n\n`; +} + +export function transformOpenAiChatHostedWebSearchResponseValue( + value: unknown, + records: BrowserWebSearchProtocolRecord[], + queryHint?: string +): { changed: boolean; value: unknown } { + if (!isRecord(value) || openAiChatResponseContainsVisibleText(value)) { + return { changed: false, value }; + } + const answer = synthesizeWebSearchAnswer(records, queryHint); + if (!answer || !Array.isArray(value.choices) || value.choices.length === 0) { + return { changed: false, value }; + } + const choices = value.choices.map((choice, index) => { + if (!isRecord(choice) || index !== 0) { + return choice; + } + const message = isRecord(choice.message) ? choice.message : {}; + return { + ...choice, + finish_reason: stringValue(choice.finish_reason) === "length" ? "stop" : choice.finish_reason, + message: { + ...message, + content: answer, + role: stringValue(message.role) || "assistant" + } + }; + }); + return { + changed: true, + value: { + ...value, + choices + } + }; +} + +export function transformOpenAiChatHostedWebSearchSseText( + body: string, + records: BrowserWebSearchProtocolRecord[], + queryHint?: string +): string { + const events = parseSseEvents(body); + if (events.length === 0 || openAiChatSseContainsVisibleText(events)) { + return body; + } + const answer = synthesizeWebSearchAnswer(records, queryHint); + if (!answer) { + return body; + } + const template = firstSseDataRecord(events); + const injected = sseEventFromValue({ + ...(template?.id ? { id: template.id } : {}), + ...(template?.model ? { model: template.model } : {}), + object: stringValue(template?.object) || "chat.completion.chunk", + choices: [ + { + delta: { content: answer }, + finish_reason: null, + index: 0 + } + ] + }); + const shifted = events.map((event) => updateOpenAiChatSseFinishReason(event)); + const insertAt = doneSseEventIndex(shifted); + shifted.splice(insertAt >= 0 ? insertAt : shifted.length, 0, injected); + return `${shifted.map(serializeSseEvent).join("\n\n")}\n\n`; +} + +export function transformOpenAiResponsesHostedWebSearchResponseValue( + value: unknown, + records: BrowserWebSearchProtocolRecord[], + requestId: string, + queryHint?: string +): { changed: boolean; value: unknown } { + if (!isRecord(value)) { + return { changed: false, value }; + } + const output = Array.isArray(value.output) ? value.output : []; + const hasSearchCall = output.some((item) => isRecord(item) && stringValue(item.type) === "web_search_call"); + const answer = openAiResponsesValueContainsVisibleText(value) ? undefined : synthesizeWebSearchAnswer(records, queryHint); + const injected = [ + ...(hasSearchCall ? [] : openAiResponsesWebSearchCallItems(records, requestId)), + ...(answer ? [openAiResponsesMessageItem(answer, requestId)] : []) + ]; + if (injected.length === 0) { + return { changed: false, value }; + } + const next: Record = { + ...value, + output: [...injected, ...output] + }; + if (answer && stringValue(next.status) === "incomplete") { + next.status = "completed"; + delete next.incomplete_details; + } + return { changed: true, value: next }; +} + +export function transformOpenAiResponsesHostedWebSearchSseText( + body: string, + records: BrowserWebSearchProtocolRecord[], + requestId: string, + queryHint?: string +): string { + const events = parseSseEvents(body); + if (events.length === 0) { + return body; + } + const visibleText = openAiResponsesSseVisibleText(events); + if (visibleText) { + return serializeOpenAiResponsesSseEvents(normalizeOpenAiResponsesSseEvents(events, visibleText)); + } + const answer = synthesizeWebSearchAnswer(records, queryHint); + if (!answer) { + return serializeOpenAiResponsesSseEvents(normalizeOpenAiResponsesSseEvents(events)); + } + const outputIndex = nextOpenAiResponsesOutputIndex(events); + const injected = openAiResponsesSseAnswerEvents(answer, requestId, outputIndex); + const shifted = events.map(updateOpenAiResponsesCompletedStatus); + const insertAt = shifted.findIndex((event) => isRecord(event.data) && stringValue(event.data.type) === "response.completed"); + shifted.splice(insertAt >= 0 ? insertAt : doneSseEventIndex(shifted) >= 0 ? doneSseEventIndex(shifted) : shifted.length, 0, ...injected); + return serializeOpenAiResponsesSseEvents(normalizeOpenAiResponsesSseEvents(shifted, answer)); +} + +export function transformGeminiHostedWebSearchResponseValue( + value: unknown, + records: BrowserWebSearchProtocolRecord[], + queryHint?: string +): { changed: boolean; value: unknown } { + if (Array.isArray(value)) { + if (geminiResponseArrayContainsVisibleText(value)) { + return { changed: false, value }; + } + const answer = synthesizeWebSearchAnswer(records, queryHint); + return answer + ? { changed: true, value: [...value, geminiAnswerCandidateChunk(answer)] } + : { changed: false, value }; + } + if (!isRecord(value) || geminiResponseValueContainsVisibleText(value)) { + return { changed: false, value }; + } + const answer = synthesizeWebSearchAnswer(records, queryHint); + if (!answer) { + return { changed: false, value }; + } + const candidates = Array.isArray(value.candidates) ? value.candidates : []; + const nextCandidate = candidates.length > 0 && isRecord(candidates[0]) + ? { + ...candidates[0], + content: { + ...(isRecord(candidates[0].content) ? candidates[0].content : {}), + parts: [{ text: answer }], + role: "model" + }, + finishReason: stringValue(candidates[0].finishReason) === "MAX_TOKENS" ? "STOP" : candidates[0].finishReason + } + : geminiAnswerCandidate(answer); + return { + changed: true, + value: { + ...value, + candidates: [nextCandidate, ...candidates.slice(1)] + } + }; +} + +export function transformGeminiHostedWebSearchSseText( + body: string, + records: BrowserWebSearchProtocolRecord[], + queryHint?: string +): string { + const events = parseSseEvents(body); + if (events.length === 0 || geminiSseContainsVisibleText(events)) { + return body; + } + const answer = synthesizeWebSearchAnswer(records, queryHint); + if (!answer) { + return body; + } + const injected = sseEventFromValue(geminiAnswerCandidateChunk(answer)); + const insertAt = doneSseEventIndex(events); + events.splice(insertAt >= 0 ? insertAt : events.length, 0, injected); + return `${events.map(serializeSseEvent).join("\n\n")}\n\n`; +} + +function openAiChatResponseContainsVisibleText(value: Record): boolean { + if (!Array.isArray(value.choices)) { + return false; + } + return value.choices.some((choice) => { + const message = isRecord(choice) && isRecord(choice.message) ? choice.message : undefined; + return Boolean(stringValue(message?.content)?.trim()); + }); +} + +function openAiChatSseContainsVisibleText(events: ParsedSseEvent[]): boolean { + return events.some((event) => { + const choices = isRecord(event.data) && Array.isArray(event.data.choices) ? event.data.choices : []; + return choices.some((choice) => { + const delta = isRecord(choice) && isRecord(choice.delta) ? choice.delta : undefined; + return Boolean(stringValue(delta?.content)?.trim()); + }); + }); +} + +function updateOpenAiChatSseFinishReason(event: ParsedSseEvent): ParsedSseEvent { + if (!isRecord(event.data) || !Array.isArray(event.data.choices)) { + return event; + } + let changed = false; + const choices = event.data.choices.map((choice) => { + if (!isRecord(choice) || stringValue(choice.finish_reason) !== "length") { + return choice; + } + changed = true; + return { ...choice, finish_reason: "stop" }; + }); + return changed ? { ...event, data: { ...event.data, choices } } : event; +} + +function openAiResponsesValueContainsVisibleText(value: Record): boolean { + return Array.isArray(value.output) && value.output.some(openAiResponsesItemContainsVisibleText); +} + +function openAiResponsesItemContainsVisibleText(item: unknown): boolean { + if (!isRecord(item)) { + return false; + } + if (stringValue(item.type) === "message" && Array.isArray(item.content)) { + return item.content.some((part) => isRecord(part) && Boolean(stringValue(part.text)?.trim())); + } + return Boolean(stringValue(item.text)?.trim()); +} + +function openAiResponsesSseContainsVisibleText(events: ParsedSseEvent[]): boolean { + return Boolean(openAiResponsesSseVisibleText(events)); +} + +function openAiResponsesSseVisibleText(events: ParsedSseEvent[]): string | undefined { + let deltaText = ""; + let doneText = ""; + let itemText = ""; + for (const event of events) { + const data = isRecord(event.data) ? event.data : undefined; + if (!data) { + continue; + } + const type = stringValue(data.type); + if (type === "response.output_text.delta") { + deltaText += stringValue(data.delta) ?? ""; + continue; + } + if (type === "response.output_text.done") { + doneText = stringValue(data.text) ?? doneText; + continue; + } + const item = isRecord(data.item) ? data.item : undefined; + if (item && openAiResponsesItemContainsVisibleText(item)) { + itemText = openAiResponsesItemText(item) ?? itemText; + } + } + return nonEmptyText(deltaText) ?? nonEmptyText(doneText) ?? nonEmptyText(itemText); +} + +function openAiResponsesItemText(item: unknown): string | undefined { + if (!isRecord(item)) { + return undefined; + } + if (stringValue(item.type) === "message" && Array.isArray(item.content)) { + const text = item.content + .flatMap((part) => isRecord(part) ? [stringValue(part.text) ?? ""] : []) + .join(""); + return text.trim() ? text : undefined; + } + return stringValue(item.text); +} + +function nonEmptyText(value: string | undefined): string | undefined { + return value && value.trim() ? value : undefined; +} + +function openAiResponsesWebSearchCallItems(records: BrowserWebSearchProtocolRecord[], requestId: string): Record[] { + return records.map((record, index) => ({ + action: { + query: record.query, + type: "search" + }, + id: `ws_${sanitizeAnthropicToolUseId(requestId)}_${index + 1}`, + status: "completed", + type: "web_search_call" + })); +} + +function openAiResponsesMessageItem(answer: string, requestId: string): Record { + return { + content: [{ annotations: [], text: answer, type: "output_text" }], + id: `msg_${sanitizeAnthropicToolUseId(requestId)}_web_search_answer`, + role: "assistant", + status: "completed", + type: "message" + }; +} + +function nextOpenAiResponsesOutputIndex(events: ParsedSseEvent[]): number { + const indexes = events.flatMap((event) => { + const data = isRecord(event.data) ? event.data : undefined; + const index = numberValue(data?.output_index); + return index === undefined ? [] : [index]; + }); + return indexes.length === 0 ? 0 : Math.max(...indexes) + 1; +} + +function openAiResponsesSseAnswerEvents(answer: string, requestId: string, outputIndex: number): ParsedSseEvent[] { + const itemId = `msg_${sanitizeAnthropicToolUseId(requestId)}_web_search_answer`; + const contentIndex = 0; + return [ + sseEventFromValue({ + item: { + id: itemId, + role: "assistant", + status: "in_progress", + type: "message" + }, + output_index: outputIndex, + type: "response.output_item.added" + }), + sseEventFromValue({ + content_index: contentIndex, + item_id: itemId, + output_index: outputIndex, + part: { annotations: [], text: "", type: "output_text" }, + type: "response.content_part.added" + }), + sseEventFromValue({ + content_index: contentIndex, + delta: answer, + item_id: itemId, + output_index: outputIndex, + type: "response.output_text.delta" + }), + sseEventFromValue({ + content_index: contentIndex, + item_id: itemId, + output_index: outputIndex, + text: answer, + type: "response.output_text.done" + }), + sseEventFromValue({ + content_index: contentIndex, + item_id: itemId, + output_index: outputIndex, + part: { annotations: [], text: answer, type: "output_text" }, + type: "response.content_part.done" + }), + sseEventFromValue({ + item: { + content: [{ annotations: [], text: answer, type: "output_text" }], + id: itemId, + role: "assistant", + status: "completed", + type: "message" + }, + output_index: outputIndex, + type: "response.output_item.done" + }) + ]; +} + +function updateOpenAiResponsesCompletedStatus(event: ParsedSseEvent): ParsedSseEvent { + if (!isRecord(event.data) || stringValue(event.data.type) !== "response.completed") { + return event; + } + const response = isRecord(event.data.response) ? event.data.response : undefined; + if (!response || stringValue(response.status) !== "incomplete") { + return event; + } + const nextResponse: Record = { ...response, status: "completed" }; + delete nextResponse.incomplete_details; + return { + ...event, + data: { + ...event.data, + response: nextResponse + } + }; +} + +function normalizeOpenAiResponsesSseEvents(events: ParsedSseEvent[], visibleText?: string): ParsedSseEvent[] { + const outputIndexMap = openAiResponsesOutputIndexMap(events); + return events.flatMap((event) => { + if (isOpenAiResponsesReasoningSseEvent(event)) { + return []; + } + return [updateOpenAiResponsesCompletedOutput( + remapOpenAiResponsesOutputIndex(event, outputIndexMap), + visibleText + )]; + }); +} + +function serializeOpenAiResponsesSseEvents(events: ParsedSseEvent[]): string { + return `${events.map(serializeSseEvent).join("\n\n")}\n\n`; +} + +function openAiResponsesOutputIndexMap(events: ParsedSseEvent[]): Map { + const indexes: number[] = []; + for (const event of events) { + if (isOpenAiResponsesReasoningSseEvent(event)) { + continue; + } + const data = isRecord(event.data) ? event.data : undefined; + const index = numberValue(data?.output_index); + if (index !== undefined && !indexes.includes(index)) { + indexes.push(index); + } + } + return new Map(indexes.sort((left, right) => left - right).map((index, nextIndex) => [index, nextIndex])); +} + +function remapOpenAiResponsesOutputIndex(event: ParsedSseEvent, outputIndexMap: Map): ParsedSseEvent { + if (!isRecord(event.data)) { + return event; + } + const index = numberValue(event.data.output_index); + if (index === undefined || !outputIndexMap.has(index)) { + return event; + } + return { + ...event, + data: { + ...event.data, + output_index: outputIndexMap.get(index) + } + }; +} + +function updateOpenAiResponsesCompletedOutput(event: ParsedSseEvent, visibleText?: string): ParsedSseEvent { + if (!isRecord(event.data) || stringValue(event.data.type) !== "response.completed") { + return event; + } + const response = isRecord(event.data.response) ? event.data.response : undefined; + if (!response) { + return event; + } + const nextResponse: Record = { ...response }; + if (Array.isArray(response.output)) { + nextResponse.output = response.output.filter((item) => !(isRecord(item) && stringValue(item.type) === "reasoning")); + } + if (visibleText) { + nextResponse.output_text = visibleText; + } + if (visibleText && stringValue(nextResponse.status) === "incomplete") { + nextResponse.status = "completed"; + delete nextResponse.incomplete_details; + } + return { + ...event, + data: { + ...event.data, + response: nextResponse + } + }; +} + +function isOpenAiResponsesReasoningSseEvent(event: ParsedSseEvent): boolean { + const data = isRecord(event.data) ? event.data : undefined; + if (!data) { + return false; + } + const type = stringValue(data.type); + if (type?.startsWith("response.reasoning")) { + return true; + } + const item = isRecord(data.item) ? data.item : undefined; + if (stringValue(item?.type) === "reasoning") { + return true; + } + const part = isRecord(data.part) ? data.part : undefined; + return stringValue(part?.type) === "reasoning_text"; +} + +function geminiResponseValueContainsVisibleText(value: Record): boolean { + return Array.isArray(value.candidates) && value.candidates.some(geminiCandidateContainsVisibleText); +} + +function geminiResponseArrayContainsVisibleText(values: unknown[]): boolean { + return values.some((value) => isRecord(value) && geminiResponseValueContainsVisibleText(value)); +} + +function geminiCandidateContainsVisibleText(candidate: unknown): boolean { + const content = isRecord(candidate) && isRecord(candidate.content) ? candidate.content : undefined; + const parts = Array.isArray(content?.parts) ? content.parts : []; + return parts.some((part) => isRecord(part) && Boolean(stringValue(part.text)?.trim())); +} + +function geminiSseContainsVisibleText(events: ParsedSseEvent[]): boolean { + return events.some((event) => isRecord(event.data) && geminiResponseValueContainsVisibleText(event.data)); +} + +function geminiAnswerCandidate(answer: string): Record { + return { + content: { + parts: [{ text: answer }], + role: "model" + }, + finishReason: "STOP", + index: 0 + }; +} + +function geminiAnswerCandidateChunk(answer: string): Record { + return { + candidates: [geminiAnswerCandidate(answer)] + }; +} + +function firstSseDataRecord(events: ParsedSseEvent[]): Record | undefined { + return events.map((event) => isRecord(event.data) ? event.data : undefined).find(Boolean); +} + +function doneSseEventIndex(events: ParsedSseEvent[]): number { + return events.findIndex((event) => event.raw?.includes("[DONE]")); +} + +function sseEventIsDone(event: ParsedSseEvent): boolean { + return Boolean(event.raw?.includes("[DONE]")); +} + +function hasAnthropicHostedWebSearchTool(tools: unknown): boolean { + if (!Array.isArray(tools)) { + return false; + } + return tools.some(isAnthropicHostedWebSearchTool); +} + +function hasHostedWebSearchDeclaration(body: Record, protocol: GatewayProviderProtocol): boolean { + if (protocol === "anthropic_messages") { + return hasAnthropicHostedWebSearchTool(body.tools); + } + if (protocol === "openai_chat_completions" || protocol === "openai_responses") { + return hasOpenAiHostedWebSearchDeclaration(body); + } + if (protocol === "gemini_generate_content") { + return hasGeminiHostedWebSearchTool(body.tools); + } + return false; +} + +function hasOpenAiHostedWebSearchDeclaration(body: Record): boolean { + if (body.web_search_options !== undefined || body.webSearchOptions !== undefined) { + return true; + } + return Array.isArray(body.tools) && body.tools.some(isOpenAiHostedWebSearchTool); +} + +function hasGeminiHostedWebSearchTool(tools: unknown): boolean { + if (!Array.isArray(tools)) { + return false; + } + return tools.some((tool) => { + if (!isRecord(tool)) { + return false; + } + if (tool.google_search !== undefined || tool.googleSearch !== undefined || tool.google_search_retrieval !== undefined || tool.googleSearchRetrieval !== undefined) { + return true; + } + return false; + }); +} + +function isAnthropicHostedWebSearchTool(tool: unknown): boolean { + if (!isRecord(tool)) { + return false; + } + return anthropicHostedWebSearchType(stringValue(tool.type)); +} + +function isOpenAiHostedWebSearchTool(tool: unknown): boolean { + if (!isRecord(tool)) { + return false; + } + return openAiHostedWebSearchType(stringValue(tool.type)); +} + +function openAiToolChoiceNamesWebSearch(value: unknown): boolean { + if (typeof value === "string") { + return openAiHostedWebSearchType(value); + } + if (!isRecord(value)) { + return false; + } + return openAiHostedWebSearchType(stringValue(value.type)); +} + +function anthropicHostedWebSearchType(value: string | undefined): boolean { + const normalized = normalizedToolProtocolName(value); + return normalized === "web_search" || normalized === "web_search_20250305"; +} + +function openAiHostedWebSearchType(value: string | undefined): boolean { + const normalized = normalizedToolProtocolName(value); + return normalized === "web_search" || + normalized === "web_search_preview" || + normalized.startsWith("web_search_preview_"); +} + +function normalizedToolProtocolName(value: string | undefined): string { + return value?.trim().toLowerCase().replace(/[-.]/g, "_") ?? ""; +} + +function readAnthropicWebSearchMaxUses(tools: unknown): number | undefined { + if (!Array.isArray(tools)) { + return undefined; + } + const tool = tools.find((item) => isRecord(item) && stringValue(item.type)?.toLowerCase() === "web_search_20250305"); + return isRecord(tool) ? numberValue(tool.max_uses ?? tool.maxUses) : undefined; +} + +function readHostedWebSearchMaxUses(body: Record, protocol: GatewayProviderProtocol): number | undefined { + if (protocol === "anthropic_messages") { + return readAnthropicWebSearchMaxUses(body.tools); + } + if (protocol === "openai_chat_completions" || protocol === "openai_responses") { + const tool = Array.isArray(body.tools) ? body.tools.find(isOpenAiHostedWebSearchTool) : undefined; + return isRecord(tool) ? numberValue(tool.max_uses ?? tool.maxUses) : undefined; + } + return undefined; +} + +export function extractHostedWebSearchQueryHint(body: Record, protocol: GatewayProviderProtocol): string | undefined { + if (protocol === "anthropic_messages") { + return extractAnthropicWebSearchQueryHint(body); + } + if (protocol === "openai_chat_completions") { + return normalizedWebSearchQueryHintFromParts(textPartsFromOpenAiChatMessages(body.messages)); + } + if (protocol === "openai_responses") { + return normalizedWebSearchQueryHintFromParts(textPartsFromOpenAiResponsesInput(body.input)); + } + if (protocol === "gemini_generate_content") { + return normalizedWebSearchQueryHintFromParts(textPartsFromGeminiContents(body.contents)); + } + return undefined; +} + +function extractAnthropicWebSearchQueryHint(body: Record): string | undefined { + const userTexts = Array.isArray(body.messages) + ? body.messages.flatMap((message) => { + if (!isRecord(message) || stringValue(message.role) !== "user") { + return []; + } + return textPartsFromAnthropicContent(message.content); + }) + : []; + return normalizedWebSearchQueryHintFromParts(userTexts); +} + +function extractClaudeCodeWebSearchToolResultQuery(body: Record): string | undefined { + for (const text of claudeCodeWebSearchToolResultTexts(body)) { + const quoted = /Web search results for query:\s*"([^"]+)"/i.exec(text); + if (quoted?.[1]) { + return normalizedWebSearchQueryHint(quoted[1]); + } + const unquoted = /Web search results for query:\s*([^\n]+)/i.exec(text); + if (unquoted?.[1]) { + return normalizedWebSearchQueryHint(unquoted[1].replace(/^["']|["']$/g, "")); + } + } + return undefined; +} + +function normalizedWebSearchQueryHintFromParts(parts: string[]): string | undefined { + const candidates = parts + .map((part) => part.trim()) + .filter(Boolean); + for (const candidate of [...candidates].reverse()) { + const explicit = extractExplicitWebSearchQuery(candidate); + if (explicit) { + return normalizedWebSearchQueryHint(explicit); + } + } + for (const candidate of [...candidates].reverse()) { + if (isRuntimeContextText(candidate)) { + continue; + } + return normalizedWebSearchQueryHint(stripSearchIntentPrefix(candidate)); + } + return normalizedWebSearchQueryHint(candidates.join("\n")); +} + +function normalizedWebSearchQueryHint(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const joined = value.trim(); + if (!joined) { + return undefined; + } + return joined.trim().slice(0, 500); +} + +function extractExplicitWebSearchQuery(value: string): string | undefined { + const explicit = /perform\s+a\s+web\s+search\s+for\s+the\s+query:\s*([\s\S]+)$/i.exec(value.trim()); + return normalizedWebSearchQueryHint(explicit?.[1]); +} + +function stripSearchIntentPrefix(value: string): string { + const trimmed = value.trim(); + const match = /^(?:请)?(?:帮我)?(?:搜索|查询|查一下|帮我查一下|搜一下)\s*[::]?\s*([\s\S]+)$/i.exec(trimmed); + return (match?.[1] || trimmed).trim(); +} + +function isRuntimeContextText(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + if (/^<(?:environment_context|permissions instructions|collaboration_mode|skills_instructions|plugins_instructions|apps_instructions)>/i.test(trimmed)) { + return true; + } + return ( + trimmed.includes("") || + trimmed.includes("") || + trimmed.includes("") || + trimmed.includes("") + ); +} + +function textPartsFromAnthropicContent(content: unknown): string[] { + if (typeof content === "string") { + return [content]; + } + if (!Array.isArray(content)) { + return []; + } + return content.flatMap((part) => isRecord(part) && typeof part.text === "string" ? [part.text] : []); +} + +function claudeCodeWebSearchToolResultTexts(body: Record): string[] { + if (!Array.isArray(body.messages)) { + return []; + } + const lastMessage = body.messages.at(-1); + if (!isRecord(lastMessage) || stringValue(lastMessage.role) !== "user" || !Array.isArray(lastMessage.content)) { + return []; + } + const latestToolResults = lastMessage.content.filter((part) => isRecord(part) && stringValue(part.type) === "tool_result"); + if (latestToolResults.length === 0) { + return []; + } + const webSearchToolUseIds = new Set(); + for (let index = body.messages.length - 2; index >= 0; index -= 1) { + const message = body.messages[index]; + if (!isRecord(message) || stringValue(message.role) !== "assistant" || !Array.isArray(message.content)) { + continue; + } + for (const part of message.content) { + if (!isRecord(part) || stringValue(part.type) !== "tool_use" || stringValue(part.name)?.toLowerCase() !== "websearch") { + continue; + } + const id = stringValue(part.id); + if (id) { + webSearchToolUseIds.add(id); + } + } + break; + } + if (webSearchToolUseIds.size === 0) { + return []; + } + const texts: string[] = []; + for (const part of latestToolResults) { + if (!isRecord(part)) { + continue; + } + const toolUseId = stringValue(part.tool_use_id); + if (!toolUseId || !webSearchToolUseIds.has(toolUseId)) { + continue; + } + const text = anthropicToolResultContentText(part.content); + if (text) { + texts.push(text); + } + } + return texts; +} + +function anthropicToolResultContentText(content: unknown): string { + if (typeof content === "string") { + return content; + } + if (!Array.isArray(content)) { + return ""; + } + return content.flatMap((part) => { + if (!isRecord(part)) { + return []; + } + const type = stringValue(part.type); + if (type === "text" || type === "input_text" || type === "output_text") { + const text = stringValue(part.text); + return text ? [text] : []; + } + return []; + }).join("\n"); +} + +function textPartsFromOpenAiChatMessages(messages: unknown): string[] { + if (!Array.isArray(messages)) { + return []; + } + return messages.flatMap((message) => { + if (!isRecord(message) || stringValue(message.role)?.toLowerCase() !== "user") { + return []; + } + return textPartsFromOpenAiContent(message.content); + }); +} + +function textPartsFromOpenAiContent(content: unknown): string[] { + if (typeof content === "string") { + return [content]; + } + if (!Array.isArray(content)) { + return []; + } + return content.flatMap((part) => { + if (!isRecord(part)) { + return []; + } + const type = stringValue(part.type); + if (type === "text" || type === "input_text" || type === "output_text") { + return stringValue(part.text) ? [stringValue(part.text) as string] : []; + } + return []; + }); +} + +function textPartsFromOpenAiResponsesInput(input: unknown): string[] { + if (typeof input === "string") { + return [input]; + } + if (!Array.isArray(input)) { + return []; + } + return input.flatMap((item) => { + if (!isRecord(item)) { + return []; + } + const role = stringValue(item.role)?.toLowerCase(); + if (role && role !== "user") { + return []; + } + return textPartsFromOpenAiContent(item.content); + }); +} + +function textPartsFromGeminiContents(contents: unknown): string[] { + if (!Array.isArray(contents)) { + return []; + } + return contents.flatMap((content) => { + if (!isRecord(content)) { + return []; + } + const role = stringValue(content.role)?.toLowerCase(); + if (role && role !== "user") { + return []; + } + const parts = Array.isArray(content.parts) ? content.parts : []; + return parts.flatMap((part) => isRecord(part) && typeof part.text === "string" ? [part.text] : []); + }); +} + +export function fusionWebSearchToolNameForRequest(config: AppConfig, model: string | undefined): string | undefined { + const normalizedModel = model ? fusionModelNameFromSelector(model) : ""; + for (const candidate of fusionBrowserWebSearchToolCandidates(config)) { + if (!normalizedModel || candidate.aliases.some((alias) => fusionModelNameFromSelector(alias).toLowerCase() === normalizedModel.toLowerCase())) { + return candidate.toolName; + } + } + return undefined; +} + +function fusionBrowserWebSearchToolCandidates(config: AppConfig): Array<{ aliases: string[]; toolName: string }> { + const rawProfiles = Array.isArray(config.virtualModelProfiles) ? config.virtualModelProfiles : []; + const profiles = normalizeCoreGatewayVirtualModelProfiles( + withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases(rawProfiles)), + config + ); + const candidates: Array<{ aliases: string[]; toolName: string }> = []; + for (const profile of profiles) { + if (!isRecord(profile) || profile.enabled === false) { + continue; + } + const metadata = isRecord(profile.metadata) ? profile.metadata : undefined; + const fusionWebSearch = isRecord(metadata?.fusionWebSearch) ? metadata.fusionWebSearch : undefined; + const webSearchConfig = readFusionWebSearchConfig(fusionWebSearch); + if (!webSearchConfig?.toolName || webSearchConfig.provider !== "browser") { + continue; + } + const match = isRecord(profile.match) ? profile.match : undefined; + const aliases = uniqueStrings([ + stringValue(profile.id), + stringValue(profile.key), + stringValue(profile.displayName), + ...stringListValue(match?.exactAliases) + ].filter((item): item is string => Boolean(item))); + candidates.push({ aliases, toolName: webSearchConfig.toolName }); + } + return candidates; +} + +async function selectHostedWebSearchProtocolRecords( + context: HostedWebSearchProtocolContext, + integration: BrowserWebSearchMcpIntegration +): Promise { + const records = [ + ...(context.records ?? []), + ...(integration.recentBrowserWebSearchResults?.({ sinceMs: context.sinceMs, toolName: context.toolName }) ?? []) + ] + .filter((record) => record.results.length > 0) + .filter(uniqueSearchRecordFilter()) + .sort((left, right) => { + const queryScoreDelta = queryMatchScore(context.queryHint, right.query) - queryMatchScore(context.queryHint, left.query); + return queryScoreDelta || left.completedAtMs - right.completedAtMs; + }); + if (records.length > 0) { + return records.slice(0, 8); + } + if (!context.queryHint || !integration.runBrowserWebSearch) { + return []; + } + const record = await integration.runBrowserWebSearch({ + count: Math.trunc(clampNumber(context.maxUses ?? 5, 1, 10)), + prompt: context.queryHint, + timeoutMs: 30_000, + toolName: context.toolName + }); + return record?.results.length ? [record] : []; +} + +function selectClaudeCodeWebSearchContinuationRecords( + context: ClaudeCodeWebSearchContinuationContext, + integration: BrowserWebSearchMcpIntegration +): BrowserWebSearchProtocolRecord[] { + const records = integration.recentBrowserWebSearchResults?.({ + sinceMs: context.sinceMs, + toolName: context.toolName + }) ?? []; + return records + .filter((record) => record.results.length > 0) + .filter(uniqueSearchRecordFilter()) + .sort((left, right) => { + const queryScoreDelta = queryMatchScore(context.queryHint, right.query) - queryMatchScore(context.queryHint, left.query); + return queryScoreDelta || right.completedAtMs - left.completedAtMs; + }) + .slice(0, 3); +} + +async function selectAnthropicWebSearchProtocolRecords( + context: AnthropicWebSearchProtocolContext, + integration: BrowserWebSearchMcpIntegration +): Promise { + return selectHostedWebSearchProtocolRecords(context, integration); +} + +function uniqueSearchRecordFilter(): (record: BrowserWebSearchProtocolRecord) => boolean { + const seen = new Set(); + return (record) => { + const key = `${record.toolName}\n${record.query}\n${record.searchUrl}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }; +} + +function queryMatchScore(queryHint: string | undefined, query: string): number { + if (!queryHint) { + return 0; + } + const left = normalizeSearchComparisonText(queryHint); + const right = normalizeSearchComparisonText(query); + if (!left || !right) { + return 0; + } + if (left === right) { + return 4; + } + if (left.includes(right) || right.includes(left)) { + return 3; + } + const leftTerms = new Set(left.split(" ").filter((item) => item.length > 2)); + const rightTerms = right.split(" ").filter((item) => item.length > 2); + return rightTerms.reduce((score, term) => score + (leftTerms.has(term) ? 1 : 0), 0); +} + +function normalizeSearchComparisonText(value: string): string { + return value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").replace(/\s+/g, " ").trim(); +} + +function responseValueContainsAnthropicWebSearchBlocks(value: Record): boolean { + return Array.isArray(value.content) && value.content.some((block) => { + const type = isRecord(block) ? stringValue(block.type) : undefined; + return type === "server_tool_use" || type === "web_search_tool_result"; + }); +} + +function responseValueContainsVisibleText(value: Record): boolean { + return Array.isArray(value.content) && value.content.some((block) => { + if (!isRecord(block) || stringValue(block.type) !== "text") { + return false; + } + return Boolean(stringValue(block.text)?.trim()); + }); +} + +function responseValueContainsAnthropicClientToolUse(value: Record): boolean { + return Array.isArray(value.content) && value.content.some((block) => { + return isRecord(block) && stringValue(block.type) === "tool_use"; + }); +} + +function leadingThinkingBlockCount(content: unknown[]): number { + let index = 0; + while (index < content.length) { + const block = content[index]; + if (!isRecord(block) || stringValue(block.type) !== "thinking") { + break; + } + index += 1; + } + return index; +} + +function webSearchProtocolInsertIndex(content: unknown[], hasWebSearchBlocks: boolean): number { + let index = leadingThinkingBlockCount(content); + if (!hasWebSearchBlocks) { + return index; + } + while (index < content.length) { + const block = content[index]; + const type = isRecord(block) ? stringValue(block.type) : undefined; + if (type !== "server_tool_use" && type !== "web_search_tool_result") { + break; + } + index += 1; + } + return index; +} + +function mergeAnthropicWebSearchUsage(usage: unknown, searchCount: number): Record { + const nextUsage = isRecord(usage) ? { ...usage } : {}; + const serverToolUse = isRecord(nextUsage.server_tool_use) ? { ...nextUsage.server_tool_use } : {}; + const webSearchRequests = Math.max(1, Math.trunc(searchCount)); + serverToolUse.web_search_requests = Math.max(numberValue(serverToolUse.web_search_requests) ?? 0, webSearchRequests); + nextUsage.server_tool_use = serverToolUse; + return nextUsage; +} + +function sseEventsContainAnthropicWebSearchBlocks(events: ParsedSseEvent[]): boolean { + return events.some((event) => { + return sseEventContainsAnthropicWebSearchBlock(event); + }); +} + +function sseEventsContainVisibleText(events: ParsedSseEvent[]): boolean { + return events.some(sseEventContainsVisibleText); +} + +function sseEventContainsAnthropicWebSearchBlock(event: ParsedSseEvent): boolean { + const data = isRecord(event.data) ? event.data : undefined; + const block = isRecord(data?.content_block) ? data.content_block : undefined; + const type = stringValue(block?.type) || stringValue(data?.type); + return type === "server_tool_use" || type === "web_search_tool_result"; +} + +function sseEventContainsVisibleText(event: ParsedSseEvent): boolean { + const data = isRecord(event.data) ? event.data : undefined; + if (!data) { + return false; + } + const block = isRecord(data.content_block) ? data.content_block : undefined; + if (stringValue(data.type) === "content_block_start" && stringValue(block?.type) === "text") { + return Boolean(stringValue(block?.text)?.trim()); + } + const delta = isRecord(data.delta) ? data.delta : undefined; + return stringValue(data.type) === "content_block_delta" && + stringValue(delta?.type) === "text_delta" && + Boolean(stringValue(delta?.text)?.trim()); +} + +function sseEventsContainAnthropicClientToolUse(events: ParsedSseEvent[]): boolean { + return events.some(sseEventContainsAnthropicClientToolUse); +} + +function sseEventContainsAnthropicClientToolUse(event: ParsedSseEvent): boolean { + const data = isRecord(event.data) ? event.data : undefined; + const block = isRecord(data?.content_block) ? data.content_block : undefined; + return stringValue(data?.type) === "content_block_start" && stringValue(block?.type) === "tool_use"; +} + +function anthropicSseTextBlockStartIndex(event: ParsedSseEvent): number | undefined { + const data = isRecord(event.data) ? event.data : undefined; + const block = isRecord(data?.content_block) ? data.content_block : undefined; + if (stringValue(data?.type) !== "content_block_start" || stringValue(block?.type) !== "text") { + return undefined; + } + const index = numberValue(data?.index); + return index === undefined ? undefined : index; +} + +function sseEventIsAnthropicMessageEnd(event: ParsedSseEvent): boolean { + const type = isRecord(event.data) ? stringValue(event.data.type) : undefined; + return type === "message_delta" || type === "message_stop"; +} + +function anthropicWebSearchSseEventsForBlock(block: Record, index: number): ParsedSseEvent[] { + if (stringValue(block.type) === "text") { + const text = stringValue(block.text) ?? ""; + return [ + sseEventFromValue({ + content_block: { text: "", type: "text" }, + index, + type: "content_block_start" + }), + sseEventFromValue({ + delta: { text, type: "text_delta" }, + index, + type: "content_block_delta" + }), + sseEventFromValue({ + index, + type: "content_block_stop" + }) + ]; + } + return [ + sseEventFromValue({ + content_block: block, + index, + type: "content_block_start" + }), + sseEventFromValue({ + index, + type: "content_block_stop" + }) + ]; +} + +function updateAnthropicWebSearchSseUsage( + event: ParsedSseEvent, + searchCount: number, + didSynthesizeAnswer: boolean, + hasClientToolUse: boolean +): ParsedSseEvent { + if (!isRecord(event.data) || stringValue(event.data.type) !== "message_delta") { + return event; + } + const delta = isRecord(event.data.delta) ? { ...event.data.delta } : event.data.delta; + const nextData: Record = { + ...event.data, + usage: mergeAnthropicWebSearchUsage(event.data.usage, searchCount) + }; + if (isRecord(delta) && shouldEndAnthropicHostedWebSearchTurn(delta.stop_reason, didSynthesizeAnswer, hasClientToolUse)) { + nextData.delta = { ...delta, stop_reason: "end_turn" }; + } + return { + ...event, + data: nextData + }; +} + +function shouldEndAnthropicHostedWebSearchTurn( + stopReason: unknown, + didSynthesizeAnswer: boolean, + hasClientToolUse: boolean +): boolean { + if (hasClientToolUse) { + return false; + } + const normalized = stringValue(stopReason); + return normalized === "tool_use" || (didSynthesizeAnswer && normalized === "max_tokens"); +} + +function synthesizeWebSearchAnswer(records: BrowserWebSearchProtocolRecord[], queryHint: string | undefined): string | undefined { + const query = queryHint || records.map((record) => record.query).find(Boolean) || ""; + const weatherAnswer = synthesizeWeatherWebSearchAnswer(records, query); + if (weatherAnswer) { + return weatherAnswer; + } + const componentChangelogAnswer = synthesizeComponentChangelogWebSearchAnswer(records, query); + if (componentChangelogAnswer) { + return componentChangelogAnswer; + } + const evidence = topWebSearchEvidenceSentences(records, query, 3); + if (evidence.length === 0) { + const sources = webSearchSourceNames(records, 3); + if (!sources) { + return undefined; + } + return containsCjkText(query) + ? `搜索已完成,但页面可提取正文不足。较相关的来源包括:${sources}。` + : `The search completed, but the pages did not expose enough extractable text. The most relevant sources are: ${sources}.`; + } + const sources = webSearchSourceNames(records, 3); + return containsCjkText(query) + ? `根据搜索结果,${evidence.join(";")}。${sources ? `来源:${sources}。` : ""}` + : `Based on the search results, ${evidence.join("; ")}.${sources ? ` Sources: ${sources}.` : ""}`; +} + +function synthesizeComponentChangelogWebSearchAnswer(records: BrowserWebSearchProtocolRecord[], query: string): string | undefined { + const normalizedQuery = normalizeSearchComparisonText(query); + const asksForComponents = /component|components|组件/i.test(query); + const asksForNewOrChangelog = /new|latest|recent|changelog|release|新增|新组件|最新|更新|官方/i.test(query); + if (!asksForComponents || !asksForNewOrChangelog) { + return undefined; + } + const items = webSearchEvidenceItems(records); + const preferred = items.find((item) => { + const normalized = normalizeSearchComparisonText(`${item.source} ${item.url} ${item.text.slice(0, 500)}`); + return normalized.includes("changelog") || normalized.includes("official") || normalized.includes("docs") || normalizedQuery.includes("official"); + }) ?? items[0]; + if (!preferred) { + return undefined; + } + const release = extractComponentReleaseTitle(preferred.text); + const components = extractLikelyComponentNames(preferred.text); + const sources = webSearchSourceNames(records, 2); + const cjk = containsCjkText(query); + if (!release && components.length === 0) { + return undefined; + } + if (cjk) { + return [ + release ? `官方相关条目是 ${release}` : "官方页面包含新增组件相关内容", + components.length > 0 ? `可提取到的相关组件包括 ${components.join("、")}` : "", + sources ? `来源:${sources}。` : "" + ].filter(Boolean).join(";"); + } + return [ + release ? `The relevant official entry is ${release}` : "The official page contains new component information", + components.length > 0 ? `extractable related components include ${components.join(", ")}` : "", + sources ? `Sources: ${sources}.` : "" + ].filter(Boolean).join("; "); +} + +function extractComponentReleaseTitle(text: string): string | undefined { + const patterns = [ + /((?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{4}\s*-\s*Components?[^.。!?]{0,90})/i, + /(\d{4}[-/]\d{1,2}[^.。!?]{0,60}Components?[^.。!?]{0,60})/i + ]; + for (const pattern of patterns) { + const match = pattern.exec(text); + const title = match?.[1]?.replace(/\s+/g, " ").trim(); + if (title) { + return title; + } + } + return undefined; +} + +function extractLikelyComponentNames(text: string): string[] { + const knownNames = [ + "Message Scroller", + "Message", + "Attachment", + "Bubble", + "Marker", + "Empty", + "Item", + "Field", + "Input OTP", + "Button Group" + ]; + const lower = text.toLowerCase(); + return knownNames.filter((name) => lower.includes(name.toLowerCase())).slice(0, 10); +} + +function synthesizeWeatherWebSearchAnswer(records: BrowserWebSearchProtocolRecord[], query: string): string | undefined { + if (!/天气|气温|温度|weather|forecast|temperature/i.test(query)) { + return undefined; + } + const items = webSearchEvidenceItems(records); + const text = items.map((item) => item.text).join(" "); + if (!text) { + return undefined; + } + const cjk = containsCjkText(query); + const location = extractWeatherLocation(query); + const temperatureRange = weatherTemperatureRange(text); + const currentTemperature = firstRegexGroup(text, [ + /(?:当前|现在|实时|实况|气温|温度)[^。;,,\d-]{0,12}(-?\d{1,2}(?:\.\d+)?)\s*℃/i, + location ? new RegExp(`${escapeRegExp(location)}\\s+(-?\\d{1,2}(?:\\.\\d+)?)\\s*℃`) : undefined + ]); + const feelsLike = firstRegexGroup(text, [/体感温度[::\s]*(-?\d{1,2}(?:\.\d+)?)\s*℃/]); + const high = firstRegexGroup(text, [/最高气温[::\s]*(-?\d{1,2}(?:\.\d+)?)\s*℃/]); + const low = firstRegexGroup(text, [/最低气温[::\s]*(-?\d{1,2}(?:\.\d+)?)\s*℃/]); + const humidity = firstRegexGroup(text, [/(?:最大相对湿度|相对湿度)[::\s]*(-?\d{1,3}(?:\.\d+)?%)/]); + const aqi = firstRegexGroup(text, [/AQI最高值[::\s]*(\d{1,3})/i]); + const airQuality = firstRegexGroup(text, [/空气质量[::\s]*([^\s,。;,;]{1,12})/]); + const rain = firstRegexGroup(text, [/(?:过去24小时总降水量|总降水量|降水量)[::\s]*(-?\d+(?:\.\d+)?mm)/i]); + const wind = firstRegexGroup(text, [/最大风力[::\s]*([<>]?\d+级|微风)/, /(东风|东南风|南风|西南风|西风|西北风|北风|东北风)\s*([<>]?\d+级|微风)/]); + + const facts = [ + currentTemperature ? (cjk ? `当前约 ${currentTemperature}℃` : `currently about ${currentTemperature}°C`) : undefined, + !currentTemperature && temperatureRange ? (cjk ? `气温约 ${temperatureRange}` : `temperatures are around ${temperatureRange}`) : undefined, + feelsLike ? (cjk ? `体感约 ${feelsLike}℃` : `feels like about ${feelsLike}°C`) : undefined, + high || low ? (cjk + ? `过去24小时${high ? `最高 ${high}℃` : ""}${high && low ? "、" : ""}${low ? `最低 ${low}℃` : ""}` + : `over the past 24 hours ${high ? `the high was ${high}°C` : ""}${high && low ? " and " : ""}${low ? `the low was ${low}°C` : ""}`) : undefined, + humidity ? (cjk ? `相对湿度最高 ${humidity}` : `relative humidity reached ${humidity}`) : undefined, + aqi ? (cjk ? `AQI 最高 ${aqi}` : `AQI reached ${aqi}`) : undefined, + airQuality && !aqi ? (cjk ? `空气质量 ${airQuality}` : `air quality is ${airQuality}`) : undefined, + rain ? (cjk ? `过去24小时降水量 ${rain}` : `24-hour rainfall is ${rain}`) : undefined, + wind ? (cjk ? `风力 ${wind}` : `wind ${wind}`) : undefined + ].filter((item): item is string => Boolean(item)); + + if (facts.length === 0) { + return undefined; + } + const sources = webSearchSourceNames(records, 2); + if (cjk) { + return `${location ? `${location}天气` : "天气"}:${facts.slice(0, 6).join(",")}。${sources ? `来源:${sources}。` : ""}`; + } + return `${location ? `${location} weather` : "Weather"}: ${facts.slice(0, 6).join(", ")}.${sources ? ` Sources: ${sources}.` : ""}`; +} + +function webSearchEvidenceItems(records: BrowserWebSearchProtocolRecord[]): Array<{ source: string; text: string; url: string }> { + return records.flatMap((record) => record.results.map((result) => ({ + source: result.title || hostnameFromUrl(result.url) || record.engine, + text: sanitizeWebSearchEvidenceText(result.content || result.snippet || ""), + url: result.url + }))).filter((item) => item.text); +} + +function topWebSearchEvidenceSentences(records: BrowserWebSearchProtocolRecord[], query: string, limit: number): string[] { + const terms = relevantSearchTerms(query); + const scored = webSearchEvidenceItems(records).flatMap((item, itemIndex) => { + const sentences = splitEvidenceSentences(item.text).slice(0, 12); + return sentences.map((sentence, sentenceIndex) => { + const normalizedSentence = normalizeSearchComparisonText(sentence); + const termScore = terms.reduce((score, term) => score + (normalizedSentence.includes(term) ? 2 : 0), 0); + const sourceBonus = itemIndex === 0 ? 2 : itemIndex === 1 ? 1 : 0; + const positionBonus = Math.max(0, 4 - sentenceIndex) / 4; + return { + score: termScore + sourceBonus + positionBonus, + sentence + }; + }); + }).filter((item) => item.sentence.length >= 12 && item.sentence.length <= 260); + scored.sort((left, right) => right.score - left.score || left.sentence.length - right.sentence.length); + const seen = new Set(); + return scored.flatMap((item) => { + const key = normalizeSearchComparisonText(item.sentence).slice(0, 120); + if (!key || seen.has(key)) { + return []; + } + seen.add(key); + return [item.sentence]; + }).slice(0, limit); +} + +function splitEvidenceSentences(text: string): string[] { + return text + .replace(/\s+/g, " ") + .split(/[。!?!?]\s*|\n+/g) + .map((sentence) => sentence.trim().replace(/[,,;;::]\s*$/, "")) + .filter((sentence) => sentence && !looksLikeNavigationText(sentence)); +} + +function looksLikeNavigationText(text: string): boolean { + const punctuationCount = (text.match(/[,,。;;::]/g) ?? []).length; + const digitCount = (text.match(/\d/g) ?? []).length; + return text.length > 160 && punctuationCount < 2 && digitCount < 2; +} + +function relevantSearchTerms(query: string): string[] { + const normalizedTerms = normalizeSearchComparisonText(query) + .split(" ") + .filter((term) => term.length >= 2); + const cjkTerms = query.match(/[\p{Script=Han}]{2,}/gu) ?? []; + return uniqueStrings([...normalizedTerms, ...cjkTerms].map((term) => term.toLowerCase())); +} + +function weatherTemperatureRange(text: string): string | undefined { + const values = Array.from(text.matchAll(/(-?\d{1,2}(?:\.\d+)?)\s*℃/g)) + .map((match) => Number(match[1])) + .filter((value) => Number.isFinite(value) && value > -80 && value < 60) + .slice(0, 8); + if (values.length === 0) { + return undefined; + } + const min = Math.min(...values); + const max = Math.max(...values); + const format = (value: number) => Number.isInteger(value) ? String(value) : value.toFixed(1); + return min === max ? `${format(min)}℃` : `${format(min)}-${format(max)}℃`; +} + +function extractWeatherLocation(query: string): string | undefined { + const cleaned = query + .replace(/perform\s+a\s+web\s+search\s+for\s+the\s+query:\s*/i, "") + .replace(/天气预报|天气|气温|温度|怎么样|如何|查询|搜索|今天|今日|现在|当前|请问|weather|forecast|temperature/gi, " ") + .replace(/\s+/g, " ") + .trim(); + if (!cleaned || cleaned.length > 24) { + return undefined; + } + return cleaned; +} + +function firstRegexGroup(text: string, patterns: Array): string | undefined { + for (const pattern of patterns) { + if (!pattern) { + continue; + } + const match = pattern.exec(text); + const value = match?.[1]; + if (value) { + return value.trim(); + } + } + return undefined; +} + +function sanitizeWebSearchEvidenceText(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +function containsCjkText(text: string): boolean { + return /\p{Script=Han}/u.test(text); +} + +function webSearchSourceNames(records: BrowserWebSearchProtocolRecord[], limit: number): string { + return uniqueStrings(records.flatMap((record) => record.results.map((result) => { + const title = result.title?.trim(); + return title || hostnameFromUrl(result.url) || record.engine; + }))).slice(0, limit).join("、"); +} + +function hostnameFromUrl(value: string): string | undefined { + try { + return new URL(value).hostname.replace(/^www\./, ""); + } catch { + return undefined; + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function anthropicWebSearchProtocolBlocks(records: BrowserWebSearchProtocolRecord[], requestId: string): Record[] { + const blocks: Record[] = []; + records.forEach((record, index) => { + const toolUseId = `srvtoolu_${sanitizeAnthropicToolUseId(requestId)}_${index + 1}`; + blocks.push({ + id: toolUseId, + input: { query: record.query }, + name: "web_search", + type: "server_tool_use" + }); + blocks.push({ + content: record.results.map(anthropicWebSearchResultBlock), + tool_use_id: toolUseId, + type: "web_search_tool_result" + }); + }); + return blocks; +} + +function anthropicWebSearchResultBlock(result: BrowserWebSearchProtocolResult): Record { + const snippet = anthropicWebSearchResultSnippet(result); + return { + encrypted_content: "", + ...(snippet ? { snippet: snippet.slice(0, 1_200) } : {}), + title: result.title, + type: "web_search_result", + url: result.url + }; +} + +function anthropicWebSearchResultSnippet(result: BrowserWebSearchProtocolResult): string | undefined { + const parts = [ + result.snippet ? `Search snippet: ${sanitizeWebSearchEvidenceText(result.snippet)}` : "", + result.content ? `Extracted page content: ${sanitizeWebSearchEvidenceText(result.content)}` : "", + result.diagnostics?.length ? `Diagnostics: ${result.diagnostics.join("; ")}` : "" + ].filter(Boolean); + return parts.length > 0 ? parts.join("\n") : undefined; +} + +function sanitizeAnthropicToolUseId(value: string): string { + return value.replace(/[^a-zA-Z0-9]/g, "").slice(0, 24) || randomBytes(8).toString("hex"); +} + +type ParsedSseEvent = { + data?: unknown; + event?: string; + raw?: string; +}; + +function parseSseEvents(body: string): ParsedSseEvent[] { + return body + .split(/\r?\n\r?\n/g) + .filter((block) => block.trim()) + .map(parseSseEventBlock); +} + +function parseSseEventBlock(raw: string): ParsedSseEvent { + const lines = raw.split(/\r?\n/g); + const event = lines + .filter((line) => line.startsWith("event:")) + .map((line) => line.slice(6).trim()) + .find(Boolean); + const data = lines + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).replace(/^ /, "")) + .join("\n"); + if (!data || data === "[DONE]") { + return { event, raw }; + } + try { + return { data: JSON.parse(data) as unknown, event, raw }; + } catch { + return { event, raw }; + } +} + +function shiftSseContentBlockIndex(event: ParsedSseEvent, startIndex: number, delta: number): ParsedSseEvent { + if (!isRecord(event.data) || !Number.isFinite(event.data.index) || Number(event.data.index) < startIndex) { + return event; + } + return { + ...event, + data: { + ...event.data, + index: Number(event.data.index) + delta + } + }; +} + +function sseEventFromValue(data: Record): ParsedSseEvent { + return { + data, + event: stringValue(data.type) + }; +} + +function serializeSseEvent(event: ParsedSseEvent): string { + if (event.data === undefined) { + return event.raw ?? ""; + } + const type = isRecord(event.data) ? stringValue(event.data.type) : undefined; + return [ + event.event || type ? `event: ${event.event || type}` : undefined, + `data: ${JSON.stringify(event.data)}` + ].filter(Boolean).join("\n"); +} + +function requestProtocolForPath(path: string): GatewayProviderProtocol | undefined { + const normalized = path.toLowerCase(); + if (normalized === "/v1/messages" || normalized === "/messages" || normalized.endsWith("/v1/messages")) { + return "anthropic_messages"; + } + if (normalized === "/v1/chat/completions" || normalized === "/chat/completions" || normalized.endsWith("/chat/completions")) { + return "openai_chat_completions"; + } + if (normalized === "/v1/responses" || normalized === "/responses" || normalized.endsWith("/responses")) { + return "openai_responses"; + } + if (/\/v1(?:beta)?\/models\/[^/]+:(?:generatecontent|streamgeneratecontent)$/i.test(normalized)) { + return "gemini_generate_content"; + } + if (/\/v1(?:beta)?\/interactions(?:\/[^/]+(?:\/cancel)?)?$/i.test(normalized)) { + return "gemini_interactions"; + } + return undefined; +} + +function rewriteProviderHeader( + headers: Record, + headerName: string, + config: AppConfig, + protocol: GatewayProviderProtocol +): void { + const value = headers[headerName]; + if (!value) { + return; + } + headers[headerName] = rewriteProviderSelectorForProtocol(value, config, protocol); +} + +function rewriteProviderListHeader( + headers: Record, + headerName: string, + config: AppConfig, + protocol: GatewayProviderProtocol +): void { + const value = headers[headerName]; + if (!value) { + return; + } + headers[headerName] = value + .split(",") + .map((item) => rewriteProviderSelectorForProtocol(item.trim(), config, protocol)) + .filter(Boolean) + .join(","); +} + +function rewriteProviderSelectorForProtocol(value: string, config: AppConfig, protocol: GatewayProviderProtocol): string { + const provider = findProviderByPublicOrInternalName(config, value); + const capability = provider ? providerCapabilityForClientProtocol(provider, protocol) : undefined; + return provider && capability ? providerCapabilityInternalName(provider, capability.type) : value; +} + +function rewriteFallbackForProtocol(fallback: RouterFallbackConfig, config: AppConfig, protocol: GatewayProviderProtocol): RouterFallbackConfig { + const models = fallback.models.map((model) => rewriteModelSelectorForProtocol(model, config, protocol) ?? model); + return models.every((model, index) => model === fallback.models[index]) + ? fallback + : { + ...fallback, + models + }; +} + +function rewriteBodyModelForProtocol(body: Buffer | undefined, config: AppConfig, protocol: GatewayProviderProtocol): Buffer | undefined { + const parsedBody = parseJsonObjectSafe(body); + if (!parsedBody) { + return body; + } + const model = stringValue(parsedBody.model); + const rewrittenModel = rewriteModelSelectorForProtocol(model, config, protocol); + if (!rewrittenModel || rewrittenModel === model) { + return body; + } + return Buffer.from(`${JSON.stringify({ ...parsedBody, model: rewrittenModel })}\n`, "utf8"); +} + +function clearTargetProviderHeadersForModelSelector( + headers: Record, + config: AppConfig, + body: Buffer | undefined, + routedModel: string | undefined +): void { + const parsedBody = parseJsonObjectSafe(body); + const model = stringValue(parsedBody?.model) || routedModel; + if (!resolveConfiguredProviderModelSelector(model, config)) { + return; + } + + delete headers["x-target-provider"]; + delete headers["x-target-providers"]; + delete headers["x-gateway-target-provider"]; +} + +function rewriteModelSelectorForProtocol( + model: string | undefined, + config: AppConfig, + protocol: GatewayProviderProtocol +): string | undefined { + const normalized = normalizeRouteSelector(model); + if (!normalized) { + return model; + } + const publicModel = resolveGatewayPublicModelId(normalized, config) ?? normalized; + const selector = + resolveConfiguredProviderModelSelector(publicModel, config) ?? + resolveUniqueConfiguredProviderModelSelector(publicModel, config); + const capability = selector ? providerCapabilityForClientProtocol(selector.provider, protocol) : undefined; + return selector && capability + ? `${providerCapabilityInternalName(selector.provider, capability.type)}/${selector.model}` + : publicModel; +} + +function providerCapabilityForClientProtocol( + provider: GatewayProviderConfig, + clientProtocol: GatewayProviderProtocol +): GatewayProviderCapability | undefined { + const capabilities = normalizedProviderCapabilities(provider); + for (const protocol of providerProtocolPreferenceForClient(clientProtocol)) { + const capability = capabilities.find((item) => item.type === protocol); + if (capability) { + return capability; + } + } + return undefined; +} + +function providerProtocolForClientProtocol( + provider: GatewayProviderConfig, + clientProtocol: GatewayProviderProtocol +): GatewayProviderProtocol | undefined { + const capability = providerCapabilityForClientProtocol(provider, clientProtocol); + if (capability) { + return capability.type; + } + const directProtocol = + normalizeProviderProtocol(provider.type) ?? + normalizeProviderProtocol(provider.provider) ?? + inferProtocol(provider); + return providerProtocolPreferenceForClient(clientProtocol).includes(directProtocol) + ? directProtocol + : undefined; +} + +function providerProtocolPreferenceForClient(clientProtocol: GatewayProviderProtocol): GatewayProviderProtocol[] { + if (clientProtocol === "openai_responses") { + return ["openai_responses", "openai_chat_completions", "anthropic_messages", "gemini_interactions"]; + } + if (clientProtocol === "anthropic_messages") { + return uniqueProviderProtocols([clientProtocol, ...gatewayProviderProtocolFallbackOrder]); + } + return [clientProtocol]; +} + +function uniqueProviderProtocols(protocols: GatewayProviderProtocol[]): GatewayProviderProtocol[] { + const seen = new Set(); + const output: GatewayProviderProtocol[] = []; + for (const protocol of protocols) { + if (seen.has(protocol)) { + continue; + } + seen.add(protocol); + output.push(protocol); + } + return output; +} + +function findProviderByPublicOrInternalName(config: AppConfig, name: string): GatewayProviderConfig | undefined { + const normalized = name.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + const credentialInternalName = parseProviderCredentialInternalName(name); + if (credentialInternalName) { + const internalProviderId = credentialInternalName.providerId.toLowerCase(); + return config.Providers.find((provider) => + provider.name.trim().toLowerCase() === internalProviderId || + providerRuntimeId(provider).toLowerCase() === internalProviderId + ); + } + return config.Providers.find((provider) => + provider.name.trim().toLowerCase() === normalized || + provider.id?.trim().toLowerCase() === normalized || + provider.provider?.trim().toLowerCase() === normalized || + providerRuntimeId(provider).toLowerCase() === normalized || + normalizedProviderCapabilities(provider).some((capability) => + providerCapabilityNameMatches(provider, capability.type, normalized) + ) + ); +} + +function rewriteCapabilityResponseHeaders(headers: Headers, config: AppConfig): Headers { + const providerName = headers.get("x-gateway-target-provider-name")?.trim(); + if (!providerName) { + return headers; + } + const credentialInternalName = parseProviderCredentialInternalName(providerName); + if (credentialInternalName) { + const provider = findProviderByPublicOrInternalName(config, credentialInternalName.providerId); + if (!provider) { + return headers; + } + const credential = findProviderCredentialBySlug(provider, credentialInternalName.credentialSlug); + const rewritten = new Headers(headers); + rewritten.set("x-gateway-target-provider-name", providerRuntimeId(provider)); + rewritten.set("x-ccr-provider-protocol", credentialInternalName.protocol); + rewritten.set("x-ccr-provider-credential-provider", providerRuntimeId(provider)); + rewritten.set("x-ccr-provider-credential-id", providerCredentialSlug(credential ? providerCredentialRuntimeId(provider, credential) : credentialInternalName.credentialSlug)); + return rewritten; + } + const provider = findProviderByPublicOrInternalName(config, providerName); + if (!provider) { + return headers; + } + const capability = normalizedProviderCapabilities(provider).find((item) => + providerCapabilityNameMatches(provider, item.type, providerName) + ); + const rewritten = new Headers(headers); + rewritten.set("x-gateway-target-provider-name", providerRuntimeId(provider)); + if (capability) { + rewritten.set("x-ccr-provider-protocol", capability.type); + } + return rewritten; +} + +async function fetchUpstreamWithFallback(input: { + body?: Buffer; + config: AppConfig; + coreAuthToken: string; + fallback: RouterFallbackConfig; + headers: Record; + method: string; + path: string; + routedModel?: string; + signal?: AbortSignal; + upstreamUrl: string; +}): Promise { + const fallbackMode = input.fallback.mode; + const attempts = buildUpstreamAttempts(input.fallback, input.method, input.body, input.routedModel); + const failedAttempts: UpstreamFailedAttempt[] = []; + + for (let index = 0; index < attempts.length; index += 1) { + if (input.signal?.aborted) { + throw new UpstreamRequestError(abortSignalMessage(input.signal), { + failedAttempts + }); + } + + const attempt = prepareUpstreamCredentialAttempt({ + attempt: attempts[index], + config: input.config, + headers: input.headers, + method: input.method, + path: input.path + }); + const hasNextAttempt = index < attempts.length - 1; + + try { + const response = await fetchWithSystemProxy(input.upstreamUrl, { + body: shouldSendBody(input.method) ? attempt.body?.toString("utf8") : undefined, + headers: withCoreGatewayAuthHeader(omitLocalObservabilityHeaders(attempt.headers ?? input.headers), input.coreAuthToken), + method: input.method, + signal: input.signal + }); + + if (hasNextAttempt && shouldFallbackAfterStatus(response.status, fallbackMode)) { + const delayMs = retryDelayAfterStatus(response.status, response.headers, failedAttempts.length); + failedAttempts.push({ + credentialChain: attempt.credentialChain, + credentialIds: attempt.credentialIds, + delayMs, + model: attempt.model, + statusCode: response.status + }); + recordProviderCredentialOutcome(input.config, input.method, attempt, response.status, response.headers); + await drainResponseBody(response); + if (delayMs > 0) { + await delay(delayMs); + } + continue; + } + + return { + attempt, + failedAttempts, + response + }; + } catch (error) { + const message = formatError(error); + const delayMs = hasNextAttempt && !input.signal?.aborted + ? retryDelayAfterNetworkError(failedAttempts.length) + : 0; + failedAttempts.push({ + credentialChain: attempt.credentialChain, + credentialIds: attempt.credentialIds, + delayMs, + error: message, + model: attempt.model + }); + if (input.signal?.aborted) { + throw new UpstreamRequestError(abortSignalMessage(input.signal), { + attempt, + cause: error, + failedAttempts + }); + } + if (hasNextAttempt) { + if (delayMs > 0) { + await delay(delayMs); + } + continue; + } + throw new UpstreamRequestError(message, { + attempt, + cause: error, + failedAttempts + }); + } + } + + throw new UpstreamRequestError("Gateway request failed before reaching an upstream provider.", { + failedAttempts + }); +} + +function prepareUpstreamCredentialAttempt(input: { + attempt: UpstreamAttempt; + config: AppConfig; + headers: Record; + method: string; + path: string; +}): UpstreamAttempt { + const normalizedBody = normalizeConfiguredProviderModelBody(input.attempt.body, input.config); + const target = resolveProviderCredentialRoutingTarget(input.config, input.headers, input.path, input.attempt.body); + const attemptBody = (body: Buffer | undefined) => usageAwareOpenAiChatAttemptBody({ + body, + config: input.config, + path: input.path, + target + }); + if (!target) { + const body = bodyHasConfiguredProviderModelSelector(input.attempt.body, input.config) + ? input.attempt.body + : normalizedBody?.body ?? input.attempt.body; + return { + ...input.attempt, + body: attemptBody(body), + headers: input.headers + }; + } + + const credentials = activeProviderCredentials(target.provider); + if (credentials.length === 0) { + return { + ...input.attempt, + body: attemptBody(target.body ?? normalizedBody?.body ?? input.attempt.body), + headers: input.headers + }; + } + + const usage = estimateLimitUsage(input.method, input.attempt.body ?? Buffer.alloc(0)); + const selection = selectProviderCredentials(target.provider, target.protocol, credentials, usage); + if (selection.credentials.length === 0) { + return { + ...input.attempt, + body: attemptBody(target.body ?? normalizedBody?.body ?? input.attempt.body), + headers: input.headers + }; + } + + const headers: Record = { + ...input.headers, + "x-target-providers": selection.credentials.map((candidate) => candidate.internalName).join(","), + "x-ccr-logical-provider": providerRuntimeId(target.provider), + "x-ccr-provider-credential-chain": selection.credentials.map((candidate) => candidate.credentialId).join(",") + }; + delete headers["x-target-provider"]; + if (selection.saturated) { + headers["x-ccr-provider-credential-saturated"] = "true"; + } + + return { + ...input.attempt, + body: attemptBody(target.body ?? normalizedBody?.body ?? input.attempt.body), + credentialChain: selection.credentials.map((candidate) => candidate.internalName), + credentialIds: selection.credentials.map((candidate) => candidate.credentialId), + credentialProtocol: target.protocol, + headers, + logicalProvider: target.provider.name + }; +} + +function usageAwareOpenAiChatAttemptBody(input: { + body: Buffer | undefined; + config: AppConfig; + path: string; + target?: { protocol: GatewayProviderProtocol }; +}): Buffer | undefined { + if (input.target?.protocol === "openai_chat_completions") { + return usageAwareOpenAiChatBody(input.body); + } + + const protocol = requestProtocolForPath(input.path); + if (!protocol) { + return input.body; + } + + const parsedBody = parseJsonObjectSafe(input.body); + const modelSelector = resolveConfiguredProviderModelSelector(stringValue(parsedBody?.model), input.config); + const providerProtocol = modelSelector + ? providerProtocolForClientProtocol(modelSelector.provider, protocol) + : undefined; + return providerProtocol === "openai_chat_completions" + ? usageAwareOpenAiChatBody(input.body) + : input.body; +} + +function usageAwareOpenAiChatBody(body: Buffer | undefined): Buffer | undefined { + const parsedBody = parseJsonObjectSafe(body); + if (!parsedBody || parsedBody.stream !== true) { + return body; + } + const streamOptions = isRecord(parsedBody.stream_options) + ? parsedBody.stream_options + : isRecord(parsedBody.streamOptions) + ? parsedBody.streamOptions + : {}; + if (streamOptions.include_usage === true || streamOptions.includeUsage === true) { + return body; + } + return Buffer.from(`${JSON.stringify({ + ...parsedBody, + stream_options: { + ...streamOptions, + include_usage: true + } + })}\n`, "utf8"); +} + +function normalizeConfiguredProviderModelBody( + body: Buffer | undefined, + config: AppConfig +): { body: Buffer; model: string } | undefined { + const parsedBody = parseJsonObjectSafe(body); + const model = stringValue(parsedBody?.model); + const selector = resolveConfiguredProviderModelSelector(model, config); + if (!parsedBody || !selector || selector.model === model) { + return undefined; + } + return { + body: serializeJsonBodyWithModel(parsedBody, selector.model), + model: selector.model + }; +} + +function bodyHasConfiguredProviderModelSelector(body: Buffer | undefined, config: AppConfig): boolean { + const parsedBody = parseJsonObjectSafe(body); + const model = stringValue(parsedBody?.model); + return Boolean(resolveConfiguredProviderModelSelector(model, config)); +} + +function resolveProviderCredentialRoutingTarget( + config: AppConfig, + headers: Record, + path: string, + body: Buffer | undefined +): { body?: Buffer; model?: string; provider: GatewayProviderConfig; protocol: GatewayProviderProtocol } | undefined { + const protocol = requestProtocolForPath(path); + if (!protocol) { + return undefined; + } + + const parsedBody = parseJsonObjectSafe(body); + const bodyModel = stringValue(parsedBody?.model); + const modelSelector = resolveConfiguredProviderModelSelector(bodyModel, config); + if (modelSelector) { + const provider = modelSelector.provider; + const providerProtocol = provider ? providerProtocolForClientProtocol(provider, protocol) : undefined; + if (provider && providerProtocol && activeProviderCredentials(provider).length > 0) { + return { + body: parsedBody ? serializeJsonBodyWithModel(parsedBody, modelSelector.model) : body, + model: modelSelector.model, + provider, + protocol: providerProtocol + }; + } + } + + const targetProviderName = firstTargetProviderHeader(headers); + if (!targetProviderName) { + return undefined; + } + + const provider = findProviderByPublicOrInternalName(config, targetProviderName); + if (!provider || activeProviderCredentials(provider).length === 0) { + return undefined; + } + const providerProtocol = providerProtocolForClientProtocol(provider, protocol); + if (!providerProtocol) { + return undefined; + } + const providerModel = resolveModelForProvider(bodyModel, provider); + + return { + body: parsedBody && providerModel && providerModel !== bodyModel + ? serializeJsonBodyWithModel(parsedBody, providerModel) + : body, + model: providerModel ?? bodyModel, + provider, + protocol: providerProtocol + }; +} + +function resolveModelForProvider( + value: string | undefined, + provider: GatewayProviderConfig +): string | undefined { + const normalized = normalizeRouteSelector(value); + if (!normalized) { + return undefined; + } + if (providerHasModel(provider, normalized)) { + return normalized; + } + const parsed = parseProviderModelSelector(normalized); + return parsed && providerHasModel(provider, parsed.model) ? parsed.model : undefined; +} + +function providerHasModel(provider: GatewayProviderConfig, model: string): boolean { + const normalized = model.trim().toLowerCase(); + return Boolean(normalized) && provider.models.some((candidate) => candidate.trim().toLowerCase() === normalized); +} + +function parseProviderModelSelector(value: string | undefined): { model: string; provider: string } | undefined { + const normalized = normalizeRouteSelector(value); + if (!normalized) { + return undefined; + } + const separator = normalized.indexOf("/"); + if (separator <= 0 || separator >= normalized.length - 1) { + return undefined; + } + const provider = normalized.slice(0, separator).trim(); + const model = normalized.slice(separator + 1).trim(); + return provider && model ? { model, provider } : undefined; +} + +function resolveConfiguredProviderModelSelector( + value: string | undefined, + config: AppConfig +): { model: string; provider: GatewayProviderConfig } | undefined { + let current = normalizeRouteSelector(value); + if (!current) { + return undefined; + } + + let selectedProvider: GatewayProviderConfig | undefined; + for (let depth = 0; depth < 4; depth += 1) { + const parsed = parseProviderModelSelector(current); + if (!parsed) { + break; + } + + const provider = findProviderByPublicOrInternalName(config, parsed.provider); + if (!provider) { + break; + } + + selectedProvider = provider; + current = parsed.model; + + const nested = parseProviderModelSelector(current); + if (!nested) { + return current ? { model: current, provider } : undefined; + } + + const nestedProvider = findProviderByPublicOrInternalName(config, nested.provider); + if (!nestedProvider || providerRuntimeId(nestedProvider) !== providerRuntimeId(provider)) { + return current ? { model: current, provider } : undefined; + } + } + + return selectedProvider && current ? { model: current, provider: selectedProvider } : undefined; +} + +function resolveUniqueConfiguredProviderModelSelector( + value: string | undefined, + config: AppConfig +): { model: string; provider: GatewayProviderConfig } | undefined { + const model = normalizeRouteSelector(value); + if (!model) { + return undefined; + } + + const exactMatches = configuredProviderModelMatches(model, config, false); + if (exactMatches.length === 1) { + return exactMatches[0]; + } + if (exactMatches.length > 1) { + return undefined; + } + + const caseInsensitiveMatches = configuredProviderModelMatches(model, config, true); + return caseInsensitiveMatches.length === 1 ? caseInsensitiveMatches[0] : undefined; +} + +function configuredProviderModelMatches( + model: string, + config: AppConfig, + caseInsensitive: boolean +): Array<{ model: string; provider: GatewayProviderConfig }> { + const normalized = caseInsensitive ? model.toLowerCase() : model; + const matches: Array<{ model: string; provider: GatewayProviderConfig }> = []; + for (const provider of config.Providers) { + for (const candidate of provider.models) { + const configuredModel = candidate.trim(); + if (!configuredModel) { + continue; + } + const comparable = caseInsensitive ? configuredModel.toLowerCase() : configuredModel; + if (comparable === normalized) { + matches.push({ model: configuredModel, provider }); + } + } + } + return matches; +} + +function firstTargetProviderHeader(headers: Record): string | undefined { + const provider = headers["x-target-provider"] || headers["x-gateway-target-provider"]; + if (provider?.trim()) { + return provider.trim(); + } + const providers = headers["x-target-providers"]; + return providers + ?.split(",") + .map((item) => item.trim()) + .find(Boolean); +} + +function activeProviderCredentials(provider: GatewayProviderConfig): ProviderCredentialConfig[] { + return (provider.credentials ?? []).filter((credential) => + credential.enabled !== false && + Boolean(providerCredentialApiKey(credential)) + ); +} + +function selectProviderCredentials( + provider: GatewayProviderConfig, + protocol: GatewayProviderProtocol, + credentials: ProviderCredentialConfig[], + usage: ApiKeyLimitUsage +): { credentials: Array<{ credential: ProviderCredentialConfig; credentialId: string; internalName: string }>; saturated: boolean } { + const candidates = credentials.map((credential, index) => { + const providerIndex = provider.credentials?.indexOf(credential) ?? index; + const limitState = providerCredentialLimitState(provider, credential, usage); + const cooldown = readProviderCredentialCooldown(provider, credential); + return { + cooldown, + credential, + credentialId: providerCredentialSlug(providerCredentialRuntimeId(provider, credential, providerIndex)), + index: providerIndex, + internalName: providerCredentialInternalName(provider, protocol, credential), + limitState, + priority: providerCredentialPriority(credential, providerIndex), + weight: Math.max(1, credential.weight ?? 1) + }; + }); + const available = candidates.filter((candidate) => !candidate.cooldown && !candidate.limitState.blocked); + const sorted = sortProviderCredentialCandidates(available.length > 0 ? available : candidates); + return { + credentials: sorted.map((candidate) => ({ + credential: candidate.credential, + credentialId: candidate.credentialId, + internalName: candidate.internalName + })), + saturated: available.length === 0 && candidates.length > 0 + }; +} + +function sortProviderCredentialCandidates(candidates: T[]): T[] { + const prioritySorted = [...candidates].sort((left, right) => + left.priority - right.priority || + left.limitState.utilization - right.limitState.utilization || + right.weight - left.weight || + left.index - right.index + ); + const primaryPriority = prioritySorted[0]?.priority; + const primaryCandidates = prioritySorted.filter((candidate) => candidate.priority === primaryPriority); + const shouldSpillOver = primaryCandidates.length > 0 && + primaryCandidates.every((candidate) => candidate.limitState.utilization >= providerCredentialSpilloverThreshold); + + if (shouldSpillOver) { + return prioritySorted.sort((left, right) => + left.limitState.utilization - right.limitState.utilization || + left.priority - right.priority || + right.weight - left.weight || + left.index - right.index + ); + } + + return prioritySorted; +} + +function providerCredentialPriority(credential: ProviderCredentialConfig, index: number): number { + return Number.isFinite(credential.priority) ? Number(credential.priority) : index + 1; +} + +function buildUpstreamAttempts(fallback: RouterFallbackConfig, method: string, body: Buffer | undefined, routedModel: string | undefined): UpstreamAttempt[] { + const initialAttempt: UpstreamAttempt = { + body, + index: 0, + model: normalizeRouteSelector(routedModel) + }; + if (fallback.mode === "off" || !shouldSendBody(method)) { + return [initialAttempt]; + } + + if (fallback.mode === "retry") { + const retryCount = clampNumber(fallback.retryCount, 0, ROUTER_FALLBACK_MAX_RETRY_COUNT); + return Array.from({ length: retryCount + 1 }, (_unused, index) => ({ + body, + index, + model: initialAttempt.model + })); + } + + const parsedBody = parseJsonObjectSafe(body); + const currentModel = normalizeRouteSelector(stringValue(parsedBody?.model)) ?? initialAttempt.model; + const configuredModels = uniqueStrings( + fallback.models + .map((model) => normalizeRouteSelector(model)) + .filter((model): model is string => Boolean(model)) + ); + const modelChain = uniqueStrings([currentModel, ...configuredModels].filter((model): model is string => Boolean(model))); + if (modelChain.length === 0 || !parsedBody) { + return [initialAttempt]; + } + + return modelChain.map((model, index) => ({ + body: serializeJsonBodyWithModel(parsedBody, model), + index, + model + })); +} + +function shouldFallbackAfterStatus(statusCode: number, mode: RouterFallbackMode): boolean { + if (mode === "model-chain" && statusCode >= 400) { + return true; + } + if (statusCode === 408 || statusCode === 409 || statusCode === 429 || statusCode >= 500) { + return true; + } + return false; +} + +function retryDelayAfterStatus(_statusCode: number, headers: Headers, failedAttemptIndex: number): number { + const retryAfterMs = parseRetryAfterHeaderMs(headers.get("retry-after")); + if (retryAfterMs !== undefined && retryAfterMs > 0) { + return clampNumber(retryAfterMs, 1, upstreamRetryAfterMaxMs); + } + return exponentialRetryBackoffMs(failedAttemptIndex); +} + +function retryDelayAfterNetworkError(failedAttemptIndex: number): number { + return exponentialRetryBackoffMs(failedAttemptIndex); +} + +export function fallbackRetryDelayAfterStatusForTest(input: { failedAttemptIndex?: number; retryAfter?: string | null; statusCode: number }): number { + const headers = new Headers(); + if (input.retryAfter !== undefined && input.retryAfter !== null) { + headers.set("retry-after", input.retryAfter); + } + return retryDelayAfterStatus(input.statusCode, headers, input.failedAttemptIndex ?? 0); +} + +export function fallbackRetryDelayAfterNetworkErrorForTest(failedAttemptIndex = 0): number { + return retryDelayAfterNetworkError(failedAttemptIndex); +} + +function parseRetryAfterHeaderMs(value: string | null): number | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + + const seconds = Number(trimmed); + if (Number.isFinite(seconds) && seconds >= 0) { + return seconds * 1000; + } + + const retryAt = Date.parse(trimmed); + return Number.isFinite(retryAt) ? Math.max(0, retryAt - Date.now()) : undefined; +} + +function exponentialRetryBackoffMs(failedAttemptIndex: number): number { + const exponent = Math.min(10, Math.max(0, failedAttemptIndex)); + return Math.min(upstreamRetryBackoffMaxMs, upstreamRetryBackoffBaseMs * 2 ** exponent); +} + +async function drainResponseBody(response: Response): Promise { + try { + await response.arrayBuffer(); + } catch { + // The failed attempt is already being skipped; body drain errors should not block the next attempt. + } +} + +async function cancelResponseBody(response: Response): Promise { + try { + await response.body?.cancel(); + } catch { + // The client already disconnected; best-effort upstream cleanup must not mask that expected path. + } +} + +function uniqueStreams(streams: Readable[]): Readable[] { + return [...new Set(streams)]; +} + +function destroyResponseStreams(streams: Readable[]): void { + for (const stream of streams) { + if (!stream.destroyed) { + // A downstream client close is an expected abort path. Destroying with + // an Error would emit another error event on Readable/Transform stages, + // and intermediate stages may not be the final responseBody listener. + stream.destroy(); + } + } +} + +function parseJsonObjectSafe(buffer: Buffer | undefined): Record | undefined { + if (!buffer || buffer.byteLength === 0) { + return undefined; + } + try { + return parseJsonObject(buffer); + } catch { + return undefined; + } +} + +function serializeJsonBodyWithModel(body: Record, model: string): Buffer { + return Buffer.from(`${JSON.stringify({ ...body, model })}\n`, "utf8"); +} + +function mergeFallbackResponseHeaders(headers: Headers, result: UpstreamFetchResult): Headers { + const credentialIds = result.attempt.credentialIds ?? []; + const credentialSaturated = result.attempt.headers?.["x-ccr-provider-credential-saturated"] === "true"; + if (result.failedAttempts.length === 0 && credentialIds.length === 0 && !credentialSaturated) { + return headers; + } + + const merged = new Headers(headers); + if (result.failedAttempts.length > 0) { + merged.set("x-ccr-fallback-attempts", String(result.failedAttempts.length + 1)); + merged.set("x-ccr-fallback-failures", formatFallbackFailures(result.failedAttempts)); + if (result.failedAttempts.some((attempt) => (attempt.delayMs ?? 0) > 0)) { + merged.set("x-ccr-fallback-delays-ms", formatFallbackDelays(result.failedAttempts)); + } + if (result.attempt.model) { + merged.set("x-ccr-fallback-model", sanitizeHeaderValue(result.attempt.model)); + } + } + if (credentialIds.length) { + merged.set("x-ccr-provider-credential-chain", credentialIds.join(",")); + } + if (credentialSaturated) { + merged.set("x-ccr-provider-credential-saturated", "true"); + } + return merged; +} + +function upstreamResponseHeaders(result: UpstreamFetchResult): Headers { + return result.response.headers; +} + +function formatFallbackFailures(failedAttempts: UpstreamFailedAttempt[]): string { + return failedAttempts + .map((attempt) => attempt.statusCode ? String(attempt.statusCode) : attempt.error ? "network" : "failed") + .join(","); +} + +function formatFallbackDelays(failedAttempts: UpstreamFailedAttempt[]): string { + return failedAttempts + .map((attempt) => String(Math.max(0, attempt.delayMs ?? 0))) + .join(","); +} + +function clampNumber(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, Math.trunc(Number.isFinite(value) ? value : min))); +} + +function uniqueStrings(values: Array): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const item = value?.trim(); + if (!item || seen.has(item)) { + continue; + } + seen.add(item); + result.push(item); + } + return result; +} + +function spawnGatewayProcess(config: AppConfig, upstreamProxyUrl: string | undefined, runtimeId: string, coreAuthToken: string): ChildProcess { + const gatewayEntry = resolveGatewayEntry(); + const proxyPreloadFile = upstreamProxyUrl ? writeGatewayProxyPreloadFile(config, upstreamProxyUrl) : undefined; + const env = createGatewayProcessEnv(config, upstreamProxyUrl, runtimeId, coreAuthToken); + const args = proxyPreloadFile ? ["--require", proxyPreloadFile, gatewayEntry] : [gatewayEntry]; + return spawn(process.execPath, args, { + cwd: dirname(config.gateway.generatedConfigFile), + env, + stdio: ["ignore", "pipe", "pipe"] + }); +} + +function resolveGatewayEntry(): string { + const override = process.env[gatewayEntryOverrideEnv]?.trim(); + if (override) { + const entry = pathResolve(override); + if (!existsSync(entry)) { + throw new Error(`${gatewayEntryOverrideEnv} points to a missing gateway entry: ${entry}`); + } + return entry; + } + + const bundledEntry = resolveBundledGatewayEntry(); + if (bundledEntry) { + return bundledEntry; + } + + for (const packageName of gatewayPackageCandidates) { + try { + return requireFromHere.resolve(packageName); + } catch { + // Try the next known package name. + } + } + return requireFromHere.resolve(gatewayPackageCandidates[0]); +} + +function resolveBundledGatewayEntry(): string | undefined { + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + return [ + pathJoin(__dirname, "next-ai-gateway.js"), + ...(resourcesPath + ? [ + pathJoin(resourcesPath, "app.asar", "dist", "main", "next-ai-gateway.js"), + pathJoin(resourcesPath, "app", "dist", "main", "next-ai-gateway.js") + ] + : []) + ].find((candidate) => existsSync(candidate)); +} + +function resolveUndiciProxyAgentModule(): string { + const bundled = resolveBundledUndiciProxyAgentModule(); + if (bundled) { + return bundled; + } + + try { + return requireFromHere.resolve("undici"); + } catch (error) { + throw new Error(`Unable to resolve undici ProxyAgent module for gateway proxy preload: ${formatError(error)}`); + } +} + +function resolveBundledUndiciProxyAgentModule(): string | undefined { + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + return [ + pathJoin(__dirname, "undici-proxy-agent.js"), + ...(resourcesPath + ? [ + pathJoin(resourcesPath, "app.asar", "dist", "main", "undici-proxy-agent.js"), + pathJoin(resourcesPath, "app", "dist", "main", "undici-proxy-agent.js") + ] + : []) + ].find((candidate) => existsSync(candidate)); +} + +function createGatewayProcessEnv(config: AppConfig, upstreamProxyUrl: string | undefined, runtimeId: string, coreAuthToken: string): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...process.env, + AUTH_ENABLED: "true", + AUTH_MODE: "static_api_key", + AUTH_REQUIRED: "true", + AUTH_STATIC_API_KEY_BEARER_ONLY: "false", + AUTH_STATIC_API_KEY_ENV: coreGatewayAuthTokenEnv, + AUTH_STATIC_API_KEY_HEADER: coreGatewayAuthHeader, + CCR_GATEWAY_RUNTIME_ID: runtimeId, + [coreGatewayAuthTokenEnv]: coreAuthToken, + ELECTRON_RUN_AS_NODE: "1", + GATEWAY_CONFIG_PATH: config.gateway.generatedConfigFile, + HOST: config.gateway.coreHost, + PORT: String(config.gateway.corePort) + }; + + const noProxy = mergeNoProxy(env.NO_PROXY || env.no_proxy, [ + "127.0.0.1", + "localhost", + "::1", + config.gateway.host, + config.gateway.coreHost + ]); + env.NO_PROXY = noProxy; + env.no_proxy = noProxy; + + if (!upstreamProxyUrl) { + return env; + } + + env.HTTP_PROXY = upstreamProxyUrl; + env.HTTPS_PROXY = upstreamProxyUrl; + env.ALL_PROXY = upstreamProxyUrl; + env.http_proxy = upstreamProxyUrl; + env.https_proxy = upstreamProxyUrl; + env.all_proxy = upstreamProxyUrl; + env.CCR_UPSTREAM_PROXY_URL = upstreamProxyUrl; + env.CCR_UNDICI_MODULE = resolveUndiciProxyAgentModule(); + return env; +} + +function writeGatewayProxyPreloadFile(config: AppConfig, upstreamProxyUrl: string): string { + const file = pathJoin(dirname(config.gateway.generatedConfigFile), "gateway-proxy-preload.cjs"); + writeFileSync( + file, + [ + "\"use strict\";", + "const up = process.env.CCR_UPSTREAM_PROXY_URL;", + "const um = process.env.CCR_UNDICI_MODULE;", + "if (up && um) {", + " const { ProxyAgent } = require(um);", + " const agent = new ProxyAgent(up);", + " const realFetch = globalThis.fetch.bind(globalThis);", + " const raw = (process.env.NO_PROXY || process.env.no_proxy || '').toLowerCase();", + " const byp = raw.split(',').map((s) => s.trim()).filter(Boolean);", + " const norm = (h) => h.replace(/^\\[/, '').replace(/\\]$/, '').replace(/\\.$/, '');", + " const isLP = (h) => h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0:0:0:0:0:0:0:1' || h === '0.0.0.0' || h.startsWith('127.');", + " const shouldBypass = (input) => {", + " let h;", + " try {", + " const u = typeof input === 'string' ? new URL(input) : input instanceof URL ? input : new URL(input && input.url ? input.url : String(input));", + " h = norm(u.hostname);", + " } catch { return true; }", + " if (!h) return false;", + " if (isLP(h)) return true;", + " return byp.some((p) => {", + " if (p === '*') return true;", + " const s = p.split(':');", + " const ph = norm(s[0]);", + " if (s.length === 2 && s[1]) {", + " if (h !== ph) return false;", + " try { return new URL(input).port === s[1]; } catch { return false; }", + " }", + " if (ph.startsWith('*.')) return h.endsWith(ph.slice(1));", + " if (ph.startsWith('.')) return h.endsWith(ph) || h === ph.slice(1);", + " return h === ph;", + " });", + " };", + " const patched = function(input, init) {", + " if (init && init.dispatcher) return realFetch(input, init);", + " if (shouldBypass(input)) return realFetch(input, init);", + " return realFetch(input, Object.assign({}, init, { dispatcher: agent }));", + " };", + " if (Object.getOwnPropertyDescriptor(globalThis, 'fetch')?.writable) {", + " globalThis.fetch = patched;", + " }", + "}" + ].join("\n"), + "utf8" + ); + return file; +} + +function mergeNoProxy(current: string | undefined, values: string[]): string { + const merged = new Set(); + for (const value of [...(current || "").split(","), ...values]) { + const trimmed = value.trim(); + if (trimmed) { + merged.add(trimmed); + } + } + return [...merged].join(","); +} + +function toCoreGatewayProviders(provider: GatewayProviderConfig): CoreGatewayProvider[] { + const capabilities = normalizedProviderCapabilities(provider); + if (capabilities.length === 0) { + return toCoreGatewayProvidersForCapability(provider); + } + + return capabilities + .flatMap((capability) => toCoreGatewayProvidersForCapability(provider, capability)) + .filter((item): item is CoreGatewayProvider => Boolean(item)); +} + +function toCoreGatewayProvidersForCapability( + provider: GatewayProviderConfig, + capability?: GatewayProviderCapability +): CoreGatewayProvider[] { + const credentials = activeProviderCredentials(provider); + if (credentials.length === 0) { + const coreProvider = toCoreGatewayProvider(provider, capability); + return coreProvider ? [coreProvider] : []; + } + + return sortProviderCredentialsForConfig(credentials) + .map((credential) => toCoreGatewayProvider(provider, capability, credential)) + .filter((item): item is CoreGatewayProvider => Boolean(item)); +} + +function toCoreGatewayProvider( + provider: GatewayProviderConfig, + capability?: GatewayProviderCapability, + credential?: ProviderCredentialConfig +): CoreGatewayProvider | undefined { + const type = + capability?.type ?? + normalizeProviderProtocol(provider.type) ?? + normalizeProviderProtocol(provider.provider) ?? + inferProtocol(provider); + const baseurl = normalizeProviderRuntimeBaseUrl(capability?.baseUrl ?? readBaseUrl(provider), type); + const apikey = credential ? providerCredentialApiKey(credential) : provider.apikey || provider.apiKey || provider.api_key; + + if (!provider.name || provider.models.length === 0) { + return undefined; + } + const safetyIssue = providerApiKeySafetyIssue({ + apiKey: apikey, + baseUrl: baseurl ?? "", + name: provider.name + }); + if (safetyIssue) { + throw new Error(safetyIssue.message); + } + + return { + apikey, + baseurl, + billing: provider.billing, + extraBody: provider.extraBody, + extraHeaders: provider.extraHeaders, + models: provider.models, + name: credential + ? providerCredentialInternalName(provider, type, credential) + : capability + ? providerCapabilityInternalName(provider, type) + : providerRuntimeId(provider), + type + }; +} + +function sortProviderCredentialsForConfig(credentials: ProviderCredentialConfig[]): ProviderCredentialConfig[] { + return [...credentials].sort((left, right) => + providerCredentialPriority(left, 0) - providerCredentialPriority(right, 0) || + providerCredentialSortKey(left).localeCompare(providerCredentialSortKey(right)) + ); +} + +function normalizedProviderCapabilities(provider: GatewayProviderConfig): GatewayProviderCapability[] { + const capabilities = Array.isArray(provider.capabilities) ? provider.capabilities : []; + const normalized: GatewayProviderCapability[] = []; + const byProtocol = new Map(); + for (const capability of capabilities) { + const type = normalizeProviderProtocol(capability.type); + const baseUrl = capability.baseUrl?.trim(); + if (!type || !baseUrl) { + continue; + } + const item = { + ...capability, + baseUrl, + type + }; + const existing = byProtocol.get(type); + if (!existing || providerCapabilityPriority(item) < providerCapabilityPriority(existing)) { + byProtocol.set(type, item); + } + } + for (const capability of capabilities) { + const type = normalizeProviderProtocol(capability.type); + const selected = type ? byProtocol.get(type) : undefined; + if (selected && !normalized.includes(selected)) { + normalized.push(selected); + } + } + return applyPresetProtocolLock(provider, normalized); +} + +function applyPresetProtocolLock( + provider: GatewayProviderConfig, + capabilities: GatewayProviderCapability[] +): GatewayProviderCapability[] { + const lockedProtocols = lockedProviderPresetProtocols(provider, capabilities); + if (lockedProtocols.length === 0) { + return capabilities; + } + + const lockedProtocolSet = new Set(lockedProtocols); + const lockedCapabilities = capabilities.filter((capability) => lockedProtocolSet.has(capability.type)); + if (lockedCapabilities.length > 0) { + return lockedCapabilities; + } + + const lockedProtocol = lockedProtocols[0]; + const baseUrl = readBaseUrl(provider); + const normalizedBaseUrl = normalizeProviderRuntimeBaseUrl(baseUrl, lockedProtocol); + return normalizedBaseUrl + ? [{ baseUrl: normalizedBaseUrl, source: "preset", type: lockedProtocol }] + : []; +} + +function lockedProviderPresetProtocols( + provider: GatewayProviderConfig, + capabilities: GatewayProviderCapability[] +): GatewayProviderProtocol[] { + const baseUrls = [ + readBaseUrl(provider), + ...capabilities.map((capability) => capability.baseUrl) + ].filter((value): value is string => Boolean(value?.trim())); + + for (const baseUrl of baseUrls) { + if (findProviderPresetByBaseUrl(baseUrl)?.id === "gemini") { + return ["gemini_generate_content", "gemini_interactions"]; + } + } + + return []; +} + +function providerCapabilityPriority(capability: GatewayProviderCapability): number { + if (capability.source === "preset") { + return 0; + } + if (capability.source === "detected") { + return 2; + } + return 1; +} + +function providerCapabilityInternalName(provider: GatewayProviderConfig, protocol: GatewayProviderProtocol): string { + return `${providerRuntimeId(provider)}::${protocol}`; +} + +function providerCapabilityLegacyInternalName(providerName: string, protocol: GatewayProviderProtocol): string { + return `${providerName}::${protocol}`; +} + +function providerCapabilityNameMatches(provider: GatewayProviderConfig, protocol: GatewayProviderProtocol, value: string): boolean { + const normalized = value.trim().toLowerCase(); + return providerCapabilityInternalName(provider, protocol).toLowerCase() === normalized || + providerCapabilityLegacyInternalName(provider.name, protocol).toLowerCase() === normalized; +} + +function providerRuntimeId(provider: GatewayProviderConfig): string { + const explicit = sanitizeProviderHeaderId(provider.id); + if (explicit) { + return explicit; + } + const normalized = provider.name + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48); + const hash = createHash("sha256").update(`${provider.name}\n${readBaseUrl(provider) ?? ""}`).digest("hex").slice(0, 10); + return `provider-${normalized || "provider"}-${hash}`; +} + +function sanitizeProviderHeaderId(value: string | undefined): string | undefined { + const normalized = value + ?.trim() + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized || undefined; +} + +function sanitizeHeaderValue(value: unknown): string { + // HTTP header values must be ByteString (code point <= 255). Values derived + // from user-facing names — model selectors like "小米mimo/...", provider + // names, route reasons — can contain non-ASCII characters that crash Node's + // fetch/undici with "Cannot convert argument to a ByteString" (surfaced as + // 502). Normalize to ASCII while preserving case and printable punctuation. + const text = typeof value === "string" && value.trim() ? value : "unknown"; + const sanitized = text + .replace(/[^\x20-\x7E]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^-+|-+$/g, ""); + return sanitized || "unknown"; +} + +function providerCredentialInternalName( + provider: GatewayProviderConfig, + protocol: GatewayProviderProtocol, + credential: ProviderCredentialConfig +): string { + return `${providerCapabilityInternalName(provider, protocol)}::cred:${providerCredentialSlug(providerCredentialRuntimeId(provider, credential))}`; +} + +function parseProviderCredentialInternalName(value: string | undefined): { + credentialSlug: string; + providerId: string; + protocol: GatewayProviderProtocol; +} | undefined { + const marker = "::cred:"; + const markerIndex = value?.lastIndexOf(marker) ?? -1; + if (!value || markerIndex <= 0) { + return undefined; + } + const baseName = value.slice(0, markerIndex); + const credentialSlug = value.slice(markerIndex + marker.length).trim(); + const protocolSeparator = baseName.lastIndexOf("::"); + if (!credentialSlug || protocolSeparator <= 0) { + return undefined; + } + const protocol = normalizeProviderProtocol(baseName.slice(protocolSeparator + 2)); + const providerId = baseName.slice(0, protocolSeparator).trim(); + return protocol && providerId ? { credentialSlug, providerId, protocol } : undefined; +} + +function providerCredentialSlug(value: string | undefined): string { + return (value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_.-]+/g, "-") + .replace(/^-+|-+$/g, "") || "key"; +} + +function providerCredentialRuntimeId( + provider: GatewayProviderConfig, + credential: ProviderCredentialConfig, + index = provider.credentials?.indexOf(credential) ?? -1 +): string { + const explicitId = credential.id?.trim(); + if (explicitId) { + return explicitId; + } + const oneBasedIndex = index >= 0 ? index + 1 : 1; + const label = credential.name?.trim() || credential.label?.trim(); + return label ? `${providerCredentialSlug(label)}-${oneBasedIndex}` : `key-${oneBasedIndex}`; +} + +function providerCredentialSortKey(credential: ProviderCredentialConfig): string { + return providerCredentialSlug(credential.id || credential.name || credential.label); +} + +function providerCredentialApiKey(credential: ProviderCredentialConfig): string { + return credential.api_key || credential.apiKey || credential.apikey || ""; +} + +function findProviderCredentialByRuntimeId( + provider: GatewayProviderConfig, + credentialId: string +): ProviderCredentialConfig | undefined { + const normalizedId = credentialId.trim(); + const normalizedSlug = providerCredentialSlug(normalizedId); + return (provider.credentials ?? []).find((credential, index) => { + const runtimeId = providerCredentialRuntimeId(provider, credential, index); + return runtimeId === normalizedId || providerCredentialSlug(runtimeId) === normalizedSlug || credential.id?.trim() === normalizedId; + }); +} + +function findProviderCredentialBySlug( + provider: GatewayProviderConfig, + credentialSlug: string +): ProviderCredentialConfig | undefined { + const normalizedSlug = providerCredentialSlug(credentialSlug); + return (provider.credentials ?? []).find((credential, index) => providerCredentialSlug(providerCredentialRuntimeId(provider, credential, index)) === normalizedSlug); +} + +function normalizeProviderProtocol(value: unknown): GatewayProviderProtocol | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim().toLowerCase(); + if (normalized === "openai" || normalized === "openai_responses") { + return "openai_responses"; + } + if (normalized === "openai_chat" || normalized === "openai_chat_completions") { + return "openai_chat_completions"; + } + if (normalized === "anthropic" || normalized === "anthropic_messages") { + return "anthropic_messages"; + } + if (normalized === "gemini" || normalized === "gemini_generate_content") { + return "gemini_generate_content"; + } + if ( + normalized === "gemini_interactions" || + normalized === "gemini-interactions" || + normalized === "google_interactions" || + normalized === "google-interactions" || + normalized === "interactions" || + normalized === "interaction" + ) { + return "gemini_interactions"; + } + return undefined; +} + +function inferProtocol(provider: GatewayProviderConfig): GatewayProviderProtocol { + const url = readBaseUrl(provider)?.toLowerCase() ?? ""; + const transformerNames = JSON.stringify(provider.transformer ?? "").toLowerCase(); + if (url.includes("/interactions") || transformerNames.includes("gemini_interactions")) { + return "gemini_interactions"; + } + if (url.includes("generativelanguage.googleapis.com") || transformerNames.includes("gemini")) { + return "gemini_generate_content"; + } + if (url.includes("anthropic") || transformerNames.includes("anthropic")) { + return "anthropic_messages"; + } + return "openai_chat_completions"; +} + +function resolveResponseProviderProtocol(headers: Headers, config: AppConfig | undefined): GatewayProviderProtocol | undefined { + const ccrProtocol = normalizeProviderProtocol(headers.get("x-ccr-provider-protocol")); + if (ccrProtocol) { + return ccrProtocol; + } + const providerName = + headers.get("x-gateway-target-provider-name")?.trim() || + headers.get("x-gateway-target-provider")?.trim(); + if (!providerName) { + return undefined; + } + const credentialInternalName = parseProviderCredentialInternalName(providerName); + if (credentialInternalName) { + return credentialInternalName.protocol; + } + const provider = config ? findProviderByPublicOrInternalName(config, providerName) : undefined; + if (!provider) { + return normalizeProviderProtocol(providerName); + } + const capability = normalizedProviderCapabilities(provider).find((item) => + providerCapabilityNameMatches(provider, item.type, providerName) + ); + if (capability) { + return capability.type; + } + return normalizeProviderProtocol(provider.type) ?? normalizeProviderProtocol(provider.provider) ?? inferProtocol(provider); +} + +function resolveProviderLogName(headers: Headers, config: AppConfig | undefined, fallbackModel?: string): string | undefined { + const providerSelector = + headers.get("x-gateway-target-provider-name")?.trim() || + headers.get("x-gateway-target-provider")?.trim(); + const headerProvider = providerSelector && config + ? findProviderByPublicOrInternalName(config, providerSelector) + : undefined; + if (headerProvider) { + return headerProvider.name; + } + + const routeProvider = parseProviderModelSelector(fallbackModel)?.provider; + const modelProvider = routeProvider && config + ? findProviderByPublicOrInternalName(config, routeProvider) + : undefined; + return modelProvider?.name; +} + +function providerMatchesName(provider: GatewayProviderConfig, name: string): boolean { + const normalizedName = name.trim().toLowerCase(); + return [provider.id, provider.name, provider.provider] + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .some((value) => value.trim().toLowerCase() === normalizedName); +} + +function normalizeProviderRuntimeBaseUrl(value: string | undefined, type: GatewayProviderProtocol): string | undefined { + if (!value) { + return undefined; + } + return normalizeProviderBaseUrlInput(value, type) || undefined; +} + +function readBaseUrl(provider: GatewayProviderConfig): string | undefined { + return provider.baseurl || provider.baseUrl || provider.api_base_url; +} + +function endpoint(host: string, port: number): string { + const endpointHost = host === "0.0.0.0" ? "127.0.0.1" : host; + return `http://${endpointHost}:${port}`; +} + +function gatewayNetworkEndpoints(host: string, port: number): GatewayNetworkEndpoint[] { + const normalizedHost = normalizeBindHost(host); + const lanAddresses = physicalLanAddresses(); + const addresses = isWildcardBindHost(normalizedHost) + ? lanAddresses + : lanAddresses.filter((entry) => entry.address === normalizedHost); + + return addresses.map((entry) => ({ + address: entry.address, + endpoint: endpoint(entry.address, port), + interfaceName: entry.interfaceName + })); +} + +function physicalLanAddresses(): Array<{ address: string; interfaceName: string }> { + const seen = new Set(); + const result: Array<{ address: string; interfaceName: string }> = []; + + for (const [interfaceName, entries] of Object.entries(networkInterfaces())) { + if (!entries || isVirtualNetworkInterface(interfaceName)) { + continue; + } + + for (const entry of entries) { + if (entry.internal || entry.family !== "IPv4" || !isPrivateIpv4(entry.address)) { + continue; + } + + const key = `${interfaceName}:${entry.address}`; + if (seen.has(key)) { + continue; + } + + seen.add(key); + result.push({ address: entry.address, interfaceName }); + } + } + + return result.sort((left, right) => + left.interfaceName.localeCompare(right.interfaceName) || + left.address.localeCompare(right.address, undefined, { numeric: true }) + ); +} + +function normalizeBindHost(host: string): string { + return host.trim().replace(/^\[|\]$/g, "").toLowerCase(); +} + +function isLoopbackBindHost(host: string): boolean { + const normalized = normalizeBindHost(host).replace(/\.$/, ""); + return normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "::1" || + normalized === "0:0:0:0:0:0:0:1" || + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized); +} + +function isWildcardBindHost(host: string): boolean { + return host === "" || host === "0.0.0.0" || host === "::" || host === "::0"; +} + +function isPrivateIpv4(address: string): boolean { + const parts = address.split(".").map((part) => Number(part)); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { + return false; + } + + return parts[0] === 10 || + (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || + (parts[0] === 192 && parts[1] === 168); +} + +function isVirtualNetworkInterface(interfaceName: string): boolean { + const normalized = interfaceName.toLowerCase(); + return [ + /^lo\d*$/, + /^awdl\d*$/, + /^llw\d*$/, + /^utun\d*$/, + /^gif\d*$/, + /^stf\d*$/, + /^bridge\d*$/, + /^br-/, + /^docker/, + /^veth/, + /^vmnet/, + /^vbox/, + /^tun\d*$/, + /^tap\d*$/, + /^wg\d*$/, + /\bloopback\b/, + /\bvirtual\b/, + /\bvirtualbox\b/, + /\bvmware\b/, + /\bhyper-v\b/, + /\bvethernet\b/, + /\bwsl\b/, + /\btunnel\b/, + /\btailscale\b/, + /\bzerotier\b/, + /\bwireguard\b/, + /\bhamachi\b/, + /\bparallels\b/, + /\bvpn\b/ + ].some((pattern) => pattern.test(normalized)); +} + +async function stopPreviousManagedCoreGateway(config: AppConfig, coreEndpoint: string): Promise { + const marker = readManagedCoreGatewayMarker(config); + const markerRuntimeId = stringValue(marker?.runtimeId); + const pid = numberValue(marker?.pid); + if (!markerRuntimeId || !pid) { + return; + } + + const health = await readCoreGatewayHealth(coreEndpoint); + if (health?.runtimeId !== markerRuntimeId) { + return; + } + + if (!isProcessAlive(pid)) { + removeManagedCoreGatewayMarker(config); + return; + } + + try { + process.kill(pid, "SIGTERM"); + } catch { + removeManagedCoreGatewayMarker(config); + return; + } + + if (await waitForCoreGatewayStop(coreEndpoint)) { + removeManagedCoreGatewayMarker(config); + return; + } + + try { + process.kill(pid, "SIGKILL"); + } catch { + // Process may have exited between the health check and SIGKILL. + } + await waitForCoreGatewayStop(coreEndpoint); + removeManagedCoreGatewayMarker(config); +} + +function readManagedCoreGatewayMarker(config: AppConfig): ManagedGatewayRuntimeMarker | undefined { + const file = managedCoreGatewayMarkerPath(config); + if (!existsSync(file)) { + return undefined; + } + try { + const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown; + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function writeManagedCoreGatewayMarker(config: AppConfig, child: ChildProcess, runtimeId: string): void { + if (!child.pid) { + return; + } + try { + writeFileSync( + managedCoreGatewayMarkerPath(config), + `${JSON.stringify( + { + generatedConfigFile: config.gateway.generatedConfigFile, + gatewayEntry: resolveGatewayEntry(), + pid: child.pid, + runtimeId, + startedAt: new Date().toISOString() + }, + null, + 2 + )}\n`, + "utf8" + ); + } catch (error) { + console.warn(`[gateway] Failed to write gateway runtime marker: ${formatError(error)}`); + } +} + +function removeManagedCoreGatewayMarker(config: AppConfig | undefined): void { + if (!config) { + return; + } + try { + rmSync(managedCoreGatewayMarkerPath(config), { force: true }); + } catch (error) { + console.warn(`[gateway] Failed to remove gateway runtime marker: ${formatError(error)}`); + } +} + +function managedCoreGatewayMarkerPath(config: AppConfig): string { + return pathJoin(dirname(config.gateway.generatedConfigFile), gatewayRuntimeMarkerFile); +} + +async function waitForCoreGatewayStop(coreEndpoint: string): Promise { + for (let index = 0; index < 20; index += 1) { + if (!(await isCoreGatewayHealthy(coreEndpoint))) { + return true; + } + await delay(100); + } + return false; +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function assertLoopbackCoreHost(host: string): void { + const error = loopbackCoreHostError(host); + if (error) { + throw new Error(error); + } +} + +function loopbackCoreHostError(host: string): string | undefined { + const normalized = host.trim().toLowerCase(); + return normalized === "127.0.0.1" || normalized === "::1" + ? undefined + : "Core gateway host must be 127.0.0.1 or ::1."; +} + +function generateCoreGatewayAuthToken(): string { + return randomBytes(32).toString("base64url"); +} + +async function isCoreGatewayHealthy(coreEndpoint: string): Promise { + const health = await readCoreGatewayHealth(coreEndpoint); + return health?.status === "ok"; +} + +async function readCoreGatewayHealth(coreEndpoint: string): Promise { + if (!coreEndpoint) { + return undefined; + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 500); + try { + const healthUrl = new URL("/health", coreEndpoint); + const response = await fetchWithSystemProxy(healthUrl, { signal: controller.signal }); + if (!response.ok) { + return undefined; + } + const body = await response.json().catch(() => undefined); + if (!isRecord(body)) { + return undefined; + } + return { + runtimeId: stringValue(body.runtimeId), + status: stringValue(body.status) + }; + } catch { + return undefined; + } finally { + clearTimeout(timer); + } +} + +function shouldRunUnifiedServer(config: AppConfig): boolean { + return config.gateway.enabled || config.proxy.enabled; +} + +function shouldRunGatewayRuntime(config: AppConfig): boolean { + return config.gateway.enabled || (config.proxy.enabled && config.proxy.mode === "gateway"); +} + +function shouldServeGatewayRequest(config: AppConfig, request: IncomingMessage): boolean { + if (config.gateway.enabled) { + return true; + } + return config.proxy.enabled && config.proxy.mode === "gateway" && readHeader(request.headers["x-ccr-proxy-mode"]) === "gateway"; +} + +function applyCors(response: ServerResponse, config?: AppConfig): void { + const origin = config ? endpoint(config.gateway.host, config.gateway.port) : "*"; + response.setHeader("Access-Control-Allow-Origin", origin); + response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, Last-Event-ID, Anthropic-Version, Anthropic-Beta, Mcp-Session-Id, MCP-Protocol-Version"); + response.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS"); + response.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id"); +} + +async function authorize(request: IncomingMessage, response: ServerResponse, config: AppConfig): Promise { + let apiKeys = await configuredApiKeys(config); + if (apiKeys.length === 0) { + sendJson(response, 403, { + error: { + message: "CCR API key is not initialized. Save a gateway API key or restart CCR to generate one." + } + }); + return { ok: false }; + } + + const token = readAuthToken(request.headers) || readRemoteControlQueryAuthToken(request); + let apiKey = token ? apiKeys.find((item) => item.key === token) : undefined; + if (!apiKey && token) { + apiKeys = await configuredApiKeys(config, { refresh: true }); + apiKey = apiKeys.find((item) => item.key === token); + } + if (apiKey) { + if (isApiKeyExpired(apiKey)) { + sendJson(response, 401, { error: { message: "API key is expired." } }); + return { ok: false }; + } + return { ok: true, apiKey }; + } + + sendJson(response, 401, { error: { message: token ? "Invalid API key." : "API key is missing." } }); + return { ok: false }; +} + +async function configuredApiKeys(config: AppConfig, options: { refresh?: boolean } = {}): Promise { + const persistedApiKeys = await loadPersistedApiKeysCached(options); + const values = [ + ...persistedApiKeys, + ...(Array.isArray(config.APIKEYS) ? config.APIKEYS : []), + ...(config.APIKEY ? [{ createdAt: new Date(0).toISOString(), id: "legacy", key: config.APIKEY }] : []) + ]; + const seen = new Set(); + const result: ApiKeyConfig[] = []; + for (const value of values) { + const key = value?.key?.trim(); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + result.push({ ...value, key }); + } + return result; +} + +async function loadPersistedApiKeysCached(options: { refresh?: boolean } = {}): Promise { + const now = Date.now(); + if (!options.refresh && persistedApiKeyCache && now - persistedApiKeyCache.loadedAt < persistedApiKeyCacheTtlMs) { + return persistedApiKeyCache.values; + } + try { + const values = await loadPersistedApiKeys(); + persistedApiKeyCache = { + loadedAt: now, + values + }; + return values; + } catch (error) { + console.warn(`[gateway] Failed to load persisted API keys: ${formatError(error)}`); + return []; + } +} + +function isApiKeyExpired(apiKey: ApiKeyConfig): boolean { + if (!apiKey.expiresAt) { + return false; + } + const expiresAt = Date.parse(apiKey.expiresAt); + return Number.isFinite(expiresAt) && expiresAt <= Date.now(); +} + +function reserveApiKeyLimits(apiKey: ApiKeyConfig | undefined, request: IncomingMessage, response: ServerResponse, requestBody: Buffer): boolean { + if (!apiKey?.limits) { + return true; + } + + const usage = estimateApiKeyLimitUsage(request, requestBody); + const rules = apiKeyLimitRules(apiKey, usage); + const now = Date.now(); + const checks = rules.map((rule) => { + const windowStart = Math.floor(now / rule.windowMs) * rule.windowMs; + return { + counterKey: ["api-key", apiKey.id, rule.name, rule.metric, rule.windowMs, windowStart].join("|"), + rule, + windowStart + }; + }); + + for (const check of checks) { + const counter = readApiKeyWindowCounter(check.counterKey, check.windowStart, check.rule.windowMs, now); + if (counter.value + check.rule.requested > check.rule.limit) { + sendJson(response, 429, { + error: { + code: "rate_limit_exceeded", + message: `API key ${check.rule.name} limit exceeded.`, + details: { + limit: check.rule.limit, + limit_name: check.rule.name, + metric: check.rule.metric, + requested: check.rule.requested, + used: counter.value, + window_ms: check.rule.windowMs + } + } + }); + return false; + } + } + + for (const check of checks) { + readApiKeyWindowCounter(check.counterKey, check.windowStart, check.rule.windowMs, now).value += check.rule.requested; + } + return true; +} + +function apiKeyLimitRules(apiKey: ApiKeyConfig, usage: ApiKeyLimitUsage): ApiKeyLimitRule[] { + return limitRules(apiKey.limits, usage); +} + +function limitRules(limits: ApiKeyLimitConfig | undefined, usage: ApiKeyLimitUsage): ApiKeyLimitRule[] { + if (!limits) { + return []; + } + const rules: ApiKeyLimitRule[] = []; + addApiKeyLimitRule(rules, "requests", "requests", limits.windowMs ?? 60_000, limits.maxRequests, 1); + addApiKeyLimitRule(rules, "rpm", "requests", 60_000, limits.rpm, 1); + addApiKeyLimitRule(rules, "rph", "requests", 3_600_000, limits.rph, 1); + addApiKeyLimitRule(rules, "rpd", "requests", 86_400_000, limits.rpd, 1); + addApiKeyLimitRule(rules, "tpm", "tokens", 60_000, limits.tpm, usage.totalTokens); + addApiKeyLimitRule(rules, "tph", "tokens", 3_600_000, limits.tph, usage.totalTokens); + addApiKeyLimitRule(rules, "tpd", "tokens", 86_400_000, limits.tpd, usage.totalTokens); + addApiKeyLimitRule(rules, "ipm", "images", 60_000, limits.ipm, usage.imageCount); + addApiKeyLimitRule(rules, "iph", "images", 3_600_000, limits.iph, usage.imageCount); + addApiKeyLimitRule(rules, "ipd", "images", 86_400_000, limits.ipd, usage.imageCount); + addApiKeyLimitRule(rules, "quota", "tokens", limits.quotaWindowMs ?? 86_400_000, limits.maxTokens, usage.totalTokens); + return rules; +} + +function providerCredentialLimitState( + provider: GatewayProviderConfig, + credential: ProviderCredentialConfig, + usage: ApiKeyLimitUsage +): { blocked: boolean; utilization: number } { + const rules = limitRules(credential.limits, usage); + if (rules.length === 0) { + return { + blocked: false, + utilization: 0 + }; + } + + const now = Date.now(); + let blocked = false; + let utilization = 0; + for (const rule of rules) { + const windowStart = Math.floor(now / rule.windowMs) * rule.windowMs; + const counter = readApiKeyWindowCounter(providerCredentialCounterKey(provider, credential, rule, windowStart), windowStart, rule.windowMs, now); + blocked = blocked || counter.value + rule.requested > rule.limit; + utilization = Math.max(utilization, (counter.value + rule.requested) / rule.limit); + } + + return { + blocked, + utilization + }; +} + +function recordProviderCredentialOutcome( + config: AppConfig, + method: string, + attempt: UpstreamAttempt, + statusCode: number, + responseHeaders: Headers +): void { + if (!attempt.logicalProvider || !attempt.credentialProtocol || !attempt.credentialChain?.length) { + return; + } + + const provider = findProviderByPublicOrInternalName(config, attempt.logicalProvider); + if (!provider) { + return; + } + + const responseCredentialId = responseHeaders.get("x-ccr-provider-credential-id")?.trim(); + const responseCredential = responseCredentialId + ? findProviderCredentialByRuntimeId(provider, responseCredentialId) + : undefined; + const fallbackCredential = providerCredentialFromInternalName(provider, attempt.credentialChain[0]); + const credential = responseCredential ?? fallbackCredential; + if (!credential) { + return; + } + + if (statusCode >= 200 && statusCode < 500 && statusCode !== 401 && statusCode !== 403 && statusCode !== 429) { + incrementProviderCredentialCounters(provider, credential, estimateLimitUsage(method, attempt.body ?? Buffer.alloc(0))); + clearProviderCredentialCooldown(provider, credential); + return; + } + + if (statusCode === 401 || statusCode === 403 || statusCode === 429 || statusCode >= 500) { + setProviderCredentialCooldown(provider, credential, providerCredentialCooldownMs, `HTTP ${statusCode}`); + } +} + +function providerCredentialFromInternalName( + provider: GatewayProviderConfig, + internalName: string | undefined +): ProviderCredentialConfig | undefined { + const parsed = parseProviderCredentialInternalName(internalName); + return parsed ? findProviderCredentialBySlug(provider, parsed.credentialSlug) : undefined; +} + +function incrementProviderCredentialCounters( + provider: GatewayProviderConfig, + credential: ProviderCredentialConfig, + usage: ApiKeyLimitUsage +): void { + const rules = limitRules(credential.limits, usage); + const now = Date.now(); + for (const rule of rules) { + const windowStart = Math.floor(now / rule.windowMs) * rule.windowMs; + readApiKeyWindowCounter(providerCredentialCounterKey(provider, credential, rule, windowStart), windowStart, rule.windowMs, now).value += rule.requested; + } +} + +function providerCredentialCounterKey( + provider: GatewayProviderConfig, + credential: ProviderCredentialConfig, + rule: ApiKeyLimitRule, + windowStart: number +): string { + return ["provider-credential", provider.name, providerCredentialRuntimeId(provider, credential), rule.name, rule.metric, rule.windowMs, windowStart].join("|"); +} + +function readProviderCredentialCooldown(provider: GatewayProviderConfig, credential: ProviderCredentialConfig): { reason: string; until: number } | undefined { + const key = providerCredentialStateKey(provider, credential); + const cooldown = providerCredentialCooldowns.get(key); + if (!cooldown) { + return undefined; + } + if (cooldown.until > Date.now()) { + return cooldown; + } + providerCredentialCooldowns.delete(key); + return undefined; +} + +function setProviderCredentialCooldown(provider: GatewayProviderConfig, credential: ProviderCredentialConfig, cooldownMs: number, reason: string): void { + providerCredentialCooldowns.set(providerCredentialStateKey(provider, credential), { + reason, + until: Date.now() + cooldownMs + }); +} + +function clearProviderCredentialCooldown(provider: GatewayProviderConfig, credential: ProviderCredentialConfig): void { + providerCredentialCooldowns.delete(providerCredentialStateKey(provider, credential)); +} + +function providerCredentialStateKey(provider: GatewayProviderConfig, credential: ProviderCredentialConfig): string { + return `${provider.name}::${providerCredentialRuntimeId(provider, credential)}`; +} + +function addApiKeyLimitRule( + rules: ApiKeyLimitRule[], + name: string, + metric: ApiKeyLimitRule["metric"], + windowMs: number, + limit: number | undefined, + requested: number +): void { + if (!limit || limit <= 0 || windowMs <= 0) { + return; + } + rules.push({ + limit, + metric, + name, + requested, + windowMs + }); +} + +function readApiKeyWindowCounter(key: string, windowStart: number, windowMs: number, now = Date.now()): ApiKeyWindowCounter { + pruneExpiredApiKeyLimitCounters(now); + const existing = apiKeyLimitCounters.get(key); + if (existing && existing.windowStart === windowStart) { + return existing; + } + const fresh = { + expiresAt: windowStart + windowMs * apiKeyLimitCounterRetentionWindows, + value: 0, + windowStart + }; + apiKeyLimitCounters.set(key, fresh); + return fresh; +} + +function pruneExpiredApiKeyLimitCounters(now: number): void { + for (const [key, counter] of apiKeyLimitCounters) { + if (counter.expiresAt <= now) { + apiKeyLimitCounters.delete(key); + } + } +} + +function estimateApiKeyLimitUsage(request: IncomingMessage, requestBody: Buffer): ApiKeyLimitUsage { + return estimateLimitUsage(request.method ?? "GET", requestBody); +} + +function estimateLimitUsage(method: string, requestBody: Buffer): ApiKeyLimitUsage { + if (method.toUpperCase() !== "POST" || requestBody.byteLength === 0) { + return { + imageCount: 0, + totalTokens: 0 + }; + } + + const body = parseJsonObject(requestBody); + const inputCharacters = countUnknownCharacters(body.messages) + countUnknownCharacters(body.system) + countUnknownCharacters(body.tools); + const inputTokens = Math.ceil(inputCharacters / 4); + const outputTokens = readPositiveNumber(body.max_tokens) ?? readPositiveNumber(body.max_output_tokens) ?? 1024; + return { + imageCount: countImageInputs(body), + totalTokens: Math.max(1, inputTokens + outputTokens) + }; +} + +function countUnknownCharacters(value: unknown): number { + if (value === undefined || value === null) { + return 0; + } + if (typeof value === "string") { + return value.length; + } + try { + return JSON.stringify(value)?.length || 0; + } catch { + return String(value).length; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function rawStringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function stringListValue(value: unknown): string[] { + return Array.isArray(value) ? value.map((item) => stringValue(item)).filter((item): item is string => Boolean(item)) : []; +} + +function numberValue(value: unknown): number | undefined { + const number = Number(value); + return Number.isFinite(number) ? Math.trunc(number) : undefined; +} + +function countImageInputs(value: unknown): number { + if (Array.isArray(value)) { + return value.reduce((sum, item) => sum + countImageInputs(item), 0); + } + if (!isRecord(value)) { + return 0; + } + const type = typeof value.type === "string" ? value.type.toLowerCase() : ""; + const isImage = type === "image" || type === "image_url" || type === "input_image" || value.image_url !== undefined || value.input_image !== undefined; + return (isImage ? 1 : 0) + Object.values(value).reduce((sum, item) => sum + countImageInputs(item), 0); +} + +function readPositiveNumber(value: unknown): number | undefined { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? Math.ceil(number) : undefined; +} + +function shouldServeGatewayModelsResponse(method: string, path: string): boolean { + return (method || "GET").toUpperCase() === "GET" && + normalizeGatewayPathname(path) === "/v1/models"; +} + +function prepareClaudeCodeDiscoveredModelRequest( + config: AppConfig, + headers: IncomingHttpHeaders, + method: string, + path: string, + body: Buffer | undefined +): { body: Buffer; diagnostic: string } | undefined { + if ( + (method || "GET").toUpperCase() !== "POST" || + normalizeGatewayPathname(path) !== "/v1/messages" || + !isClaudeCodeUserAgent(headers) + ) { + return undefined; + } + + const parsedBody = parseJsonObjectSafe(body); + const model = stringValue(parsedBody?.model); + const rewrittenModel = resolveClaudeCodeDiscoveredModelId(model, config); + if (!parsedBody || !rewrittenModel || rewrittenModel === model) { + return undefined; + } + + return { + body: serializeJsonBodyWithModel(parsedBody, rewrittenModel), + diagnostic: `${model}->${rewrittenModel}` + }; +} + +function prepareClaudeAppFallbackModelRequest( + config: AppConfig, + method: string, + path: string, + body: Buffer | undefined +): { body: Buffer; diagnostic: string; routedModel: string } | undefined { + if ( + (method || "GET").toUpperCase() !== "POST" || + normalizeGatewayPathname(path) !== "/v1/messages" + ) { + return undefined; + } + + const parsedBody = parseJsonObjectSafe(body); + const model = stringValue(parsedBody?.model); + const normalizedModel = normalizeRouteSelector(model); + if (!parsedBody || !normalizedModel) { + return undefined; + } + + const routeModel = resolveClaudeAppGatewayRouteModel(normalizedModel, config, claudeAppGatewayModelRouteOptions); + const routedModel = routeModel ?? + (normalizedModel.toLowerCase() === CLAUDE_APP_FALLBACK_MODEL ? inferClaudeAppGatewayTargetModel(config) : undefined); + if (!routedModel || routedModel.toLowerCase() === normalizedModel.toLowerCase()) { + return undefined; + } + if (isConfiguredGatewayModelSelector(normalizedModel, config) && !routeModel) { + return undefined; + } + + return { + body: serializeJsonBodyWithModel(parsedBody, routedModel), + diagnostic: `${model}->${routedModel}`, + routedModel + }; +} + +function createGatewayModelsResponse(config: AppConfig, headers: IncomingHttpHeaders, apiKey?: ApiKeyConfig): Record { + if (isClaudeAppApiKey(apiKey) || isClaudeCodeUserAgent(headers)) { + return createClaudeAppGatewayModelsResponse(config); + } + return createOpenAICompatibleGatewayModelsResponse(config); +} + +function createOpenAICompatibleGatewayModelsResponse(config: AppConfig): Record { + const data = buildGatewayDiscoverableModelIds(config).map((id) => { + const catalogEntry = findModelCatalogEntry(id); + return { + id, + object: "model", + created: 0, + owned_by: gatewayModelOwner(id), + type: "model", + ...(catalogEntry?.displayName ? { display_name: catalogEntry.displayName } : {}) + }; + }); + + return { + object: "list", + data + }; +} + +function createClaudeAppGatewayModelsResponse(config: AppConfig): Record { + const routes = buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions); + const data = routes.map((route) => { + const catalogId = stripClaudeCodeOneMillionContextSuffix(route.targetModel); + const catalogEntry = findModelCatalogEntry(catalogId); + const maxInputTokens = claudeGatewayModelContextWindow(catalogEntry, route.oneMillionContext); + const maxOutputTokens = modelCatalogMaxOutputTokens(catalogEntry); + return { + id: route.id, + capabilities: createClaudeCodeModelCapabilities(catalogEntry, { + maxInputTokens, + oneMillionContext: route.oneMillionContext + }), + created_at: "1970-01-01T00:00:00Z", + display_name: route.displayName, + max_input_tokens: maxInputTokens, + max_tokens: maxOutputTokens, + type: "model" + }; + }); + + return { + data, + first_id: data[0]?.id ?? null, + has_more: false, + last_id: data[data.length - 1]?.id ?? null + }; +} + +function createClaudeCodeModelsResponse(config: AppConfig): Record { + const models = buildClaudeCodeDiscoverableModels(config); + const data = models.map((model) => { + const claudeId = claudeCodeDiscoveryModelId(model.id); + const catalogId = stripClaudeCodeOneMillionContextSuffix(model.id); + const catalogEntry = findModelCatalogEntry(catalogId); + const maxInputTokens = claudeGatewayModelContextWindow(catalogEntry, model.oneMillionContext); + const maxOutputTokens = modelCatalogMaxOutputTokens(catalogEntry); + return { + id: claudeId, + capabilities: createClaudeCodeModelCapabilities(catalogEntry, { + maxInputTokens, + oneMillionContext: model.oneMillionContext + }), + created_at: "1970-01-01T00:00:00Z", + display_name: formatClaudeCodeModelDisplayName(claudeId, catalogEntry, model.oneMillionContext), + max_input_tokens: maxInputTokens, + max_tokens: maxOutputTokens, + type: "model" + }; + }); + + return { + data, + first_id: data[0]?.id ?? null, + has_more: false, + last_id: data[data.length - 1]?.id ?? null + }; +} + +function claudeGatewayModelContextWindow(entry: ModelCatalogEntry | undefined, oneMillionContext: boolean): number { + const contextWindow = modelCatalogMaxInputTokens(entry); + if (contextWindow > 0) { + return contextWindow; + } + return oneMillionContext ? 1_000_000 : 0; +} + +function buildClaudeCodeDiscoverableModelIds(config: AppConfig): string[] { + return buildGatewayDiscoverableModelIds(config); +} + +function buildGatewayDiscoverableModelIds(config: AppConfig): string[] { + const baseEntries: Array<{ modelName: string; providerName: string }> = []; + for (const provider of config.Providers) { + const providerName = provider.name?.trim(); + if (!providerName || !Array.isArray(provider.models)) { + continue; + } + for (const rawModel of provider.models) { + const modelName = rawModel.trim(); + if (!modelName) { + continue; + } + baseEntries.push({ modelName, providerName }); + } + } + + const ids = baseEntries.map((entry) => `${entry.providerName}/${entry.modelName}`); + for (const profile of config.virtualModelProfiles ?? []) { + if (!isVisibleVirtualModelProfile(profile)) { + continue; + } + + for (const entry of baseEntries) { + for (const prefix of profile.match?.prefixes ?? []) { + const normalizedPrefix = prefix.trim(); + if (normalizedPrefix) { + ids.push(`${entry.providerName}/${normalizedPrefix}${entry.modelName}`); + } + } + for (const suffix of profile.match?.suffixes ?? []) { + const normalizedSuffix = suffix.trim(); + if (normalizedSuffix) { + ids.push(`${entry.providerName}/${entry.modelName}${normalizedSuffix}`); + } + } + } + + for (const alias of profile.match?.exactAliases ?? []) { + const normalizedAlias = alias.trim(); + if (!normalizedAlias) { + continue; + } + ids.push(fusionModelSelector(normalizedAlias)); + } + } + + return uniqueStrings(ids); +} + +function gatewayModelOwner(id: string): string { + const separator = id.indexOf("/"); + return separator > 0 ? id.slice(0, separator).trim() || "ccr" : "ccr"; +} + +function buildClaudeCodeDiscoverableModels(config: AppConfig): ClaudeCodeDiscoverableModel[] { + const seen = new Set(); + const models: ClaudeCodeDiscoverableModel[] = []; + + const pushModel = (id: string, oneMillionContext: boolean) => { + const normalized = id.trim(); + if (!normalized) { + return; + } + const key = normalized.toLowerCase(); + if (seen.has(key)) { + return; + } + seen.add(key); + models.push({ id: normalized, oneMillionContext }); + }; + + for (const id of buildClaudeCodeDiscoverableModelIds(config)) { + pushModel(id, hasClaudeCodeOneMillionContextSuffix(id)); + const baseId = stripClaudeCodeOneMillionContextSuffix(id); + if (!hasClaudeCodeOneMillionContextSuffix(id) && findModelCatalogEntry(baseId)?.limits?.supports1MContext) { + pushModel(claudeCodeOneMillionContextModelId(baseId), true); + } + } + + return models; +} + +function isVisibleVirtualModelProfile(profile: NonNullable[number]): boolean { + return profile.enabled !== false && + profile.materialization?.enabled !== false && + profile.materialization?.includeInGatewayModels !== false; +} + +function resolveClaudeCodeDiscoveredModelId(model: string | undefined, config: AppConfig): string | undefined { + const normalized = normalizeRouteSelector(model); + if (!normalized || !normalized.toLowerCase().startsWith("claude-")) { + return undefined; + } + + if (isConfiguredGatewayModelSelector(normalized, config)) { + return undefined; + } + + const unprefixed = normalized.slice("claude-".length); + if (isConfiguredGatewayModelSelector(unprefixed, config)) { + return unprefixed; + } + + const withoutOneMillionContextSuffix = stripClaudeCodeOneMillionContextSuffix(unprefixed); + return withoutOneMillionContextSuffix !== unprefixed && + isConfiguredGatewayModelSelector(withoutOneMillionContextSuffix, config) + ? withoutOneMillionContextSuffix + : undefined; +} + +function resolveGatewayPublicModelId(model: string | undefined, config: AppConfig): string | undefined { + const normalized = normalizeRouteSelector(model); + if (!normalized || !normalized.toLowerCase().startsWith("claude-")) { + return undefined; + } + if (isConfiguredGatewayModelSelector(normalized, config)) { + return undefined; + } + return resolveClaudeCodeDiscoveredModelId(normalized, config) ?? + resolveClaudeAppGatewayRouteModel(normalized, config, claudeAppGatewayModelRouteOptions); +} + +function isConfiguredGatewayModelSelector(model: string, config: AppConfig): boolean { + const normalized = normalizeRouteSelector(model)?.toLowerCase(); + if (!normalized) { + return false; + } + + for (const id of buildClaudeCodeDiscoverableModelIds(config)) { + if (id.toLowerCase() === normalized) { + return true; + } + } + + for (const provider of config.Providers) { + if (provider.models.some((candidate) => candidate.trim().toLowerCase() === normalized)) { + return true; + } + } + + return false; +} + +function claudeCodeDiscoveryModelId(value: string): string { + return value.toLowerCase().startsWith("claude-") ? value : `claude-${value}`; +} + +function claudeCodeOneMillionContextModelId(id: string): string { + return hasClaudeCodeOneMillionContextSuffix(id) ? id : `${id}${claudeCodeOneMillionContextSuffix}`; +} + +function hasClaudeCodeOneMillionContextSuffix(id: string): boolean { + return id.trim().toLowerCase().endsWith(claudeCodeOneMillionContextSuffix); +} + +function stripClaudeCodeOneMillionContextSuffix(id: string): string { + return id.trim().replace(/\[1m\]$/i, "").trim(); +} + +function formatClaudeCodeModelDisplayName( + id: string, + entry?: ModelCatalogEntry, + oneMillionContext = hasClaudeCodeOneMillionContextSuffix(id) +): string { + if (entry?.displayName) { + return oneMillionContext ? `${entry.displayName} (1M context)` : entry.displayName; + } + + const normalized = stripClaudeCodeOneMillionContextSuffix(id.replace(/^claude-/i, "")); + const model = normalized.includes("/") ? normalized.slice(normalized.lastIndexOf("/") + 1) : normalized; + const words = model + .split(/[-_]+/) + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => (/^\d+$/.test(part) ? part : part.slice(0, 1).toUpperCase() + part.slice(1))); + const displayName = ["Claude", ...words].filter(Boolean).join(" "); + return oneMillionContext ? `${displayName} (1M context)` : displayName; +} + +function createClaudeCodeModelCapabilities( + entry?: ModelCatalogEntry, + options: { maxInputTokens?: number; oneMillionContext?: boolean } = {} +): Record { + if (!entry) { + return createDefaultClaudeCodeModelCapabilities(); + } + + const capabilities = entry.capabilities ?? {}; + const inputModalities = new Set((entry.modalities?.input ?? []).map((item) => item.toLowerCase())); + const outputModalities = new Set((entry.modalities?.output ?? []).map((item) => item.toLowerCase())); + const supportsReasoning = readCatalogCapability(capabilities, "reasoning"); + const supportsImageInput = readCatalogCapability(capabilities, "imageInput") || inputModalities.has("image"); + const supportsPdfInput = readCatalogCapability(capabilities, "pdfInput") || inputModalities.has("pdf"); + const supportsStructuredOutput = + readCatalogCapability(capabilities, "structuredOutput") || + readCatalogCapability(capabilities, "nativeStructuredOutput") || + readCatalogCapability(capabilities, "responseSchema"); + const supportsCodeExecution = readCatalogCapability(capabilities, "codeExecution"); + const supportsAdaptiveThinking = readCatalogCapability(capabilities, "adaptiveThinking"); + const supportsToolUse = + readCatalogCapability(capabilities, "toolCalling") || + readCatalogCapability(capabilities, "functionCalling"); + const supportsBatch = readCatalogCapability(capabilities, "batch"); + const supportsCitations = readCatalogCapability(capabilities, "citations"); + const supportsAudioInput = readCatalogCapability(capabilities, "audioInput") || inputModalities.has("audio"); + const supportsAudioOutput = readCatalogCapability(capabilities, "audioOutput") || outputModalities.has("audio"); + const supportsVideoInput = readCatalogCapability(capabilities, "videoInput") || inputModalities.has("video"); + const maxInputTokens = options.maxInputTokens ?? modelCatalogMaxInputTokens(entry); + const supportsOneMillionContext = Boolean(entry.limits?.supports1MContext); + + return { + audio_input: { supported: supportsAudioInput }, + audio_output: { supported: supportsAudioOutput }, + batch: { supported: supportsBatch }, + citations: { supported: supportsCitations }, + code_execution: { supported: supportsCodeExecution }, + context_management: { + clear_thinking_20251015: { supported: supportsReasoning }, + clear_tool_uses_20250919: { supported: supportsToolUse }, + compact_20260112: { supported: maxInputTokens > 0 }, + max_input_tokens: maxInputTokens, + supported: maxInputTokens > 0 + }, + context_window: { + max_input_tokens: maxInputTokens, + supported: maxInputTokens > 0, + supports_1m_context: supportsOneMillionContext, + one_million_context_variant: options.oneMillionContext === true + }, + effort: { + high: { supported: supportsReasoning }, + low: { supported: supportsReasoning }, + max: { supported: supportsReasoning }, + medium: { supported: supportsReasoning }, + supported: supportsReasoning, + xhigh: { supported: supportsReasoning } + }, + image_input: { supported: supportsImageInput }, + pdf_input: { supported: supportsPdfInput }, + structured_outputs: { supported: supportsStructuredOutput }, + thinking: { + supported: supportsReasoning, + types: { + adaptive: { supported: supportsAdaptiveThinking }, + enabled: { supported: supportsReasoning } + } + }, + tool_use: { supported: supportsToolUse }, + video_input: { supported: supportsVideoInput } + }; +} + +function createDefaultClaudeCodeModelCapabilities(): Record { + return { + batch: { supported: true }, + citations: { supported: true }, + code_execution: { supported: true }, + context_management: { + clear_thinking_20251015: { supported: true }, + clear_tool_uses_20250919: { supported: true }, + compact_20260112: { supported: true }, + supported: true + }, + effort: { + high: { supported: true }, + low: { supported: true }, + max: { supported: true }, + medium: { supported: true }, + supported: true, + xhigh: { supported: true } + }, + image_input: { supported: true }, + pdf_input: { supported: true }, + structured_outputs: { supported: true }, + thinking: { + supported: true, + types: { + adaptive: { supported: true }, + enabled: { supported: true } + } + } + }; +} + +function normalizeGatewayPathname(path: string): string { + const normalized = path.trim().replace(/\/+$/, ""); + return normalized || "/"; +} + +function isClaudeCodeUserAgent(headers: IncomingHttpHeaders): boolean { + const userAgent = readHeader(headers["user-agent"]); + if (!userAgent) { + return false; + } + const normalized = userAgent.toLowerCase(); + return normalized.includes("claude"); +} + +function isClaudeAppApiKey(apiKey: ApiKeyConfig | undefined): boolean { + const name = apiKey?.name?.trim().toLowerCase(); + return name === "claude app"; +} + +function prepareCursorOpenAICompatChatBody( + config: AppConfig, + client: string | undefined, + method: string, + path: string, + requestBody: Buffer +): CursorOpenAICompatPreparation | undefined { + if ((method || "GET").toUpperCase() !== "POST" || !isOpenAICompatChatCompletionsPath(path) || client !== "Cursor") { + return undefined; + } + + let body: Record; + try { + body = parseJsonObject(requestBody); + } catch { + return undefined; + } + if (!isSimplifiedCursorOpenAICompatChat(body)) { + return undefined; + } + + const context = readCursorOpenAICompatContext(config); + let changed = false; + if (context.systemPrompt) { + body.messages = [ + { content: context.systemPrompt, role: "system" }, + ...(Array.isArray(body.messages) ? body.messages : []) + ]; + changed = true; + } + if (context.tools.length > 0) { + body.tools = context.tools; + changed = true; + } + if (context.toolChoice !== undefined && context.tools.length > 0) { + body.tool_choice = context.toolChoice; + changed = true; + } + + if (!changed) { + if (!warnedMissingCursorOpenAICompatContext) { + warnedMissingCursorOpenAICompatContext = true; + console.warn( + "[gateway] Cursor sent an OpenAI-compatible chat request with only user messages and no system/tools. " + + "Configure plugins[].id=\"cursor-proxy\" config.systemPrompt/config.tools to inject fallback context, " + + "or route Cursor native Agent traffic through the proxy." + ); + } + return { diagnostic: "simplified-missing-context" }; + } + + return { + body: Buffer.from(`${JSON.stringify(body)}\n`, "utf8"), + diagnostic: "fallback-injected" + }; +} + +function isOpenAICompatChatCompletionsPath(path: string): boolean { + return path === "/chat/completions" || + path === "/v1/chat/completions" || + path.endsWith("/chat/completions"); +} + +function isSimplifiedCursorOpenAICompatChat(body: Record): boolean { + if (body.system !== undefined || body.systemPrompt !== undefined || body.instructions !== undefined) { + return false; + } + if (Array.isArray(body.tools) && body.tools.length > 0) { + return false; + } + if (!Array.isArray(body.messages) || body.messages.length === 0) { + return false; + } + return body.messages.every((message) => + isRecord(message) && + stringValue(message.role)?.toLowerCase() === "user" + ); +} + +function readCursorOpenAICompatContext(config: AppConfig): CursorOpenAICompatContext { + const plugin = config.plugins.find((item) => item.enabled !== false && item.id === "cursor-proxy"); + const pluginConfig = isRecord(plugin?.config) ? plugin.config : {}; + return { + systemPrompt: + stringValue(pluginConfig.systemPrompt) || + stringValue(pluginConfig.openaiSystemPrompt) || + stringValue(pluginConfig.defaultSystemPrompt), + toolChoice: normalizeCursorToolChoice( + pluginConfig.toolChoice ?? pluginConfig.openaiToolChoice ?? pluginConfig.defaultToolChoice + ), + tools: normalizeCursorTools(pluginConfig.tools ?? pluginConfig.openaiTools ?? pluginConfig.defaultTools) + }; +} + +function normalizeCursorTools(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value.map(normalizeCursorTool).filter((tool): tool is Record => Boolean(tool)); + } + if (isRecord(value)) { + if (Array.isArray(value.tools) || isRecord(value.tools)) { + return normalizeCursorTools(value.tools); + } + return Object.entries(value) + .map(([name, item]) => normalizeCursorTool(isRecord(item) ? { ...item, name: stringValue(item.name) || name } : { description: stringValue(item), name })) + .filter((tool): tool is Record => Boolean(tool)); + } + return []; +} + +function normalizeCursorTool(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const type = stringValue(value.type); + if (type && type.toLowerCase().startsWith("web_search")) { + return { ...value, type }; + } + + const fn = isRecord(value.function) ? value.function : value; + const name = + stringValue(fn.name) || + stringValue(value.name) || + stringValue(value.toolName) || + stringValue(value.functionName); + if (!name) { + return undefined; + } + return { + function: compactRecord({ + description: stringValue(fn.description) || stringValue(value.description), + name, + parameters: normalizeCursorToolParameters( + fn.parameters ?? + value.parameters ?? + fn.input_schema ?? + value.input_schema ?? + fn.inputSchema ?? + value.inputSchema ?? + fn.schema ?? + value.schema + ) + }), + type: "function" + }; +} + +function normalizeCursorToolParameters(value: unknown): Record { + if (isRecord(value)) { + return value; + } + if (typeof value === "string") { + try { + const parsed = JSON.parse(value) as unknown; + if (isRecord(parsed)) { + return parsed; + } + } catch { + // Fall through to an empty object schema. + } + } + return { properties: {}, type: "object" }; +} + +function normalizeCursorToolChoice(value: unknown): unknown { + if (typeof value === "string" && value.trim()) { + const normalized = value.trim().toLowerCase(); + if (normalized === "auto" || normalized === "none" || normalized === "required") { + return normalized; + } + return { function: { name: value.trim() }, type: "function" }; + } + if (!isRecord(value)) { + return undefined; + } + const type = stringValue(value.type); + if (type && ["auto", "none", "required"].includes(type.toLowerCase())) { + return type.toLowerCase(); + } + const fn = isRecord(value.function) ? value.function : value; + const name = stringValue(fn.name) || stringValue(value.name) || stringValue(value.toolName); + return name ? { function: { name }, type: "function" } : undefined; +} + +function compactRecord(value: Record): Record { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)); +} + +function inferGatewayClient(apiKey: ApiKeyConfig | undefined, headers: IncomingHttpHeaders): string | undefined { + const explicit = + readHeader(headers["x-ccr-client"]) ?? + readHeader(headers["x-client-name"]) ?? + readHeader(headers["x-forwarded-client-cert"]); + if (explicit) { + return explicit; + } + + const apiKeyClient = apiKey?.name?.trim() || apiKey?.id?.trim(); + const userAgentClient = inferClientFromUserAgent(headers); + if (readHeader(headers["x-ccr-proxy-mode"]) === "gateway") { + return userAgentClient ?? apiKeyClient; + } + return apiKeyClient ?? userAgentClient; +} + +function inferClientFromUserAgent(headers: IncomingHttpHeaders): string | undefined { + const userAgent = readHeader(headers["user-agent"]); + if (!userAgent) { + return undefined; + } + + const normalized = userAgent.toLowerCase(); + if (normalized.includes("codex")) { + return "Codex"; + } + if (normalized.includes("@anthropic-ai/claude-code") || normalized.includes("claude-code") || normalized.includes("claude code")) { + return "Claude Code"; + } + if (normalized.includes("claude")) { + return "Claude"; + } + if (normalized.includes("curl")) { + return "curl"; + } + if (normalized.includes("python")) { + return "Python"; + } + if (normalized.includes("node")) { + return "Node.js"; + } + if (normalized.includes("chrome")) { + return "Google Chrome"; + } + if (normalized.includes("safari") && !normalized.includes("chrome")) { + return "Safari"; + } + return userAgent.split(/[ /]/)[0]?.trim() || undefined; +} + +function readAuthToken(headers: IncomingHttpHeaders): string | undefined { + const raw = readHeader(headers.authorization) || readHeader(headers["x-api-key"]); + if (!raw) { + return undefined; + } + return raw.toLowerCase().startsWith("bearer ") ? raw.slice(7).trim() : raw; +} + +function readRemoteControlQueryAuthToken(request: IncomingMessage): string | undefined { + const url = new URL(request.url || "/", "http://127.0.0.1"); + if (url.pathname !== ccrRemoteControlPathPrefix && !url.pathname.startsWith(`${ccrRemoteControlPathPrefix}/`)) { + return undefined; + } + return url.searchParams.get("api_key")?.trim() || url.searchParams.get("key")?.trim() || undefined; +} + +function forwardHeaders(headers: IncomingHttpHeaders): Record { + const forwarded: Record = {}; + for (const [key, value] of Object.entries(headers)) { + const normalized = key.toLowerCase(); + if (proxyHeaderDenyList.has(normalized) || value === undefined) { + continue; + } + forwarded[normalized] = Array.isArray(value) ? value.join(",") : String(value); + } + return forwarded; +} + +function stripLocalGatewayAuthHeaders(headers: Record): void { + delete headers.authorization; + delete headers["x-api-key"]; + delete headers["api-key"]; +} + +function omitLocalObservabilityHeaders(headers: Record): Record { + const forwarded = { ...headers }; + for (const name of localObservabilityHeaderNames) { + delete forwarded[name]; + } + return forwarded; +} + +function withCoreGatewayAuthHeader(headers: Record, token: string): Record { + if (!token) { + throw new Error("Core gateway auth token is not initialized."); + } + return { + ...headers, + [coreGatewayAuthHeader]: token + }; +} + +function filteredResponseHeaders(headers: Headers): Array<[string, string]> { + const entries: Array<[string, string]> = []; + headers.forEach((value, key) => { + if (!responseHeaderDenyList.has(key.toLowerCase())) { + entries.push([key, value]); + } + }); + return entries; +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function abortSignalMessage(signal: AbortSignal): string { + const reason = signal.reason as unknown; + if (reason instanceof Error && reason.message) { + return reason.message; + } + if (typeof reason === "string" && reason.trim()) { + return reason.trim(); + } + return "Upstream request was aborted."; +} + +function parseJsonObject(buffer: Buffer): Record { + if (buffer.length === 0) { + return {}; + } + const parsed = JSON.parse(buffer.toString("utf8")) as unknown; + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record; + } + throw new Error("Request body must be a JSON object."); +} + +function readHeader(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) { + return value[0]?.trim(); + } + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readRequestBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + request.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + request.on("end", () => resolve(Buffer.concat(chunks))); + request.on("error", reject); + }); +} + +function sendJson(response: ServerResponse, statusCode: number, payload: unknown): void { + response.writeHead(statusCode, { "content-type": "application/json" }); + response.end(`${JSON.stringify(payload)}\n`); +} + +function closeServer(server: Server): Promise { + return new Promise((resolve) => { + let settled = false; + let timeout: NodeJS.Timeout | undefined; + const finish = () => { + if (settled) { + return; + } + settled = true; + if (timeout) { + clearTimeout(timeout); + } + resolve(); + }; + + try { + server.closeIdleConnections?.(); + timeout = setTimeout(() => { + server.closeAllConnections?.(); + finish(); + }, 800); + server.close(() => finish()); + } catch { + finish(); + } + }); +} + +function shouldSendBody(method: string | undefined): boolean { + const normalized = method?.toUpperCase(); + return normalized !== "GET" && normalized !== "HEAD"; +} + +function shouldCaptureGatewayUsage(method: string, _path: string): boolean { + return shouldSendBody(method); +} diff --git a/packages/core/src/mcp/browser-web-search-proxy-mcp.ts b/packages/core/src/mcp/browser-web-search-proxy-mcp.ts new file mode 100644 index 0000000..d034adf --- /dev/null +++ b/packages/core/src/mcp/browser-web-search-proxy-mcp.ts @@ -0,0 +1,146 @@ +const targetUrl = process.env.BROWSER_WEB_SEARCH_MCP_URL?.trim(); +const requestTimeoutMs = clampInteger(Number(process.env.BROWSER_WEB_SEARCH_PROXY_TIMEOUT_MS), 1_000, 600_000, 120_000); + +let stdinBuffer = Buffer.alloc(0); + +if (!targetUrl) { + process.stderr.write("BROWSER_WEB_SEARCH_MCP_URL is required.\n"); + process.exit(1); +} + +process.stdin.on("data", (chunk: Buffer | string) => { + stdinBuffer = Buffer.concat([stdinBuffer, typeof chunk === "string" ? Buffer.from(chunk) : chunk]); + void drainFrames(); +}); + +process.stdin.on("end", () => { + process.exit(0); +}); + +async function drainFrames(): Promise { + while (true) { + const headerEnd = stdinBuffer.indexOf("\r\n\r\n"); + if (headerEnd < 0) { + return; + } + + const headerText = stdinBuffer.slice(0, headerEnd).toString("utf8"); + const contentLength = readContentLength(headerText); + if (contentLength === undefined || contentLength < 0) { + stdinBuffer = Buffer.alloc(0); + writeFrame(jsonRpcError(null, -32600, "Invalid MCP frame header.")); + return; + } + + const payloadStart = headerEnd + 4; + const payloadEnd = payloadStart + contentLength; + if (stdinBuffer.length < payloadEnd) { + return; + } + + const body = stdinBuffer.slice(payloadStart, payloadEnd).toString("utf8"); + stdinBuffer = stdinBuffer.slice(payloadEnd); + await forwardPayload(body); + } +} + +async function forwardPayload(body: string): Promise { + const requestId = readJsonRpcId(body); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), requestTimeoutMs); + try { + const response = await fetch(targetUrl!, { + body, + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json" + }, + method: "POST", + signal: controller.signal + }); + + if (response.status === 204) { + return; + } + + const text = await response.text(); + if (!response.ok) { + writeFrame(jsonRpcError(requestId, -32603, text || `In-app browser MCP returned HTTP ${response.status}.`)); + return; + } + if (text.trim()) { + writeRawFrame(text); + } + } catch (error) { + writeFrame(jsonRpcError(requestId, -32603, formatError(error))); + } finally { + clearTimeout(timeout); + } +} + +function readContentLength(headerText: string): number | undefined { + for (const line of headerText.split(/\r?\n/)) { + const match = /^content-length\s*:\s*(\d+)\s*$/i.exec(line); + if (match) { + return Number(match[1]); + } + } + return undefined; +} + +function readJsonRpcId(body: string): null | number | string { + try { + const payload = JSON.parse(body) as unknown; + if (isRecord(payload)) { + return isJsonRpcId(payload.id) ? payload.id : null; + } + if (Array.isArray(payload)) { + const first = payload.find((item) => isRecord(item) && isJsonRpcId(item.id)); + return isRecord(first) && isJsonRpcId(first.id) ? first.id : null; + } + } catch { + // The upstream MCP endpoint will return the parse error for valid JSON-RPC framing. + } + return null; +} + +function isJsonRpcId(value: unknown): value is null | number | string { + return value === null || typeof value === "number" || typeof value === "string"; +} + +function jsonRpcError(id: null | number | string, code: number, message: string): Record { + return { + error: { + code, + message + }, + id, + jsonrpc: "2.0" + }; +} + +function writeFrame(payload: Record): void { + writeRawFrame(JSON.stringify(payload)); +} + +function writeRawFrame(body: string): void { + process.stdout.write(`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`); +} + +function clampInteger(value: number, min: number, max: number, fallback: number): number { + if (!Number.isFinite(value)) { + return fallback; + } + return Math.min(max, Math.max(min, Math.trunc(value))); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function formatError(error: unknown): string { + if (error instanceof Error) { + return error.name === "AbortError" ? "In-app browser MCP proxy request timed out." : error.message; + } + return String(error); +} diff --git a/packages/core/src/mcp/fusion-tool-fallback-mcp.ts b/packages/core/src/mcp/fusion-tool-fallback-mcp.ts new file mode 100644 index 0000000..87c9b62 --- /dev/null +++ b/packages/core/src/mcp/fusion-tool-fallback-mcp.ts @@ -0,0 +1,238 @@ +type JsonPrimitive = boolean | null | number | string; +type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +type JsonRpcRequest = { + id?: null | number | string; + jsonrpc?: string; + method?: string; + params?: unknown; +}; + +type JsonRpcResponse = + | { + id: null | number | string; + jsonrpc: "2.0"; + result: JsonValue; + } + | { + error: { + code: number; + data?: JsonValue; + message: string; + }; + id: null | number | string; + jsonrpc: "2.0"; + }; + +type McpTool = { + description: string; + inputSchema: JsonValue; + name: string; + unavailableMessage?: string; +}; + +type ToolCallResult = { + content: Array<{ text: string; type: "text" }>; + isError?: boolean; +}; + +const protocolVersion = "2024-11-05"; +const tools = readFallbackTools(); +const toolNames = new Set(tools.map((tool) => tool.name)); + +let inputBuffer = Buffer.alloc(0); + +process.stdin.on("data", (chunk) => { + inputBuffer = Buffer.concat([inputBuffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + drainInputBuffer().catch((error) => { + writeJsonRpc(jsonRpcError(null, -32603, formatError(error))); + }); +}); + +process.stdin.resume(); + +async function drainInputBuffer(): Promise { + while (true) { + const headerEnd = inputBuffer.indexOf("\r\n\r\n"); + if (headerEnd < 0) { + return; + } + + const headerText = inputBuffer.subarray(0, headerEnd).toString("utf8"); + const lengthMatch = headerText.match(/content-length:\s*(\d+)/i); + if (!lengthMatch) { + inputBuffer = inputBuffer.subarray(headerEnd + 4); + writeJsonRpc(jsonRpcError(null, -32600, "Missing Content-Length header.")); + continue; + } + + const contentLength = Number(lengthMatch[1]); + const messageStart = headerEnd + 4; + const messageEnd = messageStart + contentLength; + if (inputBuffer.length < messageEnd) { + return; + } + + const message = inputBuffer.subarray(messageStart, messageEnd).toString("utf8"); + inputBuffer = inputBuffer.subarray(messageEnd); + let payload: unknown; + try { + payload = JSON.parse(message) as unknown; + } catch (error) { + writeJsonRpc(jsonRpcError(null, -32700, `Invalid JSON-RPC request: ${formatError(error)}`)); + continue; + } + + const response = await handleJsonRpcRequest(payload); + if (response) { + writeJsonRpc(response); + } + } +} + +async function handleJsonRpcRequest(payload: unknown): Promise { + if (!isRecord(payload)) { + return jsonRpcError(null, -32600, "JSON-RPC request must be an object."); + } + + const request = payload as JsonRpcRequest; + const id = request.id ?? null; + if (request.id === undefined && request.method?.startsWith("notifications/")) { + return undefined; + } + if (request.jsonrpc !== "2.0" || !request.method) { + return jsonRpcError(id, -32600, "Invalid JSON-RPC 2.0 request."); + } + + try { + switch (request.method) { + case "initialize": + return jsonRpcResult(id, { + capabilities: { + tools: {} + }, + protocolVersion, + serverInfo: { + name: "ccr-fusion-tool-fallback", + title: "CCR Fusion Tool Fallback", + version: "1.0.0" + } + }); + case "ping": + return jsonRpcResult(id, {}); + case "tools/list": + return jsonRpcResult(id, { tools: tools as unknown as JsonValue }); + case "tools/call": + return jsonRpcResult(id, callTool(request.params) as unknown as JsonValue); + default: + return jsonRpcError(id, -32601, `Unsupported MCP method: ${request.method}`); + } + } catch (error) { + return jsonRpcError(id, -32603, formatError(error)); + } +} + +function callTool(params: unknown): ToolCallResult { + const name = isRecord(params) && typeof params.name === "string" ? params.name.trim() : ""; + const toolLabel = name || "unknown"; + const tool = tools.find((item) => item.name === toolLabel); + const knownSuffix = toolNames.has(toolLabel) ? "" : " The requested tool was not in the fallback catalog."; + return { + content: [{ + text: tool?.unavailableMessage || + `Fusion MCP tool "${toolLabel}" is temporarily unavailable. ` + + "CCR registered a fallback definition because the real MCP server did not provide the tool during discovery. " + + `Check the Fusion MCP server logs and retry.${knownSuffix}`, + type: "text" + }], + isError: true + }; +} + +function readFallbackTools(): McpTool[] { + const raw = env("FUSION_FALLBACK_TOOLS_JSON"); + if (!raw) { + return []; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + return []; + } + if (!Array.isArray(parsed)) { + return []; + } + + const seen = new Set(); + const result: McpTool[] = []; + for (const item of parsed) { + if (!isRecord(item)) { + continue; + } + const name = readString(item.name); + if (!name || seen.has(name)) { + continue; + } + seen.add(name); + result.push({ + description: + readString(item.description) || + `Fallback registration for Fusion MCP tool "${name}". The real MCP server should handle successful calls.`, + inputSchema: isRecord(item.inputSchema) ? item.inputSchema as JsonValue : objectSchema({}), + name, + unavailableMessage: readString(item.unavailableMessage) + }); + } + return result; +} + +function objectSchema(properties: Record, required: string[] = []): JsonValue { + return { + additionalProperties: true, + properties, + ...(required.length ? { required } : {}), + type: "object" + }; +} + +function jsonRpcResult(id: null | number | string, result: JsonValue): JsonRpcResponse { + return { + id, + jsonrpc: "2.0", + result + }; +} + +function jsonRpcError(id: null | number | string, code: number, message: string): JsonRpcResponse { + return { + error: { + code, + message + }, + id, + jsonrpc: "2.0" + }; +} + +function writeJsonRpc(response: JsonRpcResponse): void { + const text = JSON.stringify(response); + process.stdout.write(`Content-Length: ${Buffer.byteLength(text, "utf8")}\r\n\r\n${text}`); +} + +function env(name: string): string { + return process.env[name]?.trim() ?? ""; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/server/mcp/fusion-vision-mcp.ts b/packages/core/src/mcp/fusion-vision-mcp.ts similarity index 96% rename from src/server/mcp/fusion-vision-mcp.ts rename to packages/core/src/mcp/fusion-vision-mcp.ts index 4cb8ec7..956866c 100644 --- a/src/server/mcp/fusion-vision-mcp.ts +++ b/packages/core/src/mcp/fusion-vision-mcp.ts @@ -1,6 +1,5 @@ import { readFile } from "node:fs/promises"; import { extname } from "node:path"; -import { fetchWithSystemProxy } from "../../main/system-proxy-fetch"; type JsonPrimitive = boolean | null | number | string; type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; @@ -91,6 +90,10 @@ const visionTool = { const webSearchTool = { description: "Search the web with this Fusion profile's configured search provider.", inputSchema: objectSchema({ + allowedDomains: { items: { type: "string" }, type: "array" }, + allowed_domains: { items: { type: "string" }, type: "array" }, + blockedDomains: { items: { type: "string" }, type: "array" }, + blocked_domains: { items: { type: "string" }, type: "array" }, count: { maximum: 20, minimum: 1, type: "number" }, country: { type: "string" }, excludeDomains: { items: { type: "string" }, type: "array" }, @@ -251,7 +254,7 @@ async function analyzeVision(args: Record): Promise { } ]; const timeoutMs = clampInteger(readNumber(args.timeoutMs) ?? readNumber(env("VISION_TIMEOUT_MS")) ?? defaultTimeoutMs, 100, 600000); - const response = await fetchWithSystemProxy(resolveChatCompletionsUrl(baseUrl), { + const response = await fetch(resolveChatCompletionsUrl(baseUrl), { body: JSON.stringify({ model, messages }), headers: { ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}), @@ -281,9 +284,17 @@ async function analyzeWebSearch(args: Record): Promise const input = { count, country: readString(args.country), - excludeDomains: readStringArray(args.excludeDomains), + excludeDomains: uniqueStrings([ + ...readStringArray(args.excludeDomains), + ...readStringArray(args.blockedDomains), + ...readStringArray(args.blocked_domains) + ]), freshness: readString(args.freshness), - includeDomains: readStringArray(args.includeDomains), + includeDomains: uniqueStrings([ + ...readStringArray(args.includeDomains), + ...readStringArray(args.allowedDomains), + ...readStringArray(args.allowed_domains) + ]), includeRaw: args.includeRaw === true, language: readString(args.language), prompt, @@ -614,7 +625,7 @@ function requireEnv(name: string, label: string): string { } async function fetchJson(url: string, init: RequestInit): Promise { - const response = await fetchWithSystemProxy(url, init); + const response = await fetch(url, init); const rawText = await response.text(); const payload = rawText ? parseJson(rawText) : {}; if (!response.ok) { @@ -652,12 +663,19 @@ function isSearchResult(value: SearchResult): value is Required item.trim()).filter(Boolean); + } if (!Array.isArray(value)) { return []; } return value.map(readString).filter((item): item is string => Boolean(item)); } +function uniqueStrings(values: string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} + function textResult(text: string): ToolCallResult { return { content: [{ text, type: "text" }] diff --git a/src/server/mcp/network-capture-mcp.ts b/packages/core/src/mcp/network-capture-mcp.ts similarity index 97% rename from src/server/mcp/network-capture-mcp.ts rename to packages/core/src/mcp/network-capture-mcp.ts index bbc3601..15dd6dc 100644 --- a/src/server/mcp/network-capture-mcp.ts +++ b/packages/core/src/mcp/network-capture-mcp.ts @@ -1,7 +1,7 @@ -import { app } from "electron"; +import packageJson from "../../package.json"; import type { IncomingMessage, ServerResponse } from "node:http"; -import type { ProxyNetworkExchange } from "../../shared/app"; -import { proxyService } from "../proxy/service"; +import type { ProxyNetworkExchange } from "@ccr/core/contracts/app"; +import { proxyService } from "@ccr/core/proxy/service"; type JsonPrimitive = boolean | null | number | string; type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; @@ -151,7 +151,7 @@ async function handleJsonRpcRequest(payload: unknown): Promise { if (!proxyService.isNetworkCaptureEnabled()) { throw new Error("Network capture MCP is disabled."); diff --git a/src/server/mcp/tool-discovery.ts b/packages/core/src/mcp/tool-discovery.ts similarity index 99% rename from src/server/mcp/tool-discovery.ts rename to packages/core/src/mcp/tool-discovery.ts index 61f0276..efe2b41 100644 --- a/src/server/mcp/tool-discovery.ts +++ b/packages/core/src/mcp/tool-discovery.ts @@ -1,11 +1,11 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { fetchWithSystemProxy } from "../../main/system-proxy-fetch"; +import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch"; import type { GatewayMcpRemoteServerConfig, GatewayMcpServerConfig, GatewayMcpStdioServerConfig, GatewayMcpToolInfo -} from "../../shared/app"; +} from "@ccr/core/contracts/app"; type JsonRpcMessage = { error?: unknown; diff --git a/packages/core/src/mcp/toolhub-config.ts b/packages/core/src/mcp/toolhub-config.ts new file mode 100644 index 0000000..5c4cbd1 --- /dev/null +++ b/packages/core/src/mcp/toolhub-config.ts @@ -0,0 +1,226 @@ +import { join as pathJoin } from "node:path"; +import { CONFIGDIR } from "@ccr/core/config/constants"; +import type { AppConfig, GatewayMcpServerConfig } from "@ccr/core/contracts/app"; + +export const TOOL_HUB_MCP_SERVER_NAME = "ccr-toolhub"; +export const TOOL_HUB_MCP_RUNTIME_FILE_NAME = "toolhub-mcp.js"; +export const BROWSER_AUTOMATION_MCP_SERVER_NAME = "ccr-browser-automation"; +export const BROWSER_AUTOMATION_MCP_PATH = "/__ccr/browser-automation/mcp"; +export const BROWSER_AUTOMATION_HANDOFF_TIMEOUT_MS = 600000; +export const TOOL_HUB_DEFAULT_REQUEST_TIMEOUT_MS = 60000; + +export type ToolHubMcpRuntimeConfig = { + args: string[]; + command: string; + env: Record; +}; + +export type ClaudeCodeMcpServerConfig = ToolHubMcpRuntimeConfig | { + args?: string[]; + command: string; + cwd?: string; + env?: Record; +} | { + headers?: Record; + type: "http" | "sse"; + url: string; +}; + +export type ToolHubClaudeCodeMcpConfig = { + mcpServers: Record; +}; + +export function toolHubBackendServers( + config: AppConfig | undefined, + extraServers: unknown[] = [], + options: { + apiKey?: string; + includeBuiltIns?: boolean; + } = {} +): unknown[] { + return [ + ...(options.includeBuiltIns === false ? [] : toolHubBuiltInBackendServers(config, options)), + ...(Array.isArray(config?.agent?.mcpServers) ? config.agent.mcpServers : []), + ...(Array.isArray(config?.toolHub?.mcpServers) ? config.toolHub.mcpServers : []), + ...extraServers + ].filter(isToolHubBackendServer); +} + +export function toolHubBuiltInBackendServers( + config: AppConfig | undefined, + options: { + apiKey?: string; + } = {} +): GatewayMcpServerConfig[] { + if (!config || !browserAutomationMcpEnabled(config) || !hasGatewayEndpoint(config)) { + return []; + } + + return [ + { + apiKey: options.apiKey || firstConfiguredApiKey(config), + headers: {}, + name: BROWSER_AUTOMATION_MCP_SERVER_NAME, + protocolVersion: "2024-11-05", + requestTimeoutMs: BROWSER_AUTOMATION_HANDOFF_TIMEOUT_MS, + startupTimeoutMs: 60000, + transport: "streamable-http", + url: `${gatewayEndpoint(config)}${BROWSER_AUTOMATION_MCP_PATH}` + } + ]; +} + +export function browserAutomationMcpEnabled(config: AppConfig | undefined): boolean { + return Boolean(config?.toolHub?.enabled && config.toolHub.browserAutomation); +} + +export function toolHubMcpRuntimeConfig( + config: AppConfig | undefined, + backendServers?: unknown[], + options: { + command?: string; + entryPath?: string; + resolver?: { + apiKey?: string; + baseUrl?: string; + model?: string; + }; + } = {} +): ToolHubMcpRuntimeConfig | undefined { + const toolHub = config?.toolHub; + if (!toolHub?.enabled) { + return undefined; + } + + const resolvedBackendServers = backendServers ?? toolHubBackendServers(config, [], { + apiKey: options.resolver?.apiKey + }); + const normalizedBackendServers = resolvedBackendServers.filter(isToolHubBackendServer); + if (normalizedBackendServers.length === 0) { + return undefined; + } + const requestTimeoutMs = toolHubRequestTimeoutMs(config, normalizedBackendServers); + + return { + args: [options.entryPath ?? bundledToolHubMcpEntryPath()], + command: options.command ?? process.execPath, + env: { + ELECTRON_RUN_AS_NODE: "1", + TOOLHUB_CACHE_FILE: pathJoin(CONFIGDIR, "toolhub-cache.json"), + TOOLHUB_MAX_TOOLS: String(toolHub.maxTools ?? 10), + TOOLHUB_MCP_SERVERS_JSON: JSON.stringify(normalizedBackendServers), + TOOLHUB_OPENAI_API_KEY: options.resolver?.apiKey ?? toolHub.llm?.apiKey ?? "", + TOOLHUB_OPENAI_BASE_URL: options.resolver?.baseUrl ?? toolHub.llm?.baseUrl ?? "https://api.openai.com/v1", + TOOLHUB_OPENAI_MODEL: options.resolver?.model ?? toolHub.llm?.model ?? "", + TOOLHUB_REQUEST_TIMEOUT_MS: String(requestTimeoutMs) + } + }; +} + +export function toolHubRequestTimeoutMs(config: AppConfig | undefined, backendServers?: unknown[]): number { + const configuredTimeout = positiveInteger(config?.toolHub?.requestTimeoutMs, TOOL_HUB_DEFAULT_REQUEST_TIMEOUT_MS); + const backendTimeouts = (backendServers ?? toolHubBackendServers(config)) + .map((server) => isRecord(server) ? positiveInteger(server.requestTimeoutMs, 0) : 0); + return Math.max(configuredTimeout, ...backendTimeouts); +} + +export function toolHubClaudeCodeMcpConfig( + config: AppConfig | undefined, + options: Parameters[2] = {} +): ToolHubClaudeCodeMcpConfig | undefined { + const toolHub = config?.toolHub; + if (!toolHub?.enabled) { + return undefined; + } + + const backendServers = toolHubBackendServers(config, [], { + apiKey: options.resolver?.apiKey + }); + if (backendServers.length === 0) { + return undefined; + } + + const runtimeConfig = toolHubMcpRuntimeConfig(config, backendServers, options); + return runtimeConfig + ? { mcpServers: { [TOOL_HUB_MCP_SERVER_NAME]: runtimeConfig } } + : undefined; +} + +export function bundledToolHubMcpEntryPath(): string { + return pathJoin(__dirname, TOOL_HUB_MCP_RUNTIME_FILE_NAME); +} + +export function bundledToolHubMcpEntryPathCandidates(): string[] { + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + return uniqueStrings([ + bundledToolHubMcpEntryPath(), + ...(resourcesPath + ? [ + pathJoin(resourcesPath, "app.asar", "dist", "main", TOOL_HUB_MCP_RUNTIME_FILE_NAME), + pathJoin(resourcesPath, "app", "dist", "main", TOOL_HUB_MCP_RUNTIME_FILE_NAME) + ] + : []), + pathJoin(process.cwd(), "packages", "electron", "dist", "main", TOOL_HUB_MCP_RUNTIME_FILE_NAME), + pathJoin(process.cwd(), "packages", "cli", "dist", "main", TOOL_HUB_MCP_RUNTIME_FILE_NAME), + pathJoin(process.cwd(), "packages", "core", "dist", "main", TOOL_HUB_MCP_RUNTIME_FILE_NAME), + pathJoin(process.cwd(), "dist", "main", TOOL_HUB_MCP_RUNTIME_FILE_NAME) + ]); +} + +function isToolHubBackendServer(value: unknown): value is Record { + return isRecord(value) && stringValue(value.name)?.toLowerCase() !== TOOL_HUB_MCP_SERVER_NAME; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function firstConfiguredApiKey(config: AppConfig): string | undefined { + return (Array.isArray(config.APIKEYS) ? config.APIKEYS : []) + .find((apiKey) => apiKey.key.trim())?.key.trim() || stringValue(config.APIKEY); +} + +function positiveInteger(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback; +} + +function gatewayEndpoint(config: AppConfig): string { + return `http://${formatHost(clientGatewayHost(config.gateway.host))}:${config.gateway.port}`; +} + +function hasGatewayEndpoint(config: AppConfig): boolean { + const gateway = (config as Partial).gateway; + return Boolean(gateway && stringValue(gateway.host) && Number.isFinite(gateway.port)); +} + +function formatHost(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +} + +function clientGatewayHost(host: string): string { + const value = stringValue(host) ?? "127.0.0.1"; + if (value === "0.0.0.0") { + return "127.0.0.1"; + } + if (value === "::" || value === "[::]") { + return "::1"; + } + return value; +} + +function uniqueStrings(values: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + if (!value || seen.has(value)) { + continue; + } + seen.add(value); + result.push(value); + } + return result; +} diff --git a/packages/core/src/mcp/toolhub-mcp.ts b/packages/core/src/mcp/toolhub-mcp.ts new file mode 100644 index 0000000..9d440d9 --- /dev/null +++ b/packages/core/src/mcp/toolhub-mcp.ts @@ -0,0 +1,2944 @@ +#!/usr/bin/env node +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import OpenAI from "openai"; + +type JsonPrimitive = boolean | null | number | string; +type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +type JsonRpcRequest = { + error?: { + code?: number; + message?: string; + }; + id?: null | number | string; + jsonrpc?: string; + method?: string; + params?: unknown; + result?: unknown; +}; + +type JsonRpcResponse = + | { + id: null | number | string; + jsonrpc: "2.0"; + result: JsonValue; + } + | { + error: { + code: number; + data?: JsonValue; + message: string; + }; + id: null | number | string; + jsonrpc: "2.0"; + }; + +type GatewayMcpServerBaseConfig = { + label?: string; + name: string; + protocolVersion?: string; + requestTimeoutMs?: number; + startupTimeoutMs?: number; + transport: "stdio" | "streamable-http" | "sse"; +}; + +type GatewayMcpStdioServerConfig = GatewayMcpServerBaseConfig & { + args?: string[]; + command: string; + cwd?: string; + env?: Record; + stdioMessageMode?: "content-length" | "newline-json"; + transport: "stdio"; +}; + +type GatewayMcpRemoteServerConfig = GatewayMcpServerBaseConfig & { + apiKey?: string; + apiKeyEnv?: string; + headers?: Record; + transport: "streamable-http" | "sse"; + url: string; +}; + +type GatewayMcpServerConfig = GatewayMcpStdioServerConfig | GatewayMcpRemoteServerConfig; + +type ToolDefinition = { + description?: string; + inputSchema?: Record; + name: string; + outputSchema?: Record; + tags?: string[]; + title?: string; +}; + +type ToolInvocation = { + mode: "both" | "invoke" | "workflow"; + sideEffect: boolean; +}; + +type CatalogEntry = { + alias: string; + canonicalName: string; + description: string; + inputSchema?: Record; + invocation: ToolInvocation; + outputSchema?: Record; + remoteToolName: string; + serverId: string; + serverLabel?: string; + serverName: string; + serverNamespace: string; + status: "offline" | "online" | "unknown"; + tags: string[]; + title: string; + toolName: string; +}; + +type McpClient = { + callTool(name: string, args: Record): Promise; + close(): Promise; + listTools(): Promise; +}; + +type PendingRequest = { + reject: (error: Error) => void; + resolve: (message: JsonRpcRequest) => void; + timer: ReturnType; +}; + +type MessageMode = "content-length" | "newline-json"; + +type ResolveInput = { + constraints?: { + allowSideEffects?: boolean; + latencyBudgetMs?: number; + maxTools?: number; + preferWorkflow?: boolean; + }; + context?: Record; + task?: string; + __codeToolScopeKey?: string; + __toolHubScopeKey?: string; +}; + +type CodeToolSessionState = { + inFlightResolves: Map>; + loadedTools: Set; + recentObservations: Array<{ + resultSummary: string; + toolName: string; + }>; + recentlyResolvedTasks: Array<{ + observationCount: number; + resolvedAt: number; + taskHash: string; + toolNames: string[]; + }>; +}; + +type LlmToolResolution = { + plannedSteps?: string[]; + referencedTokens?: string[]; + selectedTools: CatalogEntry[]; + summary: string; + workflowSketch?: string; +}; + +type ResolveOutput = { + alreadyResolved?: boolean; + executionPlanInstructions?: string; + executionPlanJs?: string; + nextAction?: { + confirmationRequiredFor: string[]; + firstAction: { + missingArguments?: string[]; + toolName?: string; + type: "ask_user" | "invoke_tool"; + }; + instruction: string; + requiredArgumentsByTool: Array<{ + requiredArguments: string[]; + sideEffect: boolean; + toolName: string; + }>; + }; + plannedSteps?: string[]; + reasoningSummary: string; + referencedTokens?: string[]; + retriever?: "llm" | "local"; + runtimeContext?: { + availableContextKeys: string[]; + summary: string[]; + }; + selectedToolNames: string[]; + selectedTools: CatalogEntry[]; + tsDefinitions?: string; + usedLlm?: boolean; + workflowSketch?: string; +}; + +const protocolVersion = "2024-11-05"; +const toolHubServerName = "ccr-toolhub"; +const resolveToolName = "tool_hub.resolve"; +const invokeToolName = "tool_hub.invoke"; +const defaultRequestTimeoutMs = 60_000; +const defaultMaxTools = 10; +const discoveryCacheMaxAgeMs = 10_000; +const repeatedResolveWindowMs = 5 * 60_000; +const executionPlanInstructions = [ + "Treat executionPlanJs as the dependency plan for ToolHub invocations.", + "Each callTool(toolName, args) call maps to one tool_hub.invoke call with tool=toolName and args=args.", + "await means the next ToolHub invocation depends on the previous result and must not be started early.", + "Only callTool calls inside the same Promise.all([...]) expression may be issued as parallel tool calls.", + "If a later call needs values from an earlier result, wait for that earlier result and fill the arguments after it returns." +].join(" "); +const reservedJavaScriptWords = new Set( + "await break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof new null return super switch this throw true try typeof var void while with yield" + .split(" ") +); + +type RuntimeLike = { + callTool(params: unknown): Promise; + close(): void; +}; + +let runtime: RuntimeLike; +let inputBuffer = Buffer.alloc(0); +let lastMessageMode: MessageMode = "content-length"; + +process.stdin.on("data", (chunk) => { + inputBuffer = Buffer.concat([inputBuffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + drainInputBuffer().catch((error) => { + writeJsonRpc(jsonRpcError(null, -32603, formatError(error)), lastMessageMode); + }); +}); + +process.stdin.resume(); + +process.on("exit", () => { + runtime.close(); +}); + +async function drainInputBuffer(): Promise { + while (true) { + discardLeadingNewlines(); + if (inputBuffer.length === 0) { + return; + } + + if (!startsWithContentLengthHeader(inputBuffer)) { + const newline = inputBuffer.indexOf("\n"); + if (newline < 0) { + return; + } + const message = inputBuffer.subarray(0, newline).toString("utf8").trim(); + inputBuffer = inputBuffer.subarray(newline + 1); + if (!message) { + continue; + } + await handleJsonRpcMessage(message, "newline-json"); + continue; + } + + const headerEnd = inputBuffer.indexOf("\r\n\r\n"); + if (headerEnd < 0) { + return; + } + + const headerText = inputBuffer.subarray(0, headerEnd).toString("utf8"); + const lengthMatch = headerText.match(/content-length:\s*(\d+)/i); + if (!lengthMatch) { + inputBuffer = inputBuffer.subarray(headerEnd + 4); + writeJsonRpc(jsonRpcError(null, -32600, "Missing Content-Length header."), "content-length"); + continue; + } + + const contentLength = Number(lengthMatch[1]); + const messageStart = headerEnd + 4; + const messageEnd = messageStart + contentLength; + if (inputBuffer.length < messageEnd) { + return; + } + + const message = inputBuffer.subarray(messageStart, messageEnd).toString("utf8"); + inputBuffer = inputBuffer.subarray(messageEnd); + await handleJsonRpcMessage(message, "content-length"); + } +} + +function discardLeadingNewlines(): void { + while (inputBuffer[0] === 10 || inputBuffer[0] === 13) { + inputBuffer = inputBuffer.subarray(1); + } +} + +function startsWithContentLengthHeader(buffer: Buffer): boolean { + return /^content-length\s*:/i.test(buffer.subarray(0, Math.min(buffer.length, 64)).toString("utf8")); +} + +async function handleJsonRpcMessage(message: string, messageMode: MessageMode): Promise { + lastMessageMode = messageMode; + let payload: unknown; + try { + payload = JSON.parse(message) as unknown; + } catch (error) { + writeJsonRpc(jsonRpcError(null, -32700, `Invalid JSON-RPC request: ${formatError(error)}`), messageMode); + return; + } + + const response = await handleJsonRpcRequest(payload); + if (response) { + writeJsonRpc(response, messageMode); + } +} + +async function handleJsonRpcRequest(payload: unknown): Promise { + if (!isRecord(payload)) { + return jsonRpcError(null, -32600, "JSON-RPC request must be an object."); + } + + const request = payload as JsonRpcRequest; + const id = request.id ?? null; + if (request.id === undefined && request.method?.startsWith("notifications/")) { + return undefined; + } + if (request.jsonrpc !== "2.0" || !request.method) { + return jsonRpcError(id, -32600, "Invalid JSON-RPC 2.0 request."); + } + + try { + switch (request.method) { + case "initialize": + return jsonRpcResult(id, { + capabilities: { + tools: {} + }, + protocolVersion, + serverInfo: { + name: toolHubServerName, + title: "CCR ToolHub", + version: "1.0.0" + } + }); + case "ping": + return jsonRpcResult(id, {}); + case "tools/list": + return jsonRpcResult(id, { tools: metaTools() as unknown as JsonValue }); + case "tools/call": + return jsonRpcResult(id, await runtime.callTool(request.params) as JsonValue); + default: + return jsonRpcError(id, -32601, `Unsupported MCP method: ${request.method}`); + } + } catch (error) { + return jsonRpcError(id, -32603, formatError(error)); + } +} + +class ToolHubRuntime { + private readonly clients = new Map(); + private readonly registry = new ToolHubRegistry((serverName, config) => this.clientForServer(serverName, config)); + private readonly sessions = new Map(); + + async callTool(params: unknown): Promise { + const name = isRecord(params) && typeof params.name === "string" ? params.name.trim() : ""; + const args = isRecord(params) && isRecord(params.arguments) ? params.arguments : {}; + if (name === resolveToolName || name === "tool_hub_resolve" || name === "code_tool.resolve" || name === "code_tool_resolve") { + return this.resolveTools(args as ResolveInput); + } + if (name === invokeToolName || name === "tool_hub_invoke" || name === "code_tool.invoke" || name === "code_tool_invoke") { + return this.invokeTool(args); + } + throw new Error(`Unknown ToolHub meta tool: ${name}`); + } + + close(): void { + for (const entry of this.clients.values()) { + void entry.client.close(); + } + this.clients.clear(); + this.sessions.clear(); + } + + private async resolveTools(input: ResolveInput): Promise { + const task = typeof input.task === "string" ? input.task.trim() : ""; + if (!task) { + throw new Error(`${resolveToolName} requires task.`); + } + const maxTools = normalizeMaxTools(input.constraints?.maxTools ?? envNumber("TOOLHUB_MAX_TOOLS", defaultMaxTools)); + const scopeKey = this.scopeKey(input); + const session = this.session(scopeKey); + const taskHash = normalizeResolveTaskKey(task); + + this.registry.updateServers(readBackendServers()); + let catalog = await this.registry.listTools(); + if (catalog.length === 0) { + await this.registry.refreshServers(undefined, true); + catalog = await this.registry.listTools(); + } + if (taskWantsChromeLoginImport(task) && !catalogHasChromeLoginImportTool(catalog)) { + await this.registry.refreshServers(["ccr-browser-automation"], true); + catalog = await this.registry.listTools(); + } + if (catalog.length === 0) { + throw new Error("No MCP tools are available to resolve."); + } + + const cached = this.findRecentlyResolvedTask(scopeKey, taskHash); + if (cached) { + const selectedTools = expandToolBundleWithCompanionTools( + uniqueToolEntries([ + ...cached.toolNames + .map((toolName) => this.resolveCatalogEntry(toolName, catalog)) + .filter((entry): entry is CatalogEntry => Boolean(entry)), + ...getDeterministicTaskTools(task, catalog) + ]), + catalog + ); + if (selectedTools.length > 0) { + this.markToolsLoaded(scopeKey, selectedTools); + return toolResult(this.buildRepeatedResolveOutput(selectedTools)); + } + } + + const inFlightResolve = session.inFlightResolves.get(taskHash); + if (inFlightResolve) { + const output = await inFlightResolve; + const selectedTools = expandToolBundleWithCompanionTools( + uniqueToolEntries([ + ...output.selectedTools + .map((tool) => this.resolveCatalogEntry(tool.toolName, catalog) ?? tool) + .filter((entry): entry is CatalogEntry => Boolean(entry)), + ...getDeterministicTaskTools(task, catalog) + ]), + catalog + ); + if (selectedTools.length > 0) { + this.markToolsLoaded(scopeKey, selectedTools); + return toolResult(this.buildRepeatedResolveOutput(selectedTools)); + } + return toolResult(output); + } + + const resolvePromise = this.executeFreshResolve({ + catalog, + context: input.context, + maxTools, + observations: session.recentObservations.slice(-5), + scopeKey, + task, + taskHash, + timeoutMs: input.constraints?.latencyBudgetMs, + withoutSideEffects: input.constraints?.allowSideEffects === false + }); + session.inFlightResolves.set(taskHash, resolvePromise); + try { + return toolResult(await resolvePromise); + } finally { + if (session.inFlightResolves.get(taskHash) === resolvePromise) { + session.inFlightResolves.delete(taskHash); + } + } + } + + private async invokeTool(args: Record): Promise { + const requestedTool = typeof args.tool === "string" ? args.tool.trim() : ""; + if (!requestedTool) { + throw new Error(`${invokeToolName} requires tool.`); + } + const scopeKey = this.scopeKey(args); + this.registry.updateServers(readBackendServers()); + const catalog = await this.registry.listTools(); + const entry = this.resolveCatalogEntry(requestedTool, catalog); + if (!entry) { + return toolError("UNKNOWN_TOOL", `Unknown ToolHub tool: ${requestedTool}`); + } + if (!this.session(scopeKey).loadedTools.has(entry.toolName)) { + return toolError("TOOL_NOT_RESOLVED", `Tool ${entry.toolName} is not loaded in this session. Call ${resolveToolName} for the task first.`); + } + const toolArgs = isRecord(args.args) ? args.args : {}; + const client = this.clientForServer(entry.serverName); + const result = await client.callTool(entry.remoteToolName, toolArgs); + this.rememberObservation(scopeKey, entry.toolName, result); + return result; + } + + private async executeFreshResolve(input: { + catalog: CatalogEntry[]; + context?: Record; + maxTools: number; + observations: CodeToolSessionState["recentObservations"]; + scopeKey: string; + task: string; + taskHash: string; + timeoutMs?: number; + withoutSideEffects: boolean; + }): Promise { + let resolution: LlmToolResolution; + let retriever: NonNullable = "llm"; + let usedLlm = true; + try { + resolution = await this.resolveCatalogWithLlm(input); + } catch (error) { + resolution = this.resolveCatalogLocally({ ...input, error }); + if (resolution.selectedTools.length === 0) { + throw error; + } + retriever = "local"; + usedLlm = false; + } + + let selectedTools = expandToolBundleWithCompanionTools( + uniqueToolEntries([ + ...resolution.selectedTools, + ...getDeterministicTaskTools(input.task, input.catalog) + ]), + input.catalog + ); + if (input.withoutSideEffects) { + selectedTools = selectedTools.filter((tool) => !tool.invocation.sideEffect); + } + if (selectedTools.length === 0) { + throw new Error("ToolHub could not resolve any matching MCP tools for this task."); + } + + this.markToolsLoaded(input.scopeKey, selectedTools); + this.rememberResolvedTask(input.scopeKey, input.taskHash, selectedTools); + const executionPlanJs = buildExecutionPlanJs(resolution.workflowSketch, selectedTools); + return { + ...this.buildResolveOutput(resolution.summary, selectedTools, executionPlanJs), + plannedSteps: resolution.plannedSteps, + referencedTokens: resolution.referencedTokens, + retriever, + usedLlm, + workflowSketch: executionPlanJs + }; + } + + private async resolveCatalogWithLlm(input: { + catalog: CatalogEntry[]; + context?: Record; + maxTools: number; + observations: CodeToolSessionState["recentObservations"]; + task: string; + timeoutMs?: number; + }): Promise { + const searchAgent = new OpenAiToolHubSearchAgent({ + openAiApiKey: env("TOOLHUB_OPENAI_API_KEY"), + openAiBaseUrl: env("TOOLHUB_OPENAI_BASE_URL") || "https://api.openai.com/v1", + openAiModel: env("TOOLHUB_OPENAI_MODEL") + }); + const result = await searchAgent.search({ + catalog: input.catalog.map(toSearchCatalogItem), + code: JSON.stringify({ + context: input.context ?? {}, + observations: input.observations ?? [] + }), + query: input.task, + timeoutMs: input.timeoutMs ?? envNumber("TOOLHUB_REQUEST_TIMEOUT_MS", defaultRequestTimeoutMs), + topK: input.maxTools + }); + const selectedTools = result.selectedToolNames + .map((toolName) => this.resolveCatalogEntry(toolName, input.catalog)) + .filter((entry): entry is CatalogEntry => Boolean(entry)) + .slice(0, input.maxTools); + return { + plannedSteps: result.plannedSteps, + referencedTokens: result.referencedTokens, + selectedTools, + summary: result.summary, + workflowSketch: result.workflowSketch + }; + } + + private resolveCatalogLocally(input: { + catalog: CatalogEntry[]; + context?: Record; + error: unknown; + maxTools: number; + observations: CodeToolSessionState["recentObservations"]; + task: string; + }): LlmToolResolution { + const taskText = [ + input.task, + input.context ? JSON.stringify(input.context) : "", + ...input.observations.map((observation) => `${observation.toolName} ${observation.resultSummary}`) + ].join(" "); + const preferredToolNames = getLocalFallbackPreferredTools(taskText); + const scored = input.catalog + .map((tool, index) => ({ + index, + score: scoreLocalCatalogMatch(taskText, tool, preferredToolNames), + tool + })) + .filter((item) => item.score > 0) + .sort((left, right) => right.score - left.score || left.index - right.index); + const selectedTools = uniqueToolEntries(scored.map((item) => item.tool)).slice(0, input.maxTools); + return { + plannedSteps: selectedTools.length > 0 + ? [ + "Resolve retrieval LLM did not finish within the latency budget.", + "Selected candidate tools with local catalog matching." + ] + : undefined, + referencedTokens: tokenizeLocalSearchText(taskText), + selectedTools, + summary: `Resolve retrieval fell back to local catalog matching after LLM retrieval failed: ${formatError(input.error)}.`, + workflowSketch: buildLocalFallbackWorkflowSketch(selectedTools) + }; + } + + private clientForServer(serverName: string, config?: GatewayMcpServerConfig): McpClient { + const server = config ?? readBackendServers().find((candidate) => candidate.name === serverName); + if (!server) { + throw new Error(`ToolHub backend MCP server not found: ${serverName}`); + } + const existing = this.clients.get(serverName); + const configHash = hashToolHubMcpServerConfig(server); + if (existing && existing.configHash === configHash) { + return existing.client; + } + void existing?.client.close(); + const client = server.transport === "stdio" + ? new StdioMcpClient(server) + : server.transport === "sse" + ? new SseMcpClient(server) + : new HttpMcpClient(server); + this.clients.set(serverName, { client, configHash }); + return client; + } + + private resolveCatalogEntry(nameOrAlias: string, catalog: CatalogEntry[]): CatalogEntry | undefined { + const resolvedName = resolveCatalogItemName(catalog.map((entry) => ({ + alias: entry.alias, + canonicalName: entry.canonicalName, + name: entry.toolName, + remoteToolName: entry.remoteToolName + })), nameOrAlias); + if (!resolvedName) { + return undefined; + } + return catalog.find((entry) => entry.toolName === resolvedName); + } + + private buildResolveOutput(summary: string, selectedTools: CatalogEntry[], executionPlanJs = buildSequentialExecutionPlanJs(selectedTools)): ResolveOutput { + return { + executionPlanInstructions, + executionPlanJs, + nextAction: this.buildResolveNextAction(selectedTools), + reasoningSummary: summary, + runtimeContext: { + availableContextKeys: ["selectedTools", "executionPlanJs", "executionPlanInstructions"], + summary: [ + ...selectedTools.map((tool) => `${tool.toolName}: ${tool.description || tool.title}`), + "Follow executionPlanJs for dependency ordering before invoking selected tools." + ] + }, + selectedToolNames: selectedTools.map((tool) => tool.toolName), + selectedTools, + tsDefinitions: buildTsDefinitions(selectedTools), + workflowSketch: executionPlanJs + }; + } + + private buildRepeatedResolveOutput(selectedTools: CatalogEntry[]): ResolveOutput { + const nextAction = this.buildResolveNextAction(selectedTools); + const executionPlanJs = buildSequentialExecutionPlanJs(selectedTools); + return { + alreadyResolved: true, + executionPlanInstructions, + executionPlanJs, + nextAction, + reasoningSummary: [ + "This task has already been resolved in the current ToolHub session.", + nextAction.instruction + ].join(" "), + runtimeContext: { + availableContextKeys: ["selectedTools", "nextAction", "executionPlanJs", "executionPlanInstructions"], + summary: [ + "The selected tools are already loaded for tool_hub.invoke.", + "Do not repeat discovery for this task.", + nextAction.instruction, + "Follow executionPlanJs for dependency ordering before invoking selected tools." + ] + }, + selectedToolNames: selectedTools.map((tool) => tool.toolName), + selectedTools, + workflowSketch: executionPlanJs + }; + } + + private buildResolveNextAction(selectedTools: CatalogEntry[]): NonNullable { + const requiredArgumentsByTool = selectedTools + .map((tool) => ({ + requiredArguments: getSchemaRequiredProperties(tool.inputSchema), + sideEffect: tool.invocation.sideEffect, + toolName: tool.toolName + })) + .filter((item) => item.requiredArguments.length > 0); + const confirmationRequiredFor = selectedTools + .filter((tool) => tool.invocation.sideEffect) + .map((tool) => tool.toolName); + const firstTool = selectFirstActionTool(selectedTools); + const firstRequiredArguments = firstTool ? getSchemaRequiredProperties(firstTool.inputSchema) : []; + const firstAction = firstRequiredArguments.length > 0 + ? { + missingArguments: firstRequiredArguments, + toolName: firstTool?.toolName, + type: "ask_user" as const + } + : { + toolName: firstTool?.toolName, + type: "invoke_tool" as const + }; + const instructionParts = [ + "Do not call tool_hub.resolve again for this task.", + "Follow executionPlanJs: await is serial dependency; only Promise.all groups may run in parallel." + ]; + if (firstTool && firstRequiredArguments.length > 0) { + instructionParts.push(`Ask the user for missing required arguments for ${firstTool.toolName}: ${firstRequiredArguments.join(", ")}.`); + } else if (firstTool) { + instructionParts.push(`Call tool_hub.invoke for ${firstTool.toolName}.`); + } else { + instructionParts.push("Ask the user for the missing task details before invoking tools."); + } + if (confirmationRequiredFor.length > 0) { + instructionParts.push(`Before calling side-effecting tools, ask for explicit confirmation: ${confirmationRequiredFor.join(", ")}.`); + } + return { + confirmationRequiredFor, + firstAction, + instruction: instructionParts.join(" "), + requiredArgumentsByTool + }; + } + + private scopeKey(input: Record): string { + return readNonEmptyString(input.__toolHubScopeKey) || readNonEmptyString(input.__codeToolScopeKey) || "default"; + } + + private session(scopeKey: string): CodeToolSessionState { + const existing = this.sessions.get(scopeKey); + if (existing) { + return existing; + } + const session: CodeToolSessionState = { + inFlightResolves: new Map(), + loadedTools: new Set(), + recentObservations: [], + recentlyResolvedTasks: [] + }; + this.sessions.set(scopeKey, session); + return session; + } + + private markToolsLoaded(scopeKey: string, entries: CatalogEntry[]): void { + const session = this.session(scopeKey); + for (const entry of entries) { + session.loadedTools.add(entry.toolName); + } + } + + private rememberResolvedTask(scopeKey: string, taskHash: string, entries: CatalogEntry[]): void { + const session = this.session(scopeKey); + session.recentlyResolvedTasks = session.recentlyResolvedTasks.filter((item) => item.taskHash !== taskHash); + session.recentlyResolvedTasks.unshift({ + observationCount: session.recentObservations.length, + resolvedAt: Date.now(), + taskHash, + toolNames: entries.map((entry) => entry.toolName) + }); + session.recentlyResolvedTasks = session.recentlyResolvedTasks.slice(0, 10); + } + + private findRecentlyResolvedTask(scopeKey: string, taskHash: string): CodeToolSessionState["recentlyResolvedTasks"][number] | undefined { + const session = this.session(scopeKey); + const now = Date.now(); + session.recentlyResolvedTasks = session.recentlyResolvedTasks.filter((item) => now - item.resolvedAt <= repeatedResolveWindowMs); + return session.recentlyResolvedTasks.find((item) => item.taskHash === taskHash); + } + + private rememberObservation(scopeKey: string, toolName: string, result: unknown): void { + const session = this.session(scopeKey); + session.recentObservations.push({ + resultSummary: summarizeValue(result), + toolName + }); + session.recentObservations = session.recentObservations.slice(-20); + } +} + +class ToolHubRegistry { + private discoveryPromise: Promise | undefined; + private readonly entries = new Map(); + + constructor(private readonly clientFactory: (serverName: string, config?: GatewayMcpServerConfig) => McpClient) {} + + updateServers(servers: GatewayMcpServerConfig[]): void { + const nextServerNames = new Set(); + for (const server of servers) { + nextServerNames.add(server.name); + const configHash = hashToolHubMcpServerConfig(server); + const existing = this.entries.get(server.name); + if (existing && existing.configHash === configHash) { + existing.config = cloneServerConfig(server); + continue; + } + const cachedDiscovery = toolHubPersistentCache.readDiscovery(server.name, configHash); + this.entries.set(server.name, { + config: cloneServerConfig(server), + configHash, + lastCheckedAt: cachedDiscovery?.cachedAt, + lastSeenOnlineAt: cachedDiscovery?.lastSeenOnlineAt, + loadedFromCache: Boolean(cachedDiscovery), + status: "unknown", + tools: cachedDiscovery?.tools.map((tool) => ({ ...tool })) ?? [] + }); + } + + for (const serverName of this.entries.keys()) { + if (!nextServerNames.has(serverName)) { + this.entries.delete(serverName); + } + } + } + + async listTools(): Promise { + await this.ensureDiscoveryFresh(); + return this.listCatalogEntriesSync(); + } + + async refreshServers(serverNames?: string[], force = true): Promise { + const targetServerNames = normalizeTargetServerNames(serverNames, this.entries); + if (targetServerNames.length === 0) { + return; + } + if (this.discoveryPromise) { + await this.discoveryPromise; + if (!force) { + return; + } + } + this.discoveryPromise = this.performDiscovery(targetServerNames, force); + try { + await this.discoveryPromise; + } finally { + this.discoveryPromise = undefined; + } + } + + private async ensureDiscoveryFresh(maxAgeMs = discoveryCacheMaxAgeMs): Promise { + const staleServerNames = [...this.entries.values()] + .filter((entry) => !entry.lastCheckedAt || Date.now() - entry.lastCheckedAt > maxAgeMs) + .map((entry) => entry.config.name); + if (staleServerNames.length === 0) { + return; + } + await this.refreshServers(staleServerNames, false); + } + + private listCatalogEntriesSync(): CatalogEntry[] { + const namespaces = serverNamespaces([...this.entries.values()].map((entry) => entry.config.name)); + const entries: CatalogEntry[] = []; + for (const entry of this.entries.values()) { + const serverNamespace = namespaces.get(entry.config.name) ?? toIdentifier(entry.config.name); + for (const tool of entry.tools) { + const remoteToolName = tool.name.trim(); + if (!remoteToolName) { + continue; + } + const toolName = `mcp.${serverNamespace}.${remoteToolName}`; + entries.push({ + alias: toIdentifier(toolName), + canonicalName: `${entry.config.name}.${remoteToolName}`, + description: tool.description ?? "", + inputSchema: tool.inputSchema, + invocation: inferInvocation(tool), + outputSchema: tool.outputSchema, + remoteToolName, + serverId: entry.config.name, + serverLabel: entry.config.label, + serverName: entry.config.name, + serverNamespace, + status: entry.status, + tags: tool.tags ?? [], + title: tool.title || tool.name, + toolName + }); + } + } + return entries.sort((left, right) => left.toolName.localeCompare(right.toolName)); + } + + private async performDiscovery(serverNames: string[], force: boolean): Promise { + for (const serverName of serverNames) { + const entry = this.entries.get(serverName); + if (!entry) { + continue; + } + if (!force && entry.lastCheckedAt && Date.now() - entry.lastCheckedAt < 3_000) { + continue; + } + await this.probeServer(entry.config); + } + } + + private async probeServer(config: GatewayMcpServerConfig): Promise { + const now = Date.now(); + try { + const tools = await this.clientFactory(config.name, config).listTools(); + const entry = this.entries.get(config.name); + if (!entry) { + return; + } + entry.status = "online"; + entry.tools = tools.map((tool) => ({ ...tool })); + entry.lastCheckedAt = now; + entry.lastSeenOnlineAt = now; + entry.loadedFromCache = true; + entry.error = undefined; + toolHubPersistentCache.writeDiscovery(config.name, entry.configHash, entry.tools, now); + } catch (error) { + const entry = this.entries.get(config.name); + if (!entry) { + return; + } + entry.status = "offline"; + entry.lastCheckedAt = now; + entry.loadedFromCache = false; + entry.error = formatError(error); + } + } +} + +runtime = new ToolHubRuntime(); + +class SseMcpClient implements McpClient { + private endpointUrl = ""; + private initialized = false; + private nextId = 1; + private openPromise: Promise | undefined; + private readonly pending = new Map(); + private streamAbort: AbortController | undefined; + private streamBuffer = ""; + + constructor(private readonly server: GatewayMcpRemoteServerConfig) {} + + async listTools(): Promise { + await this.ensureInitialized(); + const result = await this.request("tools/list", {}); + return normalizeToolList(result); + } + + async callTool(name: string, args: Record): Promise { + await this.ensureInitialized(); + return this.request("tools/call", { + name, + arguments: args + }); + } + + async close(): Promise { + this.initialized = false; + this.endpointUrl = ""; + this.streamAbort?.abort(); + this.streamAbort = undefined; + this.rejectAll(new Error(`MCP SSE client closed: ${this.server.name}`)); + } + + private async ensureInitialized(): Promise { + if (this.initialized) { + return; + } + await this.ensureStream(); + await this.request("initialize", { + capabilities: {}, + clientInfo: { name: toolHubServerName, version: "1.0.0" }, + protocolVersion: this.server.protocolVersion || protocolVersion + }, this.server.startupTimeoutMs); + await this.notification("notifications/initialized", {}).catch(() => undefined); + this.initialized = true; + } + + private async ensureStream(): Promise { + if (this.endpointUrl) { + return; + } + if (!this.openPromise) { + this.openPromise = this.openStream().finally(() => { + this.openPromise = undefined; + }); + } + await this.openPromise; + } + + private async openStream(): Promise { + const controller = new AbortController(); + this.streamAbort = controller; + const response = await fetch(this.server.url, { + headers: this.headers(false), + method: "GET", + signal: controller.signal + }); + if (!response.ok || !response.body) { + throw new Error(`MCP SSE stream failed (${this.server.name}): ${response.status}`); + } + + let resolveEndpoint: () => void = () => {}; + let rejectEndpoint: (error: Error) => void = () => {}; + const endpointReady = new Promise((resolve, reject) => { + resolveEndpoint = resolve; + rejectEndpoint = reject; + }); + const timeout = setTimeout(() => { + rejectEndpoint(new Error(`MCP SSE endpoint timed out (${this.server.name}).`)); + controller.abort(); + }, this.server.startupTimeoutMs ?? defaultRequestTimeoutMs); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + void (async () => { + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + this.streamBuffer += decoder.decode(value, { stream: true }); + this.streamBuffer = consumeSseEvents(this.streamBuffer, (event) => { + if (event.event === "endpoint") { + this.endpointUrl = new URL(event.data.trim(), this.server.url).toString(); + clearTimeout(timeout); + resolveEndpoint(); + return; + } + this.routeSseMessage(event.data); + }); + } + this.rejectAll(new Error(`MCP SSE stream closed (${this.server.name}).`)); + } catch (error) { + clearTimeout(timeout); + rejectEndpoint(toError(error)); + this.rejectAll(toError(error)); + } + })(); + + await endpointReady; + } + + private request(method: string, params: Record, timeoutMs = this.server.requestTimeoutMs): Promise { + return this.ensureStream().then(() => { + const id = this.nextId++; + const message = { + id, + jsonrpc: "2.0", + method, + params + }; + const pending = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(String(id)); + reject(new Error(`MCP SSE request timed out (${this.server.name}): ${method}`)); + }, timeoutMs ?? defaultRequestTimeoutMs); + this.pending.set(String(id), { + reject, + resolve: (response) => { + if (isRecord(response.error)) { + reject(new Error(String(response.error.message ?? "MCP request failed."))); + return; + } + resolve(response.result); + }, + timer + }); + }); + return this.post(message).then(() => pending); + }); + } + + private async notification(method: string, params: Record): Promise { + await this.ensureStream(); + await this.post({ jsonrpc: "2.0", method, params }); + } + + private async post(message: Record): Promise { + const response = await fetch(this.endpointUrl, { + body: JSON.stringify(message), + headers: this.headers(true), + method: "POST" + }); + if (!response.ok) { + throw new Error(`MCP SSE post failed (${this.server.name}): ${response.status}`); + } + } + + private headers(json: boolean): Headers { + const headers = new Headers({ + ...(json ? { "content-type": "application/json" } : {}), + ...(this.server.headers ?? {}) + }); + const apiKey = this.server.apiKey || (this.server.apiKeyEnv ? process.env[this.server.apiKeyEnv] : ""); + if (apiKey && !headers.has("authorization")) { + headers.set("authorization", `Bearer ${apiKey}`); + } + return headers; + } + + private routeSseMessage(text: string): void { + let message: JsonRpcRequest; + try { + message = JSON.parse(text) as JsonRpcRequest; + } catch { + return; + } + const key = message.id === undefined || message.id === null ? "" : String(message.id); + const pending = key ? this.pending.get(key) : undefined; + if (!pending) { + return; + } + this.pending.delete(key); + clearTimeout(pending.timer); + pending.resolve(message); + } + + private rejectAll(error: Error): void { + for (const item of this.pending.values()) { + clearTimeout(item.timer); + item.reject(error); + } + this.pending.clear(); + } +} + +class HttpMcpClient implements McpClient { + private initialized = false; + private sessionId = ""; + + constructor(private readonly server: GatewayMcpRemoteServerConfig) {} + + async listTools(): Promise { + await this.ensureInitialized(); + const result = await this.request("tools/list", {}); + return normalizeToolList(result); + } + + async callTool(name: string, args: Record): Promise { + await this.ensureInitialized(); + return this.request("tools/call", { + name, + arguments: args + }); + } + + async close(): Promise { + this.initialized = false; + this.sessionId = ""; + } + + private async ensureInitialized(): Promise { + if (this.initialized) { + return; + } + await this.request("initialize", { + capabilities: {}, + clientInfo: { name: toolHubServerName, version: "1.0.0" }, + protocolVersion: this.server.protocolVersion || protocolVersion + }, this.server.startupTimeoutMs); + await this.notification("notifications/initialized", {}).catch(() => undefined); + this.initialized = true; + } + + private async notification(method: string, params: Record): Promise { + await this.frame({ jsonrpc: "2.0", method, params }, this.server.requestTimeoutMs, true); + } + + private async request(method: string, params: Record, timeoutMs = this.server.requestTimeoutMs): Promise { + const response = await this.frame({ + id: randomUUID(), + jsonrpc: "2.0", + method, + params + }, timeoutMs, false); + if (!isRecord(response)) { + throw new Error(`Invalid MCP response from ${this.server.name}.`); + } + if (isRecord(response.error)) { + throw new Error(`MCP request failed (${this.server.name}): ${String(response.error.message ?? "Unknown error")}`); + } + return response.result; + } + + private async frame(request: Record, timeoutMs = defaultRequestTimeoutMs, notification: boolean): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const headers = new Headers({ + accept: "application/json, text/event-stream", + "content-type": "application/json", + ...(this.server.headers ?? {}) + }); + const apiKey = this.server.apiKey || (this.server.apiKeyEnv ? process.env[this.server.apiKeyEnv] : ""); + if (apiKey && !headers.has("authorization")) { + headers.set("authorization", `Bearer ${apiKey}`); + } + if (this.sessionId) { + headers.set("mcp-session-id", this.sessionId); + } + const response = await fetch(this.server.url, { + body: JSON.stringify(request), + headers, + method: "POST", + signal: controller.signal + }); + this.sessionId = response.headers.get("mcp-session-id") || response.headers.get("x-mcp-session-id") || this.sessionId; + if (notification && response.status === 204) { + return undefined; + } + const text = await response.text(); + if (!response.ok) { + throw new Error(`MCP HTTP request failed (${this.server.name}): ${response.status} ${text.slice(0, 300)}`); + } + if (!text.trim()) { + return undefined; + } + return parseHttpJsonRpcResponse(text); + } finally { + clearTimeout(timer); + } + } +} + +class StdioMcpClient implements McpClient { + private child: ChildProcessWithoutNullStreams | undefined; + private initialized = false; + private nextId = 1; + private readonly pending = new Map(); + private stdoutBuffer = Buffer.alloc(0); + + constructor(private readonly server: GatewayMcpStdioServerConfig) {} + + async listTools(): Promise { + await this.ensureInitialized(); + const result = await this.request("tools/list", {}, this.server.requestTimeoutMs); + return normalizeToolList(result); + } + + async callTool(name: string, args: Record): Promise { + await this.ensureInitialized(); + return this.request("tools/call", { + name, + arguments: args + }, this.server.requestTimeoutMs); + } + + async close(): Promise { + this.initialized = false; + for (const item of this.pending.values()) { + clearTimeout(item.timer); + item.reject(new Error(`MCP stdio client closed: ${this.server.name}`)); + } + this.pending.clear(); + if (this.child && !this.child.killed) { + this.child.kill(); + } + this.child = undefined; + } + + private async ensureInitialized(): Promise { + if (this.initialized) { + return; + } + this.ensureChild(); + await this.request("initialize", { + capabilities: {}, + clientInfo: { name: toolHubServerName, version: "1.0.0" }, + protocolVersion: this.server.protocolVersion || protocolVersion + }, this.server.startupTimeoutMs); + this.notify("notifications/initialized", {}); + this.initialized = true; + } + + private ensureChild(): ChildProcessWithoutNullStreams { + if (this.child) { + return this.child; + } + const child = spawn(this.server.command, this.server.args ?? [], { + cwd: this.server.cwd || undefined, + env: { + ...process.env, + ...(this.server.env ?? {}) + }, + stdio: ["pipe", "pipe", "pipe"] + }) as ChildProcessWithoutNullStreams; + child.stdout.on("data", (chunk: Buffer) => this.readStdout(chunk)); + child.stderr.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf8").trim(); + if (text) { + console.error(`[ToolHub backend ${this.server.name}] ${text}`); + } + }); + child.on("error", (error) => this.rejectAll(error)); + child.on("exit", (code, signal) => { + this.initialized = false; + this.child = undefined; + this.rejectAll(new Error(`MCP server exited (${this.server.name}): ${signal ?? code ?? "unknown"}`)); + }); + this.child = child; + return child; + } + + private request(method: string, params: Record, timeoutMs = defaultRequestTimeoutMs): Promise { + const id = this.nextId++; + const message = { + id, + jsonrpc: "2.0", + method, + params + }; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(String(id)); + reject(new Error(`MCP stdio request timed out (${this.server.name}): ${method}`)); + }, timeoutMs); + this.pending.set(String(id), { + reject, + resolve: (response) => { + if (isRecord(response.error)) { + reject(new Error(String(response.error.message ?? "MCP request failed."))); + return; + } + resolve(response.result); + }, + timer + }); + this.write(message); + }); + } + + private notify(method: string, params: Record): void { + this.write({ jsonrpc: "2.0", method, params }); + } + + private write(message: Record): void { + const child = this.ensureChild(); + const text = JSON.stringify(message); + if (this.server.stdioMessageMode === "newline-json") { + child.stdin.write(`${text}\n`); + return; + } + child.stdin.write(`Content-Length: ${Buffer.byteLength(text, "utf8")}\r\n\r\n${text}`); + } + + private readStdout(chunk: Buffer): void { + this.stdoutBuffer = Buffer.concat([this.stdoutBuffer, chunk]); + if (this.server.stdioMessageMode === "newline-json") { + this.drainNewlineJsonStdout(); + } else { + this.drainContentLengthStdout(); + } + } + + private drainContentLengthStdout(): void { + while (true) { + const headerEnd = this.stdoutBuffer.indexOf("\r\n\r\n"); + if (headerEnd < 0) return; + const headerText = this.stdoutBuffer.subarray(0, headerEnd).toString("utf8"); + const lengthMatch = headerText.match(/content-length:\s*(\d+)/i); + if (!lengthMatch) { + this.stdoutBuffer = this.stdoutBuffer.subarray(headerEnd + 4); + continue; + } + const contentLength = Number(lengthMatch[1]); + const messageStart = headerEnd + 4; + const messageEnd = messageStart + contentLength; + if (this.stdoutBuffer.length < messageEnd) return; + const text = this.stdoutBuffer.subarray(messageStart, messageEnd).toString("utf8"); + this.stdoutBuffer = this.stdoutBuffer.subarray(messageEnd); + this.routeMessage(text); + } + } + + private drainNewlineJsonStdout(): void { + while (true) { + const newline = this.stdoutBuffer.indexOf("\n"); + if (newline < 0) return; + const text = this.stdoutBuffer.subarray(0, newline).toString("utf8").trim(); + this.stdoutBuffer = this.stdoutBuffer.subarray(newline + 1); + if (text) { + this.routeMessage(text); + } + } + } + + private routeMessage(text: string): void { + let message: JsonRpcRequest; + try { + message = JSON.parse(text) as JsonRpcRequest; + } catch { + return; + } + const key = message.id === undefined || message.id === null ? "" : String(message.id); + const pending = key ? this.pending.get(key) : undefined; + if (!pending) { + return; + } + this.pending.delete(key); + clearTimeout(pending.timer); + pending.resolve(message); + } + + private rejectAll(error: Error): void { + for (const item of this.pending.values()) { + clearTimeout(item.timer); + item.reject(error); + } + this.pending.clear(); + } +} + +const treeSitterToolName = "tree_sitter_collect_tool_references"; +const defaultMaxTurns = 3; +const maxTurnsLimit = 6; +const minSearchTimeoutMs = 8_000; +const maxTurnTimeoutMs = 60_000; +const minTurnRemainingMs = 3_500; +const minFinalAnswerTurnTimeoutMs = 8_000; +const timeoutHeadroomMs = 1_500; + +type SearchCatalogItem = { + alias: string; + canonicalName?: string; + description: string; + invocationMode: string; + name: string; + remoteToolName?: string; + required?: string[]; + serverId?: string; + serverLabel?: string; + sideEffect: boolean; + title: string; +}; + +type SearchMessage = { + content?: string | null; + role: "assistant" | "system" | "tool" | "user"; + tool_call_id?: string; + tool_calls?: Array<{ + function: { + arguments: string; + name: string; + }; + id: string; + type: "function"; + }>; +}; + +type SearchResult = { + plannedSteps?: string[]; + referencedTokens: string[]; + selectedToolNames: string[]; + summary: string; + workflowSketch?: string; +}; + +type ToolReference = { + column: number; + line: number; + rawName: string; + source: string; +}; + +class OpenAiToolHubSearchAgent { + private readonly analyzer = new ToolReferenceAnalyzer(); + + constructor(private readonly config: { + openAiApiKey?: string; + openAiBaseUrl?: string; + openAiModel?: string; + }) {} + + async search(input: { + catalog: SearchCatalogItem[]; + code?: string; + maxTurns?: number; + query: string; + timeoutMs?: number; + topK?: number; + }): Promise { + const query = input.query.trim(); + if (!query) { + throw new Error("ToolHub resolve query must be non-empty."); + } + const apiKey = this.config.openAiApiKey || env("TOOLHUB_OPENAI_API_KEY"); + const baseURL = this.config.openAiBaseUrl || env("TOOLHUB_OPENAI_BASE_URL") || "https://api.openai.com/v1"; + const model = this.config.openAiModel || env("TOOLHUB_OPENAI_MODEL"); + if (!apiKey || !model) { + throw new Error("ToolHub resolver requires TOOLHUB_OPENAI_API_KEY and TOOLHUB_OPENAI_MODEL."); + } + + const topK = normalizeTopK(input.topK); + const maxTurns = normalizeSearchMaxTurns(input.maxTurns); + const timeoutMs = normalizeSearchTimeout(input.timeoutMs); + const deadlineAt = Date.now() + timeoutMs; + await waitForLocalResolverEndpoint(baseURL, apiKey, timeoutMs); + const client = new OpenAI({ apiKey, baseURL }); + const messages: SearchMessage[] = [ + { + role: "user", + content: JSON.stringify({ + context: input.code ?? "", + query + }, null, 2) + } + ]; + + let didCallAnalyzer = false; + let analyzerCallCount = 0; + let summary = ""; + let workflowSketch = ""; + let plannedSteps: string[] = []; + let llmSelectedNames: string[] = []; + let referencedTokens: string[] = []; + let latestResolvedFromAnalyzer: string[] = []; + + for (let turn = 0; turn < maxTurns; turn += 1) { + const remainingMs = deadlineAt - Date.now(); + const minTurnTimeoutMs = didCallAnalyzer ? minFinalAnswerTurnTimeoutMs : minTurnRemainingMs; + if (remainingMs <= minTurnTimeoutMs + timeoutHeadroomMs) { + break; + } + const turnTimeoutMs = Math.min(remainingMs - timeoutHeadroomMs, maxTurnTimeoutMs); + const responseMessage = await this.callOpenAiWithTools( + client, + model, + buildSearchSystemPrompt(input.catalog, topK), + messages, + turnTimeoutMs + ); + + const toolCalls = responseMessage.tool_calls ?? []; + if (toolCalls.length > 0) { + messages.push(responseMessage); + const roundResolvedToolNames: string[] = []; + for (const toolCall of toolCalls) { + if (toolCall.function.name !== treeSitterToolName) { + messages.push({ + role: "tool", + tool_call_id: toolCall.id, + content: JSON.stringify({ error: `Unknown tool: ${toolCall.function.name}` }) + }); + continue; + } + didCallAnalyzer = true; + analyzerCallCount += 1; + const toolInput = parseToolArguments(toolCall.function.arguments); + const analyzeCode = typeof toolInput.code === "string" ? toolInput.code : ""; + const references = this.analyzer.collectReferences(analyzeCode); + const tokens = uniqueStrings(references.map((item) => item.rawName)); + const resolvedToolNames = uniqueStrings( + tokens + .map((token) => resolveCatalogItemName(input.catalog, token)) + .filter((name): name is string => typeof name === "string") + ); + referencedTokens = uniqueStrings([...referencedTokens, ...tokens]); + roundResolvedToolNames.push(...resolvedToolNames); + messages.push({ + role: "tool", + tool_call_id: toolCall.id, + content: JSON.stringify({ + references, + resolvedToolNames, + tokens + }) + }); + } + latestResolvedFromAnalyzer = uniqueStrings(roundResolvedToolNames); + continue; + } + + const contentText = typeof responseMessage.content === "string" ? responseMessage.content.trim() : ""; + const parsed = firstJsonObject(contentText); + if (!parsed) { + messages.push(responseMessage); + messages.push({ + role: "user", + content: didCallAnalyzer + ? "Return only a valid JSON object with keys \"summary\", \"steps\", \"workflowSketch\", and \"toolNames\"." + : `You must call ${treeSitterToolName} on a TypeScript workflow sketch before your final answer.` + }); + continue; + } + + summary = typeof parsed.summary === "string" ? parsed.summary.trim() : ""; + workflowSketch = typeof parsed.workflowSketch === "string" ? parsed.workflowSketch : ""; + plannedSteps = toStringArray(parsed.steps); + const workflowReferences = this.analyzer.collectReferences(workflowSketch); + const workflowTokens = uniqueStrings(workflowReferences.map((item) => item.rawName)); + const workflowResolvedNames = uniqueStrings( + workflowTokens + .map((token) => resolveCatalogItemName(input.catalog, token)) + .filter((name): name is string => typeof name === "string") + ); + referencedTokens = uniqueStrings([...referencedTokens, ...workflowTokens]); + llmSelectedNames = uniqueStrings( + [ + ...workflowResolvedNames, + ...toStringArray(parsed.toolNames) + ] + .map((name) => resolveCatalogItemName(input.catalog, name)) + .filter((name): name is string => typeof name === "string") + ); + const selectedToolNames = uniqueStrings([...latestResolvedFromAnalyzer, ...llmSelectedNames]).slice(0, topK); + const refinementFeedback = didCallAnalyzer + ? buildSearchRefinementFeedback({ + selectedToolNames, + summary, + workflowSketch + }) + : selectedToolNames.length === 0 + ? "Your current answer resolved to zero valid catalog tools. Call the tree-sitter tool on a revised TypeScript workflow sketch before answering." + : undefined; + if (refinementFeedback && turn + 1 < maxTurns) { + messages.push(responseMessage); + messages.push({ role: "user", content: refinementFeedback }); + continue; + } + break; + } + + const selectedToolNames = uniqueStrings([...latestResolvedFromAnalyzer, ...llmSelectedNames]).slice(0, topK); + if (selectedToolNames.length === 0) { + throw new Error(didCallAnalyzer || analyzerCallCount > 0 + ? "Resolve retrieval did not converge on any valid catalog tools after AST refinement." + : "Resolve retrieval did not converge on any valid catalog tools."); + } + if (!didCallAnalyzer || analyzerCallCount === 0) { + referencedTokens = uniqueStrings([...referencedTokens, ...selectedToolNames]); + } + if (didCallAnalyzer && analyzerCallCount === 0) { + throw new Error("Resolve retrieval LLM did not complete an AST planning round."); + } + if (!summary) { + summary = didCallAnalyzer + ? "Resolved a planned end-to-end tool bundle with AST-assisted retrieval." + : "Resolved a planned end-to-end tool bundle from the resolver model response."; + } else if (summary.toLowerCase().includes("no strong tool bundle match was found")) { + summary = "Resolved a candidate tool bundle after iterative AST refinement."; + } + return { + plannedSteps: plannedSteps.length > 0 ? plannedSteps : undefined, + referencedTokens, + selectedToolNames, + summary, + workflowSketch: workflowSketch || undefined + }; + } + + private async callOpenAiWithTools( + client: OpenAI, + model: string, + system: string, + messages: SearchMessage[], + timeoutMs: number + ): Promise { + const response = await client.chat.completions.create({ + model, + temperature: 0, + messages: [ + { role: "system", content: system }, + ...messages + ] as OpenAI.Chat.Completions.ChatCompletionMessageParam[], + stream: false, + tools: [ + { + type: "function", + function: { + name: treeSitterToolName, + description: "Analyze a short TypeScript workflow sketch and extract exact ToolHub tool references.", + parameters: { + type: "object", + properties: { + code: { + type: "string", + description: "TypeScript workflow sketch that references ToolHub tools." + } + }, + required: ["code"], + additionalProperties: false + } + } + } + ], + tool_choice: "auto" + } as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming, { + timeout: timeoutMs + }); + + const message = response.choices[0]?.message; + if (!message) { + throw new Error("OpenAI resolve retrieval returned no assistant message."); + } + const content = typeof message.content === "string" ? message.content : ""; + const toolCalls = (message.tool_calls ?? []) + .map((toolCall, index) => { + const rawToolCall = toolCall as unknown as { function?: unknown }; + const functionCall = isRecord(rawToolCall.function) ? rawToolCall.function : {}; + return { + id: toolCall.id || `tool_call_${index}`, + type: "function" as const, + function: { + arguments: typeof functionCall.arguments === "string" ? functionCall.arguments : "", + name: typeof functionCall.name === "string" ? functionCall.name : "" + } + }; + }) + .filter((toolCall) => toolCall.function.name.length > 0); + if (!content && toolCalls.length === 0) { + throw new Error("OpenAI resolve retrieval returned no assistant content or tool calls."); + } + return { + role: "assistant", + content, + tool_calls: toolCalls + }; + } +} + +async function waitForLocalResolverEndpoint(baseURL: string, apiKey: string, timeoutMs: number): Promise { + const readinessUrl = localResolverReadinessUrl(baseURL); + if (!readinessUrl) { + return; + } + const deadline = Date.now() + Math.min(Math.max(timeoutMs, 1000), 30_000); + let lastError: unknown; + while (Date.now() < deadline) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 1000); + try { + await fetch(readinessUrl, { + headers: { authorization: `Bearer ${apiKey}` }, + method: "GET", + signal: controller.signal + }); + return; + } catch (error) { + lastError = error; + if (!isRetryableLocalResolverError(error)) { + return; + } + await delay(300); + } finally { + clearTimeout(timer); + } + } + throw new Error(`ToolHub resolver could not connect to CCR Gateway at ${readinessUrl}: ${formatError(lastError)}.`); +} + +function localResolverReadinessUrl(baseURL: string): string | undefined { + let parsed: URL; + try { + parsed = new URL(baseURL); + } catch { + return undefined; + } + if (!isLoopbackHostname(parsed.hostname)) { + return undefined; + } + const base = baseURL.replace(/\/+$/g, ""); + return `${base}/models`; +} + +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, ""); + return normalized === "localhost" || + normalized === "::1" || + normalized === "0:0:0:0:0:0:0:1" || + normalized.startsWith("127."); +} + +function isRetryableLocalResolverError(error: unknown): boolean { + if (error instanceof DOMException && error.name === "AbortError") { + return true; + } + const code = errorCode(error); + return code === "ECONNREFUSED" || + code === "ECONNRESET" || + code === "EHOSTUNREACH" || + code === "ENETUNREACH" || + code === "ETIMEDOUT"; +} + +function errorCode(error: unknown): string { + if (!isRecord(error)) { + return ""; + } + const direct = typeof error.code === "string" ? error.code : ""; + if (direct) { + return direct; + } + const cause = isRecord(error.cause) ? error.cause : undefined; + return typeof cause?.code === "string" ? cause.code : ""; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function metaTools(): Array<{ description: string; inputSchema: Record; name: string }> { + return [ + { + name: resolveToolName, + description: [ + "Search ToolHub with the configured model and resolve installed MCP tools needed for a task.", + `MUST be called before answering any request about external services, installed MCP capabilities, business APIs, orders, coupons, stores, accounts, available tools, or capabilities that are not already obvious from the eager tools.`, + `Call this even if the user did not mention ToolHub or ${resolveToolName}.`, + `Use ${invokeToolName} after this tool returns selected tools.`, + "Follow the returned executionPlanJs: await means serial dependency, and only calls grouped by Promise.all may be invoked in parallel.", + "Use the user's request as task and include concise context so the resolver can select the right tools." + ].join(" "), + inputSchema: { + type: "object", + properties: { + task: { type: "string" }, + context: { type: "object", additionalProperties: true }, + constraints: { + type: "object", + properties: { + maxTools: { type: "number" } + }, + additionalProperties: false + } + }, + required: ["task"], + additionalProperties: false + } + }, + { + name: invokeToolName, + description: `Invoke one MCP tool selected by ${resolveToolName}. Follow executionPlanJs from ${resolveToolName}; do not parallelize invoke calls unless that plan groups them in Promise.all.`, + inputSchema: { + type: "object", + properties: { + tool: { type: "string" }, + args: { type: "object", additionalProperties: true } + }, + required: ["tool"], + additionalProperties: false + } + } + ]; +} + +function readBackendServers(): GatewayMcpServerConfig[] { + const raw = env("TOOLHUB_MCP_SERVERS_JSON"); + if (!raw) { + return []; + } + try { + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) + ? parsed.map(normalizeServerConfig).filter((server): server is GatewayMcpServerConfig => Boolean(server)) + : []; + } catch { + return []; + } +} + +function normalizeServerConfig(value: unknown): GatewayMcpServerConfig | undefined { + if (!isRecord(value)) { + return undefined; + } + const rawTransport = typeof value.transport === "string" ? value.transport : typeof value.type === "string" ? value.type : ""; + const normalizedTransport = rawTransport.toLowerCase().replace(/_/g, "-"); + const transport = normalizedTransport === "streamable-http" || normalizedTransport === "streamablehttp" || normalizedTransport === "http" + ? "streamable-http" + : normalizedTransport === "sse" + ? "sse" + : "stdio"; + const name = typeof value.name === "string" && value.name.trim() ? value.name.trim() : ""; + if (!name) { + return undefined; + } + const base = { + label: typeof value.label === "string" && value.label.trim() ? value.label.trim() : undefined, + name, + protocolVersion: typeof value.protocolVersion === "string" ? value.protocolVersion : protocolVersion, + requestTimeoutMs: normalizeTimeout(value.requestTimeoutMs, defaultRequestTimeoutMs), + startupTimeoutMs: normalizeTimeout(value.startupTimeoutMs, defaultRequestTimeoutMs), + transport + }; + if (transport !== "stdio") { + const url = typeof value.url === "string" && value.url.trim() ? value.url.trim() : ""; + if (!url) { + return undefined; + } + return { + ...base, + apiKey: typeof value.apiKey === "string" ? value.apiKey : undefined, + apiKeyEnv: typeof value.apiKeyEnv === "string" ? value.apiKeyEnv : undefined, + headers: isStringRecord(value.headers) ? value.headers : {}, + transport, + url + }; + } + const command = typeof value.command === "string" && value.command.trim() ? value.command.trim() : ""; + if (!command) { + return undefined; + } + return { + ...base, + args: Array.isArray(value.args) ? value.args.filter((item): item is string => typeof item === "string") : [], + command, + cwd: typeof value.cwd === "string" && value.cwd.trim() ? value.cwd.trim() : undefined, + env: isStringRecord(value.env) ? value.env : {}, + stdioMessageMode: value.stdioMessageMode === "newline-json" ? "newline-json" : "content-length", + transport + }; +} + +function normalizeToolList(value: unknown): ToolDefinition[] { + const tools = isRecord(value) && Array.isArray(value.tools) ? value.tools : []; + const result: ToolDefinition[] = []; + for (const tool of tools) { + if (!isRecord(tool) || typeof tool.name !== "string" || !tool.name.trim()) { + continue; + } + result.push({ + description: typeof tool.description === "string" ? tool.description : "", + inputSchema: normalizeInputSchema(tool.inputSchema ?? tool.input_schema), + name: tool.name.trim(), + outputSchema: normalizeOptionalSchema(tool.outputSchema ?? tool.output_schema), + tags: Array.isArray(tool.tags) ? tool.tags.filter((tag): tag is string => typeof tag === "string") : undefined, + title: typeof tool.title === "string" && tool.title.trim() ? tool.title.trim() : undefined + }); + } + return result; +} + +function normalizeInputSchema(value: unknown): Record { + return isRecord(value) ? value : { type: "object", properties: {} }; +} + +function normalizeOptionalSchema(value: unknown): Record | undefined { + return isRecord(value) ? value : undefined; +} + +function parseHttpJsonRpcResponse(text: string): unknown { + if (/^event:/m.test(text) || /^data:/m.test(text)) { + const events = text.split(/\n\n+/); + for (const event of events) { + const data = event + .split(/\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trim()) + .join("\n"); + if (!data) continue; + try { + return JSON.parse(data) as unknown; + } catch { + continue; + } + } + } + return JSON.parse(text) as unknown; +} + +function consumeSseEvents(buffer: string, handle: (event: { data: string; event: string }) => void): string { + let offset = 0; + for (;;) { + const nextMatch = /\r?\n\r?\n/.exec(buffer.slice(offset)); + if (!nextMatch || nextMatch.index < 0) { + return buffer.slice(offset); + } + const next = offset + nextMatch.index; + const raw = buffer.slice(offset, next); + offset = next + nextMatch[0].length; + let event = "message"; + const data: string[] = []; + for (const line of raw.split(/\r?\n/)) { + if (line.startsWith("event:")) { + event = line.slice("event:".length).trim() || "message"; + } else if (line.startsWith("data:")) { + data.push(line.slice("data:".length).trimStart()); + } + } + if (data.length > 0 || event !== "message") { + handle({ data: data.join("\n"), event }); + } + } +} + +function firstJsonObject(text: string): Record | undefined { + try { + const parsed = JSON.parse(text) as unknown; + return isRecord(parsed) ? parsed : undefined; + } catch { + const start = text.indexOf("{"); + const end = text.lastIndexOf("}"); + if (start < 0 || end <= start) return undefined; + try { + const parsed = JSON.parse(text.slice(start, end + 1)) as unknown; + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } + } +} + +function serverNamespaces(serverNames: string[]): Map { + const counts = new Map(); + const output = new Map(); + for (const serverName of serverNames) { + const base = toIdentifier(serverName); + const count = (counts.get(base) ?? 0) + 1; + counts.set(base, count); + output.set(serverName, count === 1 ? base : `${base}_${count}`); + } + return output; +} + +function toIdentifier(value: string): string { + const normalized = value.replace(/[^a-zA-Z0-9_]/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, ""); + if (!normalized) { + return "tool"; + } + return /^\d/.test(normalized) ? `_${normalized}` : normalized; +} + +function toSearchCatalogItem(entry: CatalogEntry): SearchCatalogItem { + return { + alias: entry.alias, + canonicalName: entry.canonicalName, + description: entry.description, + invocationMode: entry.invocation.mode, + name: entry.toolName, + remoteToolName: entry.remoteToolName, + required: getSchemaRequiredProperties(entry.inputSchema), + serverId: entry.serverId, + serverLabel: entry.serverLabel, + sideEffect: entry.invocation.sideEffect, + title: entry.title + }; +} + +function buildSearchSystemPrompt(catalog: SearchCatalogItem[], topK: number): string { + return [ + "You are the ToolHub resolve planner and retrieval agent.", + "Your job is to return the complete anticipated tool bundle for the user task, not just the first tool.", + "You must think ahead about the next likely steps needed to finish the task end-to-end.", + `You MUST call ${treeSitterToolName} before your final answer.`, + "You may need multiple planning and search rounds.", + "If your first workflow sketch yields zero, partial, or weak tool matches, revise the sketch and call the tree-sitter tool again before answering.", + "Workflow you must follow:", + "1. Plan the likely execution steps.", + "2. Draft a short JavaScript/TypeScript workflow sketch that uses the exact catalog tool names or aliases you expect to need.", + "2a. Prefer sketches that call tools through string literals such as callTool(\"\", {...}) or tools.call(\"\", {...}).", + "3. Call the tree-sitter tool on that sketch.", + "4. Inspect the tree-sitter result and check whether the bundle is complete end-to-end.", + "5. If it is incomplete, revise the sketch and call the tree-sitter tool again.", + "6. Return the final JSON only after the bundle is complete.", + `Return at most ${topK} tool names.`, + "Final answer MUST be a JSON object with this shape:", + "{ \"summary\": string, \"steps\": string[], \"workflowSketch\": string, \"toolNames\": string[] }", + "Rules:", + "- toolNames must be exact catalog names or aliases.", + "- workflowSketch is the dependency plan that will be returned to the caller as executionPlanJs.", + "- In workflowSketch, use await to show serial dependencies and Promise.all([...]) only for tool calls that are independent and safe to invoke in parallel.", + "- Do not put side-effecting calls in Promise.all unless they are truly independent and safe to run concurrently.", + "- In workflowSketch, prefer string-literal tool calls over reconstructed member chains whenever possible.", + "- Include prerequisite and downstream tools you will likely need after the first action.", + "- Never invent tool names.", + "- Generic browser automation tools are a strong match for web tasks such as ordering, buying, booking, delivery, or checkout when no domain-specific MCP tool exists.", + "- For browser navigation/open calls, prefer omitting waitUntil or using waitUntil: \"interactive\" so the agent can inspect and act as soon as the page is usable.", + "- Do not use waitUntil: \"network_idle\" for Gmail, Google sign-in, SPAs, mail, chat, auth, checkout, verification, or pages with long-lived requests. Use network_idle only when the user explicitly asks for network quiescence.", + "- When selecting CCR browser automation tools, include the human-handoff follow-up tools needed if login, CAPTCHA, verification, blocked navigation, or manual confirmation appears.", + "- For CCR browser automation bundles, include browser_handoff_request and browser_handoff_wait when the workflow may need user help. Do not assume all browser tools are preloaded.", + "- For importing existing Chrome login state into CCR's in-app browser, select browser_chrome_login_import and include browser_chrome_login_import_status to check completion.", + "- If using member-call syntax, prefer tools.(...) or mcp..(...).", + "- For lookup tasks, do not stop at opening or navigating. Include the tools needed to read or extract the answer.", + "- If the catalog has no strong match, return an empty toolNames array.", + "", + "Tool catalog:", + JSON.stringify(catalog, null, 2) + ].join("\n"); +} + +function buildSearchRefinementFeedback(input: { + selectedToolNames: string[]; + summary: string; + workflowSketch: string; +}): string | undefined { + const issues: string[] = []; + if (!input.workflowSketch.trim()) { + issues.push("workflowSketch is empty."); + } + if (input.selectedToolNames.length === 0) { + issues.push("Your current answer resolved to zero valid catalog tools."); + } + if (input.summary.trim().toLowerCase().includes("no strong tool bundle match was found")) { + issues.push("Your summary says the bundle is weak; revise the workflow sketch and search again."); + } + if (issues.length === 0) { + return undefined; + } + return [ + "Your last candidate tool bundle is incomplete.", + ...issues.map((issue, index) => `${index + 1}. ${issue}`), + `Revise the workflow sketch, call ${treeSitterToolName} again, and then return updated JSON only.` + ].join("\n"); +} + +class ToolReferenceAnalyzer { + collectReferences(code: string): ToolReference[] { + if (!code.trim()) { + return []; + } + const references: ToolReference[] = []; + this.collectStringCallReferences(code, references); + this.collectMemberReferences(code, references); + return uniqueToolReferences(references); + } + + private collectStringCallReferences(code: string, output: ToolReference[]): void { + const patterns: Array<{ regex: RegExp; source: string }> = [ + { regex: /\bcallTool\s*\(\s*(["'`])([^"'`]+)\1/g, source: "callTool" }, + { regex: /\btools\s*\.\s*call\s*\(\s*(["'`])([^"'`]+)\1/g, source: "tools.call" }, + { regex: /\b[\w$]+\s*\.\s*callTool\s*\(\s*(["'`])([^"'`]+)\1/g, source: "context.callTool" }, + { regex: /\btools\s*\[\s*(["'`])([^"'`]+)\1\s*\]\s*\(/g, source: "tools.subscript" } + ]; + for (const pattern of patterns) { + for (const match of code.matchAll(pattern.regex)) { + const rawName = match[2]?.trim(); + if (rawName) { + output.push(buildToolReference(code, match.index ?? 0, rawName, pattern.source)); + } + } + } + } + + private collectMemberReferences(code: string, output: ToolReference[]): void { + const patterns: Array<{ regex: RegExp; source: string }> = [ + { regex: /\btools\s*\.\s*([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*\(/g, source: "tools.member" }, + { regex: /\bmcp\s*\.\s*([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*){1,})\s*\(/g, source: "mcp.member" } + ]; + for (const pattern of patterns) { + for (const match of code.matchAll(pattern.regex)) { + const pathText = match[1]?.replace(/\s+/g, ""); + if (!pathText || pathText === "call") { + continue; + } + const rawName = pattern.source === "mcp.member" ? `mcp.${pathText}` : pathText; + output.push(buildToolReference(code, match.index ?? 0, rawName, pattern.source)); + } + } + } +} + +function buildToolReference(code: string, index: number, rawName: string, source: string): ToolReference { + const before = code.slice(0, index); + const lines = before.split(/\r?\n/); + return { + column: lines[lines.length - 1].length + 1, + line: lines.length, + rawName, + source + }; +} + +function uniqueToolReferences(references: ToolReference[]): ToolReference[] { + const seen = new Set(); + const output: ToolReference[] = []; + for (const reference of references) { + const key = `${reference.rawName}\u0000${reference.source}\u0000${reference.line}\u0000${reference.column}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(reference); + } + return output; +} + +function parseToolArguments(value: string): Record { + try { + const parsed = JSON.parse(value) as unknown; + return isRecord(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function toStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +} + +function normalizeTopK(value: unknown): number { + return Math.min(Math.max(toPositiveIntOrDefault(value, defaultMaxTools), 1), 20); +} + +function normalizeSearchMaxTurns(value: unknown): number { + return Math.min(Math.max(toPositiveIntOrDefault(value, defaultMaxTurns), 1), maxTurnsLimit); +} + +function normalizeSearchTimeout(value: unknown): number { + return Math.max(toPositiveIntOrDefault(value, defaultRequestTimeoutMs), minSearchTimeoutMs); +} + +function toPositiveIntOrDefault(value: unknown, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return fallback; + } + const rounded = Math.floor(value); + return rounded > 0 ? rounded : fallback; +} + +function resolveCatalogItemName( + catalog: Array<{ alias: string; canonicalName?: string; name: string; remoteToolName?: string }>, + token: string +): string | undefined { + const normalized = token.trim(); + if (!normalized) { + return undefined; + } + const lookupCandidates = uniqueStrings([ + normalized, + toIdentifier(normalized), + normalized.startsWith("mcp.") ? normalized : `mcp.${normalized}`, + normalized.startsWith("mcp_") ? normalized : `mcp_${toIdentifier(normalized)}` + ]); + for (const item of catalog) { + const itemExactNames = uniqueStrings([ + item.name, + item.alias, + item.canonicalName ?? "", + item.canonicalName ? toIdentifier(item.canonicalName) : "" + ].filter(Boolean)); + for (const candidate of lookupCandidates) { + if (itemExactNames.includes(candidate)) { + return item.name; + } + } + } + const suffixMatches = catalog.filter((item) => matchesUniqueSuffix(item, normalized)); + return suffixMatches.length === 1 ? suffixMatches[0].name : undefined; +} + +function matchesUniqueSuffix( + item: { alias: string; canonicalName?: string; name: string; remoteToolName?: string }, + token: string +): boolean { + const tokenAlias = toIdentifier(token); + const nameSuffix = item.name.split(".").slice(1).join("."); + const remoteToolName = item.remoteToolName || item.name.split(".").at(-1) || ""; + const suffixes = uniqueStrings([ + item.canonicalName ?? "", + item.canonicalName ? toIdentifier(item.canonicalName) : "", + nameSuffix, + toIdentifier(nameSuffix), + remoteToolName, + toIdentifier(remoteToolName) + ].filter(Boolean)); + return suffixes.includes(token) || suffixes.includes(tokenAlias); +} + +function buildTsDefinitions(entries: CatalogEntry[]): string { + return entries + .map((entry) => { + const typeName = toTypeName(entry.toolName); + const argsTypeName = `${typeName}Args`; + return [ + "/**", + ` * ${entry.description || entry.title}`, + ` * Exact tool name: "${entry.toolName}".`, + ` * Callable references: "${entry.toolName}", "${entry.alias}".`, + " */", + `type ${argsTypeName} = ${schemaToType(entry.inputSchema)};`, + `type ${typeName}Call = { tool: "${entry.toolName}"; args: ${argsTypeName}; };` + ].join("\n"); + }) + .join("\n\n"); +} + +function schemaToType(schema: Record | undefined): string { + if (!isRecord(schema) || !isRecord(schema.properties)) { + return "Record"; + } + const required = new Set(getSchemaRequiredProperties(schema)); + const lines = ["{"]; + for (const [key, value] of Object.entries(schema.properties)) { + const propertySchema = isRecord(value) ? value : {}; + lines.push(` ${JSON.stringify(key)}${required.has(key) ? "" : "?"}: ${jsonSchemaTypeToTs(propertySchema)};`); + } + lines.push("}"); + return lines.join("\n"); +} + +function jsonSchemaTypeToTs(schema: Record): string { + if (Array.isArray(schema.enum) && schema.enum.every((item) => typeof item === "string")) { + return schema.enum.map((item) => JSON.stringify(item)).join(" | ") || "string"; + } + switch (schema.type) { + case "array": + return "unknown[]"; + case "boolean": + return "boolean"; + case "integer": + case "number": + return "number"; + case "object": + return "Record"; + case "string": + return "string"; + default: + return "unknown"; + } +} + +function toTypeName(value: string): string { + const words = value.replace(/[^a-zA-Z0-9]+/g, " ").trim().split(/\s+/); + const name = words.map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`).join(""); + return name || "Tool"; +} + +function scoreLocalCatalogMatch(taskText: string, tool: CatalogEntry, preferredToolScores: Map): number { + const taskTokens = tokenizeLocalSearchText(taskText); + const taskTokenSet = new Set(taskTokens); + const toolText = [ + tool.toolName, + tool.alias, + tool.canonicalName, + tool.remoteToolName, + tool.serverId, + tool.serverLabel ?? "", + tool.title, + tool.description, + ...tool.tags + ].join(" "); + const toolTokens = tokenizeLocalSearchText(toolText); + let score = preferredToolScores.get(tool.remoteToolName) ?? preferredToolScores.get(tool.toolName) ?? 0; + for (const token of toolTokens) { + if (taskTokenSet.has(token)) { + score += token.length > 4 ? 3 : 1; + } + } + if (tool.invocation.sideEffect) { + score -= 8; + } + return score; +} + +function getLocalFallbackPreferredTools(taskText: string): Map { + const tokens = new Set(tokenizeLocalSearchText(taskText)); + const normalizedText = taskText.toLowerCase(); + const scores = new Map(); + const add = (toolName: string, score: number) => scores.set(toolName, Math.max(scores.get(toolName) ?? 0, score)); + if (hasAnyToken(tokens, [ + "app", + "book", + "booking", + "browse", + "buy", + "cart", + "checkout", + "coffee", + "delivery", + "latte", + "order", + "pickup", + "product", + "purchase", + "restaurant", + "search", + "shop", + "store", + "website" + ]) || /咖啡|拿铁|生椰|点单|下单|外卖|配送|自取|门店|商品|购买|结账|订单/.test(normalizedText)) { + add("browser_session_open", 120); + add("browser_navigate", 112); + add("browser_snapshot", 108); + add("browser_ax_query", 96); + add("browser_element_input", 88); + add("browser_element_click", 84); + add("browser_screenshot", 60); + add("browser_events_await", 44); + } + if (hasAnyToken(tokens, [ + "auth", + "chrome", + "cookie", + "cookies", + "import", + "localstorage", + "login", + "session", + "signin", + "storage" + ]) || /chrome|cookie|cookies|localstorage|local storage|登录态|登录状态|导入登录|浏览器登录|本地存储/.test(normalizedText)) { + add("browser_chrome_login_import", 128); + add("browser_chrome_login_import_status", 92); + } + if (hasAnyToken(tokens, ["address", "delivery", "geo", "geolocation", "location", "nearby", "pickup", "store"]) || + /地址|定位|位置|附近|配送|外卖|自取|门店/.test(normalizedText)) { + add("location_permission_status", 90); + add("location_get_current", 86); + } + if (hasAnyToken(tokens, ["ask", "choice", "confirm", "confirmation", "missing", "option", "preference", "question", "select", "user"]) || + /确认|选择|偏好|口味|缺少|询问|用户/.test(normalizedText)) { + add("user_interaction_collect", 82); + } + return scores; +} + +function getDeterministicTaskTools(taskText: string, catalog: CatalogEntry[]): CatalogEntry[] { + if (!taskWantsChromeLoginImport(taskText)) { + return []; + } + return [ + catalog.find((tool) => isBrowserAutomationTool(tool) && tool.remoteToolName === "browser_chrome_login_import"), + catalog.find((tool) => isBrowserAutomationTool(tool) && tool.remoteToolName === "browser_chrome_login_import_status") + ].filter((tool): tool is CatalogEntry => Boolean(tool)); +} + +function taskWantsChromeLoginImport(taskText: string): boolean { + const normalizedText = taskText.toLowerCase(); + return normalizedText.includes("browser_chrome_login_import") || + /(chrome|谷歌浏览器|浏览器).*(login|signin|auth|cookie|localstorage|local storage|session|登录态|登录状态|登录信息|本地存储|cookie)/.test(normalizedText) || + /(import|导入|迁移|同步).*(chrome|谷歌浏览器).*(login|signin|auth|cookie|localstorage|local storage|session|登录态|登录状态|登录信息|本地存储)/.test(normalizedText) || + /(登录态|登录状态|登录信息).*(chrome|谷歌浏览器|浏览器).*(导入|迁移|同步)/.test(normalizedText); +} + +function catalogHasChromeLoginImportTool(catalog: CatalogEntry[]): boolean { + return catalog.some((tool) => isBrowserAutomationTool(tool) && tool.remoteToolName === "browser_chrome_login_import"); +} + +function tokenizeLocalSearchText(text: string): string[] { + const tokens = text + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .toLowerCase() + .split(/[^a-z0-9_]+/) + .map((token) => token.trim()) + .filter((token) => token.length > 1 && !localSearchStopWords.has(token)); + return uniqueStrings(tokens); +} + +const localSearchStopWords = new Set(["a", "an", "and", "are", "as", "be", "by", "for", "from", "in", "is", "it", "need", "of", "or", "the", "then", "to", "with"]); + +function uniqueToolEntries(entries: CatalogEntry[]): CatalogEntry[] { + const seen = new Set(); + const output: CatalogEntry[] = []; + for (const entry of entries) { + if (seen.has(entry.toolName)) { + continue; + } + seen.add(entry.toolName); + output.push(entry); + } + return output; +} + +function expandToolBundleWithCompanionTools(selectedTools: CatalogEntry[], catalog: CatalogEntry[]): CatalogEntry[] { + const output = uniqueToolEntries(selectedTools); + const selectedNames = new Set(output.map((tool) => tool.toolName)); + const selectedRemoteNames = new Set(output.map((tool) => tool.remoteToolName)); + const hasBrowserAutomationTool = output.some((tool) => isBrowserAutomationTool(tool)); + const hasHandoffRequestTool = output.some((tool) => + isBrowserAutomationTool(tool) && + (tool.remoteToolName === "browser_handoff_request" || tool.remoteToolName === "askHumanHelp") + ); + const hasChromeLoginImportTool = output.some((tool) => + isBrowserAutomationTool(tool) && + tool.remoteToolName === "browser_chrome_login_import" + ); + const companionRemoteNames = new Set(); + + if (hasBrowserAutomationTool) { + companionRemoteNames.add("browser_handoff_request"); + companionRemoteNames.add("browser_handoff_status"); + companionRemoteNames.add("browser_handoff_wait"); + } + if (hasHandoffRequestTool) { + companionRemoteNames.add("browser_handoff_wait"); + } + if (hasChromeLoginImportTool) { + companionRemoteNames.add("browser_chrome_login_import_status"); + } + + for (const remoteToolName of companionRemoteNames) { + if (selectedRemoteNames.has(remoteToolName)) { + continue; + } + const companion = catalog.find((tool) => + isBrowserAutomationTool(tool) && + tool.remoteToolName === remoteToolName && + !selectedNames.has(tool.toolName) + ); + if (companion) { + output.push(companion); + selectedNames.add(companion.toolName); + selectedRemoteNames.add(companion.remoteToolName); + } + } + + return output; +} + +function isBrowserAutomationTool(tool: CatalogEntry): boolean { + return tool.serverName === "ccr-browser-automation" || + tool.serverId === "ccr-browser-automation" || + tool.serverNamespace === "ccr_browser_automation" || + tool.toolName.startsWith("mcp.ccr_browser_automation."); +} + +function buildLocalFallbackWorkflowSketch(selectedTools: CatalogEntry[]): string | undefined { + return selectedTools.length > 0 ? buildSequentialExecutionPlanJs(selectedTools) : undefined; +} + +function isBrowserNavigationTool(tool: CatalogEntry): boolean { + return isBrowserAutomationTool(tool) && ( + tool.toolName.endsWith("browser_session_open") || + tool.toolName.endsWith("browser_navigate") || + tool.remoteToolName === "browser_session_open" || + tool.remoteToolName === "browser_navigate" + ); +} + +function buildExecutionPlanJs(workflowSketch: string | undefined, selectedTools: CatalogEntry[]): string { + const trimmed = typeof workflowSketch === "string" ? workflowSketch.trim() : ""; + return trimmed || buildSequentialExecutionPlanJs(selectedTools); +} + +function buildSequentialExecutionPlanJs(selectedTools: CatalogEntry[]): string { + if (selectedTools.length === 0) { + return [ + "async function runWithToolHub() {", + " // Ask the user for missing task details before invoking tools.", + "}" + ].join("\n"); + } + const lines = [ + "async function runWithToolHub() {", + " // Invoke calls in this order unless the plan explicitly uses Promise.all." + ]; + selectedTools.forEach((tool, index) => { + lines.push(` const step${index + 1} = await callTool(${JSON.stringify(tool.toolName)}, ${buildExecutionPlanArgs(tool)});`); + }); + lines.push("}"); + return lines.join("\n"); +} + +function buildExecutionPlanArgs(tool: CatalogEntry): string { + if (isBrowserNavigationTool(tool)) { + return "{ url, waitUntil: \"interactive\" }"; + } + const required = getSchemaRequiredProperties(tool.inputSchema); + if (required.length === 0) { + return "{}"; + } + return `{ ${required.map((key) => `${JSON.stringify(key)}: ${toPlanVariableName(key)}`).join(", ")} }`; +} + +function toPlanVariableName(value: string): string { + const identifier = toIdentifier(value).replace(/^[A-Z]/, (match) => match.toLowerCase()); + if (!identifier || reservedJavaScriptWords.has(identifier)) { + return "value"; + } + return identifier; +} + +function inferInvocation(tool: ToolDefinition): ToolInvocation { + const text = `${tool.name} ${tool.title ?? ""} ${tool.description ?? ""}`; + const tokenList = tokenizeToolText(text); + const tokens = new Set(tokenList); + const sideEffect = hasAnyToken(tokens, [ + "book", + "cancel", + "commit", + "create", + "delete", + "execute", + "merge", + "post", + "publish", + "purchase", + "push", + "remove", + "reserve", + "run", + "send", + "submit", + "update", + "write" + ]); + const workflowOnly = hasAnyToken(tokens, ["batch", "bulk", "foreach", "iterate", "parallel"]) || + hasTokenSequence(tokenList, ["for", "each"]) || + hasTokenSequence(tokenList, ["sync", "all"]) || + hasTokenSequence(tokenList, ["export", "all"]); + return { + mode: workflowOnly ? "workflow" : "both", + sideEffect + }; +} + +function tokenizeToolText(text: string): string[] { + const spaced = text + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .replace(/[^a-zA-Z0-9_]+/g, " ") + .toLowerCase(); + return spaced.split(/\s+|_+/).filter(Boolean); +} + +function hasAnyToken(tokens: Set, candidates: string[]): boolean { + return candidates.some((candidate) => tokens.has(candidate)); +} + +function hasTokenSequence(tokens: string[], sequence: string[]): boolean { + if (sequence.length === 0 || tokens.length < sequence.length) { + return false; + } + for (let index = 0; index <= tokens.length - sequence.length; index += 1) { + if (sequence.every((token, offset) => tokens[index + offset] === token)) { + return true; + } + } + return false; +} + +function selectFirstActionTool(selectedTools: CatalogEntry[]): CatalogEntry | undefined { + const candidates = selectedTools + .map((tool, index) => ({ + index, + rank: rankFirstActionTool(tool), + tool + })) + .filter((candidate) => !candidate.tool.invocation.sideEffect); + const rankedCandidates = candidates.length + ? candidates + : selectedTools.map((tool, index) => ({ + index, + rank: rankFirstActionTool(tool), + tool + })); + return rankedCandidates.sort((left, right) => { + return left.rank.derivedRequiredCount - right.rank.derivedRequiredCount || + right.rank.userSuppliedRequiredCount - left.rank.userSuppliedRequiredCount || + left.rank.requiredCount - right.rank.requiredCount || + left.index - right.index; + })[0]?.tool; +} + +function rankFirstActionTool(tool: CatalogEntry): { + derivedRequiredCount: number; + requiredCount: number; + userSuppliedRequiredCount: number; +} { + const required = getSchemaRequiredProperties(tool.inputSchema); + return { + derivedRequiredCount: required.filter(isLikelyWorkflowIntermediateArgument).length, + requiredCount: required.length, + userSuppliedRequiredCount: required.filter(isLikelyUserSuppliedArgument).length + }; +} + +function isLikelyWorkflowIntermediateArgument(name: string): boolean { + const normalized = normalizeSchemaPropertyName(name); + return /(?:dept|department|shop|store|merchant|product|goods|sku|cart|order|payment|coupon|address)id$/.test(normalized) || + /(?:sku|order|product|goods|shop|store)(?:code|no|num|number)$/.test(normalized) || + normalized === "id" || + normalized.endsWith("list"); +} + +function isLikelyUserSuppliedArgument(name: string): boolean { + const normalized = normalizeSchemaPropertyName(name); + return normalized === "query" || + normalized === "keyword" || + normalized === "keywords" || + normalized === "search" || + normalized === "address" || + normalized === "city" || + normalized === "latitude" || + normalized === "lat" || + normalized === "longitude" || + normalized === "lng" || + normalized === "lon" || + normalized.endsWith("name") || + normalized.endsWith("text"); +} + +function normalizeSchemaPropertyName(name: string): string { + return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, ""); +} + +function getSchemaRequiredProperties(schema: Record | undefined): string[] { + return Array.isArray(schema?.required) + ? schema.required.filter((item): item is string => typeof item === "string") + : []; +} + +function cloneServerConfig(config: GatewayMcpServerConfig): GatewayMcpServerConfig { + if (config.transport === "stdio") { + return { + ...config, + args: config.args ? [...config.args] : undefined, + env: config.env ? { ...config.env } : undefined + }; + } + return { + ...config, + headers: config.headers ? { ...config.headers } : undefined + }; +} + +function normalizeTargetServerNames(serverNames: string[] | undefined, entries: Map): string[] { + if (!serverNames || serverNames.length === 0) { + return [...entries.keys()]; + } + return uniqueStrings(serverNames.map((item) => item.trim()).filter((item) => entries.has(item))); +} + +const toolHubPersistentCache = { + readDiscovery(serverName: string, configHash: string): { + cachedAt: number; + configHash: string; + lastSeenOnlineAt?: number; + tools: ToolDefinition[]; + } | null { + const store = readCacheStore(); + const entry = store.discovery[serverName]; + if (!entry) { + return null; + } + if (entry.configHash !== configHash) { + delete store.discovery[serverName]; + writeCacheStore(store); + return null; + } + return { + cachedAt: entry.cachedAt, + configHash: entry.configHash, + lastSeenOnlineAt: entry.lastSeenOnlineAt, + tools: entry.tools.map((tool) => ({ ...tool })) + }; + }, + writeDiscovery(serverName: string, configHash: string, tools: ToolDefinition[], lastSeenOnlineAt = Date.now()): void { + const store = readCacheStore(); + store.discovery[serverName] = { + cachedAt: Date.now(), + configHash, + lastSeenOnlineAt, + tools: tools.map((tool) => ({ ...tool })) + }; + writeCacheStore(store); + } +}; + +type ToolHubCacheStore = { + discovery: Record; + version: 1; +}; + +function readCacheStore(): ToolHubCacheStore { + try { + const file = toolHubCacheFile(); + if (!existsSync(file)) { + return createEmptyCacheStore(); + } + const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown; + return normalizeCacheStore(parsed); + } catch { + return createEmptyCacheStore(); + } +} + +function writeCacheStore(store: ToolHubCacheStore): void { + const file = toolHubCacheFile(); + mkdirSync(path.dirname(file), { recursive: true }); + const tempFile = `${file}.${process.pid}.tmp`; + writeFileSync(tempFile, `${JSON.stringify(store, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + renameSync(tempFile, file); +} + +function createEmptyCacheStore(): ToolHubCacheStore { + return { discovery: {}, version: 1 }; +} + +function normalizeCacheStore(value: unknown): ToolHubCacheStore { + if (!isRecord(value) || value.version !== 1 || !isRecord(value.discovery)) { + return createEmptyCacheStore(); + } + const discovery: ToolHubCacheStore["discovery"] = {}; + for (const [serverName, entryValue] of Object.entries(value.discovery)) { + if (!isRecord(entryValue) || typeof entryValue.configHash !== "string" || !Array.isArray(entryValue.tools)) { + continue; + } + discovery[serverName] = { + cachedAt: typeof entryValue.cachedAt === "number" && Number.isFinite(entryValue.cachedAt) ? entryValue.cachedAt : Date.now(), + configHash: entryValue.configHash, + lastSeenOnlineAt: typeof entryValue.lastSeenOnlineAt === "number" && Number.isFinite(entryValue.lastSeenOnlineAt) ? entryValue.lastSeenOnlineAt : undefined, + tools: entryValue.tools + .map(normalizeToolDefinitionForCache) + .filter((tool): tool is ToolDefinition => Boolean(tool)) + }; + } + return { discovery, version: 1 }; +} + +function normalizeToolDefinitionForCache(value: unknown): ToolDefinition | null { + if (!isRecord(value) || typeof value.name !== "string" || !value.name.trim()) { + return null; + } + return { + description: typeof value.description === "string" ? value.description : "", + inputSchema: normalizeOptionalSchema(value.inputSchema), + name: value.name.trim(), + outputSchema: normalizeOptionalSchema(value.outputSchema), + tags: Array.isArray(value.tags) ? value.tags.filter((tag): tag is string => typeof tag === "string") : undefined, + title: typeof value.title === "string" && value.title.trim() ? value.title.trim() : undefined + }; +} + +function toolHubCacheFile(): string { + return env("TOOLHUB_CACHE_FILE") || path.join(os.homedir(), ".claude-code-router", "toolhub-cache.json"); +} + +function hashToolHubMcpServerConfig(config: GatewayMcpServerConfig): string { + return createHash("sha256").update(stableJsonStringify(config) ?? "null").digest("hex"); +} + +function stableJsonStringify(value: unknown): string | undefined { + if (value === undefined) { + return undefined; + } + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableJsonStringify(item) ?? "null").join(",")}]`; + } + const record = value as Record; + const entries = Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJsonStringify(record[key]) ?? "null"}`); + return `{${entries.join(",")}}`; +} + +function normalizeResolveTaskKey(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, " "); +} + +function readNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function summarizeValue(value: unknown): string { + try { + const text = JSON.stringify(value); + return text.length > 600 ? `${text.slice(0, 600)}...` : text; + } catch { + return String(value); + } +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values)]; +} + +function jsonRpcResult(id: null | number | string, result: JsonValue): JsonRpcResponse { + return { + id, + jsonrpc: "2.0", + result + }; +} + +function jsonRpcError(id: null | number | string, code: number, message: string): JsonRpcResponse { + return { + error: { + code, + message + }, + id, + jsonrpc: "2.0" + }; +} + +function writeJsonRpc(response: JsonRpcResponse, messageMode: MessageMode = "content-length"): void { + const text = JSON.stringify(response); + if (messageMode === "newline-json") { + process.stdout.write(`${text}\n`); + return; + } + process.stdout.write(`Content-Length: ${Buffer.byteLength(text, "utf8")}\r\n\r\n${text}`); +} + +function toolError(code: string, message: string): unknown { + return { + content: [{ + text: `${code}: ${message}`, + type: "text" + }], + isError: true + }; +} + +function toolResult(payload: Record): unknown { + return { + ...payload, + content: [{ + text: JSON.stringify(payload, null, 2), + type: "text" + }], + structuredContent: payload + }; +} + +function env(name: string): string { + return process.env[name]?.trim() ?? ""; +} + +function envNumber(name: string, fallback: number): number { + const parsed = Number(process.env[name]); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function normalizeMaxTools(value: unknown): number { + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? Math.min(Math.max(Math.floor(parsed), 1), 20) : defaultMaxTools; +} + +function normalizeTimeout(value: unknown, fallback: number): number { + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) ? Math.min(Math.max(Math.floor(parsed), 100), 600_000) : fallback; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isStringRecord(value: unknown): value is Record { + return isRecord(value) && Object.values(value).every((item) => typeof item === "string"); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/packages/core/src/models/catalog-file.ts b/packages/core/src/models/catalog-file.ts new file mode 100644 index 0000000..08c09e1 --- /dev/null +++ b/packages/core/src/models/catalog-file.ts @@ -0,0 +1,48 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve as pathResolve } from "node:path"; + +export type LoadedModelCatalogPayload = { + loadedFrom: string; + payload: unknown; +}; + +export function loadModelCatalogPayload(): LoadedModelCatalogPayload | undefined { + for (const candidate of modelCatalogPathCandidates()) { + if (!existsSync(candidate)) { + continue; + } + return { + loadedFrom: candidate, + payload: JSON.parse(readFileSync(candidate, "utf8")) as unknown + }; + } + return undefined; +} + +export function modelCatalogPathCandidates(): string[] { + return uniqueStrings([ + process.env.CCR_MODEL_CATALOG_PATH?.trim() || "", + process.env.CCR_MODELS_JSON_PATH?.trim() || "", + pathResolve(process.cwd(), "models.json"), + pathResolve(process.cwd(), "packages", "core", "models.json"), + pathResolve(process.cwd(), "packages", "cli", "models.json"), + pathResolve(__dirname, "..", "models.json"), + pathResolve(__dirname, "..", "assets", "models.json"), + pathResolve(__dirname, "..", "..", "models.json"), + pathResolve(__dirname, "..", "..", "..", "models.json") + ]); +} + +function uniqueStrings(values: Array): string[] { + const seen = new Set(); + const strings: string[] = []; + for (const value of values) { + const trimmed = value?.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + strings.push(trimmed); + } + return strings; +} diff --git a/src/main/model-pricing-service.ts b/packages/core/src/models/pricing-service.ts similarity index 99% rename from src/main/model-pricing-service.ts rename to packages/core/src/models/pricing-service.ts index 92c074d..e581d21 100644 --- a/src/main/model-pricing-service.ts +++ b/packages/core/src/models/pricing-service.ts @@ -1,4 +1,4 @@ -import { fetchWithSystemProxy } from "./system-proxy-fetch"; +import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch"; type ModelPricingSource = "litellm" | "models.dev" | "openrouter"; diff --git a/src/main/request-log-store.ts b/packages/core/src/observability/request-log-store.ts similarity index 97% rename from src/main/request-log-store.ts rename to packages/core/src/observability/request-log-store.ts index b791dd8..edd289e 100644 --- a/src/main/request-log-store.ts +++ b/packages/core/src/observability/request-log-store.ts @@ -1,10 +1,10 @@ import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { StringDecoder } from "node:string_decoder"; -import { REQUEST_LOGS_DB_FILE } from "./constants"; -import { estimateUsageCostUsd } from "./model-pricing-service"; -import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "./sqlite-native"; -import { normalizeUsageInputTokens } from "./usage-normalization"; +import { REQUEST_LOGS_DB_FILE } from "@ccr/core/config/constants"; +import { estimateUsageCostUsd } from "@ccr/core/models/pricing-service"; +import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "@ccr/core/storage/sqlite-native"; +import { normalizeUsageInputTokens } from "@ccr/core/usage/normalization"; import type { AgentAnalysisAgentRow, AgentAnalysisFilter, @@ -30,13 +30,15 @@ import type { AgentKind, GatewayProviderProtocol, RequestLogBody, + RequestLogDetailRequest, + RequestLogEntry, RequestLogFilterOptions, RequestLogListFilter, RequestLogPage, RequestLogRetryAttempt, RequestLogStatusFilter, UsageStatsRange -} from "../shared/app"; +} from "@ccr/core/contracts/app"; type SqlDatabase = BetterSqliteDatabase; type SqlValue = bigint | Buffer | number | string | null; @@ -71,6 +73,7 @@ type RequestLogRecordInput = { fallbackModel?: string; method: string; path: string; + providerName?: string; providerProtocol?: GatewayProviderProtocol; requestBody: Buffer; requestHeaders: HeaderRecord; @@ -194,6 +197,10 @@ const maxBodyBytes = 2 * 1024 * 1024; const maxAgentAnalysisRows = 5000; const maxAgentSessionDetailRequests = 250; const maxTracePayloadPreviewChars = 1600; +const requestLogBodyMetadataSelect = ` + '' AS request_body_text, + '' AS response_body_text +`; const emptyAgentAnalysisTotals: AgentAnalysisTotals = { avgDurationMs: 0, cacheRatio: 0, @@ -226,10 +233,18 @@ const sensitiveHeaderNames = new Set([ "x-auth-sub" ]); -class RequestLogStore { +type AgentAnalysisCacheEntry = { + filterKey: string; + revision: number; + snapshot: AgentAnalysisSnapshot; +}; + +export class RequestLogStore { private database?: SqlDatabase; private initPromise?: Promise; private lastRetentionCleanupDay?: string; + private revision = 0; + private analysisCache?: AgentAnalysisCacheEntry; constructor(private readonly dbFile: string) {} @@ -250,6 +265,7 @@ class RequestLogStore { const route = splitRouteSelector(input.fallbackModel); const requestModel = extractModelFromBody(input.requestBody.toString("utf8")); const provider = + normalizeFilterValue(input.providerName) ?? readResponseHeader(input.responseHeaders, "x-gateway-target-provider-name") ?? readResponseHeader(input.responseHeaders, "x-gateway-target-provider") ?? route.provider; @@ -369,6 +385,7 @@ class RequestLogStore { responseBody.truncated ? 1 : 0, responseError ?? "" ); + this.revision += 1; } async updateFromRawTrace(input: RequestLogRawTraceUpdateInput): Promise { @@ -518,6 +535,7 @@ class RequestLogStore { } database.prepare(`UPDATE request_logs SET ${sets.join(", ")} WHERE request_id = ?`).run(...params, requestId); + this.revision += 1; return true; } @@ -561,12 +579,11 @@ class RequestLogStore { cost_usd, request_headers, response_headers, - request_body_text, + ${requestLogBodyMetadataSelect}, request_body_encoding, request_body_content_type, request_body_size_bytes, request_body_truncated, - response_body_text, response_body_encoding, response_body_content_type, response_body_size_bytes, @@ -591,10 +608,26 @@ class RequestLogStore { }; } + async getDetail(request: RequestLogDetailRequest): Promise { + const database = await this.getDatabase(); + const requestLogId = normalizeCount(request.id); + if (requestLogId <= 0) { + return undefined; + } + return readRequestLogById(database, requestLogId); + } + async analyze(filter: AgentAnalysisFilter = {}): Promise { const database = await this.getDatabase(); this.pruneOldRequestLogs(database); const now = new Date(); + const filterKey = agentAnalysisCacheKey(filter); + if (this.analysisCache?.revision === this.revision && this.analysisCache.filterKey === filterKey) { + return { + ...this.analysisCache.snapshot, + generatedAt: now.toISOString() + }; + } const range = normalizeAgentAnalysisRange(filter.range); const since = getAgentAnalysisSince(range, now); const rows = queryRows( @@ -663,7 +696,7 @@ class RequestLogStore { ? buildAgentSessionDetail(analysisRequests) : undefined; - return { + const snapshot: AgentAnalysisSnapshot = { agents: buildAgentRows(analysisRequests), clients: buildAgentClientRows(analysisRequests), concurrency: buildAgentConcurrencySeries(range, now, analysisRequests), @@ -680,6 +713,12 @@ class RequestLogStore { tools: buildAgentToolRows(analysisRequests), totals: buildAgentAnalysisTotals(analysisRequests) }; + this.analysisCache = { + filterKey, + revision: this.revision, + snapshot + }; + return snapshot; } async getTracePayload(request: AgentAnalysisTracePayloadRequest): Promise { @@ -838,6 +877,15 @@ export async function getRequestLogs(filter?: RequestLogListFilter): Promise { + try { + return await requestLogStore.getDetail(request); + } catch (error) { + console.warn(`[request-log] Failed to read request log detail: ${formatError(error)}`); + throw error; + } +} + export async function getAgentAnalysis(filter?: AgentAnalysisFilter): Promise { try { return await requestLogStore.analyze(filter); @@ -900,6 +948,15 @@ function toAnalyzedAgentRequest(entry: StoredRequestLogEntry): AnalyzedAgentRequ }; } +function agentAnalysisCacheKey(filter: AgentAnalysisFilter): string { + return JSON.stringify({ + agent: normalizeAgentFilter(filter.agent), + range: normalizeAgentAnalysisRange(filter.range), + sessionAgent: normalizeAgentFilter(filter.sessionAgent), + sessionId: normalizeFilterValue(filter.sessionId) + }); +} + function extractAgentLogDetails(entry: StoredRequestLogEntry): AgentLogDetails { const requestPayloads = parseLogBodyPayloads(entry.requestBody); const responsePayloads = parseLogBodyPayloads(entry.responseBody); @@ -2630,6 +2687,11 @@ function ensureRequestLogSchema(database: SqlDatabase): void { database.exec("CREATE INDEX IF NOT EXISTS request_logs_provider_idx ON request_logs(provider)"); database.exec("CREATE INDEX IF NOT EXISTS request_logs_source_usage_id_idx ON request_logs(source_usage_id)"); database.exec("CREATE INDEX IF NOT EXISTS request_logs_status_idx ON request_logs(ok, status_code)"); + database.exec("CREATE INDEX IF NOT EXISTS request_logs_list_idx ON request_logs(source_usage_id, created_at DESC, id DESC)"); + database.exec("CREATE INDEX IF NOT EXISTS request_logs_credential_created_at_idx ON request_logs(credential_id, created_at DESC)"); + database.exec("CREATE INDEX IF NOT EXISTS request_logs_model_created_at_idx ON request_logs(model, created_at DESC)"); + database.exec("CREATE INDEX IF NOT EXISTS request_logs_provider_created_at_idx ON request_logs(provider, created_at DESC)"); + database.exec("CREATE INDEX IF NOT EXISTS request_logs_status_created_at_idx ON request_logs(ok, created_at DESC)"); } function backfillRequestLogStreamFlags(database: SqlDatabase): void { diff --git a/src/main/windows-app-discovery.ts b/packages/core/src/platform/windows-app-discovery.ts similarity index 99% rename from src/main/windows-app-discovery.ts rename to packages/core/src/platform/windows-app-discovery.ts index 1737231..827e6f7 100644 --- a/src/main/windows-app-discovery.ts +++ b/packages/core/src/platform/windows-app-discovery.ts @@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process"; import { readdirSync, statSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { windowsSystemCommand } from "./windows-system"; +import { windowsSystemCommand } from "@ccr/core/platform/windows-system"; export type WindowsDesktopAppDiscoveryOptions = { appDirs: string[]; diff --git a/src/main/windows-system.ts b/packages/core/src/platform/windows-system.ts similarity index 100% rename from src/main/windows-system.ts rename to packages/core/src/platform/windows-system.ts diff --git a/src/server/backend-service.ts b/packages/core/src/plugins/backend-service.ts similarity index 99% rename from src/server/backend-service.ts rename to packages/core/src/plugins/backend-service.ts index 09c142b..84abbf2 100644 --- a/src/server/backend-service.ts +++ b/packages/core/src/plugins/backend-service.ts @@ -1,7 +1,7 @@ import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; import http, { type IncomingMessage, type Server, type ServerResponse } from "node:http"; import path from "node:path"; -import { createBetterSqliteDatabase, type BetterSqliteDatabase, type BetterSqliteStatement } from "../main/sqlite-native"; +import { createBetterSqliteDatabase, type BetterSqliteDatabase, type BetterSqliteStatement } from "@ccr/core/storage/sqlite-native"; type MaybePromise = T | Promise; export type SqliteValue = bigint | Buffer | number | string | Uint8Array | null; diff --git a/src/main/plugins/service.ts b/packages/core/src/plugins/service.ts similarity index 85% rename from src/main/plugins/service.ts rename to packages/core/src/plugins/service.ts index 16da667..05fc11c 100644 --- a/src/main/plugins/service.ts +++ b/packages/core/src/plugins/service.ts @@ -14,9 +14,9 @@ import type { ProviderAccountMeter, ProviderAccountPluginConnectorConfig, ProviderAccountSnapshot -} from "../../shared/app"; -import { backendService, type RegisteredHttpBackend, type SqliteStore, type SqliteStoreOptions } from "../../server/backend-service"; -import { CONFIGDIR, DATADIR } from "../constants"; +} from "@ccr/core/contracts/app"; +import { backendService, type RegisteredHttpBackend, type SqliteStore, type SqliteStoreOptions } from "@ccr/core/plugins/backend-service"; +import { CONFIGDIR, DATADIR } from "@ccr/core/config/constants"; type MaybePromise = T | Promise; type PluginLogger = { @@ -144,6 +144,18 @@ type LoadedPlugin = { stop?: () => MaybePromise; }; +type PluginServiceStateSnapshot = { + apps: InstalledBrowserApp[]; + coreGatewayConfig: Record; + coreProviderPlugins: unknown[]; + gatewayRoutes: RegisteredGatewayRoute[]; + providerAccountConnectors: Map; + proxyRoutes: RegisteredProxyRoute[]; + resourceOwnerIds: Set; + stopHooks: Array<() => MaybePromise>; + virtualModelProfiles: unknown[]; +}; + const requireFromHere = createRequire(__filename); const builtInMarketplacePluginModules = new Map([ ["claude-design", path.join(__dirname, "..", "marketplace", "plugins", "claude-design-plugin.cjs")], @@ -172,8 +184,14 @@ class GatewayPluginService { if (pluginConfig.enabled === false) { continue; } + const snapshot = this.createStateSnapshot(); this.resourceOwnerIds.add(pluginConfig.id); - await this.loadConfiguredPlugin(pluginConfig); + try { + await this.loadConfiguredPlugin(pluginConfig); + } catch (error) { + await this.rollbackConfiguredPluginLoad(pluginConfig.id, snapshot); + console.warn(`[plugin:${pluginConfig.id}] Disabled after startup failure: ${formatError(error)}`); + } } } @@ -513,6 +531,47 @@ class GatewayPluginService { ): Promise { return backendService.openSqliteStore(pluginId, pluginDataDir, options); } + + private createStateSnapshot(): PluginServiceStateSnapshot { + return { + apps: [...this.apps], + coreGatewayConfig: { ...this.coreGatewayConfig }, + coreProviderPlugins: [...this.coreProviderPlugins], + gatewayRoutes: [...this.gatewayRoutes], + providerAccountConnectors: new Map(this.providerAccountConnectors), + proxyRoutes: [...this.proxyRoutes], + resourceOwnerIds: new Set(this.resourceOwnerIds), + stopHooks: [...this.stopHooks], + virtualModelProfiles: [...this.virtualModelProfiles] + }; + } + + private async rollbackConfiguredPluginLoad(pluginId: string, snapshot: PluginServiceStateSnapshot): Promise { + const newStopHooks = this.stopHooks.slice(snapshot.stopHooks.length).reverse(); + this.apps = snapshot.apps; + this.coreGatewayConfig = snapshot.coreGatewayConfig; + this.coreProviderPlugins = snapshot.coreProviderPlugins; + this.gatewayRoutes = snapshot.gatewayRoutes; + this.providerAccountConnectors = snapshot.providerAccountConnectors; + this.proxyRoutes = snapshot.proxyRoutes; + this.resourceOwnerIds = snapshot.resourceOwnerIds; + this.stopHooks = snapshot.stopHooks; + this.virtualModelProfiles = snapshot.virtualModelProfiles; + + for (const stopHook of newStopHooks) { + try { + await stopHook(); + } catch (error) { + console.warn(`[plugin:${pluginId}] Rollback stop hook failed: ${formatError(error)}`); + } + } + + try { + await backendService.stopOwner(pluginId); + } catch (error) { + console.warn(`[plugin:${pluginId}] Rollback resource cleanup failed: ${formatError(error)}`); + } + } } export const pluginService = new GatewayPluginService(); @@ -523,14 +582,49 @@ async function loadPluginModule(modulePath: string): Promise { } function resolvePluginModule(modulePath: string): string { - const expanded = expandHome(modulePath); + const resolved = requireFromHere.resolve(resolveLocalModulePath(modulePath, "Plugin module")); + assertJavaScriptModulePath(resolved, "Plugin module"); + return resolved; +} + +function resolveLocalModulePath(value: string, label: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`${label} path is required.`); + } + + const expanded = expandHome(trimmed); if (path.isAbsolute(expanded)) { - return requireFromHere.resolve(expanded); + return expanded; } - if (expanded.startsWith(".")) { - return requireFromHere.resolve(path.resolve(CONFIGDIR, expanded)); + if (isProtocolSpecifier(expanded)) { + throw new Error(`${label} must be a local JavaScript file path, not a URL or protocol specifier.`); } - return requireFromHere.resolve(expanded, { paths: [CONFIGDIR, process.cwd()] }); + if (!expanded.startsWith(".")) { + throw new Error(`${label} must be an explicit local JavaScript path. Package specifiers are not loaded from configuration.`); + } + + const resolved = path.resolve(CONFIGDIR, expanded); + if (!isPathInside(resolved, CONFIGDIR)) { + throw new Error(`${label} relative paths must stay inside the CCR config directory.`); + } + return resolved; +} + +function assertJavaScriptModulePath(resolved: string, label: string): void { + const extension = path.extname(resolved).toLowerCase(); + if (![".cjs", ".js", ".mjs"].includes(extension)) { + throw new Error(`${label} must resolve to a JavaScript module file.`); + } +} + +function isProtocolSpecifier(value: string): boolean { + return /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value); +} + +function isPathInside(file: string, root: string): boolean { + const relative = path.relative(root, file); + return relative === "" || (Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative)); } function normalizeLoadedPlugin(moduleValue: unknown): LoadedPlugin { diff --git a/src/main/profile-launch-core.ts b/packages/core/src/profiles/launch-core.ts similarity index 84% rename from src/main/profile-launch-core.ts rename to packages/core/src/profiles/launch-core.ts index b2d2139..ab187fd 100644 --- a/src/main/profile-launch-core.ts +++ b/packages/core/src/profiles/launch-core.ts @@ -1,6 +1,7 @@ import path from "node:path"; -import type { AppConfig, ProfileConfig, ProfileOpenSurface } from "../shared/app"; -import { resolveZcodeConfigFile } from "./zcode-profile-config"; +import type { AppConfig, ProfileConfig, ProfileOpenSurface } from "@ccr/core/contracts/app"; +import { claudeCodeUtcTimezoneEnvOverride } from "@ccr/core/agents/claude-code/environment"; +import { resolveZcodeConfigFile } from "@ccr/core/agents/zcode/profile-config"; export type ProfileLaunchPlan = { args: string[]; @@ -69,14 +70,22 @@ export function resolveProfileOpenSurface(profile: ProfileConfig, surface?: Prof return surfaces[0]; } +export function defaultProfileOpenSurface(profile: Pick): ProfileOpenSurface { + return profile.agent === "zcode" ? "app" : "cli"; +} + export function profileOpenCommand( profile: ProfileConfig, - surface: ProfileOpenSurface = profile.agent === "zcode" ? "app" : "cli", + surface: ProfileOpenSurface = defaultProfileOpenSurface(profile), command = "ccr", profileRef = profile.name?.trim() || profile.id ): string { const quote = process.platform === "win32" ? windowsCommandQuote : shellQuote; - return [quote(command), quote(profileRef), ...(surface === "app" ? ["--app"] : [])].join(" "); + const parts = [quote(command), quote(profileRef)]; + if (surface === "app") { + parts.push(surface); + } + return parts.join(" "); } export function buildProfileLaunchPlan( @@ -172,7 +181,9 @@ function buildClaudeCodeLaunchPlan( command: launcher, env: { CLAUDE_CONFIG_DIR: path.dirname(settingsFile), - CCR_PROFILE_SURFACE: surface + CCR_PROFILE_SURFACE: surface, + ...claudeCodeModelEnv(profile), + ...claudeCodeUtcTimezoneEnvOverride() }, profile, surface @@ -209,6 +220,35 @@ function normalizeProfileSurface(value: ProfileConfig["surface"]): "auto" | "cli return value === "cli" || value === "app" ? value : "auto"; } +function claudeCodeModelEnv(profile: ProfileConfig): Record { + const env: Record = {}; + const model = normalizeClientModel(profile.model); + if (model) { + env.ANTHROPIC_MODEL = model; + env.CCR_CLAUDE_CODE_MODEL = model; + env.CODEXL_CLAUDE_CODE_MODEL = model; + } + const smallFastModel = normalizeClientModel(profile.smallFastModel); + if (smallFastModel) { + env.ANTHROPIC_SMALL_FAST_MODEL = smallFastModel; + } + return env; +} + +function normalizeClientModel(value: string | undefined): string { + const trimmed = value?.trim() || ""; + if (!trimmed) { + return ""; + } + const commaIndex = trimmed.indexOf(","); + if (commaIndex > 0 && commaIndex < trimmed.length - 1) { + const provider = trimmed.slice(0, commaIndex).trim(); + const model = trimmed.slice(commaIndex + 1).trim(); + return provider && model ? `${provider}/${model}` : ""; + } + return trimmed; +} + function isGeneratedProfileScope(value: ProfileConfig["scope"]): boolean { return value === "ccr" || value === "custom"; } diff --git a/src/main/profile-launch-service.ts b/packages/core/src/profiles/launch-service.ts similarity index 68% rename from src/main/profile-launch-service.ts rename to packages/core/src/profiles/launch-service.ts index e1965e8..a0b1e7f 100644 --- a/src/main/profile-launch-service.ts +++ b/packages/core/src/profiles/launch-service.ts @@ -1,24 +1,34 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { AppConfig, ProfileOpenCommandResult, ProfileOpenRequest, ProfileOpenResult, ProfileRuntimeEntry, ProfileRuntimeStatus, ProfileStopResult } from "../shared/app"; -import { botGatewayProfileEnv } from "./bot-gateway-env"; -import { applyClaudeAppGatewayConfig } from "./claude-app-gateway-service"; -import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "./claude-app-launch"; -import { launchCodexAppProfile, launchZcodeAppProfile, refreshCodexCompatibleAppProfileFiles } from "./codex-app-launch"; -import { codexCliMiddlewareRuntimeScript } from "./codex-cli-middleware-runtime"; -import { CONFIGDIR } from "./constants"; -import { gatewayService } from "../server/gateway/service"; -import { buildProfileLaunchPlan, findProfileForOpen, profileLaunchSpawnCommand, profileOpenCommand, resolveClaudeCodeSettingsFile, resolveProfileOpenSurface } from "./profile-launch-core"; -import { applyProfileConfig } from "./profile-service"; -import { broadcastWindowsEnvironmentChanged, windowsSystemCommand } from "./windows-system"; +import { assertAvailableGatewayModels, type AppConfig, type ProfileOpenCommandResult, type ProfileOpenRequest, type ProfileOpenResult, type ProfileRuntimeEntry, type ProfileRuntimeStatus, type ProfileStopResult } from "@ccr/core/contracts/app"; +import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env"; +import { applyClaudeAppGatewayConfig, readClaudeAppGatewayApiKeyCandidates } from "@ccr/core/agents/claude-app/gateway-service"; +import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "@ccr/core/agents/claude-app/launch"; +import { claudeCodeUtcTimezoneEnvOverride } from "@ccr/core/agents/claude-code/environment"; +import { launchCodexAppProfile, launchZcodeAppProfile, refreshCodexCompatibleAppProfileFiles } from "@ccr/core/agents/codex/app-launch"; +import { codexCliMiddlewareRuntimeScript } from "@ccr/core/agents/codex/cli-middleware-runtime"; +import { CONFIGDIR } from "@ccr/core/config/constants"; +import { gatewayService } from "@ccr/core/gateway/service"; +import { TOOL_HUB_MCP_RUNTIME_FILE_NAME, bundledToolHubMcpEntryPathCandidates } from "@ccr/core/mcp/toolhub-config"; +import { buildProfileLaunchPlan, findProfileForOpen, profileLaunchSpawnCommand, profileOpenCommand, resolveClaudeCodeSettingsFile, resolveProfileOpenSurface } from "@ccr/core/profiles/launch-core"; +import { applyProfileConfig, cleanupGeneratedBinBackups } from "@ccr/core/profiles/service"; +import { broadcastWindowsEnvironmentChanged, windowsSystemCommand } from "@ccr/core/platform/windows-system"; const ccrPathBlockStart = "# >>> Claude Code Router CLI >>>"; const ccrPathBlockEnd = "# <<< Claude Code Router CLI <<<"; +export const desktopCliCommandName = "ccr-app"; +const desktopCliRuntimeFileName = "ccr-cli.js"; +const desktopCliCommandNameEnv = "CCR_CLI_COMMAND_NAME"; let claudeAppBotWorker: ChildProcess | undefined; let claudeAppBotWorkerProfileId: string | undefined; +type ProfileOpenCommandOptions = { + commandName?: string; + ensureLauncher?: boolean; +}; + type ProfileAppLaunchResult = { child: ChildProcess; claudeDesignProxy?: boolean; @@ -40,13 +50,16 @@ type RunningProfileApp = ProfileRuntimeEntry & { process.once("exit", () => stopClaudeAppBotWorker()); -export async function getProfileOpenCommand(config: AppConfig, request: ProfileOpenRequest): Promise { +export async function getProfileOpenCommand(config: AppConfig, request: ProfileOpenRequest, options: ProfileOpenCommandOptions = {}): Promise { + assertAvailableGatewayModels(config); await applyProfileConfig(config); const profile = findProfileForOpen(config, request.profileId); const surface = resolveProfileOpenSurface(profile, request.surface); - ensureCcrCliLauncher(); + if (options.ensureLauncher) { + ensureCcrCliLauncher(); + } return { - command: profileOpenCommand(profile, surface, "ccr", commandProfileRef(config, profile)), + command: profileOpenCommand(profile, surface, options.commandName ?? "ccr", commandProfileRef(config, profile)), profileId: profile.id, profileName: profile.name, surface @@ -54,6 +67,7 @@ export async function getProfileOpenCommand(config: AppConfig, request: ProfileO } export async function openProfileFromCcr(config: AppConfig, request: ProfileOpenRequest): Promise { + assertAvailableGatewayModels(config); await applyProfileConfig(config); const profile = findProfileForOpen(config, request.profileId); const surface = resolveProfileOpenSurface(profile, request.surface); @@ -74,7 +88,8 @@ export async function openProfileFromCcr(config: AppConfig, request: ProfileOpen env: { ...process.env, ...plan.env, - ...botGatewayProfileEnv(config, profile, surface) + ...botGatewayProfileEnv(config, profile, surface), + ...(profile.agent === "claude-code" ? claudeCodeUtcTimezoneEnvOverride() : {}) }, stdio: "ignore" }); @@ -156,7 +171,7 @@ async function openClaudeAppProfile(config: AppConfig, profile: ReturnType, - appName: string + appName: string, + options: { reuseExisting?: boolean; startIfMissing?: boolean } = {} ): Promise { const profileGatewayConfig = profileGatewayConfigFor(config, profile); - await ensureGatewayConfigRunning(profileGatewayConfig, appName); - return profileGatewayConfig; + const result = await ensureGatewayConfigRunning(profileGatewayConfig, profile, appName, options, config); + return result.acceptedApiKey && result.acceptedApiKey !== result.config.APIKEY + ? profileGatewayConfigWithToken(result.config, profile, result.acceptedApiKey) + : result.config; } -async function ensureGatewayConfigRunning(config: AppConfig, appName: string): Promise { - const startedStatus = await gatewayService.start(config); - if (startedStatus.state !== "running") { - throw new Error(startedStatus.lastError || `CCR gateway did not start for ${appName}.`); +type EnsureGatewayConfigRunningResult = { + acceptedApiKey?: string; + config: AppConfig; +}; + +async function ensureGatewayConfigRunning( + config: AppConfig, + profile: ReturnType, + appName: string, + options: { reuseExisting?: boolean; startIfMissing?: boolean } = {}, + candidateConfig: AppConfig = config +): Promise { + const startIfMissing = options.startIfMissing !== false; + if (options.reuseExisting) { + const existingGateway = await probeExistingProfileGateway(config, profile, candidateConfig); + if (existingGateway.state === "usable") { + return { acceptedApiKey: existingGateway.apiKey, config }; + } + if (existingGateway.state === "unavailable") { + if (!startIfMissing) { + throw new Error(`CCR gateway is not running at ${profileGatewayEndpoint(config)}. Start CCR Desktop or run ccr start before opening ${appName}.`); + } + } else { + throw new Error(existingGatewayConflictMessage(existingGateway, appName)); + } } + + if (!startIfMissing) { + throw new Error(`CCR gateway is not running at ${profileGatewayEndpoint(config)}. Start CCR Desktop or run ccr start before opening ${appName}.`); + } + + const startedStatus = await gatewayService.start(config); + if (startedStatus.state === "running") { + return { config }; + } + + if (options.reuseExisting && isAddressInUseError(startedStatus.lastError)) { + const existingGateway = await probeExistingProfileGateway(config, profile, candidateConfig); + if (existingGateway.state === "usable") { + return { acceptedApiKey: existingGateway.apiKey, config }; + } + throw new Error(existingGatewayConflictMessage(existingGateway, appName)); + } + + throw new Error(startedStatus.lastError || `CCR gateway did not start for ${appName}.`); +} + +type ExistingProfileGatewayProbe = + | { endpoint: string; reason?: string; state: "unavailable" } + | { endpoint: string; status?: number; state: "not-ccr" } + | { endpoint: string; message?: string; status: number; state: "unauthorized" } + | { endpoint: string; status: number; state: "unusable" } + | { apiKey?: string; endpoint: string; state: "usable" }; + +type ExistingGatewayHttpProbe = { + payload?: unknown; + reason?: string; + status?: number; +}; + +async function probeExistingProfileGateway( + config: AppConfig, + profile: ReturnType, + candidateConfig: AppConfig = config +): Promise { + const endpoint = profileGatewayEndpoint(config); + const root = await fetchExistingGateway(endpoint, "/"); + if (root.status === undefined) { + return { endpoint, reason: root.reason, state: "unavailable" }; + } + + let ccrGateway = isCcrGatewayRoot(root.payload); + if (!ccrGateway) { + const health = await fetchExistingGateway(endpoint, "/health"); + ccrGateway = isCcrGatewayHealth(health.payload); + } + if (!ccrGateway) { + return { endpoint, status: root.status, state: "not-ccr" }; + } + + let lastUnauthorized: ExistingGatewayHttpProbe | undefined; + for (const apiKey of existingGatewayApiKeyCandidates(config, profile, candidateConfig)) { + const headers: Record = { + "user-agent": "Claude Code" + }; + if (apiKey) { + headers.authorization = `Bearer ${apiKey}`; + } + const models = await fetchExistingGateway(endpoint, "/v1/models", { headers }); + if (models.status === 200) { + return { apiKey, endpoint, state: "usable" }; + } + if (models.status === 401 || models.status === 403) { + lastUnauthorized = models; + continue; + } + return { endpoint, status: models.status ?? 0, state: "unusable" }; + } + + if (lastUnauthorized?.status === 401 || lastUnauthorized?.status === 403) { + return { + endpoint, + message: readGatewayErrorMessage(lastUnauthorized.payload), + status: lastUnauthorized.status, + state: "unauthorized" + }; + } + return { endpoint, status: 0, state: "unusable" }; +} + +async function fetchExistingGateway( + endpoint: string, + pathname: string, + init: RequestInit = {} +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 1200); + try { + const response = await fetch(new URL(pathname, endpoint).toString(), { + ...init, + signal: controller.signal + }); + return { + payload: await readResponseJson(response), + status: response.status + }; + } catch (error) { + return { reason: formatError(error) }; + } finally { + clearTimeout(timeout); + } +} + +async function readResponseJson(response: Response): Promise { + const text = await response.text(); + if (!text.trim()) { + return undefined; + } + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} + +function isCcrGatewayRoot(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + return value.name === "claude-code-router" || value.plugin === "claude-code-router"; +} + +function isCcrGatewayHealth(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + return typeof value.core === "string" && typeof value.status === "string" && typeof value.timestamp === "string"; +} + +function readGatewayErrorMessage(value: unknown): string | undefined { + if (!isRecord(value) || !isRecord(value.error)) { + return undefined; + } + return typeof value.error.message === "string" ? value.error.message : undefined; +} + +function existingGatewayApiKeyCandidates( + config: AppConfig, + profile: ReturnType, + candidateConfig: AppConfig +): Array { + const values = [ + config.APIKEY, + ...(Array.isArray(config.APIKEYS) ? config.APIKEYS.map((apiKey) => apiKey.key) : []), + candidateConfig.APIKEY, + ...(Array.isArray(candidateConfig.APIKEYS) ? candidateConfig.APIKEYS.map((apiKey) => apiKey.key) : []), + ...readClaudeAppGatewayApiKeyCandidates(), + ...readClaudeCodeApiKeyHelperCandidates(profile) + ]; + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const key = value?.trim(); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + result.push(key); + } + return result.length > 0 ? result : [undefined]; +} + +function readClaudeCodeApiKeyHelperCandidates(profile: ReturnType): string[] { + const file = path.join(CONFIGDIR, "bin", claudeCodeApiKeyHelperFilename(profile)); + const files = [ + file, + ...readBackupFiles(file) + ]; + return uniqueStrings(files.map(readClaudeCodeApiKeyHelperToken)); +} + +function claudeCodeApiKeyHelperFilename(profile: ReturnType): string { + const slug = sanitizeProfilePathSegment(profile.id || profile.name || profile.agent) || "claude-code"; + return process.platform === "win32" + ? `ccr-claude-code-api-key-${slug}.cmd` + : `ccr-claude-code-api-key-${slug}`; +} + +function readBackupFiles(file: string): string[] { + const dir = path.dirname(file); + const prefix = `${path.basename(file)}.ccr-backup-`; + try { + return readdirSync(dir) + .filter((entry) => entry.startsWith(prefix)) + .sort() + .reverse() + .map((entry) => path.join(dir, entry)); + } catch { + return []; + } +} + +function readClaudeCodeApiKeyHelperToken(file: string): string { + if (!existsSync(file)) { + return ""; + } + try { + const content = readFileSync(file, "utf8"); + for (const line of content.split(/\r?\n/g)) { + const token = parseClaudeCodeApiKeyHelperLine(line); + if (token) { + return token; + } + } + } catch { + return ""; + } + return ""; +} + +function parseClaudeCodeApiKeyHelperLine(line: string): string { + const trimmed = line.trim(); + const shellPrefix = "printf '%s\\n' "; + if (trimmed.startsWith(shellPrefix)) { + return unquoteShellValue(trimmed.slice(shellPrefix.length).trim()); + } + if (/^echo\s+/i.test(trimmed)) { + return trimmed.replace(/^echo\s+/i, "").trim().replace(/^"|"$/g, ""); + } + return ""; +} + +function unquoteShellValue(value: string): string { + if (value.startsWith("'") && value.endsWith("'")) { + return value.slice(1, -1).replace(/'\\''/g, "'"); + } + return value.replace(/^"|"$/g, ""); +} + +function existingGatewayConflictMessage(probe: ExistingProfileGatewayProbe, appName: string): string { + if (probe.state === "unauthorized") { + const details = probe.message ? ` ${probe.message}` : ""; + return `CCR gateway is already running at ${probe.endpoint}, but it does not accept the API key for ${appName}.${details} Restart CCR Desktop or run ccr start to refresh the gateway before opening this profile.`; + } + if (probe.state === "unusable") { + return `CCR gateway is already running at ${probe.endpoint}, but it cannot serve ${appName} right now (HTTP ${probe.status}). Restart CCR Desktop or run ccr start to refresh the gateway before opening this profile.`; + } + if (probe.state === "not-ccr") { + return `Port ${probe.endpoint} is already in use by a non-CCR service. Stop that process or change the CCR gateway port.`; + } + if (probe.state === "unavailable") { + return `CCR gateway is not reachable at ${probe.endpoint}${probe.reason ? `: ${probe.reason}` : ""}.`; + } + return `CCR gateway is already running at ${probe.endpoint}.`; +} + +function isAddressInUseError(message: string | undefined): boolean { + return /\bEADDRINUSE\b/i.test(message || ""); +} + +function profileGatewayEndpoint(config: AppConfig): string { + const host = probeGatewayHost(config.gateway.host); + return `http://${formatEndpointHost(host)}:${config.gateway.port}/`; +} + +function probeGatewayHost(host: string): string { + if (!host || host === "0.0.0.0") { + return "127.0.0.1"; + } + if (host === "::") { + return "::1"; + } + return host; +} + +function formatEndpointHost(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; } function profileGatewayConfigFor(config: AppConfig, profile: ReturnType): AppConfig { @@ -201,6 +511,10 @@ function profileGatewayConfigFor(config: AppConfig, profile: ReturnType, token: string): AppConfig { return { ...config, APIKEY: token, @@ -211,11 +525,7 @@ function profileGatewayConfigFor(config: AppConfig, profile: ReturnType existsSync(candidate)); + if (!source) { + return; + } + writeFileIfChanged(file, readFileSync(source, "utf8")); + chmodSafe(file); +} + function posixCcrLauncher(runtimeFile: string): string { + const nodePath = bundledNodePath(); return [ "#!/bin/sh", + `${desktopCliCommandNameEnv}=${shQuote(desktopCliCommandName)}`, + `export ${desktopCliCommandNameEnv}`, + `CCR_CLI_NODE_PATH=${shQuote(nodePath)}`, + 'if [ -n "$NODE_PATH" ]; then', + ' export NODE_PATH="$CCR_CLI_NODE_PATH:$NODE_PATH"', + "else", + ' export NODE_PATH="$CCR_CLI_NODE_PATH"', + "fi", 'if [ -n "$CCR_NODE_BIN" ]; then', ` exec "$CCR_NODE_BIN" ${shQuote(runtimeFile)} "$@"`, "fi", - "if command -v node >/dev/null 2>&1; then", - ` exec node ${shQuote(runtimeFile)} "$@"`, - "fi", `ELECTRON_RUN_AS_NODE=1 exec ${shQuote(process.execPath)} ${shQuote(runtimeFile)} "$@"` ].join("\n") + "\n"; } function windowsCcrLauncher(runtimeFile: string): string { + const nodePath = bundledNodePath(); return [ "@echo off", "setlocal", + `set "${desktopCliCommandNameEnv}=${desktopCliCommandName}"`, `set "CCR_CLI_RUNTIME=${cmdEnvValue(runtimeFile)}"`, + `set "CCR_CLI_NODE_PATH=${cmdEnvValue(nodePath)}"`, + "if defined NODE_PATH (", + " set \"NODE_PATH=%CCR_CLI_NODE_PATH%;%NODE_PATH%\"", + ") else (", + " set \"NODE_PATH=%CCR_CLI_NODE_PATH%\"", + ")", "if defined CCR_NODE_BIN (", ' "%CCR_NODE_BIN%" "%CCR_CLI_RUNTIME%" %*', " exit /b %ERRORLEVEL%", ")", - "where node >nul 2>nul", - "if %ERRORLEVEL%==0 (", - ' node "%CCR_CLI_RUNTIME%" %*', - " exit /b %ERRORLEVEL%", - ")", "set \"ELECTRON_RUN_AS_NODE=1\"", `${cmdQuote(process.execPath)} "%CCR_CLI_RUNTIME%" %*`, "exit /b %ERRORLEVEL%" ].join("\r\n") + "\r\n"; } +function bundledNodePath(): string { + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + const candidates = [ + path.join(__dirname, "..", "..", "node_modules"), + ...(resourcesPath + ? [ + path.join(resourcesPath, "app.asar", "node_modules"), + path.join(resourcesPath, "app.asar.unpacked", "node_modules"), + path.join(resourcesPath, "app", "node_modules") + ] + : []), + path.join(process.cwd(), "node_modules") + ]; + return uniqueStrings(candidates).join(path.delimiter); +} + +function uniqueStrings(values: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + if (!value || seen.has(value)) { + continue; + } + seen.add(value); + result.push(value); + } + return result; +} + function writeFileIfChanged(file: string, content: string): void { if (existsSync(file) && readFileSync(file, "utf8") === content) { return; @@ -980,7 +1364,7 @@ function shellRcPathBlock(): string { const binDir = "$HOME/.claude-code-router/bin"; return [ ccrPathBlockStart, - "# Added by Claude Code Router. Enables the ccr command in new shells.", + "# Added by Claude Code Router. Enables the ccr-app command in new shells.", 'case ":$PATH:" in', ` *":${binDir}:"*) ;;`, ` *) export PATH="${binDir}:$PATH" ;;`, @@ -1013,7 +1397,7 @@ function ensureFishPathBlock(file: string, binDir: string): void { function fishPathBlock(): string { return [ ccrPathBlockStart, - "# Added by Claude Code Router. Enables the ccr command in new shells.", + "# Added by Claude Code Router. Enables the ccr-app command in new shells.", 'set -l ccr_bin "$HOME/.claude-code-router/bin"', "if not contains $ccr_bin $PATH", " set -gx PATH $ccr_bin $PATH", diff --git a/src/main/profile-service.ts b/packages/core/src/profiles/service.ts similarity index 55% rename from src/main/profile-service.ts rename to packages/core/src/profiles/service.ts index 68b5888..7376260 100644 --- a/src/main/profile-service.ts +++ b/packages/core/src/profiles/service.ts @@ -1,20 +1,39 @@ import { randomBytes } from "node:crypto"; -import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, enforceSingleEnabledGlobalProfilePerAgent, type ApiKeyConfig, type AppConfig, type ProfileApplyResult, type ProfileClientApplyStatus, type ProfileClientKind, type ProfileConfig } from "../shared/app"; -import { replacePersistedApiKeys } from "./api-key-store"; -import { botGatewayProfileEnv } from "./bot-gateway-env"; -import { codexCliMiddlewareRuntimeScript } from "./codex-cli-middleware-runtime"; -import { codexModelCatalogJson } from "./codex-model-catalog"; -import { CONFIGDIR } from "./constants"; -import { resolveZcodeConfigFile, writeZcodeGatewayConfig, zcodeHomeFromConfigFile } from "./zcode-profile-config"; -import { normalizeRouteSelector } from "../server/gateway/claude-code-router-plugin"; +import { CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, enforceSingleEnabledGlobalProfilePerAgent, hasAvailableGatewayModels, type ApiKeyConfig, type AppConfig, type ProfileApplyResult, type ProfileClientApplyStatus, type ProfileClientKind, type ProfileConfig } from "@ccr/core/contracts/app"; +import { replacePersistedApiKeys } from "@ccr/core/config/api-key-store"; +import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env"; +import { + CLAUDE_CODE_MCP_CONFIG_ENV, + CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV, + claudeCodeMcpConfigEnv, + claudeCodeUtcTimezoneEnvOverride +} from "@ccr/core/agents/claude-code/environment"; +import { writeCodexCompatibleAppModelCatalog } from "@ccr/core/agents/codex/app-launch"; +import { codexCliMiddlewareRuntimeScript } from "@ccr/core/agents/codex/cli-middleware-runtime"; +import { codexModelCatalogJson } from "@ccr/core/agents/codex/model-catalog"; +import { CONFIGDIR } from "@ccr/core/config/constants"; +import { resolveZcodeConfigFile, writeZcodeGatewayConfig, zcodeHomeFromConfigFile } from "@ccr/core/agents/zcode/profile-config"; +import { normalizeRouteSelector } from "@ccr/core/gateway/claude-code-router-plugin"; +import { + TOOL_HUB_MCP_RUNTIME_FILE_NAME, + TOOL_HUB_MCP_SERVER_NAME, + bundledToolHubMcpEntryPathCandidates, + toolHubClaudeCodeMcpConfig, + toolHubMcpRuntimeConfig, + type ToolHubMcpRuntimeConfig +} from "@ccr/core/mcp/toolhub-config"; const managedRootStart = "# BEGIN CCR managed profile"; const managedRootEnd = "# END CCR managed profile"; const managedProviderStart = "# BEGIN CCR managed Codex provider"; const managedProviderEnd = "# END CCR managed Codex provider"; +const managedToolHubMcpStart = "# BEGIN CCR managed ToolHub MCP"; +const managedToolHubMcpEnd = "# END CCR managed ToolHub MCP"; +const originalBackupSuffix = ".ccr-original"; +const originalMissingSuffix = ".ccr-original-missing"; const fallbackClientToken = "ccr-local"; const privateDirMode = 0o700; const privateExecutableMode = 0o700; @@ -22,15 +41,46 @@ const privateFileMode = 0o600; const publicExecutableMode = 0o755; export async function applyProfileConfig(config: AppConfig): Promise { + cleanupGeneratedBinBackups(); const appliedAt = new Date().toISOString(); const profiles = profileEntries(config); - const profileApiKeys = await ensureProfileApiKeys(config, profiles); const result: ProfileApplyResult = { appliedAt, clients: [], enabled: profiles.some((profile) => profile.enabled) }; + if (!result.enabled) { + result.clients = profiles.map(disabledProfileStatus); + return result; + } + + if (!hasAvailableGatewayModels(config)) { + const managedCleanupResult = cleanupManagedClaudeCodeToolHubArtifacts(profiles, { includeActive: true }); + result.clients = profiles.map((profile) => { + const cleanupResult = profile.agent === "claude-code" + ? cleanupClaudeCodeToolHubArtifacts(profile) + : { ok: true }; + const status = profile.enabled + ? unavailableModelStatus(profile, profilePath(profile)) + : disabledProfileStatus(profile); + const cleanupMessage = [managedCleanupResult, cleanupResult] + .filter((item) => !item.ok) + .map((item) => item.message) + .filter(Boolean) + .join("; "); + return cleanupMessage + ? { + ...status, + message: `${status.message} Failed to clean stale ToolHub config: ${cleanupMessage}` + } + : status; + }); + return result; + } + + const profileApiKeys = await ensureProfileApiKeys(config, profiles); + for (const profile of profiles) { const token = profileApiKeys.get(profile.id) ?? fallbackClientToken; result.clients.push( @@ -41,20 +91,157 @@ export async function applyProfileConfig(config: AppConfig): Promise profile.agent === "claude-code") + .map((profile) => normalizedFileKey(claudeCodeToolHubMcpConfigFile(profile)))); + const activeGeneratedSettingsFiles = new Set(profiles + .filter((profile) => profile.agent === "claude-code" && isGeneratedProfileScope(profile.scope)) + .map((profile) => normalizedFileKey(resolveClaudeCodeSettingsFile(profile)))); + const errors: string[] = []; + let changed = false; + + for (const file of managedClaudeCodeToolHubMcpConfigFiles()) { + if (!options.includeActive && activeToolHubFiles.has(normalizedFileKey(file))) { + continue; + } + try { + rmSync(file, { force: true }); + changed = true; + } catch (error) { + errors.push(`${file}: ${formatError(error)}`); + } + } + + for (const file of managedClaudeCodeSettingsFiles()) { + if (!options.includeActive && activeGeneratedSettingsFiles.has(normalizedFileKey(file))) { + continue; + } + try { + changed = cleanupClaudeCodeToolHubSettingsFile(file, { backup: false }).changed || changed; + } catch (error) { + errors.push(`${file}: ${formatError(error)}`); + } + } + + return errors.length > 0 + ? { changed, message: errors.join("; "), ok: false } + : { changed, ok: true }; +} + +function managedClaudeCodeToolHubMcpConfigFiles(): string[] { + return managedClaudeCodeGeneratedFiles("toolhub-mcp.json"); +} + +function managedClaudeCodeSettingsFiles(): string[] { + return managedClaudeCodeGeneratedFiles("settings.json"); +} + +function managedClaudeCodeGeneratedFiles(fileName: string): string[] { + const profilesDir = path.join(CONFIGDIR, "profiles"); + let entries; + try { + entries = readdirSync(profilesDir, { withFileTypes: true }); + } catch { + return []; + } + const files: string[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const profileDir = path.join(profilesDir, entry.name); + for (const file of [ + path.join(profileDir, "claude", fileName), + path.join(profileDir, "custom", "claude", fileName) + ]) { + if (existsSync(file)) { + files.push(file); + } + } + } + return files; +} + +function normalizedFileKey(file: string): string { + const normalized = path.resolve(file); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; +} + +function cleanupClaudeCodeToolHubSettingsFile(file: string, options: { backup: boolean }): { changed: boolean } { + const settings = readJsonObject(file); + const env = isRecord(settings.env) ? { ...settings.env } : {}; + if (!deleteClaudeCodeToolHubEnv(env)) { + return { changed: false }; + } + const content = `${JSON.stringify({ ...settings, env }, null, 2)}\n`; + const writeResult = options.backup + ? writeFileWithBackup(file, content, { mode: privateFileMode }) + : writeGeneratedFileIfChanged(file, content, { mode: privateFileMode }); + return { changed: writeResult.changed }; +} + +function deleteClaudeCodeToolHubEnv(env: Record): boolean { + let changed = false; + for (const key of [CLAUDE_CODE_MCP_CONFIG_ENV, CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV]) { + if (key in env) { + delete env[key]; + changed = true; + } + } + return changed; +} + +function cleanupClaudeCodeToolHubArtifacts(profile: ProfileConfig): { changed?: boolean; message?: string; ok: boolean } { + try { + let changed = false; + const mcpConfigFile = claudeCodeToolHubMcpConfigFile(profile); + if (existsSync(mcpConfigFile)) { + rmSync(mcpConfigFile, { force: true }); + changed = true; + } + changed = cleanupClaudeCodeToolHubSettingsFile(resolveClaudeCodeSettingsFile(profile), { backup: true }).changed || changed; + return { changed, ok: true }; + } catch (error) { + return { + message: formatError(error), + ok: false + }; + } +} + +export function applyProfileRuntimeConfig(config: AppConfig, profile: ProfileConfig, token: string): ProfileClientApplyStatus { + cleanupGeneratedBinBackups(); + const appliedAt = new Date().toISOString(); + return profile.agent === "claude-code" + ? applyClaudeCodeProfile(config, profile, token, appliedAt) + : profile.agent === "zcode" + ? applyZcodeProfile(config, profile, token, appliedAt) + : applyCodexProfile(config, profile, token, appliedAt); +} + function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token: string, appliedAt: string): ProfileClientApplyStatus { const settingsFile = resolveClaudeCodeSettingsFile(profile); if (!profile.enabled) { - return disabledStatus("claude-code", settingsFile, "Claude Code profile is disabled."); + return restoreDisabledGlobalProfile(profile, settingsFile, "Claude Code profile is disabled.", isManagedClaudeCodeSettingsContent); } try { const endpoint = gatewayEndpoint(config); const settings = readJsonObject(settingsFile); + const settingsEnv = withoutBotGatewayEnv(Object.fromEntries(stringRecord(settings.env))); + delete settingsEnv[CLAUDE_CODE_MCP_CONFIG_ENV]; + delete settingsEnv[CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV]; const env = { - ...withoutBotGatewayEnv(Object.fromEntries(stringRecord(settings.env))), + ...settingsEnv, ...profileEnv(profile) }; env.ANTHROPIC_BASE_URL = endpoint; @@ -63,25 +250,32 @@ function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token delete env.ANTHROPIC_AUTH_TOKEN; delete env.ANTHROPIC_API_KEY; if (profile.model.trim()) { - env.ANTHROPIC_MODEL = normalizeClientModel(profile.model); + const model = normalizeClientModel(profile.model); + env.ANTHROPIC_MODEL = model; + env.CCR_CLAUDE_CODE_MODEL = model; + env.CODEXL_CLAUDE_CODE_MODEL = model; } else { delete env.ANTHROPIC_MODEL; + delete env.CCR_CLAUDE_CODE_MODEL; + delete env.CODEXL_CLAUDE_CODE_MODEL; } if (profile.smallFastModel?.trim()) { env.ANTHROPIC_SMALL_FAST_MODEL = normalizeClientModel(profile.smallFastModel); } else { delete env.ANTHROPIC_SMALL_FAST_MODEL; } + const toolHubMcpConfigResult = writeClaudeCodeToolHubMcpConfig(config, profile, token); + Object.assign(env, claudeCodeMcpConfigEnv(toolHubMcpConfigResult.file), claudeCodeUtcTimezoneEnvOverride()); const helperResult = writeClaudeCodeApiKeyHelper(profile, token); - const wrapperResult = writeClaudeCodeWrapper(config, profile); + const wrapperResult = writeClaudeCodeWrapper(config, profile, helperResult.file, toolHubMcpConfigResult.file); const nextSettings = { ...settings, apiKeyHelper: process.platform === "win32" ? `"${helperResult.file}"` : helperResult.file, env }; const writeResult = writeFileWithBackup(settingsFile, `${JSON.stringify(nextSettings, null, 2)}\n`, { mode: privateFileMode }); - const changed = writeResult.changed || helperResult.changed || wrapperResult.changed; + const changed = writeResult.changed || helperResult.changed || wrapperResult.changed || toolHubMcpConfigResult.changed; return { appliedAt, backupFile: writeResult.backupFile ?? helperResult.backupFile ?? wrapperResult.backupFile, @@ -108,7 +302,12 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str const clientName = codexCompatibleClientName(profile.agent); const configFile = resolveCodexConfigFile(profile); if (!profile.enabled) { - return disabledStatus(profile.agent, configFile, `${clientName} profile is disabled.`); + return restoreDisabledGlobalProfile( + profile, + configFile, + `${clientName} profile is disabled.`, + (content) => isManagedCodexConfigContent(content, sanitizeCodexProviderId(profile.providerId || "") || "claude-code-router") + ); } try { @@ -120,7 +319,9 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str const configFormat = normalizeCodexConfigFormat(profile.configFormat); const modelCatalogFile = codexModelCatalogFile(configFile); const modelCatalogResult = writeFileWithBackup(modelCatalogFile, codexModelCatalogJson(config, model)); + const appModelCatalogResult = writeCodexCompatibleAppModelCatalog(CONFIGDIR, { ...profile, model }, config); const showAllSessions = profile.agent === "zcode" ? false : Boolean(profile.showAllSessions); + const toolHubMcpResult = writeCodexToolHubMcpRuntimeConfig(config, token); const nextConfig = buildCodexConfigToml(source, { baseUrl: endpoint, modelCatalogFile, @@ -129,7 +330,8 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str providerId, providerName, showAllSessions, - token + token, + toolHubMcp: toolHubMcpResult.runtime }); const writeResult = writeFileWithBackup(configFile, nextConfig, { mode: privateFileMode }); const separateProfileResult = maybeWriteSeparateCodexProfileFile(configFile, source, { @@ -147,9 +349,16 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str providerId }) : undefined; - const changed = writeResult.changed || modelCatalogResult.changed || Boolean(separateProfileResult?.changed) || Boolean(middlewareResult?.changed); + const changed = writeResult.changed || + modelCatalogResult.changed || + appModelCatalogResult.changed || + toolHubMcpResult.changed || + Boolean(separateProfileResult?.changed) || + Boolean(middlewareResult?.changed); const extras = [ modelCatalogFile ? `catalog ${modelCatalogFile}` : "", + appModelCatalogResult.file ? `app catalog ${appModelCatalogResult.file}` : "", + toolHubMcpResult.file ? `toolhub runtime ${toolHubMcpResult.file}` : "", separateProfileResult?.file ? `profile ${separateProfileResult.file}` : "", middlewareResult?.file ? `middleware ${middlewareResult.file}` : "" ].filter(Boolean); @@ -178,7 +387,7 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str function applyZcodeProfile(config: AppConfig, profile: ProfileConfig, token: string, appliedAt: string): ProfileClientApplyStatus { const configFile = resolveZcodeConfigFile(profile); if (!profile.enabled) { - return disabledStatus("zcode", configFile, "ZCode profile is disabled."); + return restoreDisabledZcodeProfile(profile, configFile); } try { @@ -295,6 +504,64 @@ function resolveClaudeCodeSettingsFile(profile: ProfileConfig): string { return resolveUserPath(profile.settingsFile || "~/.claude/settings.json"); } +function claudeCodeToolHubMcpConfigFile(profile: ProfileConfig): string { + return path.join(ccrManagedProfileDir(profile), "claude", "toolhub-mcp.json"); +} + +function writeClaudeCodeToolHubMcpConfig(config: AppConfig, profile: ProfileConfig, token: string): { changed: boolean; file?: string } { + const file = claudeCodeToolHubMcpConfigFile(profile); + const entryPath = path.join(CONFIGDIR, "bin", TOOL_HUB_MCP_RUNTIME_FILE_NAME); + const mcpConfig = toolHubClaudeCodeMcpConfig(config, { + entryPath, + resolver: { + apiKey: token, + baseUrl: `${gatewayEndpoint(config).replace(/\/+$/g, "")}/v1`, + model: toolHubResolverModel(config) + } + }); + if (!mcpConfig) { + if (existsSync(file)) { + rmSync(file, { force: true }); + return { changed: true }; + } + return { changed: false }; + } + + const runtimeResult = ensureToolHubMcpRuntimeFile(entryPath); + const writeResult = writeGeneratedFileIfChanged(file, `${JSON.stringify(mcpConfig, null, 2)}\n`, { mode: privateFileMode }); + return { changed: runtimeResult.changed || writeResult.changed, file }; +} + +function writeCodexToolHubMcpRuntimeConfig(config: AppConfig, token: string): { changed: boolean; file?: string; runtime?: ToolHubMcpRuntimeConfig } { + const entryPath = path.join(CONFIGDIR, "bin", TOOL_HUB_MCP_RUNTIME_FILE_NAME); + const runtime = toolHubMcpRuntimeConfig(config, undefined, { + entryPath, + resolver: { + apiKey: token, + baseUrl: `${gatewayEndpoint(config).replace(/\/+$/g, "")}/v1`, + model: toolHubResolverModel(config) + } + }); + if (!runtime) { + return { changed: false }; + } + + const runtimeResult = ensureToolHubMcpRuntimeFile(entryPath); + return { + changed: runtimeResult.changed, + file: entryPath, + runtime + }; +} + +function ensureToolHubMcpRuntimeFile(file: string): { changed: boolean } { + const source = bundledToolHubMcpEntryPathCandidates().find((candidate) => existsSync(candidate)); + if (!source) { + throw new Error(`ToolHub MCP runtime was not found. Rebuild or reinstall CCR and try again. Checked: ${bundledToolHubMcpEntryPathCandidates().join(", ")}`); + } + return writeGeneratedFileIfChanged(file, readFileSync(source, "utf8"), { mode: publicExecutableMode }); +} + function resolveCodexConfigFile(profile: ProfileConfig): string { if (profile.agent === "zcode") { return resolveZcodeConfigFile(profile); @@ -334,11 +601,14 @@ function buildCodexConfigToml( providerName: string; showAllSessions: boolean; token: string; + toolHubMcp?: ToolHubMcpRuntimeConfig; } ): string { let content = removeManagedBlock(source, managedRootStart, managedRootEnd); content = removeManagedBlock(content, managedProviderStart, managedProviderEnd); + content = removeManagedBlock(content, managedToolHubMcpStart, managedToolHubMcpEnd); content = removeCodexProviderTable(content, values.providerId); + content = removeCodexMcpServerTable(content, TOOL_HUB_MCP_SERVER_NAME); if (values.configFormat === "separate_profile_files") { content = removeCodexProfileTable(content, values.providerId); } @@ -367,8 +637,29 @@ function buildCodexConfigToml( managedProviderEnd, "" ].join("\n"); + const toolHubMcpBlock = buildCodexToolHubMcpBlock(values.toolHubMcp); - return `${rootBlock}${trimLeadingBlankLines(cleanedRoot)}${restSource}${providerBlock}`.replace(/\n{4,}/g, "\n\n\n"); + return `${rootBlock}${trimLeadingBlankLines(cleanedRoot)}${restSource}${providerBlock}${toolHubMcpBlock}`.replace(/\n{4,}/g, "\n\n\n"); +} + +function buildCodexToolHubMcpBlock(runtime: ToolHubMcpRuntimeConfig | undefined): string { + if (!runtime) { + return ""; + } + + const serverTable = `mcp_servers.${tomlKey(TOOL_HUB_MCP_SERVER_NAME)}`; + return [ + "", + managedToolHubMcpStart, + `[${serverTable}]`, + `command = ${tomlString(runtime.command)}`, + `args = ${tomlStringArray(runtime.args)}`, + "", + `[${serverTable}.env]`, + ...Object.entries(runtime.env).map(([key, value]) => `${tomlKey(key)} = ${tomlString(value)}`), + managedToolHubMcpEnd, + "" + ].join("\n"); } function maybeWriteSeparateCodexProfileFile( @@ -425,12 +716,8 @@ function writeClaudeCodeApiKeyHelper(profile: ProfileConfig, token: string): { b const content = process.platform === "win32" ? claudeCodeApiKeyHelperCmdScript(token) : claudeCodeApiKeyHelperShellScript(token); - const writeResult = writeFileWithBackup(file, content, { mode: privateExecutableMode }); - if (process.platform !== "win32") { - chmodSync(file, privateExecutableMode); - } + const writeResult = writeGeneratedFileIfChanged(file, content, { mode: privateExecutableMode }); return { - backupFile: writeResult.backupFile, changed: writeResult.changed, file }; @@ -459,24 +746,17 @@ function claudeCodeApiKeyHelperCmdScript(token: string): string { ].join("\r\n"); } -function writeClaudeCodeWrapper(config: AppConfig, profile: ProfileConfig): { backupFile?: string; changed: boolean; file: string } { +function writeClaudeCodeWrapper(config: AppConfig, profile: ProfileConfig, apiKeyHelperFile: string, mcpConfigFile: string | undefined): { backupFile?: string; changed: boolean; file: string } { const binDir = path.join(CONFIGDIR, "bin"); mkdirSync(binDir, { mode: privateDirMode, recursive: true }); const runtimeFile = path.join(binDir, codexMiddlewareRuntimeFilename()); - const runtimeResult = writeFileWithBackup(runtimeFile, codexCliMiddlewareRuntimeScript()); - if (process.platform !== "win32") { - chmodSync(runtimeFile, publicExecutableMode); - } + const runtimeResult = writeGeneratedFileIfChanged(runtimeFile, codexCliMiddlewareRuntimeScript(), { mode: publicExecutableMode }); const file = path.join(binDir, claudeCodeWrapperFilename(profile)); const content = process.platform === "win32" - ? claudeCodeWrapperCmdScript(config, profile, runtimeFile) - : claudeCodeWrapperShellScript(config, profile, runtimeFile); - const writeResult = writeFileWithBackup(file, content, { mode: privateExecutableMode }); - if (process.platform !== "win32") { - chmodSync(file, privateExecutableMode); - } + ? claudeCodeWrapperCmdScript(config, profile, runtimeFile, apiKeyHelperFile, mcpConfigFile) + : claudeCodeWrapperShellScript(config, profile, runtimeFile, apiKeyHelperFile, mcpConfigFile); + const writeResult = writeGeneratedFileIfChanged(file, content, { mode: privateExecutableMode }); return { - backupFile: writeResult.backupFile ?? runtimeResult.backupFile, changed: writeResult.changed || runtimeResult.changed, file }; @@ -489,9 +769,11 @@ function claudeCodeWrapperFilename(profile: ProfileConfig): string { : `ccr-claude-code-wrapper-${slug}`; } -function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string): string { +function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string, apiKeyHelperFile: string, mcpConfigFile: string | undefined): string { const realClaude = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude"; const surface = normalizeProfileSurface(profile.surface); + const remoteEndpoint = `${gatewayEndpoint(config)}/__ccr/remote`; + const settingsDir = path.dirname(resolveClaudeCodeSettingsFile(profile)); const envExports = Object.entries(profileEnv(profile)) .filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN") .map(([key, value]) => `export ${key}=${shellQuote(value)}`); @@ -499,20 +781,31 @@ function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig, return [ "#!/bin/sh", ...envExports, + ...shellEnvExports(claudeCodeRuntimeEnv(config, profile, settingsDir)), + ...shellEnvExports(claudeCodeMcpConfigEnv(mcpConfigFile)), + ...shellEnvExports(claudeCodeUtcTimezoneEnvOverride()), `: "\${CCR_PROFILE_SURFACE:=${surface}}"`, "export CCR_PROFILE_SURFACE", ...botEnvExports, `export CCR_CLAUDE_CODE_WRAPPER=1`, `export CCR_REAL_CLAUDE_CODE_BIN=${shellQuote(realClaude)}`, `export CODEXL_CLAUDE_CODE_BIN=${shellQuote(realClaude)}`, + `if [ -z "\${CCR_REMOTE_SYNC_ENABLED:-}" ]; then CCR_REMOTE_SYNC_ENABLED=1; fi`, + `if [ -z "\${CCR_REMOTE_SYNC_ENDPOINT:-}" ]; then CCR_REMOTE_SYNC_ENDPOINT=${shellQuote(remoteEndpoint)}; fi`, + `if [ -z "\${CCR_REMOTE_SYNC_API_KEY_HELPER:-}" ]; then CCR_REMOTE_SYNC_API_KEY_HELPER=${shellQuote(apiKeyHelperFile)}; fi`, + `if [ -z "\${CCR_REMOTE_SYNC_PROFILE_ID:-}" ]; then CCR_REMOTE_SYNC_PROFILE_ID=${shellQuote(profile.id || profile.name || "claude-code")}; fi`, + `if [ -z "\${CCR_REMOTE_SYNC_PROFILE_NAME:-}" ]; then CCR_REMOTE_SYNC_PROFILE_NAME=${shellQuote(profile.name || profile.id || "Claude Code")}; fi`, + "export CCR_REMOTE_SYNC_ENABLED CCR_REMOTE_SYNC_ENDPOINT CCR_REMOTE_SYNC_API_KEY_HELPER CCR_REMOTE_SYNC_PROFILE_ID CCR_REMOTE_SYNC_PROFILE_NAME", ...nodeRuntimeShellExecLines(runtimeFile), "" ].join("\n"); } -function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string): string { +function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string, apiKeyHelperFile: string, mcpConfigFile: string | undefined): string { const realClaude = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude"; const surface = normalizeProfileSurface(profile.surface); + const remoteEndpoint = `${gatewayEndpoint(config)}/__ccr/remote`; + const settingsDir = path.dirname(resolveClaudeCodeSettingsFile(profile)); const envExports = Object.entries(profileEnv(profile)) .filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN") .map(([key, value]) => cmdSetLine(key, value)); @@ -520,11 +813,19 @@ function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, r return [ "@echo off", ...envExports, + ...cmdEnvExports(claudeCodeRuntimeEnv(config, profile, settingsDir)), + ...cmdEnvExports(claudeCodeMcpConfigEnv(mcpConfigFile)), + ...cmdEnvExports(claudeCodeUtcTimezoneEnvOverride()), `if not defined CCR_PROFILE_SURFACE ${cmdSetLine("CCR_PROFILE_SURFACE", surface)}`, ...botEnvExports, cmdSetLine("CCR_CLAUDE_CODE_WRAPPER", "1"), cmdSetLine("CCR_REAL_CLAUDE_CODE_BIN", realClaude), cmdSetLine("CODEXL_CLAUDE_CODE_BIN", realClaude), + `if not defined CCR_REMOTE_SYNC_ENABLED ${cmdSetLine("CCR_REMOTE_SYNC_ENABLED", "1")}`, + `if not defined CCR_REMOTE_SYNC_ENDPOINT ${cmdSetLine("CCR_REMOTE_SYNC_ENDPOINT", remoteEndpoint)}`, + `if not defined CCR_REMOTE_SYNC_API_KEY_HELPER ${cmdSetLine("CCR_REMOTE_SYNC_API_KEY_HELPER", apiKeyHelperFile)}`, + `if not defined CCR_REMOTE_SYNC_PROFILE_ID ${cmdSetLine("CCR_REMOTE_SYNC_PROFILE_ID", profile.id || profile.name || "claude-code")}`, + `if not defined CCR_REMOTE_SYNC_PROFILE_NAME ${cmdSetLine("CCR_REMOTE_SYNC_PROFILE_NAME", profile.name || profile.id || "Claude Code")}`, ...nodeRuntimeCmdExecLines(runtimeFile), "" ].join("\r\n"); @@ -544,24 +845,39 @@ function writeCodexCliMiddleware( const binDir = path.join(CONFIGDIR, "bin"); mkdirSync(binDir, { mode: privateDirMode, recursive: true }); const runtimeFile = path.join(binDir, codexMiddlewareRuntimeFilename()); - const runtimeResult = writeFileWithBackup(runtimeFile, codexCliMiddlewareRuntimeScript()); - if (process.platform !== "win32") { - chmodSync(runtimeFile, publicExecutableMode); - } + const runtimeResult = writeGeneratedFileIfChanged(runtimeFile, codexCliMiddlewareRuntimeScript(), { mode: publicExecutableMode }); const file = path.join(binDir, codexMiddlewareFilename(profile, values.providerId)); const content = process.platform === "win32" ? codexMiddlewareCmdScript(config, profile, values, runtimeFile) : codexMiddlewareShellScript(config, profile, values, runtimeFile); - const writeResult = writeFileWithBackup(file, content, { mode: privateExecutableMode }); - if (process.platform !== "win32") { - chmodSync(file, privateExecutableMode); - } + const writeResult = writeGeneratedFileIfChanged(file, content, { mode: privateExecutableMode }); return { changed: writeResult.changed || runtimeResult.changed, file }; } +function claudeCodeRuntimeEnv(config: AppConfig, profile: ProfileConfig, settingsDir: string): Record { + const endpoint = gatewayEndpoint(config); + const env: Record = { + ANTHROPIC_API_BASE_URL: endpoint, + ANTHROPIC_BASE_URL: endpoint, + CLAUDE_AGENT_API_BASE_URL: endpoint, + CLAUDE_CONFIG_DIR: settingsDir + }; + const model = normalizeClientModel(profile.model); + if (model) { + env.ANTHROPIC_MODEL = model; + env.CCR_CLAUDE_CODE_MODEL = model; + env.CODEXL_CLAUDE_CODE_MODEL = model; + } + const smallFastModel = normalizeClientModel(profile.smallFastModel); + if (smallFastModel) { + env.ANTHROPIC_SMALL_FAST_MODEL = smallFastModel; + } + return env; +} + function codexMiddlewareRuntimeFilename(): string { return "ccr-codex-cli-middleware.js"; } @@ -806,6 +1122,10 @@ function shellBotGatewayEnvExports(config: AppConfig, profile: ProfileConfig): s ]; } +function shellEnvExports(env: Record): string[] { + return Object.entries(env).map(([key, value]) => `export ${key}=${shellQuote(value)}`); +} + function cmdBotGatewayEnvExports(config: AppConfig, profile: ProfileConfig): string[] { return [ `if /I "%CCR_PROFILE_SURFACE%"=="app" (`, @@ -816,6 +1136,10 @@ function cmdBotGatewayEnvExports(config: AppConfig, profile: ProfileConfig): str ]; } +function cmdEnvExports(env: Record): string[] { + return Object.entries(env).map(([key, value]) => cmdSetLine(key, value)); +} + function withoutBotGatewayEnv(values: Record): Record { return Object.fromEntries(Object.entries(values).filter(([key]) => !isBotGatewayEnvKey(key))); } @@ -841,6 +1165,31 @@ function removeCodexProfileTable(source: string, providerId: string): string { return removeTomlTable(source, "profiles", providerId); } +function removeCodexMcpServerTable(source: string, serverName: string): string { + const lines = source.split(/(?<=\n)/); + const headers = new Set([ + `[mcp_servers.${serverName}]`, + `[mcp_servers.${tomlQuotedKey(serverName)}]`, + `[mcp_servers.${serverName}.env]`, + `[mcp_servers.${tomlQuotedKey(serverName)}.env]` + ]); + const kept: string[] = []; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (!headers.has(line.trim())) { + kept.push(line); + continue; + } + + index += 1; + while (index < lines.length && !/^\s*\[/.test(lines[index])) { + index += 1; + } + index -= 1; + } + return kept.join(""); +} + function removeTomlTable(source: string, section: string, name: string): string { const lines = source.split(/(?<=\n)/); const headers = new Set([ @@ -920,6 +1269,7 @@ function writeFileWithBackup( chmodFileIfRequested(file, options.mode); return { changed: false }; } + ensureOriginalSnapshot(file, previous, options.mode); const backupFile = previous === undefined ? undefined : backupFilePath(file); if (backupFile) { copyFileSync(file, backupFile); @@ -930,6 +1280,321 @@ function writeFileWithBackup( return { backupFile, changed: true }; } +function writeGeneratedFileIfChanged( + file: string, + content: string, + options: { mode?: number } = {} +): { changed: boolean } { + mkdirSync(path.dirname(file), { recursive: true }); + const previous = existsSync(file) ? readFileSync(file, "utf8") : undefined; + if (previous === content) { + chmodFileIfRequested(file, options.mode); + return { changed: false }; + } + writeFileSync(file, content, options.mode === undefined ? "utf8" : { encoding: "utf8", mode: options.mode }); + chmodFileIfRequested(file, options.mode); + return { changed: true }; +} + +export function cleanupGeneratedBinBackups(configDir = CONFIGDIR): number { + const binDir = path.join(configDir, "bin"); + let entries: string[]; + try { + entries = readdirSync(binDir); + } catch { + return 0; + } + + let removed = 0; + for (const entry of entries) { + const baseName = generatedBinBackupBaseName(entry); + if (!baseName || !isManagedGeneratedBinFile(baseName)) { + continue; + } + try { + rmSync(path.join(binDir, entry), { force: true }); + removed += 1; + } catch { + // Cleanup is best effort; stale backups should never block profile launch. + } + } + return removed; +} + +function generatedBinBackupBaseName(entry: string): string | undefined { + const backupMarker = ".ccr-backup-"; + const backupIndex = entry.indexOf(backupMarker); + if (backupIndex !== -1) { + return entry.slice(0, backupIndex); + } + for (const suffix of [originalMissingSuffix, originalBackupSuffix]) { + if (entry.endsWith(suffix)) { + return entry.slice(0, -suffix.length); + } + } + return undefined; +} + +function isManagedGeneratedBinFile(fileName: string): boolean { + const normalized = fileName.replace(/\.cmd$/i, ""); + return normalized === "ccr" || + normalized === "ccr-app" || + normalized === "ccr-cli.js" || + normalized === TOOL_HUB_MCP_RUNTIME_FILE_NAME || + normalized === codexMiddlewareRuntimeFilename() || + normalized.startsWith("ccr-claude-code-api-key-") || + normalized.startsWith("ccr-claude-code-wrapper-") || + normalized.startsWith("ccr-codex-cli-stdio-"); +} + +type RestoreFileResult = { + backupFile?: string; + changed: boolean; + file: string; + missingBackup: boolean; + restored: boolean; +}; + +function restoreDisabledGlobalProfile( + profile: ProfileConfig, + file: string, + disabledMessage: string, + isManagedContent: (content: string) => boolean +): ProfileClientApplyStatus { + if (!isGlobalProfile(profile)) { + return disabledStatus(profile.agent, file, disabledMessage); + } + + const restoreResult = restoreGlobalConfigFile(file, { isManagedContent, mode: privateFileMode }); + return disabledRestoreStatus(profile.agent, file, disabledMessage, restoreResult, profile.name || profile.id || profile.agent); +} + +function disabledProfileStatus(profile: ProfileConfig): ProfileClientApplyStatus { + if (profile.agent === "claude-code") { + return restoreDisabledGlobalProfile(profile, resolveClaudeCodeSettingsFile(profile), "Claude Code profile is disabled.", isManagedClaudeCodeSettingsContent); + } + if (profile.agent === "zcode") { + return restoreDisabledZcodeProfile(profile, resolveZcodeConfigFile(profile)); + } + const providerId = sanitizeCodexProviderId(profile.providerId || "") || "claude-code-router"; + return restoreDisabledGlobalProfile( + profile, + resolveCodexConfigFile(profile), + "Codex profile is disabled.", + (content) => isManagedCodexConfigContent(content, providerId) + ); +} + +export function restoreInactiveGlobalProfileConfigs(profiles: ProfileConfig[]): ProfileClientApplyStatus[] { + const statuses: ProfileClientApplyStatus[] = []; + if (!profiles.some((profile) => profile.agent === "claude-code" && profile.enabled && isGlobalProfile(profile))) { + for (const file of uniqueResolvedPaths([ + "~/.claude/settings.json", + ...profiles + .filter((profile) => profile.agent === "claude-code") + .map((profile) => profile.settingsFile || "") + .filter(Boolean) + ])) { + const restoreResult = restoreGlobalConfigFile(file, { + isManagedContent: isManagedClaudeCodeSettingsContent, + mode: privateFileMode + }); + if (restoreResult.changed || restoreResult.missingBackup) { + statuses.push(inactiveGlobalCleanupStatus("claude-code", file, restoreResult)); + } + } + } + return statuses; +} + +function inactiveGlobalCleanupStatus( + client: ProfileClientKind, + file: string, + restoreResult: RestoreFileResult +): ProfileClientApplyStatus { + return { + backupFile: restoreResult.backupFile, + client, + enabled: false, + message: restoreResult.missingBackup + ? `No active global ${codexCompatibleClientName(client)} profile is configured, but the global config is managed by CCR and no original backup was found.` + : `${codexCompatibleClientName(client)} global config was restored because no active global profile is configured.`, + ok: !restoreResult.missingBackup, + path: resolveUserPath(file) + }; +} + +function uniqueResolvedPaths(paths: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const item of paths) { + const resolved = resolveUserPath(item); + const key = process.platform === "win32" ? resolved.toLowerCase() : resolved; + if (seen.has(key)) { + continue; + } + seen.add(key); + result.push(resolved); + } + return result; +} + +function restoreDisabledZcodeProfile(profile: ProfileConfig, configFile: string): ProfileClientApplyStatus { + const disabledMessage = "ZCode profile is disabled."; + if (!isGlobalProfile(profile)) { + return disabledStatus("zcode", configFile, disabledMessage); + } + + const providerId = sanitizeCodexProviderId(profile.providerId || "") || "claude-code-router"; + const storageRoot = zcodeHomeFromConfigFile(configFile); + const files = [ + configFile, + path.join(storageRoot, "v2", "config.json"), + path.join(storageRoot, "v2", "bots-model-cache.v2.json") + ]; + const results = files.map((file) => + restoreGlobalConfigFile(file, { + isManagedContent: (content) => isManagedZcodeConfigContent(content, providerId), + mode: privateFileMode + }) + ); + const changed = results.some((result) => result.changed); + const restored = results.some((result) => result.restored); + const missingBackup = results.some((result) => result.missingBackup); + return { + backupFile: results.find((result) => result.backupFile)?.backupFile, + client: "zcode", + enabled: false, + message: missingBackup + ? `${disabledMessage} No original ZCode config backup was found for ${profile.name || profile.id || "this profile"}.` + : restored + ? changed + ? "ZCode config was restored from the CCR backup because the global profile is disabled." + : "ZCode config already matches the CCR backup; profile is disabled." + : disabledMessage, + ok: !missingBackup, + path: resolveUserPath(configFile) + }; +} + +function disabledRestoreStatus( + client: ProfileClientKind, + file: string, + disabledMessage: string, + restoreResult: RestoreFileResult, + profileName: string +): ProfileClientApplyStatus { + return { + backupFile: restoreResult.backupFile, + client, + enabled: false, + message: restoreResult.missingBackup + ? `${disabledMessage} No original ${codexCompatibleClientName(client)} config backup was found for ${profileName}.` + : restoreResult.restored + ? restoreResult.changed + ? `${codexCompatibleClientName(client)} config was restored from the CCR backup because the global profile is disabled.` + : `${codexCompatibleClientName(client)} config already matches the CCR backup; profile is disabled.` + : disabledMessage, + ok: !restoreResult.missingBackup, + path: resolveUserPath(file) + }; +} + +function restoreGlobalConfigFile( + file: string, + options: { + isManagedContent: (content: string) => boolean; + mode?: number; + } +): RestoreFileResult { + const current = existsSync(file) ? readFileSync(file, "utf8") : undefined; + const currentManaged = current !== undefined && options.isManagedContent(current); + if (current !== undefined && !currentManaged) { + return { changed: false, file, missingBackup: false, restored: false }; + } + + if (existsSync(originalMissingFilePath(file))) { + if (currentManaged) { + const backupFile = backupCurrentConfigFile(file, options.mode); + rmSync(file, { force: true }); + return { backupFile, changed: true, file, missingBackup: false, restored: true }; + } + return { changed: false, file, missingBackup: false, restored: current === undefined }; + } + + const snapshot = originalSnapshotCandidate(file, options.isManagedContent); + if (!snapshot) { + return { + changed: false, + file, + missingBackup: Boolean(currentManaged), + restored: false + }; + } + + if (current === snapshot.content) { + chmodFileIfRequested(file, options.mode); + return { changed: false, file, missingBackup: false, restored: true }; + } + + const backupFile = current === undefined ? undefined : backupCurrentConfigFile(file, options.mode); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, snapshot.content, options.mode === undefined ? "utf8" : { encoding: "utf8", mode: options.mode }); + chmodFileIfRequested(file, options.mode); + return { backupFile, changed: true, file, missingBackup: false, restored: true }; +} + +function originalSnapshotCandidate( + file: string, + isManagedContent: (content: string) => boolean +): { content: string; file: string } | undefined { + for (const candidate of [originalBackupFilePath(file), ...backupFiles(file)]) { + if (!existsSync(candidate)) { + continue; + } + const content = readFileSync(candidate, "utf8"); + if (!isManagedContent(content)) { + return { content, file: candidate }; + } + } + return undefined; +} + +function backupCurrentConfigFile(file: string, mode: number | undefined): string { + const backupFile = backupFilePath(file); + copyFileSync(file, backupFile); + chmodFileIfRequested(backupFile, mode); + return backupFile; +} + +function backupFiles(file: string): string[] { + const dir = path.dirname(file); + const prefix = `${path.basename(file)}.ccr-backup-`; + try { + return readdirSync(dir) + .filter((entry) => entry.startsWith(prefix)) + .sort() + .map((entry) => path.join(dir, entry)); + } catch { + return []; + } +} + +function ensureOriginalSnapshot(file: string, previous: string | undefined, mode: number | undefined): void { + const originalBackup = originalBackupFilePath(file); + const originalMissing = originalMissingFilePath(file); + if (existsSync(originalBackup) || existsSync(originalMissing)) { + return; + } + if (previous === undefined) { + writeFileSync(originalMissing, "", "utf8"); + chmodFileIfRequested(originalMissing, mode); + return; + } + copyFileSync(file, originalBackup); + chmodFileIfRequested(originalBackup, mode); +} + function chmodFileIfRequested(file: string, mode: number | undefined): void { if (mode === undefined || process.platform === "win32") { return; @@ -946,6 +1611,14 @@ function backupFilePath(file: string): string { return `${file}.ccr-backup-${timestamp}`; } +function originalBackupFilePath(file: string): string { + return `${file}${originalBackupSuffix}`; +} + +function originalMissingFilePath(file: string): string { + return `${file}${originalMissingSuffix}`; +} + function disabledStatus(client: ProfileClientKind, file: string, message: string): ProfileClientApplyStatus { return { client, @@ -956,6 +1629,81 @@ function disabledStatus(client: ProfileClientKind, file: string, message: string }; } +function unavailableModelStatus(profile: ProfileConfig, file: string): ProfileClientApplyStatus { + return { + client: profile.agent, + enabled: true, + message: NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, + ok: false, + path: resolveUserPath(file) + }; +} + +function disabledProfileMessage(profile: ProfileConfig): string { + if (profile.agent === "claude-code") { + return "Claude Code profile is disabled."; + } + return `${codexCompatibleClientName(profile.agent)} profile is disabled.`; +} + +function isGlobalProfile(profile: ProfileConfig): boolean { + return normalizeProfileScope(profile.scope) === "global"; +} + +function isManagedClaudeCodeSettingsContent(content: string): boolean { + const settings = parseJsonContent(content); + if (!settings) { + return false; + } + const apiKeyHelper = typeof settings.apiKeyHelper === "string" ? settings.apiKeyHelper : ""; + if (apiKeyHelper.includes("ccr-claude-code-api-key-")) { + return true; + } + const env = isRecord(settings.env) ? settings.env : {}; + return typeof env.ANTHROPIC_BASE_URL === "string" && + typeof env.ANTHROPIC_API_BASE_URL === "string" && + typeof env.CLAUDE_AGENT_API_BASE_URL === "string"; +} + +function isManagedCodexConfigContent(content: string, providerId: string): boolean { + if (content.includes(managedRootStart) || content.includes(managedProviderStart)) { + return true; + } + const escapedProvider = escapeRegExp(providerId); + return new RegExp(`^\\s*\\[model_providers\\.(?:${escapedProvider}|${escapeRegExp(tomlQuotedKey(providerId))})\\]`, "m").test(content); +} + +function isManagedZcodeConfigContent(content: string, providerId: string): boolean { + const config = parseJsonContent(content); + if (!config) { + return false; + } + if (isRecord(config.provider) && hasOwn(config.provider, providerId)) { + return true; + } + if (isRecord(config.model) && typeof config.model.main === "string" && config.model.main.startsWith(`${providerId}/`)) { + return true; + } + for (const key of ["defaultModel", "lastUsed", "lastUsedModel"]) { + const modelRef = config[key]; + if (isRecord(modelRef) && modelRef.providerId === providerId) { + return true; + } + } + return Array.isArray(config.providers) && config.providers.some((provider) => + isRecord(provider) && provider.id === providerId + ); +} + +function parseJsonContent(content: string): Record | undefined { + try { + const parsed = JSON.parse(content) as unknown; + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + function gatewayEndpoint(config: AppConfig): string { const host = config.gateway.host === "0.0.0.0" ? "127.0.0.1" : config.gateway.host || "127.0.0.1"; const formattedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; @@ -963,10 +1711,6 @@ function gatewayEndpoint(config: AppConfig): string { } function defaultClientModel(config: AppConfig): string { - const configuredDefault = normalizeClientModel(config.Router.default); - if (configuredDefault) { - return configuredDefault; - } const preferred = config.Providers.find((provider) => provider.name === config.preferredProvider) ?? config.Providers[0]; if (preferred?.name && preferred.models[0]) { return `${preferred.name}/${preferred.models[0]}`; @@ -974,6 +1718,30 @@ function defaultClientModel(config: AppConfig): string { return "gpt-5-codex"; } +function toolHubResolverModel(config: AppConfig): string { + const model = config.toolHub.llm.model.trim(); + if (!model) { + return ""; + } + if (model.includes("/")) { + return model; + } + const baseUrl = normalizeUrlForMatch(config.toolHub.llm.baseUrl); + const provider = config.Providers.find((candidate) => + candidate.models.includes(model) && + (!baseUrl || normalizeUrlForMatch(providerBaseUrl(candidate)) === baseUrl) + ) ?? config.Providers.find((candidate) => candidate.models.includes(model)); + return provider?.name ? `${provider.name}/${model}` : model; +} + +function providerBaseUrl(provider: AppConfig["Providers"][number]): string { + return provider.api_base_url || provider.baseUrl || provider.baseurl || ""; +} + +function normalizeUrlForMatch(value: string | undefined): string { + return (value || "").trim().replace(/\/+$/g, ""); +} + function normalizeClientModel(value: string | undefined): string { return normalizeRouteSelector(value)?.trim() || ""; } @@ -1018,6 +1786,9 @@ function normalizeProfileSurface(value: ProfileConfig["surface"]): "auto" | "cli } function codexCompatibleClientName(agent: ProfileConfig["agent"]): string { + if (agent === "claude-code") { + return "Claude Code"; + } return agent === "zcode" ? "ZCode" : "Codex"; } @@ -1072,6 +1843,10 @@ function tomlString(value: string): string { return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r")}"`; } +function tomlStringArray(values: string[]): string { + return `[${values.map((value) => tomlString(value)).join(", ")}]`; +} + function tomlStringContent(value: string): string { return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r"); } @@ -1109,6 +1884,10 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function hasOwn(value: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/src/main/provider-account-service.ts b/packages/core/src/providers/account-service.ts similarity index 71% rename from src/main/provider-account-service.ts rename to packages/core/src/providers/account-service.ts index 52b5478..adeb61d 100644 --- a/src/main/provider-account-service.ts +++ b/packages/core/src/providers/account-service.ts @@ -1,11 +1,12 @@ -import { createHash } from "node:crypto"; -import { loadAppConfig } from "./config"; -import { localAgentProviderApiKey, readCodexAuth } from "./local-agent-provider-service"; -import { pluginService } from "./plugins/service"; -import { getUsageTotalsSince } from "./usage-store"; -import { findProviderPresetByBaseUrl, providerEndpointCanReceiveProviderApiKey } from "./presets"; -import { fetchWithSystemProxy } from "./system-proxy-fetch"; -import { normalizeProviderBaseUrl, providerUrlWithDefaultScheme } from "../shared/provider-url"; +import { createHash, randomUUID } from "node:crypto"; +import { loadAppConfig } from "@ccr/core/config/config"; +import { attachCodexRateLimitResetCreditDetails } from "@ccr/core/agents/local-providers/codex"; +import { localAgentProviderApiKey, readCodexAuth } from "@ccr/core/agents/local-providers/service"; +import { pluginService } from "@ccr/core/plugins/service"; +import { getUsageTotalsSince } from "@ccr/core/usage/store"; +import { findProviderPresetByBaseUrl, providerEndpointCanReceiveProviderApiKey } from "@ccr/core/providers/presets/index"; +import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch"; +import { normalizeProviderBaseUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; import type { AppConfig, GatewayProviderConfig, @@ -18,19 +19,24 @@ import type { ProviderAccountLocalEstimateConnectorConfig, ProviderAccountLocalWindowConfig, ProviderAccountMappedMeterConfig, + ProviderAccountMappedNumberExpression, + ProviderAccountMappedStringExpression, ProviderAccountMeter, + ProviderAccountMeterDetail, ProviderAccountMeterKind, ProviderAccountMeterUnit, ProviderAccountPluginConnectorConfig, ProviderAccountSnapshot, ProviderAccountSnapshotRequestOptions, + ProviderAccountResetRequest, + ProviderAccountResetResult, ProviderAccountTestPath, ProviderAccountTestRequest, ProviderAccountTestResult, ProviderAccountStandardConnectorConfig, ProviderCredentialConfig, ProviderAccountStatus -} from "../shared/app"; +} from "@ccr/core/contracts/app"; type CacheEntry = { expiresAt: number; @@ -63,6 +69,7 @@ const maxErrorRefreshIntervalMs = 60 * 1000; const maxStaleAccountSnapshotMs = 2 * 60 * 1000; const maxCacheEntries = 500; const standardAccountPaths = ["/.well-known/ccr/account", "/v1/account/limits"]; +const codexRateLimitResetCreditConsumeEndpoint = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume"; const cache = new Map(); const inFlightRefreshes = new Map>(); let cacheGeneration = 0; @@ -82,9 +89,13 @@ export async function getProviderAccountSnapshots( }); const snapshots = await Promise.all( - providers.flatMap((provider) => - providerAccountTargets(provider).map((target) => resolveProviderAccountSnapshot(config, target, options)) - ) + providers.flatMap((provider) => { + const targets = providerAccountTargets(provider); + if (targets.length > 0) { + return targets.map((target) => resolveProviderAccountSnapshot(config, target, options)); + } + return providerAccountUnavailableSnapshots(provider).map((snapshot) => Promise.resolve(snapshot)); + }) ); return snapshots.filter((snapshot): snapshot is ProviderAccountSnapshot => Boolean(snapshot)); } @@ -125,9 +136,38 @@ export async function testProviderAccountConnector(request: ProviderAccountTestR type: "http-json" }; const payload = await fetchJson(connector.endpoint, provider, connector.auth, connector.headers, connector.method, connector.body); - const meters = connector.mapping.meters - .map((meter) => mappedMeterFromPayload(meter, payload)) - .filter((meter): meter is ProviderAccountMeter => Boolean(meter)); + if (connector.parser === "kimi-code-usages") { + const meters = kimiCodeUsageMeters(payload); + return { + meters, + message: meters.length === 0 ? "No usage data available." : undefined, + paths: flattenJsonPaths(payload), + payload, + status: statusFromMeters(meters, [], 1) + }; + } + if (connector.parser === "new-api-key-usage") { + const meters = newApiKeyUsageMeters(payload); + return { + meters, + message: meters.length === 0 ? newApiKeyUsageFallbackMessage(payload) : readMappedString(connector.mapping.message, payload), + paths: flattenJsonPaths(payload), + payload, + status: statusFromMeters(meters, [], 1) + }; + } + if (connector.parser === "new-api-user-self") { + const meters = newApiUserSelfMeters(payload); + return { + meters, + message: meters.length === 0 ? readMappedString(connector.mapping.message, payload) ?? "No user balance data available." : readMappedString(connector.mapping.message, payload), + paths: flattenJsonPaths(payload), + payload, + status: statusFromMeters(meters, [], 1) + }; + } + + const meters = mappedMetersFromPayload(connector, payload); return { meters, @@ -138,6 +178,54 @@ export async function testProviderAccountConnector(request: ProviderAccountTestR }; } +export function newApiKeyUsageMetersForTest(payload: unknown): ProviderAccountMeter[] { + return newApiKeyUsageMeters(payload); +} + +export function newApiKeyUsageFallbackMessageForTest(payload: unknown): string { + return newApiKeyUsageFallbackMessage(payload); +} + +export function newApiUserSelfMetersForTest(payload: unknown): ProviderAccountMeter[] { + return newApiUserSelfMeters(payload); +} + +export async function resetCodexRateLimitCredit(request: ProviderAccountResetRequest): Promise { + const providerName = request.provider?.trim(); + const creditId = request.creditId?.trim(); + if (!providerName) { + throw new Error("Provider is required."); + } + if (!creditId) { + throw new Error("Reset credit id is required."); + } + + const config = await loadAppConfig(); + const provider = codexResetProvider(config, providerName, request.credentialId); + const materialized = materializeProviderAccountRequest(config, provider); + const payload = await fetchJson( + codexRateLimitResetCreditConsumeEndpoint, + materialized.provider, + "provider-api-key", + { + "User-Agent": "codex-cli", + ...(materialized.headers ?? {}) + }, + "POST", + { + credit_id: creditId, + redeem_request_id: randomUUID() + } + ); + + invalidateProviderAccountSnapshotCache(providerName); + return { + code: isRecord(payload) ? readString(payload.code) : undefined, + creditId, + ok: true + }; +} + async function resolveProviderAccountSnapshot( config: AppConfig, target: ProviderAccountTarget, @@ -298,6 +386,80 @@ function providerAccountTargets(provider: GatewayProviderConfig): ProviderAccoun return providerAccount && providerApiKey(provider) ? [{ account: providerAccount, provider }] : []; } +function codexResetProvider(config: AppConfig, providerName: string, credentialId: string | undefined): GatewayProviderConfig { + const normalizedProviderName = normalizeProviderName(providerName); + const provider = config.Providers.find((candidate) => normalizeProviderName(candidate.name) === normalizedProviderName); + if (!provider) { + throw new Error("Provider account was not found."); + } + + const target = providerAccountTargets(provider).find((candidate) => { + const candidateCredentialId = candidate.credential ? providerCredentialRuntimeId(candidate.provider, candidate.credential) : undefined; + return !credentialId || candidateCredentialId === credentialId; + }); + if (!target) { + throw new Error("Provider account credential was not found."); + } + if (!providerAccountHasCodexResetCreditsConnector(target.account)) { + throw new Error("Provider account does not support Codex manual reset credits."); + } + + return target.credential + ? providerWithCredentialApiKey(target.provider, target.credential, target.account) + : { ...target.provider, account: target.account }; +} + +function providerAccountHasCodexResetCreditsConnector(account: ProviderAccountConfig): boolean { + return normalizeConnectors(account).some((connector) => + connector.type === "http-json" && + /\/wham\/rate-limit-reset-credits(?:$|[?#/])/i.test(connector.endpoint.trim()) + ); +} + +function providerAccountUnavailableSnapshots(provider: GatewayProviderConfig): ProviderAccountSnapshot[] { + const credentials = activeProviderCredentials(provider); + if (credentials.length > 0) { + return credentials + .map((credential) => providerAccountUnavailableSnapshot(provider, credential.account ?? provider.account, credential)) + .filter((snapshot): snapshot is ProviderAccountSnapshot => Boolean(snapshot)); + } + + const snapshot = providerAccountUnavailableSnapshot(provider, provider.account); + return snapshot ? [snapshot] : []; +} + +function providerAccountUnavailableSnapshot( + provider: GatewayProviderConfig, + account: ProviderAccountConfig | undefined, + credential?: ProviderCredentialConfig +): ProviderAccountSnapshot | undefined { + const providerName = provider.name.trim(); + if (!providerName || !account?.enabled || !providerAccountConnectorsAreDefaultStandard(account.connectors ?? [])) { + return undefined; + } + if (effectiveProviderAccountConfig(provider, account)) { + return undefined; + } + + const message = "No supported account usage endpoint is available for this provider. Configure an HTTP JSON connector or disable account balance."; + return { + credentialId: credential ? providerCredentialRuntimeId(provider, credential) : undefined, + credentialLabel: credential?.name ?? credential?.label ?? credential?.id, + errors: [ + { + message, + source: "unsupported" + } + ], + message, + meters: [], + provider: providerName, + source: "unsupported", + status: "unsupported", + updatedAt: new Date().toISOString() + }; +} + function effectiveProviderAccount(provider: GatewayProviderConfig): ProviderAccountConfig | undefined { return effectiveProviderAccountConfig(provider, provider.account); } @@ -447,9 +609,35 @@ async function resolveHttpJsonConnector( ...(connector.headers ?? {}), ...(request.headers ?? {}) }, connector.method, connector.body); - const meters = connector.mapping.meters - .map((meter) => mappedMeterFromPayload(meter, payload)) - .filter((meter): meter is ProviderAccountMeter => Boolean(meter)); + if (connector.parser === "kimi-code-usages") { + const meters = kimiCodeUsageMeters(payload); + return { + errors: [], + message: meters.length === 0 ? "No usage data available." : undefined, + meters, + source: "http-json" + }; + } + if (connector.parser === "new-api-key-usage") { + const meters = newApiKeyUsageMeters(payload); + return { + errors: [], + message: meters.length === 0 ? newApiKeyUsageFallbackMessage(payload) : readMappedString(connector.mapping.message, payload), + meters, + source: "http-json" + }; + } + if (connector.parser === "new-api-user-self") { + const meters = newApiUserSelfMeters(payload); + return { + errors: [], + message: meters.length === 0 ? readMappedString(connector.mapping.message, payload) ?? "No user balance data available." : readMappedString(connector.mapping.message, payload), + meters, + source: "http-json" + }; + } + + const meters = mappedMetersFromPayload(connector, payload); return { errors: [], meters, @@ -620,6 +808,256 @@ function normalizeRemoteSnapshot( }; } +function newApiKeyUsageMeters(payload: unknown): ProviderAccountMeter[] { + const meter = newApiKeyUsageMeter(payload); + return meter ? [meter] : []; +} + +function newApiKeyUsageMeter(payload: unknown): ProviderAccountMeter | undefined { + const data = newApiKeyUsageData(payload); + if (!data) { + return undefined; + } + + const unlimited = readBoolean(readJsonRecordValue(data, "unlimited_quota")) === true; + const remaining = normalizeNumber(readJsonRecordValue(data, "total_available")); + const limit = normalizeNumber(readJsonRecordValue(data, "total_granted")); + const used = normalizeNumber(readJsonRecordValue(data, "total_used")); + if (unlimited || (limit === undefined && remaining === undefined && used === undefined)) { + return undefined; + } + + return { + id: "new_api_key_quota", + kind: "quota", + label: "API key quota", + limit, + remaining, + source: "http-json", + unit: "quota", + used + }; +} + +function newApiKeyUsageFallbackMessage(payload: unknown): string { + const data = newApiKeyUsageData(payload); + if (data && readBoolean(readJsonRecordValue(data, "unlimited_quota")) === true) { + return "API key has no dedicated quota limit. Configure a New API user balance connector with an access token and New-Api-User to read account balance."; + } + return readMappedString("$.message", payload) ?? "No API key quota data available."; +} + +function newApiUserSelfMeters(payload: unknown): ProviderAccountMeter[] { + const meter = newApiUserSelfMeter(payload); + return meter ? [meter] : []; +} + +function newApiUserSelfMeter(payload: unknown): ProviderAccountMeter | undefined { + const data = newApiUserSelfData(payload); + if (!data) { + return undefined; + } + + const remaining = normalizeNumber(readJsonRecordValue(data, "quota")); + const used = normalizeNumber(readJsonRecordValue(data, "used_quota")); + if (remaining === undefined && used === undefined) { + return undefined; + } + + return { + id: "new_api_user_balance", + kind: "balance", + label: "User balance", + limit: remaining !== undefined && used !== undefined ? remaining + used : undefined, + remaining, + source: "http-json", + unit: "quota", + used + }; +} + +function newApiKeyUsageData(payload: unknown): Record | undefined { + if (!isRecord(payload)) { + return undefined; + } + const data = readJsonRecordValue(payload, "data"); + return isRecord(data) ? data : payload; +} + +function newApiUserSelfData(payload: unknown): Record | undefined { + if (!isRecord(payload)) { + return undefined; + } + const data = readJsonRecordValue(payload, "data"); + return isRecord(data) ? data : payload; +} + +function kimiCodeUsageMeters(payload: unknown): ProviderAccountMeter[] { + if (!isRecord(payload)) { + return []; + } + + const meters: ProviderAccountMeter[] = []; + const usage = isRecord(payload.usage) ? kimiCodeUsageMeter(payload.usage, "weekly_quota", "Weekly quota") : undefined; + if (usage) { + meters.push(usage); + } + + if (Array.isArray(payload.limits)) { + const seenIds = new Set(meters.map((meter) => meter.id)); + payload.limits.forEach((item, index) => { + if (!isRecord(item)) { + return; + } + const detail = isRecord(item.detail) ? item.detail : item; + const window = isRecord(item.window) ? item.window : {}; + const label = kimiCodeUsageLimitLabel(item, detail, window, index); + const meter = kimiCodeUsageMeter(detail, uniqueKimiCodeUsageMeterId(kimiCodeUsageMeterId(item, detail, label, index), seenIds), label, item); + if (meter) { + seenIds.add(meter.id); + meters.push(meter); + } + }); + } + + return meters; +} + +function kimiCodeUsageMeter( + data: Record, + id: string, + defaultLabel: string, + fallbackData?: Record +): ProviderAccountMeter | undefined { + const limit = normalizeNumber(data.limit); + let used = normalizeNumber(data.used); + let remaining = normalizeNumber(data.remaining); + if (used === undefined && remaining !== undefined && limit !== undefined) { + used = limit - remaining; + } + if (remaining === undefined && used !== undefined && limit !== undefined) { + remaining = limit - used; + } + if (limit === undefined && used === undefined && remaining === undefined) { + return undefined; + } + + const label = readString(data.name) || readString(data.title) || defaultLabel; + const resetAt = kimiCodeUsageResetAt(data) ?? (fallbackData ? kimiCodeUsageResetAt(fallbackData) : undefined); + if (limit !== undefined && limit > 0) { + const remainingRatio = remaining !== undefined + ? Math.max(0, Math.min(1, remaining / limit)) + : used !== undefined + ? Math.max(0, Math.min(1, (limit - used) / limit)) + : undefined; + const remainingPercent = remainingRatio === undefined ? undefined : remainingRatio * 100; + return { + id, + kind: "quota", + label, + limit: 100, + remaining: remainingPercent, + resetAt, + source: "http-json", + unit: "%", + used: remainingPercent === undefined ? undefined : 100 - remainingPercent + }; + } + + return { + id, + kind: "quota", + label, + limit, + remaining, + resetAt, + source: "http-json", + unit: "quota", + used + }; +} + +function kimiCodeUsageLimitLabel( + item: Record, + detail: Record, + window: Record, + index: number +): string { + const named = readString(item.name) || readString(detail.name) || readString(item.title) || readString(detail.title) || readString(item.scope) || readString(detail.scope); + if (named) { + return named; + } + + const duration = normalizeNumber(window.duration) ?? normalizeNumber(item.duration) ?? normalizeNumber(detail.duration); + const timeUnit = (readString(window.timeUnit) || readString(item.timeUnit) || readString(detail.timeUnit) || "").toUpperCase(); + if (duration && duration > 0) { + if (timeUnit.includes("MINUTE")) { + return duration >= 60 && duration % 60 === 0 ? `${duration / 60}h quota` : `${duration}m quota`; + } + if (timeUnit.includes("HOUR")) { + return `${duration}h quota`; + } + if (timeUnit.includes("DAY")) { + return `${duration}d quota`; + } + return `${duration}s quota`; + } + + return `Limit #${index + 1}`; +} + +function kimiCodeUsageMeterId( + item: Record, + detail: Record, + label: string, + index: number +): string { + const explicit = readString(item.id) || readString(detail.id) || readString(item.name) || readString(detail.name) || readString(item.scope) || readString(detail.scope); + return providerCredentialSlug(explicit || label || `limit-${index + 1}`) || `limit-${index + 1}`; +} + +function uniqueKimiCodeUsageMeterId(id: string, seenIds: Set): string { + if (!seenIds.has(id)) { + return id; + } + let index = 2; + while (seenIds.has(`${id}-${index}`)) { + index += 1; + } + return `${id}-${index}`; +} + +function kimiCodeUsageResetAt(data: Record): string | undefined { + for (const key of ["reset_at", "resetAt", "reset_time", "resetTime"]) { + const value = data[key]; + const timestamp = typeof value === "number" + ? value + : typeof value === "string" && /^\d+$/.test(value.trim()) + ? Number(value) + : undefined; + if (timestamp !== undefined && Number.isFinite(timestamp)) { + const milliseconds = timestamp < 1_000_000_000_000 ? timestamp * 1000 : timestamp; + const date = new Date(milliseconds); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); + } + + const dateString = readString(value); + if (dateString) { + const date = new Date(dateString); + return Number.isNaN(date.getTime()) ? dateString : date.toISOString(); + } + } + + for (const key of ["reset_in", "resetIn", "ttl", "window"]) { + const seconds = normalizeNumber(data[key]); + if (seconds && seconds > 0) { + return new Date(Date.now() + seconds * 1000).toISOString(); + } + } + + return undefined; +} + function normalizeRemoteErrors(value: unknown, source: ProviderAccountConnectorSource): ProviderAccountConnectorError[] | undefined { if (!Array.isArray(value)) { return undefined; @@ -669,6 +1107,13 @@ function mappedMeterFromPayload(config: ProviderAccountMappedMeterConfig, payloa }, "http-json"); } +function mappedMetersFromPayload(connector: ProviderAccountHttpJsonConnectorConfig, payload: unknown): ProviderAccountMeter[] { + const meters = connector.mapping.meters + .map((meter) => mappedMeterFromPayload(meter, payload)) + .filter((meter): meter is ProviderAccountMeter => Boolean(meter)); + return attachCodexRateLimitResetCreditDetails(meters, payload); +} + function normalizeMeter(value: unknown, source: ProviderAccountConnectorSource): ProviderAccountMeter | undefined { if (!isRecord(value)) { return undefined; @@ -682,7 +1127,9 @@ function normalizeMeter(value: unknown, source: ProviderAccountConnectorSource): const limit = normalizeNumber(value.limit); const used = normalizeNumber(value.used); const remaining = normalizeNumber(value.remaining) ?? (limit !== undefined && used !== undefined ? limit - used : undefined); + const details = normalizeMeterDetails(value.details); return { + ...(details.length > 0 ? { details } : {}), id, kind: normalizeMeterKind(readString(value.kind)) ?? inferMeterKind(unit), label, @@ -696,6 +1143,31 @@ function normalizeMeter(value: unknown, source: ProviderAccountConnectorSource): }; } +function normalizeMeterDetails(value: unknown): ProviderAccountMeterDetail[] { + if (!Array.isArray(value)) { + return []; + } + return value + .map(normalizeMeterDetail) + .filter((detail): detail is ProviderAccountMeterDetail => Boolean(detail)); +} + +function normalizeMeterDetail(value: unknown): ProviderAccountMeterDetail | undefined { + if (!isRecord(value)) { + return undefined; + } + const detail: ProviderAccountMeterDetail = { + description: readString(value.description), + effectiveAt: readString(value.effectiveAt), + expiresAt: readString(value.expiresAt), + id: readString(value.id), + label: readString(value.label), + redeemable: readBoolean(value.redeemable), + status: readString(value.status) + }; + return detail.description || detail.effectiveAt || detail.expiresAt || detail.id || detail.label || detail.redeemable !== undefined || detail.status ? detail : undefined; +} + function materializeProviderAccountRequest( config: AppConfig, provider: GatewayProviderConfig @@ -773,14 +1245,16 @@ function localAgentProviderPluginMatches(plugin: unknown, provider: GatewayProvi function localCodexAccountCredential(plugin: Record): { apiKey?: string; headers?: Record } { const codexOauth = isRecord(plugin.codexOauth) ? plugin.codexOauth : {}; const codexAuth = readCodexAuth(); + // Imported plugins contain a point-in-time access token. Prefer the live Codex + // auth file so account checks follow tokens refreshed by Codex CLI/App. const apiKey = + codexAuth?.accessToken || readString(codexOauth.accessToken) || - readString(codexOauth.access_token) || - codexAuth?.accessToken; + readString(codexOauth.access_token); const accountId = + codexAuth?.accountId || readString(codexOauth.accountId) || - readString(codexOauth.account_id) || - codexAuth?.accountId; + readString(codexOauth.account_id); const headers = { ...localProviderPluginAuthHeaders(plugin), ...(accountId ? { "ChatGPT-Account-Id": accountId } : {}), @@ -1108,7 +1582,16 @@ type JsonPathFilterCondition = { path: string[]; }; -function readMappedNumber(value: number | string | undefined, payload: unknown): number | undefined { +function readMappedNumber(value: ProviderAccountMappedNumberExpression | undefined, payload: unknown): number | undefined { + if (Array.isArray(value)) { + for (const candidate of value) { + const resolved = readMappedNumber(candidate, payload); + if (resolved !== undefined) { + return resolved; + } + } + return undefined; + } if (typeof value === "number") { return normalizeNumber(value); } @@ -1145,7 +1628,16 @@ function resolveMappedNumberTerm(term: string, payload: unknown): unknown { return trimmed.startsWith("$") ? readJsonPath(payload, trimmed) : trimmed; } -function readMappedString(value: string | undefined, payload: unknown): string | undefined { +function readMappedString(value: ProviderAccountMappedStringExpression | undefined, payload: unknown): string | undefined { + if (Array.isArray(value)) { + for (const candidate of value) { + const resolved = readMappedString(candidate, payload); + if (resolved !== undefined) { + return resolved; + } + } + return undefined; + } if (!value) { return undefined; } @@ -1153,7 +1645,16 @@ function readMappedString(value: string | undefined, payload: unknown): string | return readString(resolved); } -function readMappedDateString(value: string | undefined, payload: unknown): string | undefined { +function readMappedDateString(value: ProviderAccountMappedStringExpression | undefined, payload: unknown): string | undefined { + if (Array.isArray(value)) { + for (const candidate of value) { + const resolved = readMappedDateString(candidate, payload); + if (resolved !== undefined) { + return resolved; + } + } + return undefined; + } if (!value) { return undefined; } @@ -1411,6 +1912,10 @@ function readString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } +function readBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + function joinUrlPath(basePath: string, suffix: string): string { const normalizedBase = basePath === "/" ? "" : basePath.replace(/\/+$/, ""); const normalizedSuffix = suffix.startsWith("/") ? suffix : `/${suffix}`; diff --git a/src/main/provider-icons.ts b/packages/core/src/providers/icons.ts similarity index 97% rename from src/main/provider-icons.ts rename to packages/core/src/providers/icons.ts index c522032..78f2c17 100644 --- a/src/main/provider-icons.ts +++ b/packages/core/src/providers/icons.ts @@ -2,10 +2,10 @@ import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { PROVIDER_ICON_CACHE_DIR } from "./constants"; -import { fetchWithSystemProxy } from "./system-proxy-fetch"; -import type { ProviderIconDetectionRequest, ProviderIconDetectionResult } from "../shared/app"; -import { compactProviderUrl, providerUrlWithDefaultScheme } from "../shared/provider-url"; +import { PROVIDER_ICON_CACHE_DIR } from "@ccr/core/config/constants"; +import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch"; +import type { ProviderIconDetectionRequest, ProviderIconDetectionResult } from "@ccr/core/contracts/app"; +import { compactProviderUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; const faviconServiceUrl = "https://t0.gstatic.com/faviconV2"; const maxProviderIconBytes = 1024 * 1024; diff --git a/src/main/provider-manifest-service.ts b/packages/core/src/providers/manifest-service.ts similarity index 97% rename from src/main/provider-manifest-service.ts rename to packages/core/src/providers/manifest-service.ts index f287364..40bdf89 100644 --- a/src/main/provider-manifest-service.ts +++ b/packages/core/src/providers/manifest-service.ts @@ -1,9 +1,9 @@ import { lookup } from "node:dns/promises"; import https from "node:https"; import net from "node:net"; -import { parseProviderManifestPayload } from "../shared/deep-link"; -import { findProviderPresetByBaseUrl, providerEndpointCanReceiveProviderApiKey, providerIdentitySafetyIssue } from "./presets"; -import { providerUrlWithDefaultScheme } from "../shared/provider-url"; +import { parseProviderManifestPayload } from "@ccr/core/contracts/deep-link"; +import { findProviderPresetByBaseUrl, providerEndpointCanReceiveProviderApiKey, providerIdentitySafetyIssue } from "@ccr/core/providers/presets/index"; +import { providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; import type { GatewayProviderConfig, ProviderAccountConnectorConfig, @@ -12,7 +12,7 @@ import type { ProviderDeepLinkPayload, ProviderManifestFetchRequest, ProviderManifestFetchResult -} from "../shared/app"; +} from "@ccr/core/contracts/app"; type SafeAddress = { address: string; diff --git a/src/main/provider-model-catalog.ts b/packages/core/src/providers/model-catalog.ts similarity index 86% rename from src/main/provider-model-catalog.ts rename to packages/core/src/providers/model-catalog.ts index 55cea18..ef57208 100644 --- a/src/main/provider-model-catalog.ts +++ b/packages/core/src/providers/model-catalog.ts @@ -1,8 +1,7 @@ -import { existsSync, readFileSync } from "node:fs"; -import { resolve as pathResolve } from "node:path"; -import type { ProviderCatalogModelsRequest, ProviderCatalogModelsResult } from "../shared/app"; -import { providerUrlWithDefaultScheme } from "../shared/provider-url"; -import { findProviderPreset, findProviderPresetByBaseUrl } from "./presets"; +import type { ProviderCatalogModelsRequest, ProviderCatalogModelsResult } from "@ccr/core/contracts/app"; +import { providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; +import { loadModelCatalogPayload } from "@ccr/core/models/catalog-file"; +import { findProviderPreset, findProviderPresetByBaseUrl } from "@ccr/core/providers/presets/index"; type CatalogProviderEntry = { apiUrls: string[]; @@ -35,8 +34,10 @@ const presetCatalogProviderIds: Record = { bailian: ["alibaba-cn"], deepseek: ["deepseek"], gemini: ["google"], + "kimi-coding": ["kimi-for-coding"], mistral: ["mistral"], moonshot: ["moonshotai-cn"], + "moonshot-global": ["moonshotai"], openai: ["openai"], openrouter: ["openrouter"], siliconflow: ["siliconflow-cn"], @@ -46,10 +47,29 @@ const presetCatalogProviderIds: Record = { "zhipu-cn-general": ["zhipuai"] }; +const presetCatalogModelOverrides: Record; models: string[]; provider?: string; providerName?: string }> = { + "kimi-coding": { + modelDisplayNames: { + "kimi-for-coding": "K2.7 Code" + }, + models: ["kimi-for-coding"], + provider: "kimi-for-coding", + providerName: "Kimi Code" + } +}; + let catalogIndex: CatalogIndex | undefined; export function getProviderCatalogModels(request: ProviderCatalogModelsRequest): ProviderCatalogModelsResult { const index = loadCatalogIndex(); + const modelOverride = providerCatalogModelOverride(request); + if (modelOverride) { + return { + loadedFrom: index.loadedFrom, + ...modelOverride + }; + } + const match = findBestCatalogProviderMatch(index.providers, request); if (!match) { return { @@ -67,22 +87,47 @@ export function getProviderCatalogModels(request: ProviderCatalogModelsRequest): }; } +function providerCatalogModelOverride(request: ProviderCatalogModelsRequest): ProviderCatalogModelsResult | undefined { + const providerPresetId = request.providerPresetId?.trim() || ""; + const providerPresetOverride = presetCatalogModelOverrides[providerPresetId]; + if (providerPresetOverride) { + return { + matchedBy: "provider-id", + modelDisplayNames: providerPresetOverride.modelDisplayNames, + models: providerPresetOverride.models, + provider: providerPresetOverride.provider, + providerName: providerPresetOverride.providerName + }; + } + + const baseUrlPresetId = request.baseUrl ? findProviderPresetByBaseUrl(request.baseUrl)?.id ?? "" : ""; + const baseUrlOverride = presetCatalogModelOverrides[baseUrlPresetId]; + if (baseUrlOverride) { + return { + matchedBy: "base-url", + modelDisplayNames: baseUrlOverride.modelDisplayNames, + models: baseUrlOverride.models, + provider: baseUrlOverride.provider, + providerName: baseUrlOverride.providerName + }; + } + + return undefined; +} + function loadCatalogIndex(): CatalogIndex { if (catalogIndex) { return catalogIndex; } - for (const candidate of catalogPathCandidates()) { - if (!existsSync(candidate)) { - continue; - } - try { - const payload = JSON.parse(readFileSync(candidate, "utf8")) as unknown; - catalogIndex = buildCatalogIndex(payload, candidate); + try { + const loaded = loadModelCatalogPayload(); + if (loaded) { + catalogIndex = buildCatalogIndex(loaded.payload, loaded.loadedFrom); return catalogIndex; - } catch (error) { - console.warn(`Failed to load provider model catalog from ${candidate}:`, error); } + } catch (error) { + console.warn("Failed to load provider model catalog:", error); } catalogIndex = { @@ -91,17 +136,6 @@ function loadCatalogIndex(): CatalogIndex { return catalogIndex; } -function catalogPathCandidates(): string[] { - return uniqueStrings([ - process.env.CCR_MODEL_CATALOG_PATH?.trim() || "", - process.env.CCR_MODELS_JSON_PATH?.trim() || "", - pathResolve(process.cwd(), "models.json"), - pathResolve(__dirname, "..", "models.json"), - pathResolve(__dirname, "..", "assets", "models.json"), - pathResolve(__dirname, "..", "..", "..", "models.json") - ]); -} - function buildCatalogIndex(payload: unknown, loadedFrom: string): CatalogIndex { const providers = new Map(); const models = isRecord(payload) && Array.isArray(payload.models) ? payload.models : []; diff --git a/packages/core/src/providers/new-api.ts b/packages/core/src/providers/new-api.ts new file mode 100644 index 0000000..44c2609 --- /dev/null +++ b/packages/core/src/providers/new-api.ts @@ -0,0 +1,92 @@ +import type { ProviderAccountConfig, ProviderAccountHttpJsonConnectorConfig } from "@ccr/core/contracts/app"; +import { compactProviderUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; + +export type DetectedProviderKind = "new-api"; + +const newApiHeaderNames = ["x-new-api-version", "x-oneapi-request-id"]; + +export function detectedProviderFromHeaders(headers: Record): DetectedProviderKind | undefined { + return hasNewApiHeaders(headers) ? "new-api" : undefined; +} + +export function hasNewApiHeaders(headers: Record): boolean { + const normalized = new Set(Object.keys(headers).map((key) => key.toLowerCase())); + return newApiHeaderNames.some((header) => normalized.has(header)); +} + +export function newApiKeyUsageAccountConfig(baseUrl: string): ProviderAccountConfig { + return { + connectors: [ + { + auth: "provider-api-key", + endpoint: newApiKeyUsageEndpoint(baseUrl), + mapping: { + message: "$.message", + meters: [ + { + id: "new_api_key_quota", + kind: "quota", + label: "API key quota", + limit: "$.data.total_granted", + remaining: "$.data.total_available", + unit: "quota", + used: "$.data.total_used", + } + ] + }, + method: "GET", + parser: "new-api-key-usage", + type: "http-json" + } + ], + enabled: true + }; +} + +export function newApiKeyUsageEndpoint(baseUrl: string): string { + const root = newApiRootBaseUrl(baseUrl); + return `${root}/api/usage/token/`; +} + +export function newApiUserSelfConnectorConfig(baseUrl: string): ProviderAccountHttpJsonConnectorConfig { + return { + auth: "none", + endpoint: newApiUserSelfEndpoint(baseUrl), + headers: { + Authorization: "Bearer ", + "New-Api-User": "" + }, + mapping: { + meters: [ + { + id: "new_api_user_balance", + kind: "balance", + label: "User balance", + remaining: "$.data.quota", + unit: "quota", + used: "$.data.used_quota" + } + ] + }, + method: "GET", + parser: "new-api-user-self", + type: "http-json" + }; +} + +export function newApiUserSelfEndpoint(baseUrl: string): string { + const root = newApiRootBaseUrl(baseUrl); + return `${root}/api/user/self`; +} + +export function newApiRootBaseUrl(baseUrl: string): string { + try { + const url = new URL(providerUrlWithDefaultScheme(baseUrl.trim())); + url.pathname = url.pathname.replace(/\/+(v1|api)$/i, "").replace(/\/+$/, "") || "/"; + url.search = ""; + url.hash = ""; + return compactProviderUrl(url); + } catch { + return baseUrl.trim().replace(/[?#].*$/, "").replace(/\/+$/, "").replace(/\/(v1|api)$/i, ""); + } +} diff --git a/src/main/presets/anthropic/index.ts b/packages/core/src/providers/presets/anthropic/index.ts similarity index 84% rename from src/main/presets/anthropic/index.ts rename to packages/core/src/providers/presets/anthropic/index.ts index 1408c31..929a717 100644 --- a/src/main/presets/anthropic/index.ts +++ b/packages/core/src/providers/presets/anthropic/index.ts @@ -1,4 +1,4 @@ -import { defaultProviderAccountConfig, type ProviderPreset } from "../../../shared/provider-presets"; +import { defaultProviderAccountConfig, type ProviderPreset } from "@ccr/core/providers/presets/types"; export const anthropicProviderPreset: ProviderPreset = { account: defaultProviderAccountConfig, @@ -14,5 +14,6 @@ export const anthropicProviderPreset: ProviderPreset = { name: "Anthropic", officialApiKeyPatterns: [ { flags: "i", source: "^sk-ant-[a-z0-9_-]+$" } - ] + ], + websiteUrl: "https://www.anthropic.com/" }; diff --git a/src/main/presets/bailian/index.ts b/packages/core/src/providers/presets/bailian/index.ts similarity index 76% rename from src/main/presets/bailian/index.ts rename to packages/core/src/providers/presets/bailian/index.ts index f9c596e..5a21269 100644 --- a/src/main/presets/bailian/index.ts +++ b/packages/core/src/providers/presets/bailian/index.ts @@ -1,4 +1,4 @@ -import { defaultProviderAccountConfig, type ProviderPreset } from "../../../shared/provider-presets"; +import { defaultProviderAccountConfig, type ProviderPreset } from "@ccr/core/providers/presets/types"; export const bailianProviderPreset: ProviderPreset = { account: defaultProviderAccountConfig, @@ -10,5 +10,6 @@ export const bailianProviderPreset: ProviderPreset = { } ], id: "bailian", - name: "Alibaba Bailian" + name: "Alibaba Bailian", + websiteUrl: "https://bailian.console.aliyun.com/" }; diff --git a/packages/core/src/providers/presets/claudeapi/index.ts b/packages/core/src/providers/presets/claudeapi/index.ts new file mode 100644 index 0000000..85a7b65 --- /dev/null +++ b/packages/core/src/providers/presets/claudeapi/index.ts @@ -0,0 +1,15 @@ +import { defaultProviderAccountConfig, type ProviderPreset } from "@ccr/core/providers/presets/types"; + +export const claudeApiProviderPreset: ProviderPreset = { + account: defaultProviderAccountConfig, + aliases: ["claudeapi", "claudeapi.com", "www.claudeapi.com"], + endpoints: [ + { + baseUrl: "https://gw.claudeapi.com", + protocols: ["anthropic_messages"] + } + ], + id: "claudeapi", + name: "claudeapi", + websiteUrl: "https://www.claudeapi.com?source=claudecoderouter" +}; diff --git a/packages/core/src/providers/presets/code0/index.ts b/packages/core/src/providers/presets/code0/index.ts new file mode 100644 index 0000000..b87be18 --- /dev/null +++ b/packages/core/src/providers/presets/code0/index.ts @@ -0,0 +1,15 @@ +import { defaultProviderAccountConfig, type ProviderPreset } from "@ccr/core/providers/presets/types"; + +export const code0ProviderPreset: ProviderPreset = { + account: defaultProviderAccountConfig, + aliases: ["code0", "code0.ai", "code 0"], + endpoints: [ + { + baseUrl: "https://console.code0.ai", + protocols: ["anthropic_messages", "openai_chat_completions", "openai_responses"] + } + ], + id: "code0", + name: "code0.ai", + websiteUrl: "https://code0.ai?source=claudecoderouter" +}; diff --git a/src/main/presets/deepseek/index.ts b/packages/core/src/providers/presets/deepseek/index.ts similarity index 85% rename from src/main/presets/deepseek/index.ts rename to packages/core/src/providers/presets/deepseek/index.ts index dfbc177..ee51cd3 100644 --- a/src/main/presets/deepseek/index.ts +++ b/packages/core/src/providers/presets/deepseek/index.ts @@ -1,5 +1,5 @@ -import type { ProviderAccountConfig } from "../../../shared/app"; -import type { ProviderPreset } from "../../../shared/provider-presets"; +import type { ProviderAccountConfig } from "@ccr/core/contracts/app"; +import type { ProviderPreset } from "@ccr/core/providers/presets/types"; const deepSeekProviderAccountConfig: ProviderAccountConfig = { connectors: [ @@ -47,5 +47,6 @@ export const deepSeekProviderPreset: ProviderPreset = { } ], id: "deepseek", - name: "DeepSeek" + name: "DeepSeek", + websiteUrl: "https://www.deepseek.com/" }; diff --git a/src/main/presets/gemini/index.ts b/packages/core/src/providers/presets/gemini/index.ts similarity index 72% rename from src/main/presets/gemini/index.ts rename to packages/core/src/providers/presets/gemini/index.ts index 808a043..7e0fec7 100644 --- a/src/main/presets/gemini/index.ts +++ b/packages/core/src/providers/presets/gemini/index.ts @@ -1,4 +1,4 @@ -import { defaultProviderAccountConfig, type ProviderPreset } from "../../../shared/provider-presets"; +import { defaultProviderAccountConfig, type ProviderPreset } from "@ccr/core/providers/presets/types"; export const geminiProviderPreset: ProviderPreset = { account: defaultProviderAccountConfig, @@ -6,12 +6,13 @@ export const geminiProviderPreset: ProviderPreset = { endpoints: [ { baseUrl: "https://generativelanguage.googleapis.com", - protocols: ["gemini_generate_content"] + protocols: ["gemini_generate_content", "gemini_interactions"] } ], id: "gemini", name: "Google Gemini", officialApiKeyPatterns: [ { flags: "i", source: "^AIza[a-z0-9_-]{20,}$" } - ] + ], + websiteUrl: "https://gemini.google.com/" }; diff --git a/packages/core/src/providers/presets/index.ts b/packages/core/src/providers/presets/index.ts new file mode 100644 index 0000000..7ea4306 --- /dev/null +++ b/packages/core/src/providers/presets/index.ts @@ -0,0 +1,93 @@ +import { anthropicProviderPreset } from "@ccr/core/providers/presets/anthropic/index"; +import { bailianProviderPreset } from "@ccr/core/providers/presets/bailian/index"; +import { claudeApiProviderPreset } from "@ccr/core/providers/presets/claudeapi/index"; +import { code0ProviderPreset } from "@ccr/core/providers/presets/code0/index"; +import { deepSeekProviderPreset } from "@ccr/core/providers/presets/deepseek/index"; +import { geminiProviderPreset } from "@ccr/core/providers/presets/gemini/index"; +import { kimiCodingProviderPreset } from "@ccr/core/providers/presets/kimi-coding/index"; +import { minimaxChinaProviderPreset, minimaxGlobalProviderPreset } from "@ccr/core/providers/presets/minimax/index"; +import { mistralProviderPreset } from "@ccr/core/providers/presets/mistral/index"; +import { moonshotChinaProviderPreset, moonshotGlobalProviderPreset } from "@ccr/core/providers/presets/moonshot/index"; +import { openaiProviderPreset } from "@ccr/core/providers/presets/openai/index"; +import { openRouterProviderPreset } from "@ccr/core/providers/presets/openrouter/index"; +import { runApiProviderPreset } from "@ccr/core/providers/presets/runapi/index"; +import { siliconFlowProviderPreset } from "@ccr/core/providers/presets/siliconflow/index"; +import { teamoRouterProviderPreset } from "@ccr/core/providers/presets/teamorouter/index"; +import { zaiGlobalCodingProviderPreset } from "@ccr/core/providers/presets/zai-global-coding/index"; +import { zaiGlobalGeneralProviderPreset } from "@ccr/core/providers/presets/zai-global-general/index"; +import { zhipuCnCodingProviderPreset } from "@ccr/core/providers/presets/zhipu-cn-coding/index"; +import { zhipuCnGeneralProviderPreset } from "@ccr/core/providers/presets/zhipu-cn-general/index"; +import { + findProviderPresetByBaseUrlInList, + findProviderPresetInList, + primaryProviderPresetEndpoint, + providerApiKeySafetyIssueInList, + providerEndpointCanReceiveProviderApiKeyInList, + providerIdentitySafetyIssueInList, + providerPresetMatchesBaseUrl +} from "@ccr/core/providers/presets/utils"; +import type { ProviderIdentitySafetyIssue, ProviderPreset } from "@ccr/core/providers/presets/types"; + +export const providerPresets: ProviderPreset[] = [ + openaiProviderPreset, + anthropicProviderPreset, + geminiProviderPreset, + openRouterProviderPreset, + deepSeekProviderPreset, + kimiCodingProviderPreset, + zhipuCnCodingProviderPreset, + zhipuCnGeneralProviderPreset, + zaiGlobalCodingProviderPreset, + zaiGlobalGeneralProviderPreset, + minimaxGlobalProviderPreset, + minimaxChinaProviderPreset, + mistralProviderPreset, + moonshotChinaProviderPreset, + moonshotGlobalProviderPreset, + bailianProviderPreset, + siliconFlowProviderPreset, + runApiProviderPreset, + teamoRouterProviderPreset, + code0ProviderPreset, + claudeApiProviderPreset +]; + +export function getProviderPresets(): ProviderPreset[] { + return JSON.parse(JSON.stringify(providerPresets)) as ProviderPreset[]; +} + +export function findProviderPreset(id: string | undefined): ProviderPreset | undefined { + return findProviderPresetInList(providerPresets, id); +} + +export function findProviderPresetByBaseUrl(baseUrl: string): ProviderPreset | undefined { + return findProviderPresetByBaseUrlInList(providerPresets, baseUrl); +} + +export { primaryProviderPresetEndpoint, providerPresetMatchesBaseUrl }; + +export function providerIdentitySafetyIssue(input: { + baseUrl: string; + name?: string; + presetId?: string; +}): ProviderIdentitySafetyIssue | undefined { + return providerIdentitySafetyIssueInList(providerPresets, input); +} + +export function providerApiKeySafetyIssue(input: { + apiKey?: string; + baseUrl: string; + name?: string; + presetId?: string; +}): ProviderIdentitySafetyIssue | undefined { + return providerApiKeySafetyIssueInList(providerPresets, input); +} + +export function providerEndpointCanReceiveProviderApiKey(input: { + apiKey?: string; + endpoint: string; + providerName?: string; + providerPresetId?: string; +}): ProviderIdentitySafetyIssue | undefined { + return providerEndpointCanReceiveProviderApiKeyInList(providerPresets, input); +} diff --git a/packages/core/src/providers/presets/kimi-coding/index.ts b/packages/core/src/providers/presets/kimi-coding/index.ts new file mode 100644 index 0000000..924fd10 --- /dev/null +++ b/packages/core/src/providers/presets/kimi-coding/index.ts @@ -0,0 +1,41 @@ +import type { ProviderAccountConfig } from "@ccr/core/contracts/app"; +import type { ProviderPreset } from "@ccr/core/providers/presets/types"; + +const kimiCodingProviderAccountConfig: ProviderAccountConfig = { + connectors: [ + { + auth: "provider-api-key", + endpoint: "https://api.kimi.com/coding/v1/usages", + mapping: { + meters: [] + }, + parser: "kimi-code-usages", + type: "http-json" + } + ], + enabled: true +}; + +export const kimiCodingProviderPreset: ProviderPreset = { + account: kimiCodingProviderAccountConfig, + aliases: ["kimi code", "kimi coding", "kimi coding plan", "kimi-for-coding"], + defaultModelDisplayNames: { + "kimi-for-coding": "K2.7 Code" + }, + defaultModels: ["kimi-for-coding"], + endpoints: [ + { + baseUrl: "https://api.kimi.com/coding/v1", + protocols: ["openai_chat_completions"], + websiteUrl: "https://www.kimi.com/code?aff=ccr" + }, + { + baseUrl: "https://api.kimi.com/coding/", + protocols: ["anthropic_messages"], + websiteUrl: "https://www.kimi.com/code?aff=ccr" + } + ], + id: "kimi-coding", + name: "Kimi Code - Coding Plan", + websiteUrl: "https://www.kimi.com/code?aff=ccr" +}; diff --git a/packages/core/src/providers/presets/minimax/index.ts b/packages/core/src/providers/presets/minimax/index.ts new file mode 100644 index 0000000..f01fee0 --- /dev/null +++ b/packages/core/src/providers/presets/minimax/index.ts @@ -0,0 +1,44 @@ +import type { ProviderPreset } from "@ccr/core/providers/presets/types"; +import { standardProviderAccountConfig } from "@ccr/core/providers/presets/types"; + +export const minimaxGlobalProviderPreset: ProviderPreset = { + account: standardProviderAccountConfig, + aliases: ["minimax", "minimax global"], + defaultModels: ["MiniMax-M3"], + endpoints: [ + { + baseUrl: "https://api.minimax.io/v1", + protocols: ["openai_chat_completions"], + websiteUrl: "https://platform.minimax.io/docs" + }, + { + baseUrl: "https://api.minimax.io/anthropic/v1", + protocols: ["anthropic_messages"], + websiteUrl: "https://platform.minimax.io/docs" + } + ], + id: "minimax-global", + name: "MiniMax (Global)", + websiteUrl: "https://platform.minimax.io/docs" +}; + +export const minimaxChinaProviderPreset: ProviderPreset = { + account: standardProviderAccountConfig, + aliases: ["minimax", "minimaxi", "minimax china"], + defaultModels: ["MiniMax-M3"], + endpoints: [ + { + baseUrl: "https://api.minimaxi.com/v1", + protocols: ["openai_chat_completions"], + websiteUrl: "https://platform.minimaxi.com/docs" + }, + { + baseUrl: "https://api.minimaxi.com/anthropic/v1", + protocols: ["anthropic_messages"], + websiteUrl: "https://platform.minimaxi.com/docs" + } + ], + id: "minimax-cn", + name: "MiniMax (China)", + websiteUrl: "https://platform.minimaxi.com/docs" +}; diff --git a/src/main/presets/mistral/index.ts b/packages/core/src/providers/presets/mistral/index.ts similarity index 82% rename from src/main/presets/mistral/index.ts rename to packages/core/src/providers/presets/mistral/index.ts index 10ec1fa..e12bc2b 100644 --- a/src/main/presets/mistral/index.ts +++ b/packages/core/src/providers/presets/mistral/index.ts @@ -1,5 +1,5 @@ -import type { ProviderAccountConfig } from "../../../shared/app"; -import type { ProviderPreset } from "../../../shared/provider-presets"; +import type { ProviderAccountConfig } from "@ccr/core/contracts/app"; +import type { ProviderPreset } from "@ccr/core/providers/presets/types"; const mistralProviderAccountConfig: ProviderAccountConfig = { connectors: [ @@ -41,5 +41,6 @@ export const mistralProviderPreset: ProviderPreset = { } ], id: "mistral", - name: "Mistral" + name: "Mistral", + websiteUrl: "https://mistral.ai/" }; diff --git a/packages/core/src/providers/presets/moonshot/index.ts b/packages/core/src/providers/presets/moonshot/index.ts new file mode 100644 index 0000000..b4ea02d --- /dev/null +++ b/packages/core/src/providers/presets/moonshot/index.ts @@ -0,0 +1,106 @@ +import type { ProviderAccountConfig } from "@ccr/core/contracts/app"; +import type { ProviderPreset } from "@ccr/core/providers/presets/types"; + +const moonshotGlobalProviderAccountConfig: ProviderAccountConfig = { + connectors: [ + { + auth: "provider-api-key", + endpoint: "https://api.moonshot.ai/v1/users/me/balance", + mapping: { + meters: [ + { + id: "balance", + kind: "balance", + label: "Balance", + remaining: "$.data.available_balance", + unit: "CNY" + }, + { + id: "voucher_balance", + kind: "balance", + label: "Voucher balance", + remaining: "$.data.voucher_balance", + unit: "CNY" + }, + { + id: "cash_balance", + kind: "balance", + label: "Cash balance", + remaining: "$.data.cash_balance", + unit: "CNY" + } + ] + }, + type: "http-json" + } + ], + enabled: true +}; + +const moonshotChinaProviderAccountConfig: ProviderAccountConfig = { + connectors: [ + { + auth: "provider-api-key", + endpoint: "https://api.moonshot.cn/v1/users/me/balance", + mapping: { + meters: [ + { + id: "balance", + kind: "balance", + label: "Balance", + remaining: "$.data.available_balance", + unit: "CNY" + }, + { + id: "voucher_balance", + kind: "balance", + label: "Voucher balance", + remaining: "$.data.voucher_balance", + unit: "CNY" + }, + { + id: "cash_balance", + kind: "balance", + label: "Cash balance", + remaining: "$.data.cash_balance", + unit: "CNY" + } + ] + }, + type: "http-json" + } + ], + enabled: true +}; + +export const moonshotChinaProviderPreset: ProviderPreset = { + account: moonshotChinaProviderAccountConfig, + aliases: ["kimi", "kimi api", "moonshot", "moonshot kimi"], + defaultModels: ["kimi-k2.7-code"], + endpoints: [ + { + baseUrl: "https://api.moonshot.cn/v1", + protocols: ["openai_chat_completions"], + websiteUrl: "https://platform.kimi.com/?aff=ccr" + } + ], + id: "moonshot", + name: "Kimi API (China)", + websiteUrl: "https://platform.kimi.com/?aff=ccr" +}; + +export const moonshotGlobalProviderPreset: ProviderPreset = { + account: moonshotGlobalProviderAccountConfig, + aliases: ["kimi", "kimi api", "moonshot", "moonshot kimi"], + defaultModels: ["kimi-k2.7-code"], + endpoints: [ + { + baseUrl: "https://api.moonshot.ai/v1", + protocols: ["openai_chat_completions"], + websiteUrl: "https://platform.kimi.ai/?aff=ccr" + } + ], + id: "moonshot-global", + name: "Kimi API (Global)", + websiteUrl: "https://platform.kimi.ai/?aff=ccr" +}; diff --git a/src/main/presets/openai/index.ts b/packages/core/src/providers/presets/openai/index.ts similarity index 86% rename from src/main/presets/openai/index.ts rename to packages/core/src/providers/presets/openai/index.ts index cec8000..a4ea97f 100644 --- a/src/main/presets/openai/index.ts +++ b/packages/core/src/providers/presets/openai/index.ts @@ -1,4 +1,4 @@ -import { defaultProviderAccountConfig, type ProviderPreset } from "../../../shared/provider-presets"; +import { defaultProviderAccountConfig, type ProviderPreset } from "@ccr/core/providers/presets/types"; export const openaiProviderPreset: ProviderPreset = { account: defaultProviderAccountConfig, @@ -14,5 +14,6 @@ export const openaiProviderPreset: ProviderPreset = { name: "OpenAI", officialApiKeyPatterns: [ { flags: "i", source: "^sk-(?:proj|svcacct)-[a-z0-9_-]+$" } - ] + ], + websiteUrl: "https://openai.com/" }; diff --git a/src/main/presets/openrouter/index.ts b/packages/core/src/providers/presets/openrouter/index.ts similarity index 87% rename from src/main/presets/openrouter/index.ts rename to packages/core/src/providers/presets/openrouter/index.ts index 1294dae..7570322 100644 --- a/src/main/presets/openrouter/index.ts +++ b/packages/core/src/providers/presets/openrouter/index.ts @@ -1,5 +1,5 @@ -import type { ProviderAccountConfig } from "../../../shared/app"; -import type { ProviderPreset } from "../../../shared/provider-presets"; +import type { ProviderAccountConfig } from "@ccr/core/contracts/app"; +import type { ProviderPreset } from "@ccr/core/providers/presets/types"; const openRouterProviderAccountConfig: ProviderAccountConfig = { connectors: [ @@ -51,5 +51,6 @@ export const openRouterProviderPreset: ProviderPreset = { name: "OpenRouter", officialApiKeyPatterns: [ { flags: "i", source: "^sk-or-v1-[a-z0-9_-]+$" } - ] + ], + websiteUrl: "https://openrouter.ai/" }; diff --git a/packages/core/src/providers/presets/runapi/index.ts b/packages/core/src/providers/presets/runapi/index.ts new file mode 100644 index 0000000..5f982ab --- /dev/null +++ b/packages/core/src/providers/presets/runapi/index.ts @@ -0,0 +1,15 @@ +import { defaultProviderAccountConfig, type ProviderPreset } from "@ccr/core/providers/presets/types"; + +export const runApiProviderPreset: ProviderPreset = { + account: defaultProviderAccountConfig, + aliases: ["runapi"], + endpoints: [ + { + baseUrl: "https://runapi.co/v1", + protocols: ["openai_responses", "openai_chat_completions"] + } + ], + id: "runapi", + name: "RunAPI", + websiteUrl: "https://runapi.co/register?aff=IX1t" +}; diff --git a/src/main/presets/siliconflow/index.ts b/packages/core/src/providers/presets/siliconflow/index.ts similarity index 84% rename from src/main/presets/siliconflow/index.ts rename to packages/core/src/providers/presets/siliconflow/index.ts index 8436699..a4af567 100644 --- a/src/main/presets/siliconflow/index.ts +++ b/packages/core/src/providers/presets/siliconflow/index.ts @@ -1,5 +1,5 @@ -import type { ProviderAccountConfig } from "../../../shared/app"; -import type { ProviderPreset } from "../../../shared/provider-presets"; +import type { ProviderAccountConfig } from "@ccr/core/contracts/app"; +import type { ProviderPreset } from "@ccr/core/providers/presets/types"; const siliconFlowProviderAccountConfig: ProviderAccountConfig = { connectors: [ @@ -47,5 +47,6 @@ export const siliconFlowProviderPreset: ProviderPreset = { } ], id: "siliconflow", - name: "SiliconFlow" + name: "SiliconFlow", + websiteUrl: "https://siliconflow.cn/" }; diff --git a/packages/core/src/providers/presets/teamorouter/index.ts b/packages/core/src/providers/presets/teamorouter/index.ts new file mode 100644 index 0000000..e7909fa --- /dev/null +++ b/packages/core/src/providers/presets/teamorouter/index.ts @@ -0,0 +1,15 @@ +import { defaultProviderAccountConfig, type ProviderPreset } from "@ccr/core/providers/presets/types"; + +export const teamoRouterProviderPreset: ProviderPreset = { + account: defaultProviderAccountConfig, + aliases: ["teamorouter", "teamo router", "teamo"], + endpoints: [ + { + baseUrl: "https://api.teamorouter.com", + protocols: ["anthropic_messages", "openai_chat_completions", "openai_responses"] + } + ], + id: "teamorouter", + name: "TeamoRouter", + websiteUrl: "https://teamorouter.com/" +}; diff --git a/src/shared/provider-presets.ts b/packages/core/src/providers/presets/types.ts similarity index 87% rename from src/shared/provider-presets.ts rename to packages/core/src/providers/presets/types.ts index d0ef571..c4811ff 100644 --- a/src/shared/provider-presets.ts +++ b/packages/core/src/providers/presets/types.ts @@ -1,9 +1,10 @@ -import type { GatewayProviderProtocol, ProviderAccountConfig } from "./app"; +import type { GatewayProviderProtocol, ProviderAccountConfig } from "@ccr/core/contracts/app"; export type ProviderPresetEndpoint = { baseUrl: string; label?: string; protocols: GatewayProviderProtocol[]; + websiteUrl?: string; }; export type ProviderOfficialKeyPattern = { @@ -14,11 +15,13 @@ export type ProviderOfficialKeyPattern = { export type ProviderPreset = { account?: ProviderAccountConfig; aliases: string[]; + defaultModelDisplayNames?: Record; defaultModels?: string[]; endpoints: ProviderPresetEndpoint[]; id: string; name: string; officialApiKeyPatterns?: ProviderOfficialKeyPattern[]; + websiteUrl?: string; }; export type ProviderIdentitySafetyIssue = { diff --git a/src/shared/provider-preset-utils.ts b/packages/core/src/providers/presets/utils.ts similarity index 83% rename from src/shared/provider-preset-utils.ts rename to packages/core/src/providers/presets/utils.ts index 3ac2907..9a46da5 100644 --- a/src/shared/provider-preset-utils.ts +++ b/packages/core/src/providers/presets/utils.ts @@ -3,8 +3,8 @@ import { type ProviderIdentitySafetyIssue, type ProviderPreset, type ProviderPresetEndpoint -} from "./provider-presets"; -import { providerUrlWithDefaultScheme } from "./provider-url"; +} from "@ccr/core/providers/presets/types"; +import { providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; export function findProviderPresetInList( presets: ProviderPreset[], @@ -25,6 +25,13 @@ export function findProviderPresetByBaseUrlInList( ); } +export function findProviderPresetByIdentityInList( + presets: ProviderPreset[], + name: string | undefined +): ProviderPreset | undefined { + return findProviderPresetsByIdentity(presets, name)[0]; +} + export function primaryProviderPresetEndpoint(preset: ProviderPreset): ProviderPresetEndpoint | undefined { return preset.endpoints[0]; } @@ -42,14 +49,14 @@ export function providerIdentitySafetyIssueInList( } const selectedPreset = findProviderPresetInList(presets, input.presetId); - if (selectedPreset && !providerPresetMatchesBaseUrl(selectedPreset, input.baseUrl)) { + if (selectedPreset && !providerBaseUrlCanReceiveOfficialKey(selectedPreset, input.baseUrl)) { return createProviderIdentitySafetyIssue(selectedPreset); } const namedPresets = findProviderPresetsByIdentity(presets, input.name); if ( namedPresets.length > 0 && - !namedPresets.some((preset) => providerPresetMatchesBaseUrl(preset, input.baseUrl)) + !namedPresets.some((preset) => providerBaseUrlCanReceiveOfficialKey(preset, input.baseUrl)) ) { return createProviderIdentitySafetyIssue(namedPresets[0]); } @@ -121,15 +128,30 @@ function findProviderPresetsByIdentity(presets: ProviderPreset[], name: string | return []; } - return presets.filter((preset) => { - const identities = [preset.id, preset.name, ...preset.aliases] - .map(normalizeProviderIdentityText) - .filter(Boolean); - return identities.some((identity) => - normalizedName === identity || - (identity.length >= 4 && normalizedName.includes(identity)) - ); - }); + return presets + .map((preset) => ({ + preset, + score: providerPresetIdentityMatchScore(preset, normalizedName) + })) + .filter((item) => item.score > 0) + .sort((left, right) => right.score - left.score) + .map((item) => item.preset); +} + +function providerPresetIdentityMatchScore(preset: ProviderPreset, normalizedName: string): number { + const identities = [preset.id, preset.name, ...preset.aliases] + .map(normalizeProviderIdentityText) + .filter(Boolean); + + return Math.max(0, ...identities.map((identity) => { + if (normalizedName === identity) { + return 10_000 + identity.length; + } + if (identity.length >= 4 && normalizedName.includes(identity)) { + return identity.length; + } + return 0; + })); } function createProviderIdentitySafetyIssue(preset: ProviderPreset): ProviderIdentitySafetyIssue { @@ -181,7 +203,11 @@ function providerEndpointMatchesBaseUrl(endpointBaseUrl: string, baseUrl: string const endpointPath = normalizeProviderPresetPath(endpoint.pathname); const candidatePath = normalizeProviderPresetPath(candidate.pathname); - return endpointPath === "/" || candidatePath === "/" || candidatePath === endpointPath || candidatePath.startsWith(`${endpointPath}/`); + return endpointPath === "/" || + candidatePath === "/" || + candidatePath === endpointPath || + candidatePath.startsWith(`${endpointPath}/`) || + endpointPath.startsWith(`${candidatePath}/`); } function providerEndpointHost(baseUrl: string): string | undefined { diff --git a/src/main/presets/zai-global-coding/index.ts b/packages/core/src/providers/presets/zai-global-coding/index.ts similarity index 90% rename from src/main/presets/zai-global-coding/index.ts rename to packages/core/src/providers/presets/zai-global-coding/index.ts index 8e0e41e..24c0719 100644 --- a/src/main/presets/zai-global-coding/index.ts +++ b/packages/core/src/providers/presets/zai-global-coding/index.ts @@ -1,5 +1,5 @@ -import type { ProviderAccountConfig, ProviderAccountMappingConfig } from "../../../shared/app"; -import type { ProviderPreset } from "../../../shared/provider-presets"; +import type { ProviderAccountConfig, ProviderAccountMappingConfig } from "@ccr/core/contracts/app"; +import type { ProviderPreset } from "@ccr/core/providers/presets/types"; const zaiQuotaMapping: ProviderAccountMappingConfig = { meters: [ @@ -58,5 +58,6 @@ export const zaiGlobalCodingProviderPreset: ProviderPreset = { } ], id: "zai-global-coding", - name: "Z.ai (Global) - Coding Plan" + name: "Z.ai (Global) - Coding Plan", + websiteUrl: "https://z.ai/" }; diff --git a/src/main/presets/zai-global-general/index.ts b/packages/core/src/providers/presets/zai-global-general/index.ts similarity index 90% rename from src/main/presets/zai-global-general/index.ts rename to packages/core/src/providers/presets/zai-global-general/index.ts index b0d63c5..377b564 100644 --- a/src/main/presets/zai-global-general/index.ts +++ b/packages/core/src/providers/presets/zai-global-general/index.ts @@ -1,5 +1,5 @@ -import type { ProviderAccountConfig, ProviderAccountMappingConfig } from "../../../shared/app"; -import type { ProviderPreset } from "../../../shared/provider-presets"; +import type { ProviderAccountConfig, ProviderAccountMappingConfig } from "@ccr/core/contracts/app"; +import type { ProviderPreset } from "@ccr/core/providers/presets/types"; const zaiQuotaMapping: ProviderAccountMappingConfig = { meters: [ @@ -54,5 +54,6 @@ export const zaiGlobalGeneralProviderPreset: ProviderPreset = { } ], id: "zai-global-general", - name: "Z.ai (Global) - General Endpoint" + name: "Z.ai (Global) - General Endpoint", + websiteUrl: "https://z.ai/" }; diff --git a/src/main/presets/zhipu-cn-coding/index.ts b/packages/core/src/providers/presets/zhipu-cn-coding/index.ts similarity index 90% rename from src/main/presets/zhipu-cn-coding/index.ts rename to packages/core/src/providers/presets/zhipu-cn-coding/index.ts index 17456d3..04540d2 100644 --- a/src/main/presets/zhipu-cn-coding/index.ts +++ b/packages/core/src/providers/presets/zhipu-cn-coding/index.ts @@ -1,5 +1,5 @@ -import type { ProviderAccountConfig, ProviderAccountMappingConfig } from "../../../shared/app"; -import type { ProviderPreset } from "../../../shared/provider-presets"; +import type { ProviderAccountConfig, ProviderAccountMappingConfig } from "@ccr/core/contracts/app"; +import type { ProviderPreset } from "@ccr/core/providers/presets/types"; const zhipuQuotaMapping: ProviderAccountMappingConfig = { meters: [ @@ -58,5 +58,6 @@ export const zhipuCnCodingProviderPreset: ProviderPreset = { } ], id: "zhipu-cn-coding", - name: "Zhipu AI (China) - Coding Plan" + name: "Zhipu AI (China) - Coding Plan", + websiteUrl: "https://www.bigmodel.cn/" }; diff --git a/src/main/presets/zhipu-cn-general/index.ts b/packages/core/src/providers/presets/zhipu-cn-general/index.ts similarity index 89% rename from src/main/presets/zhipu-cn-general/index.ts rename to packages/core/src/providers/presets/zhipu-cn-general/index.ts index c178ba5..2db2f5d 100644 --- a/src/main/presets/zhipu-cn-general/index.ts +++ b/packages/core/src/providers/presets/zhipu-cn-general/index.ts @@ -1,5 +1,5 @@ -import type { ProviderAccountConfig, ProviderAccountMappingConfig } from "../../../shared/app"; -import type { ProviderPreset } from "../../../shared/provider-presets"; +import type { ProviderAccountConfig, ProviderAccountMappingConfig } from "@ccr/core/contracts/app"; +import type { ProviderPreset } from "@ccr/core/providers/presets/types"; const zhipuQuotaMapping: ProviderAccountMappingConfig = { meters: [ @@ -54,5 +54,6 @@ export const zhipuCnGeneralProviderPreset: ProviderPreset = { } ], id: "zhipu-cn-general", - name: "Zhipu AI (China) - General Endpoint" + name: "Zhipu AI (China) - General Endpoint", + websiteUrl: "https://www.bigmodel.cn/" }; diff --git a/src/main/provider-probe.ts b/packages/core/src/providers/probe.ts similarity index 77% rename from src/main/provider-probe.ts rename to packages/core/src/providers/probe.ts index 9f3b5dc..94b4f5a 100644 --- a/src/main/provider-probe.ts +++ b/packages/core/src/providers/probe.ts @@ -10,15 +10,20 @@ import type { GatewayProviderProbeRequest, GatewayProviderProbeResult, GatewayProviderProtocol -} from "../shared/app"; -import { providerApiKeySafetyIssue } from "./presets"; -import { fetchWithSystemProxy } from "./system-proxy-fetch"; +} from "@ccr/core/contracts/app"; +import { providerApiKeySafetyIssue } from "@ccr/core/providers/presets/index"; +import { fetchWithSystemProxy } from "@ccr/core/proxy/system-proxy-fetch"; import { compactProviderUrl, parseProviderBaseUrl, providerBaseUrlForProtocol, type ParsedProviderBaseUrl -} from "../shared/provider-url"; +} from "@ccr/core/providers/url"; +import { + detectedProviderFromHeaders, + newApiKeyUsageAccountConfig, + type DetectedProviderKind +} from "@ccr/core/providers/new-api"; type ModelSource = NonNullable; @@ -27,6 +32,8 @@ type ParsedProviderUrl = ParsedProviderBaseUrl & { }; type FetchJsonResult = { + detectedProvider?: DetectedProviderKind; + headers?: Record; payload?: unknown; status?: number; text: string; @@ -34,12 +41,14 @@ type FetchJsonResult = { type ModelProbeResult = { baseUrl?: string; + modelDisplayNames?: Record; models: string[]; source?: ModelSource; }; type ModelFetchResult = { baseUrl?: string; + modelDisplayNames?: Record; models: string[]; }; @@ -57,7 +66,8 @@ const protocolOrder: GatewayProviderProtocol[] = [ "openai_responses", "openai_chat_completions", "anthropic_messages", - "gemini_generate_content" + "gemini_generate_content", + "gemini_interactions" ]; const modelSourceOrder: ModelSource[] = ["openai", "anthropic", "gemini"]; @@ -218,18 +228,26 @@ async function resolveGatewayProviderProbe(request: GatewayProviderProbeRequest) const modelProbe = mode !== "models" || request.skipModelDiscovery ? { models: [] } : await probeModels(parsed, request.apiKey, protocols); - const models = mode === "connectivity" && modelProbe.models.length > 0 ? modelProbe.models : typedModels; - const protocolResults = mode === "models" ? [] : await probeProtocols(parsed, request.apiKey, models, protocols, mode); + const models = (mode === "connectivity" || mode === "models") && modelProbe.models.length > 0 + ? modelProbe.models + : typedModels; + const protocolResults = await probeProtocols(parsed, request.apiKey, models, protocols, mode); const detectedProtocol = detectProtocol(parsed, protocolResults, modelProbe.source, protocols); + const normalizedBaseUrl = detectedProtocol + ? resolveProbeBaseUrl(parsed, detectedProtocol, protocolResults, modelProbe) + : parsed.normalizedInputBaseUrl; + const detectedProvider = detectProvider(protocolResults); + const account = detectedProvider === "new-api" ? newApiKeyUsageAccountConfig(normalizedBaseUrl) : undefined; return { + ...(account ? { account } : {}), capabilities: capabilitiesFromProtocolResults(protocolResults), + ...(detectedProvider ? { detectedProvider } : {}), detectedProtocol, + modelDisplayNames: modelProbe.modelDisplayNames, modelSource: modelProbe.source, models: modelProbe.models, - normalizedBaseUrl: detectedProtocol - ? resolveProbeBaseUrl(parsed, detectedProtocol, protocolResults, modelProbe) - : parsed.normalizedInputBaseUrl, + normalizedBaseUrl, protocols: protocolResults }; } @@ -321,21 +339,30 @@ function providerProbeCapabilities( probe: GatewayProviderProbeResult ): GatewayProviderCapability[] { const detectedCapabilities = mergeProviderCapabilities(probe.capabilities ?? []); - if (detectedCapabilities.length > 0) { - return detectedCapabilities; - } + const presetCapabilities = providerProbePresetCapabilities(candidate); + return mergeProviderCapabilities(detectedCapabilities, presetCapabilities); +} +function providerProbePresetCapabilities(candidate: GatewayProviderProbeCandidate): GatewayProviderCapability[] { if (candidate.source !== "preset") { return []; } - return candidate.protocols.map((type) => ({ - baseUrl: probe.normalizedBaseUrl || candidate.baseUrl, + return uniqueProtocols(candidate.declaredProtocols ?? []).map((type) => ({ + baseUrl: providerProbeCandidateBaseUrlForProtocol(candidate.baseUrl, type), source: "preset" as const, type })); } +function providerProbeCandidateBaseUrlForProtocol(baseUrl: string, protocol: GatewayProviderProtocol): string { + try { + return providerBaseUrlForProtocol(parseProviderBaseUrl(baseUrl), protocol); + } catch { + return baseUrl.trim(); + } +} + function mergeProviderCapabilities(...groups: GatewayProviderCapability[][]): GatewayProviderCapability[] { const seen = new Set(); const capabilities: GatewayProviderCapability[] = []; @@ -402,11 +429,11 @@ async function fetchModelsForSource(parsed: ParsedProviderUrl, source: ModelSour }, method: "GET" }); - const models = parseModelIds(result.payload, "openai"); - if (models.length > 0) { + const modelList = parseModelList(result.payload, "openai"); + if (modelList.models.length > 0) { return { baseUrl, - models + ...modelList }; } } @@ -424,11 +451,11 @@ async function fetchModelsForSource(parsed: ParsedProviderUrl, source: ModelSour }, method: "GET" }); - const models = parseModelIds(result.payload, "anthropic"); - if (models.length > 0) { + const modelList = parseModelList(result.payload, "anthropic"); + if (modelList.models.length > 0) { return { baseUrl, - models + ...modelList }; } } @@ -446,7 +473,7 @@ async function fetchModelsForSource(parsed: ParsedProviderUrl, source: ModelSour }); return { baseUrl: parsed.geminiBaseUrl, - models: parseModelIds(result.payload, "gemini") + ...parseModelList(result.payload, "gemini") }; } @@ -463,7 +490,7 @@ async function probeProtocols( results.push( mode === "connectivity" ? await probeProtocolConnectivity(parsed, apiKey, models, protocol) - : await probeProtocolSupport(parsed, protocol) + : await probeProtocolSupport(parsed, apiKey, protocol) ); } @@ -472,6 +499,7 @@ async function probeProtocols( async function probeProtocolSupport( parsed: ParsedProviderUrl, + apiKey: string | undefined, protocol: GatewayProviderProtocol ): Promise { const endpoints = endpointsForProtocol(parsed, protocol, undefined); @@ -479,11 +507,12 @@ async function probeProtocolSupport( let firstResult: GatewayProviderProbeProtocolResult | undefined; for (const candidate of endpoints) { - const result = await requestJson(candidate.endpoint, requestForProtocolSupport(protocol)); + const result = await requestJson(candidate.endpoint, requestForProtocolSupport(protocol, apiKey)); const message = readResponseMessage(result); - const supported = isProtocolEndpointSupported(result.status, message); + const supported = isProviderProtocolEndpointSupportedForProbe(result.status, message, protocol, parsed.hints); const probeResult = { baseUrl: candidate.baseUrl, + ...(result.detectedProvider ? { detectedProvider: result.detectedProvider } : {}), endpoint: candidate.endpoint, message, protocol, @@ -529,9 +558,10 @@ async function probeProtocolConnectivity( for (const candidate of endpoints) { const result = await requestJson(candidate.endpoint, requestForProtocol(protocol, model, apiKey)); const message = readResponseMessage(result); - const supported = isProtocolSupported(result.status, message); + const supported = isProtocolSupported(result.status, message, protocol); const probeResult = { baseUrl: candidate.baseUrl, + ...(result.detectedProvider ? { detectedProvider: result.detectedProvider } : {}), endpoint: candidate.endpoint, message, protocol, @@ -602,6 +632,24 @@ function requestForProtocol(protocol: GatewayProviderProtocol, model: string, ap }; } + if (protocol === "gemini_interactions") { + return { + body: JSON.stringify({ + generation_config: { + max_output_tokens: probeOutputTokenLimit + }, + input: "ping", + model, + store: false + }), + headers: { + "content-type": "application/json", + ...geminiHeaders(apiKey) + }, + method: "POST" + }; + } + return { body: JSON.stringify({ contents: [{ parts: [{ text: "ping" }], role: "user" }], @@ -617,12 +665,12 @@ function requestForProtocol(protocol: GatewayProviderProtocol, model: string, ap }; } -function requestForProtocolSupport(protocol: GatewayProviderProtocol): RequestInit { +function requestForProtocolSupport(protocol: GatewayProviderProtocol, apiKey: string | undefined): RequestInit { return { body: JSON.stringify({}), headers: { "content-type": "application/json", - ...(protocol === "anthropic_messages" ? { "anthropic-version": "2023-06-01" } : {}) + ...headersForProtocol(protocol, apiKey) }, method: "POST" }; @@ -638,7 +686,10 @@ async function requestJson(url: string, init: RequestInit): Promise item.detectedProvider)?.detectedProvider; +} + +function responseHeadersRecord(headers: Headers): Record { + const record: Record = {}; + headers.forEach((value, key) => { + record[key] = value; + }); + return record; +} + function parseProviderUrl(value: string): ParsedProviderUrl { const parsed = parseProviderBaseUrl(value); const url = new URL(parsed.normalizedInputBaseUrl); @@ -683,10 +746,29 @@ function endpointsForProtocol( } if (protocol === "anthropic_messages") { - return parsed.anthropicBaseUrlCandidates.map((baseUrl) => ({ - baseUrl, - endpoint: `${baseUrl}/v1/messages` - })); + return parsed.anthropicBaseUrlCandidates.flatMap((baseUrl) => uniqueProtocolEndpoints([ + { + baseUrl, + endpoint: `${baseUrl}/v1/messages` + }, + { + baseUrl, + endpoint: `${baseUrl}/messages` + } + ])); + } + + if (protocol === "gemini_interactions") { + return [ + { + baseUrl: parsed.geminiBaseUrl, + endpoint: `${parsed.geminiBaseUrl}/v1beta/interactions` + }, + { + baseUrl: parsed.geminiBaseUrl, + endpoint: `${parsed.geminiBaseUrl}/v1/interactions` + } + ]; } const encodedModel = encodeURIComponent(stripGeminiModelPrefix(model || "model")); @@ -731,17 +813,51 @@ function geminiHeaders(apiKey: string | undefined): Record { : {}; } -function parseModelIds(payload: unknown, source: ModelSource): string[] { +function headersForProtocol(protocol: GatewayProviderProtocol, apiKey: string | undefined): Record { + if (protocol === "anthropic_messages") { + return anthropicHeaders(apiKey); + } + if (protocol === "gemini_generate_content" || protocol === "gemini_interactions") { + return geminiHeaders(apiKey); + } + return openAiHeaders(apiKey); +} + +function parseModelList(payload: unknown, source: ModelSource): Pick { if (!isRecord(payload)) { - return []; + return { + models: [] + }; } const items = Array.isArray(payload.data) ? payload.data : Array.isArray(payload.models) ? payload.models : []; - const models = items - .map((item) => readModelId(item, source)) - .filter((item): item is string => Boolean(item)); + const models: string[] = []; + const modelDisplayNames: Record = {}; - return uniqueStrings(models); + for (const item of items) { + const model = readModelId(item, source); + if (!model) { + continue; + } + models.push(model); + + const displayName = readModelDisplayName(item); + if (displayName && displayName !== model) { + modelDisplayNames[model] = displayName; + } + } + + const uniqueModels = uniqueStrings(models); + const uniqueDisplayNames = Object.fromEntries( + uniqueModels + .map((model) => [model, modelDisplayNames[model]] as const) + .filter((entry): entry is [string, string] => Boolean(entry[1])) + ); + + return { + modelDisplayNames: Object.keys(uniqueDisplayNames).length > 0 ? uniqueDisplayNames : undefined, + models: uniqueModels + }; } function readModelId(value: unknown, source: ModelSource): string | undefined { @@ -771,6 +887,13 @@ function readModelId(value: unknown, source: ModelSource): string | undefined { return rawId; } +function readModelDisplayName(value: unknown): string | undefined { + if (!isRecord(value)) { + return undefined; + } + return readString(value.display_name) || readString(value.displayName) || readString(value.label); +} + function stripGeminiModelPrefix(value: string): string { return value.replace(/^models\//i, ""); } @@ -781,7 +904,7 @@ function pickProbeModel(models: string[], protocol: GatewayProviderProtocol): st return undefined; } - if (protocol === "gemini_generate_content") { + if (protocol === "gemini_generate_content" || protocol === "gemini_interactions") { return candidates.find((model) => model.toLowerCase().includes("gemini")) ?? candidates[0]; } @@ -830,7 +953,7 @@ function protocolModelSource(protocol: GatewayProviderProtocol): ModelSource { if (protocol === "anthropic_messages") { return "anthropic"; } - if (protocol === "gemini_generate_content") { + if (protocol === "gemini_generate_content" || protocol === "gemini_interactions") { return "gemini"; } return "openai"; @@ -868,8 +991,13 @@ function detectProtocol( return "anthropic_messages"; } - if (modelSource === "gemini" && protocolIsAllowed("gemini_generate_content", allowedProtocols)) { - return "gemini_generate_content"; + if (modelSource === "gemini") { + const geminiProtocols = orderedProtocols(parsed, allowedProtocols).filter((protocol) => + protocol === "gemini_generate_content" || protocol === "gemini_interactions" + ); + return geminiProtocols.find((protocol) => parsed.hints.includes(protocol)) ?? + geminiProtocols.find((protocol) => protocol === "gemini_generate_content") ?? + geminiProtocols[0]; } if (modelSource === "openai") { @@ -922,14 +1050,24 @@ function protocolHints(value: string): GatewayProviderProtocol[] { if (normalized.includes("anthropic") || normalized.includes("/messages")) { hints.push("anthropic_messages"); } + if (normalized.includes("interactions") || normalized.includes("gemini_interactions") || normalized.includes("google_interactions")) { + hints.push("gemini_interactions"); + } if (normalized.includes("generativelanguage.googleapis.com") || normalized.includes("gemini") || normalized.includes("generatecontent")) { hints.push("gemini_generate_content"); } + if (normalized.includes("generativelanguage.googleapis.com")) { + hints.push("gemini_interactions"); + } return hints; } -function isProtocolSupported(status: number | undefined, message: string): boolean { +function isProtocolSupported( + status: number | undefined, + message: string, + protocol?: GatewayProviderProtocol +): boolean { if (status === undefined) { return false; } @@ -944,25 +1082,47 @@ function isProtocolSupported(status: number | undefined, message: string): boole if (status === 400) { const normalized = message.toLowerCase(); - return /model|max_tokens|max output|messages|input|required/.test(normalized) && !/not found|unknown endpoint|unknown route|no route/.test(normalized); + if (/not found|unknown endpoint|unknown route|no route/.test(normalized)) { + return false; + } + return true; } return false; } -function isProtocolEndpointSupported(status: number | undefined, message: string): boolean { - if (isProtocolSupported(status, message)) { +export function isProviderProtocolEndpointSupportedForProbe( + status: number | undefined, + message: string, + protocol: GatewayProviderProtocol, + hints: GatewayProviderProtocol[] = [] +): boolean { + if (isProtocolSupported(status, message, protocol)) { return true; } if (status === 401 || status === 403) { const normalized = message.toLowerCase(); - return !/not found|unknown endpoint|unknown route|no route/.test(normalized); + return (hints.length === 0 || protocolMatchesHints(protocol, hints)) && + !/not found|unknown endpoint|unknown route|no route/.test(normalized); } return false; } +function protocolMatchesHints(protocol: GatewayProviderProtocol, hints: GatewayProviderProtocol[]): boolean { + if (hints.includes(protocol)) { + return true; + } + if (protocol === "openai_chat_completions") { + return hints.includes("openai_responses"); + } + if (protocol === "openai_responses") { + return hints.includes("openai_chat_completions"); + } + return false; +} + function readResponseMessage(result: FetchJsonResult): string { if (result.status === undefined) { return result.text || "Request failed."; @@ -1033,6 +1193,20 @@ function uniqueProtocols(values: GatewayProviderProtocol[]): GatewayProviderProt return values.filter((value, index) => values.indexOf(value) === index); } +function uniqueProtocolEndpoints(values: ProtocolEndpoint[]): ProtocolEndpoint[] { + const seen = new Set(); + const result: ProtocolEndpoint[] = []; + for (const value of values) { + const key = `${value.baseUrl}\n${value.endpoint}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + result.push(value); + } + return result; +} + function uniqueModelSources(values: ModelSource[]): ModelSource[] { return values.filter((value, index) => values.indexOf(value) === index); } diff --git a/src/shared/provider-url.ts b/packages/core/src/providers/url.ts similarity index 91% rename from src/shared/provider-url.ts rename to packages/core/src/providers/url.ts index fce24da..afb4c3a 100644 --- a/src/shared/provider-url.ts +++ b/packages/core/src/providers/url.ts @@ -1,4 +1,4 @@ -import type { GatewayProviderProtocol } from "./app"; +import type { GatewayProviderProtocol } from "@ccr/core/contracts/app"; export type ParsedProviderBaseUrl = { anthropicBaseUrl: string; @@ -102,6 +102,9 @@ function stripProviderEndpointPath(pathname: string): string { [/\/messages$/i, ""], [/\/v1beta\/models\/[^/]+:(generateContent|streamGenerateContent)$/i, "/v1beta"], [/\/v1\/models\/[^/]+:(generateContent|streamGenerateContent)$/i, "/v1"], + [/\/v1beta\/interactions(?:\/[^/]+(?:\/cancel)?)?$/i, "/v1beta"], + [/\/v1\/interactions(?:\/[^/]+(?:\/cancel)?)?$/i, "/v1"], + [/\/interactions(?:\/[^/]+(?:\/cancel)?)?$/i, ""], [/\/v1beta\/models$/i, "/v1beta"], [/\/v1\/models$/i, "/v1"], [/\/models$/i, ""] @@ -178,10 +181,13 @@ function normalizeProviderBaseUrlText(value: string, protocol?: GatewayProviderP if (protocol === "anthropic_messages") { return normalized.replace(/\/v1\/messages$/i, "").replace(/\/messages$/i, "").replace(/\/v1$/i, ""); } - if (protocol === "gemini_generate_content") { + if (protocol === "gemini_generate_content" || protocol === "gemini_interactions") { return normalized .replace(/\/v1beta\/models\/[^/]+:(generateContent|streamGenerateContent)$/i, "") .replace(/\/v1\/models\/[^/]+:(generateContent|streamGenerateContent)$/i, "") + .replace(/\/v1beta\/interactions(?:\/[^/]+(?:\/cancel)?)?$/i, "") + .replace(/\/v1\/interactions(?:\/[^/]+(?:\/cancel)?)?$/i, "") + .replace(/\/interactions(?:\/[^/]+(?:\/cancel)?)?$/i, "") .replace(/\/v1beta\/models$/i, "") .replace(/\/v1\/models$/i, "") .replace(/\/v1beta$/i, "") diff --git a/src/server/proxy/certificates.ts b/packages/core/src/proxy/certificates.ts similarity index 85% rename from src/server/proxy/certificates.ts rename to packages/core/src/proxy/certificates.ts index 29e4b4e..57bb1d6 100644 --- a/src/server/proxy/certificates.ts +++ b/packages/core/src/proxy/certificates.ts @@ -1,12 +1,15 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { createHash, randomBytes } from "node:crypto"; import net from "node:net"; import os from "node:os"; import path from "node:path"; import forge from "node-forge"; -import { CERTDIR, PROXY_CA_CERT_DER_FILE, PROXY_CA_CERT_FILE, PROXY_CA_KEY_FILE } from "../../main/constants"; +import { CERTDIR, PROXY_CA_CERT_DER_FILE, PROXY_CA_CERT_FILE, PROXY_CA_KEY_FILE } from "@ccr/core/config/constants"; const pki = forge.pki; +const certificateDirectoryMode = 0o700; +const certificateFileMode = 0o644; +const privateKeyFileMode = 0o600; export type CertificateAuthority = { cert: forge.pki.Certificate; @@ -25,8 +28,10 @@ type SubjectAltName = { }; export function ensureProxyCertificateAuthority(): void { - mkdirSync(CERTDIR, { recursive: true }); + mkdirSync(CERTDIR, { mode: certificateDirectoryMode, recursive: true }); + securePathPermissions(CERTDIR, certificateDirectoryMode); if (existsSync(PROXY_CA_CERT_FILE) && existsSync(PROXY_CA_KEY_FILE)) { + secureCertificateAuthorityFilePermissions(); ensureProxyCertificateDerFile(); return; } @@ -68,8 +73,9 @@ export function ensureProxyCertificateAuthority(): void { ]); cert.sign(keys.privateKey, forge.md.sha256.create()); - writeFileSync(PROXY_CA_CERT_FILE, pki.certificateToPem(cert), "utf8"); - writeFileSync(PROXY_CA_KEY_FILE, pki.privateKeyToPem(keys.privateKey), "utf8"); + writeFileSync(PROXY_CA_CERT_FILE, pki.certificateToPem(cert), { encoding: "utf8", mode: certificateFileMode }); + writeFileSync(PROXY_CA_KEY_FILE, pki.privateKeyToPem(keys.privateKey), { encoding: "utf8", mode: privateKeyFileMode }); + secureCertificateAuthorityFilePermissions(); ensureProxyCertificateDerFile(); } @@ -214,7 +220,21 @@ function ensureProxyCertificateDerFile(): boolean { function writeProxyCertificateDerFile(cert: forge.pki.Certificate): void { const der = forge.asn1.toDer(pki.certificateToAsn1(cert)).getBytes(); - writeFileSync(PROXY_CA_CERT_DER_FILE, Buffer.from(der, "binary")); + writeFileSync(PROXY_CA_CERT_DER_FILE, Buffer.from(der, "binary"), { mode: certificateFileMode }); + securePathPermissions(PROXY_CA_CERT_DER_FILE, certificateFileMode); +} + +function secureCertificateAuthorityFilePermissions(): void { + securePathPermissions(PROXY_CA_CERT_FILE, certificateFileMode); + securePathPermissions(PROXY_CA_KEY_FILE, privateKeyFileMode); + securePathPermissions(PROXY_CA_CERT_DER_FILE, certificateFileMode); +} + +function securePathPermissions(file: string, mode: number): void { + if (process.platform === "win32" || !existsSync(file)) { + return; + } + chmodSync(file, mode); } function createSerialNumber(): string { diff --git a/src/server/proxy/service.ts b/packages/core/src/proxy/service.ts similarity index 97% rename from src/server/proxy/service.ts rename to packages/core/src/proxy/service.ts index 2af626f..8c8cfa0 100644 --- a/src/server/proxy/service.ts +++ b/packages/core/src/proxy/service.ts @@ -1,6 +1,5 @@ import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { BrowserWindow, dialog, shell } from "electron"; import { chmodSync, writeFileSync } from "node:fs"; import http, { type ClientRequest, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import https from "node:https"; @@ -9,6 +8,7 @@ import os from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream"; import tls from "node:tls"; +import { pathToFileURL } from "node:url"; import { brotliDecompressSync, gunzipSync, inflateRawSync, inflateSync } from "node:zlib"; import type { AppConfig, @@ -21,10 +21,10 @@ import type { ProxyNetworkSnapshot, ProxyRouteTarget, ProxyStatus -} from "../../shared/app"; -import { PROXY_CA_CERT_FILE } from "../../main/constants"; -import { windowsSystemCommand } from "../../main/windows-system"; -import { pluginService, type GatewayPluginProxyRouteMatch } from "../../main/plugins/service"; +} from "@ccr/core/contracts/app"; +import { PROXY_CA_CERT_FILE } from "@ccr/core/config/constants"; +import { windowsSystemCommand } from "@ccr/core/platform/windows-system"; +import { pluginService, type GatewayPluginProxyRouteMatch } from "@ccr/core/plugins/service"; import { createCertificateForHost, ensureProxyCertificateAuthority, @@ -36,8 +36,8 @@ import { readProxyCertificateFingerprintSha256, readProxyCertificateAuthority, type CertificateAuthority -} from "./certificates"; -import { formatUpstreamProxy, readCurrentSystemUpstreamProxy, systemProxyManager, type UpstreamProxyConfig, type UpstreamProxyServer } from "./system-proxy"; +} from "@ccr/core/proxy/certificates"; +import { formatUpstreamProxy, readCurrentSystemUpstreamProxy, systemProxyManager, type UpstreamProxyConfig, type UpstreamProxyServer } from "@ccr/core/proxy/system-proxy"; type MitmServer = { host: string; @@ -85,6 +85,11 @@ type ActiveProxyNetworkCapture = { setResponse: (statusCode: number, headers: CapturedHeaders | IncomingHttpHeaders) => void; }; +export type ProxyDesktopIntegration = { + requestMacosCertificateInstallPermission?: () => Promise; + revealFile?: (file: string) => Promise; +}; + const requestHopByHopHeaders = new Set([ "connection", "keep-alive", @@ -119,6 +124,7 @@ class ProxyService { private authority?: CertificateAuthority; private attachedServer?: AttachedServer; private config?: AppConfig; + private desktopIntegration: ProxyDesktopIntegration = {}; private networkCaptureEnabled = false; private networkCaptures: ProxyNetworkCaptureRecord[] = []; private secureServers = new Map>(); @@ -134,6 +140,10 @@ class ProxyService { }; private upstreamProxy?: UpstreamProxyConfig; + setDesktopIntegration(integration: ProxyDesktopIntegration): void { + this.desktopIntegration = integration; + } + async start(config: AppConfig): Promise { await this.stop(); this.config = config; @@ -375,7 +385,7 @@ class ProxyService { async installCertificate(): Promise { ensureProxyCertificateAuthority(); if (process.platform === "darwin") { - const approved = await requestMacosCertificateInstallPermission(); + const approved = await this.requestMacosCertificateInstallPermission(); if (!approved) { const status = await this.getCertificateStatus(); return { @@ -398,7 +408,7 @@ class ProxyService { const installerFile = await openMacosTerminalCertificateInstaller(); terminalMessage = ` Opened Terminal installer: ${installerFile}`; } catch (terminalError) { - shell.showItemInFolder(PROXY_CA_CERT_FILE); + await this.revealFile(PROXY_CA_CERT_FILE).catch(() => undefined); terminalMessage = ` Could not open Terminal installer: ${formatError(terminalError)}`; } const status = await this.getCertificateStatus(); @@ -455,6 +465,18 @@ class ProxyService { }; } + private async requestMacosCertificateInstallPermission(): Promise { + return await (this.desktopIntegration.requestMacosCertificateInstallPermission?.() ?? Promise.resolve(true)); + } + + private async revealFile(file: string): Promise { + if (this.desktopIntegration.revealFile) { + await this.desktopIntegration.revealFile(file); + return; + } + await revealFileWithSystemCommand(file); + } + async getCertificateStatus(): Promise { const base = { caCertFile: proxyCaCertFile(), @@ -1469,22 +1491,6 @@ function execFilePromise(file: string, args: string[]): Promise { }); } -async function requestMacosCertificateInstallPermission(): Promise { - const window = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; - const options = { - buttons: ["Continue", "Cancel"], - cancelId: 1, - defaultId: 0, - detail: - "macOS will ask for administrator credentials. This is required so Chrome can trust HTTPS certificates generated by CCR proxy mode.", - message: "Install CCR Proxy CA into the macOS System keychain?", - noLink: true, - type: "warning" as const - }; - const result = window ? await dialog.showMessageBox(window, options) : await dialog.showMessageBox(options); - return result.response === 0; -} - function execFileText(file: string, args: string[]): Promise { return new Promise((resolve, reject) => { execFile(file, args, { maxBuffer: 8 * 1024 * 1024, windowsHide: process.platform === "win32" }, (error, stdout, stderr) => { @@ -1552,13 +1558,22 @@ async function openMacosTerminalCertificateInstaller(): Promise { const installerFile = path.join(os.tmpdir(), `ccr-install-proxy-ca-${randomUUID()}.command`); writeFileSync(installerFile, `${macosTerminalCertificateInstallScript()}\n`, "utf8"); chmodSync(installerFile, 0o700); - const errorMessage = await shell.openPath(installerFile); - if (errorMessage) { - throw new Error(errorMessage); - } + await execFilePromise("/usr/bin/open", [installerFile]); return installerFile; } +async function revealFileWithSystemCommand(file: string): Promise { + if (process.platform === "darwin") { + await execFilePromise("/usr/bin/open", ["-R", file]); + return; + } + if (process.platform === "win32") { + await execFilePromise("explorer.exe", ["/select,", file]); + return; + } + await execFilePromise("xdg-open", [pathToFileURL(path.dirname(file)).toString()]); +} + function macosTerminalCertificateInstallScript(): string { return [ "#!/bin/zsh", diff --git a/src/main/system-proxy-fetch.ts b/packages/core/src/proxy/system-proxy-fetch.ts similarity index 73% rename from src/main/system-proxy-fetch.ts rename to packages/core/src/proxy/system-proxy-fetch.ts index 3851f69..f1e10bc 100644 --- a/src/main/system-proxy-fetch.ts +++ b/packages/core/src/proxy/system-proxy-fetch.ts @@ -1,7 +1,7 @@ import { ProxyAgent, type Dispatcher } from "undici"; -import { loadAppConfig } from "./config"; -import { readCurrentSystemUpstreamProxy, systemProxyManager, type UpstreamProxyConfig, type UpstreamProxyServer } from "../server/proxy/system-proxy"; -import type { AppConfig } from "../shared/app"; +import { loadAppConfig } from "@ccr/core/config/config"; +import { readCurrentSystemUpstreamProxy, systemProxyManager, type UpstreamProxyConfig, type UpstreamProxyServer } from "@ccr/core/proxy/system-proxy"; +import type { AppConfig } from "@ccr/core/contracts/app"; type FetchInitWithDispatcher = RequestInit & { dispatcher?: Dispatcher; @@ -38,16 +38,26 @@ export async function fetchWithSystemProxy(input: RequestInfo | URL, init?: Requ } as FetchInitWithDispatcher); } +export function readEnvProxyUrl(): string | undefined { + const envProxy = process.env.HTTPS_PROXY || process.env.https_proxy || + process.env.HTTP_PROXY || process.env.http_proxy; + return envProxy ? envProxy.trim() : undefined; +} + export async function getSystemProxyUrlForProtocol(protocol: "http" | "https" = "https"): Promise { const cache = await readSystemProxy(); const server = proxyServerForRequest(cache.upstreamProxy, protocol); - return server ? formatProxyUrl(server) : undefined; + if (server) return formatProxyUrl(server); + + return readEnvProxyUrl(); } async function systemProxyUrlForRequest(url: URL): Promise { const cache = await readSystemProxy(); const server = proxyServerForRequest(cache.upstreamProxy, url.protocol === "https:" ? "https" : "http"); - return server ? formatProxyUrl(server) : undefined; + if (server) return formatProxyUrl(server); + + return readEnvProxyUrl(); } async function readSystemProxy(): Promise { @@ -187,12 +197,65 @@ function isHttpUrl(url: URL): boolean { function shouldBypassProxy(url: URL): boolean { const hostname = normalizeHostname(url.hostname); - return hostname === "localhost" || + if (hostname === "localhost" || hostname === "127.0.0.1" || hostname.startsWith("127.") || hostname === "0.0.0.0" || hostname === "::1" || - hostname === "0:0:0:0:0:0:0:1"; + hostname === "0:0:0:0:0:0:0:1") { + return true; + } + + const noProxy = process.env.NO_PROXY || process.env.no_proxy; + if (!noProxy) return false; + + const patterns = noProxy.split(",").map((s) => s.trim()).filter(Boolean); + for (const pattern of patterns) { + if (pattern === "*") return true; + + if (pattern.startsWith(".")) { + if (hostname.endsWith(pattern.toLowerCase()) || hostname === pattern.slice(1).toLowerCase()) return true; + continue; + } + + if (pattern.includes("/") && isPlainIp(hostname)) { + if (isInCidr(hostname, pattern)) return true; + continue; + } + + if (hostname === pattern.toLowerCase()) return true; + } + + return false; +} + +function isPlainIp(s: string): boolean { + return /^\d+\.\d+\.\d+\.\d+$/.test(s); +} + +function isInCidr(ip: string, cidr: string): boolean { + const [network, prefixStr] = cidr.split("/"); + const prefix = parseInt(prefixStr, 10); + if (isNaN(prefix) || prefix < 0 || prefix > 32) return false; + + const ipInt = ipToInt(ip); + const netInt = ipToInt(network); + if (ipInt === null || netInt === null) return false; + + const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0; + return (ipInt & mask) === (netInt & mask); +} + +function ipToInt(ip: string): number | null { + const parts = ip.split("."); + if (parts.length !== 4) return null; + let result = 0; + for (const part of parts) { + const num = parseInt(part, 10); + if (isNaN(num) || num < 0 || num > 255) return null; + result = (result << 8) | num; + } + return result >>> 0; } function normalizeHostname(hostname: string): string { diff --git a/src/server/proxy/system-proxy.ts b/packages/core/src/proxy/system-proxy.ts similarity index 99% rename from src/server/proxy/system-proxy.ts rename to packages/core/src/proxy/system-proxy.ts index 90d91eb..c17f7e8 100644 --- a/src/server/proxy/system-proxy.ts +++ b/packages/core/src/proxy/system-proxy.ts @@ -1,9 +1,9 @@ import { execFile } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; -import type { ProxySystemStatus } from "../../shared/app"; -import { DATADIR } from "../../main/constants"; -import { windowsSystemCommand } from "../../main/windows-system"; +import type { ProxySystemStatus } from "@ccr/core/contracts/app"; +import { DATADIR } from "@ccr/core/config/constants"; +import { windowsSystemCommand } from "@ccr/core/platform/windows-system"; export type UpstreamProxyServer = { host: string; diff --git a/packages/core/src/proxy/undici-proxy-agent.ts b/packages/core/src/proxy/undici-proxy-agent.ts new file mode 100644 index 0000000..e94e364 --- /dev/null +++ b/packages/core/src/proxy/undici-proxy-agent.ts @@ -0,0 +1 @@ +export { ProxyAgent } from "undici"; diff --git a/packages/core/src/runtime/app-paths.ts b/packages/core/src/runtime/app-paths.ts new file mode 100644 index 0000000..874977f --- /dev/null +++ b/packages/core/src/runtime/app-paths.ts @@ -0,0 +1,81 @@ +import os from "node:os"; +import path from "node:path"; + +export const APP_NAME = "Claude Code Router"; +export const APP_STORAGE_NAME = "claude-code-router"; +export const LEGACY_CONFIGDIR = path.join(os.homedir(), ".claude-code-router"); + +const homeDirEnv = "CCR_INTERNAL_HOME_DIR"; +const appDataDirEnv = "CCR_INTERNAL_APP_DATA_DIR"; +const userDataDirEnv = "CCR_INTERNAL_USER_DATA_DIR"; + +type RuntimePathName = "appData" | "home" | "userData"; + +export type RuntimeAppPaths = Partial>; + +export function setRuntimeAppPaths(paths: RuntimeAppPaths): void { + setPathEnv(homeDirEnv, paths.home); + setPathEnv(appDataDirEnv, paths.appData); + setPathEnv(userDataDirEnv, paths.userData); +} + +export function resolveRuntimeAppPath(name: RuntimePathName): string { + const configured = readConfiguredPath(name); + if (configured) { + return configured; + } + if (name === "home") { + return os.homedir(); + } + if (name === "appData") { + return fallbackAppDataDir(); + } + return fallbackUserDataDir(); +} + +export function resolveRuntimeConfigDir(): string { + if (process.platform === "win32") { + return path.join(resolveRuntimeAppPath("appData"), APP_STORAGE_NAME); + } + return path.join(resolveRuntimeAppPath("home"), `.${APP_STORAGE_NAME}`); +} + +export function resolveRuntimeDataDir(): string { + const configured = readConfiguredPath("userData"); + if (configured) { + return configured; + } + if (process.platform === "win32") { + return resolveRuntimeConfigDir(); + } + return path.join(resolveRuntimeConfigDir(), "app-data"); +} + +function readConfiguredPath(name: RuntimePathName): string | undefined { + const key = name === "home" + ? homeDirEnv + : name === "appData" + ? appDataDirEnv + : userDataDirEnv; + const value = process.env[key]?.trim(); + return value || undefined; +} + +function setPathEnv(key: string, value: string | undefined): void { + if (value?.trim()) { + process.env[key] = value; + } +} + +function fallbackAppDataDir(): string { + if (process.platform === "win32") { + return process.env.APPDATA || + process.env.LOCALAPPDATA || + (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, "AppData", "Roaming") : path.join(os.homedir(), "AppData", "Roaming")); + } + return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); +} + +function fallbackUserDataDir(): string { + return resolveRuntimeDataDir(); +} diff --git a/packages/core/src/storage/migration.ts b/packages/core/src/storage/migration.ts new file mode 100644 index 0000000..dda7cc2 --- /dev/null +++ b/packages/core/src/storage/migration.ts @@ -0,0 +1,23 @@ +import { cpSync, existsSync, mkdirSync } from "node:fs"; +import path from "node:path"; + +export function copyMissingDirectoryContents(source: string, target: string, label: string): void { + if (!source || !target || sameFilesystemPath(source, target) || !existsSync(source)) { + return; + } + + try { + mkdirSync(target, { recursive: true }); + cpSync(source, target, { errorOnExist: false, force: false, recursive: true }); + } catch (error) { + console.warn(`Failed to migrate ${label} from ${source} to ${target}: ${formatError(error)}`); + } +} + +export function sameFilesystemPath(left: string, right: string): boolean { + return path.resolve(left).toLowerCase() === path.resolve(right).toLowerCase(); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/sqlite-native.ts b/packages/core/src/storage/sqlite-native.ts similarity index 100% rename from src/main/sqlite-native.ts rename to packages/core/src/storage/sqlite-native.ts diff --git a/src/main/usage-normalization.ts b/packages/core/src/usage/normalization.ts similarity index 83% rename from src/main/usage-normalization.ts rename to packages/core/src/usage/normalization.ts index 03ca697..e2355b8 100644 --- a/src/main/usage-normalization.ts +++ b/packages/core/src/usage/normalization.ts @@ -1,4 +1,4 @@ -import type { GatewayProviderProtocol } from "../shared/app"; +import type { GatewayProviderProtocol } from "@ccr/core/contracts/app"; export type UsageTokenAccounting = { cacheReadTokens?: number; @@ -60,7 +60,12 @@ function inputIncludesCacheTokensForProtocol(protocol: GatewayProviderProtocol | if (protocol === "anthropic_messages") { return false; } - if (protocol === "openai_chat_completions" || protocol === "openai_responses" || protocol === "gemini_generate_content") { + if ( + protocol === "openai_chat_completions" || + protocol === "openai_responses" || + protocol === "gemini_generate_content" || + protocol === "gemini_interactions" + ) { return true; } return undefined; @@ -71,7 +76,12 @@ function inputIncludesCacheTokensForPath(path: string | undefined): boolean | un if (!normalized) { return undefined; } - if (normalized.includes("/chat/completions") || normalized.includes("/responses") || normalized.includes(":generatecontent")) { + if ( + normalized.includes("/chat/completions") || + normalized.includes("/responses") || + normalized.includes(":generatecontent") || + normalized.includes("/interactions") + ) { return true; } if (normalized.includes("/messages")) { diff --git a/src/main/usage-store.ts b/packages/core/src/usage/store.ts similarity index 65% rename from src/main/usage-store.ts rename to packages/core/src/usage/store.ts index da17971..1df1839 100644 --- a/src/main/usage-store.ts +++ b/packages/core/src/usage/store.ts @@ -1,10 +1,12 @@ -import { mkdirSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; import { EventEmitter } from "node:events"; -import { dirname } from "node:path"; -import { USAGE_DB_FILE } from "./constants"; -import { estimateUsageCostUsd } from "./model-pricing-service"; -import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "./sqlite-native"; -import { normalizeUsageInputTokens } from "./usage-normalization"; +import { randomBytes } from "node:crypto"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "@ccr/core/config/constants"; +import { estimateUsageCostUsd } from "@ccr/core/models/pricing-service"; +import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "@ccr/core/storage/sqlite-native"; +import { normalizeUsageInputTokens } from "@ccr/core/usage/normalization"; import type { GatewayProviderProtocol, UsageComparisonRow, @@ -13,7 +15,7 @@ import type { UsageStatsRange, UsageStatsSnapshot, UsageTotals -} from "../shared/app"; +} from "@ccr/core/contracts/app"; type SqlDatabase = BetterSqliteDatabase; type SqlValue = bigint | Buffer | number | string | null; @@ -48,6 +50,7 @@ type UsageCaptureInput = { fallbackModel?: string; method: string; path: string; + providerName?: string; providerProtocol?: GatewayProviderProtocol; requestId?: string; responseHeaders: Headers; @@ -58,6 +61,15 @@ type UsageStatsQueryOptions = { includeProxy?: boolean; }; +type UsageStoreOptions = { + requestLogDbFile?: string; +}; + +type UsageWhereClause = { + params: SqlValue[]; + where: string; +}; + type StoredUsageEvent = { cacheReadTokens: number; cacheWriteTokens: number; @@ -84,6 +96,7 @@ type UsageSnapshot = UsageNumbers & { }; const usageEvents = new EventEmitter(); +const usageStatsRanges = new Set(["today", "24h", "7d", "30d"]); const emptyTotals: UsageTotals = { avgDurationMs: 0, cacheRatio: 0, @@ -97,11 +110,15 @@ const emptyTotals: UsageTotals = { totalTokens: 0 }; -class UsageStore { +export class UsageStore { private database?: SqlDatabase; private initPromise?: Promise; + private readonly requestLogDbFile?: string; + private requestLogBackfillFailureLogged = false; - constructor(private readonly dbFile: string) {} + constructor(private readonly dbFile: string, options: UsageStoreOptions = {}) { + this.requestLogDbFile = options.requestLogDbFile; + } async record(event: UsageEventInput): Promise { const database = await this.getDatabase(); @@ -169,31 +186,30 @@ class UsageStore { usageEvents.emit("recorded"); } - async getStats(range: UsageStatsRange = "7d", filter: UsageStatsFilter = {}): Promise { + async getStats(range: UsageStatsRange | null | undefined = "7d", filter: UsageStatsFilter | null | undefined = {}): Promise { const database = await this.getDatabase(); const now = new Date(); - const since = getRangeSince(range, now); - const query = buildUsageStatsQuery(since, filter); - const events = queryRows(database, query.sql, query.params).map(toStoredUsageEvent); + const normalizedRange = normalizeUsageRange(range); + const since = getRangeSince(normalizedRange, now); + this.backfillFromRequestLogs(database, since); + const query = buildUsageWhereClause(since, filter); return { - clientModels: buildClientModelRows(events), + clientModels: readClientModelRows(database, query), generatedAt: now.toISOString(), - models: buildModelRows(events), - providerModels: buildProviderModelRows(events), - range, - recentRequests: buildRecentRequestRows(events), - series: buildSeries(range, now, events), - totals: buildTotals(events) + models: readModelRows(database, query), + providerModels: readProviderModelRows(database, query), + range: normalizedRange, + recentRequests: readRecentRequestRows(database, query), + series: readUsageSeries(database, normalizedRange, now, query), + totals: readUsageTotals(database, query) }; } - async getTotalsSince(since: Date, filter: UsageStatsFilter = {}, options: UsageStatsQueryOptions = {}): Promise { + async getTotalsSince(since: Date, filter: UsageStatsFilter | null | undefined = {}, options: UsageStatsQueryOptions | null | undefined = {}): Promise { const database = await this.getDatabase(); - const query = buildUsageStatsQuery(since, filter, options); - const events = queryRows(database, query.sql, query.params).map(toStoredUsageEvent); - - return buildTotals(events); + this.backfillFromRequestLogs(database, since); + return readUsageTotals(database, buildUsageWhereClause(since, filter, options)); } private async getDatabase(): Promise { @@ -240,9 +256,100 @@ class UsageStore { this.database = database; return database; } + + private backfillFromRequestLogs(database: SqlDatabase, since: Date): void { + const requestLogDbFile = this.requestLogDbFile; + if (!requestLogDbFile || !existsSync(requestLogDbFile)) { + return; + } + + let tempRequestLogDbFile: string | undefined; + try { + try { + this.backfillFromAttachedRequestLog(database, requestLogDbFile, since); + } catch { + tempRequestLogDbFile = copySqliteDatabaseToTemp(requestLogDbFile); + this.backfillFromAttachedRequestLog(database, tempRequestLogDbFile, since); + } + this.requestLogBackfillFailureLogged = false; + } catch (error) { + if (!this.requestLogBackfillFailureLogged) { + console.warn(`[usage] Failed to backfill usage from request logs: ${formatError(error)}`); + this.requestLogBackfillFailureLogged = true; + } + } finally { + if (tempRequestLogDbFile) { + cleanupSqliteTempCopy(tempRequestLogDbFile); + } + } + } + + private backfillFromAttachedRequestLog(database: SqlDatabase, requestLogDbFile: string, since: Date): void { + database.exec(`ATTACH DATABASE ${sqlString(requestLogDbFile)} AS request_log_source`); + try { + database.prepare(` + INSERT INTO usage_events ( + created_at, + request_id, + client, + method, + path, + model, + provider, + credential_id, + status_code, + duration_ms, + input_tokens, + output_tokens, + cache_read_tokens, + cache_write_tokens, + total_tokens, + cost_usd, + cost_source + ) + SELECT + logs.created_at, + logs.request_id, + logs.client, + logs.method, + logs.path, + logs.model, + logs.provider, + logs.credential_id, + logs.status_code, + logs.duration_ms, + logs.input_tokens, + logs.output_tokens, + logs.cache_read_tokens, + logs.cache_write_tokens, + logs.total_tokens, + logs.cost_usd, + 'request_log' + FROM request_log_source.request_logs AS logs + WHERE logs.source_usage_id IS NULL + AND logs.path NOT LIKE ? + AND logs.created_at >= ? + AND NOT EXISTS ( + SELECT 1 + FROM usage_events AS existing + WHERE ( + logs.request_id <> '' + AND existing.request_id = logs.request_id + ) OR ( + logs.request_id = '' + AND existing.created_at = logs.created_at + AND existing.path = logs.path + AND existing.model = logs.model + ) + ) + `).run("%/count_tokens%", since.toISOString()); + } finally { + database.exec("DETACH DATABASE request_log_source"); + } + } } -export const usageStore = new UsageStore(USAGE_DB_FILE); +export const usageStore = new UsageStore(USAGE_DB_FILE, { requestLogDbFile: REQUEST_LOGS_DB_FILE }); export function onUsageRecorded(listener: () => void): () => void { usageEvents.on("recorded", listener); @@ -276,18 +383,22 @@ function ensureUsageSchema(database: SqlDatabase): void { database.exec("CREATE INDEX IF NOT EXISTS usage_events_credential_id_idx ON usage_events(credential_id)"); database.exec("CREATE INDEX IF NOT EXISTS usage_events_model_idx ON usage_events(model)"); database.exec("CREATE INDEX IF NOT EXISTS usage_events_path_idx ON usage_events(path)"); + database.exec("CREATE INDEX IF NOT EXISTS usage_events_created_filter_idx ON usage_events(created_at, provider, model, credential_id)"); + database.exec("CREATE INDEX IF NOT EXISTS usage_events_provider_created_at_idx ON usage_events(provider, created_at)"); + database.exec("CREATE INDEX IF NOT EXISTS usage_events_model_created_at_idx ON usage_events(model, created_at)"); + database.exec("CREATE INDEX IF NOT EXISTS usage_events_credential_created_at_idx ON usage_events(credential_id, created_at)"); } -export async function getUsageStats(range?: UsageStatsRange, filter?: UsageStatsFilter): Promise { +export async function getUsageStats(range?: UsageStatsRange | null, filter?: UsageStatsFilter | null): Promise { try { return await usageStore.getStats(range, filter); } catch (error) { console.warn(`[usage] Failed to read usage stats: ${formatError(error)}`); - return emptySnapshot(range ?? "7d"); + return emptySnapshot(normalizeUsageRange(range)); } } -export async function getTodayUsageTotals(filter?: UsageStatsFilter, options?: UsageStatsQueryOptions): Promise { +export async function getTodayUsageTotals(filter?: UsageStatsFilter | null, options?: UsageStatsQueryOptions | null): Promise { try { return await usageStore.getTotalsSince(floorDay(new Date()), filter, options); } catch (error) { @@ -296,7 +407,7 @@ export async function getTodayUsageTotals(filter?: UsageStatsFilter, options?: U } } -export async function getUsageTotalsSince(since: Date, filter?: UsageStatsFilter, options?: UsageStatsQueryOptions): Promise { +export async function getUsageTotalsSince(since: Date, filter?: UsageStatsFilter | null, options?: UsageStatsQueryOptions | null): Promise { try { return await usageStore.getTotalsSince(since, filter, options); } catch (error) { @@ -316,6 +427,7 @@ export async function recordGatewayUsageCapture(input: UsageCaptureInput): Promi }); const route = splitRouteSelector(input.fallbackModel); const provider = + input.providerName ?? readHeader(input.responseHeaders, "x-gateway-target-provider-name") ?? readHeader(input.responseHeaders, "x-gateway-target-provider") ?? route.provider; @@ -337,21 +449,23 @@ export async function recordGatewayUsageCapture(input: UsageCaptureInput): Promi } } -function buildUsageStatsQuery( +function buildUsageWhereClause( since: Date, - filter: UsageStatsFilter, - options: UsageStatsQueryOptions = {} -): { params: SqlValue[]; sql: string } { + filter: UsageStatsFilter | null | undefined, + options: UsageStatsQueryOptions | null | undefined = {} +): UsageWhereClause { + const normalizedFilter = normalizeUsageFilter(filter); + const normalizedOptions = normalizeUsageQueryOptions(options); const where = ["created_at >= ?"]; const params: SqlValue[] = [since.toISOString()]; - const credential = normalizeFilterValue(filter.credential); - const provider = normalizeFilterValue(filter.provider); - const model = normalizeFilterValue(filter.model); + const credential = normalizeFilterValue(normalizedFilter.credential); + const provider = normalizeFilterValue(normalizedFilter.provider); + const model = normalizeFilterValue(normalizedFilter.model); if (provider) { where.push("provider = ?"); params.push(provider); - } else if (!options.includeProxy && filter.includeProxy !== true) { + } else if (!normalizedOptions.includeProxy && normalizedFilter.includeProxy !== true) { where.push("provider <> ?"); params.push("proxy"); } @@ -366,33 +480,30 @@ function buildUsageStatsQuery( return { params, - sql: ` - SELECT - id, - created_at, - request_id, - client, - method, - path, - model, - provider, - credential_id, - status_code, - duration_ms, - input_tokens, - output_tokens, - cache_read_tokens, - cache_write_tokens, - total_tokens, - cost_usd, - cost_source - FROM usage_events - WHERE ${where.join(" AND ")} - ORDER BY created_at ASC - ` + where: where.join(" AND ") }; } +function normalizeUsageRange(range: UsageStatsRange | null | undefined): UsageStatsRange { + return range && usageStatsRanges.has(range) ? range : "7d"; +} + +function normalizeUsageFilter(filter: UsageStatsFilter | null | undefined): UsageStatsFilter { + if (!isRecord(filter)) { + return {}; + } + return { + credential: typeof filter.credential === "string" ? filter.credential : undefined, + includeProxy: filter.includeProxy === true, + model: typeof filter.model === "string" ? filter.model : undefined, + provider: typeof filter.provider === "string" ? filter.provider : undefined + }; +} + +function normalizeUsageQueryOptions(options: UsageStatsQueryOptions | null | undefined): UsageStatsQueryOptions { + return isRecord(options) && options.includeProxy === true ? { includeProxy: true } : {}; +} + function configureSqliteDatabase(database: SqlDatabase): void { database.pragma("journal_mode = WAL"); database.pragma("synchronous = NORMAL"); @@ -403,6 +514,28 @@ function queryRows(database: SqlDatabase, sql: string, params: SqlValue[] = []): return database.prepare(sql).all(...params) as Record[]; } +function sqlString(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +function copySqliteDatabaseToTemp(file: string): string { + const target = join(tmpdir(), `ccr-request-logs-${process.pid}-${Date.now()}-${randomBytes(4).toString("hex")}.sqlite`); + copyFileSync(file, target); + for (const suffix of ["-wal", "-shm"]) { + const source = `${file}${suffix}`; + if (existsSync(source)) { + copyFileSync(source, `${target}${suffix}`); + } + } + return target; +} + +function cleanupSqliteTempCopy(file: string): void { + for (const item of [file, `${file}-wal`, `${file}-shm`]) { + rmSync(item, { force: true }); + } +} + function toStoredUsageEvent(row: Record): StoredUsageEvent { return { cacheReadTokens: normalizeCount(row.cache_read_tokens), @@ -426,6 +559,220 @@ function toStoredUsageEvent(row: Record): StoredUsageEvent { }; } +const usageTotalsSelect = ` + COUNT(*) AS request_count, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens, + COALESCE(SUM(CASE + WHEN total_tokens > 0 THEN total_tokens + ELSE input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + END), 0) AS computed_total_tokens, + COALESCE(SUM(COALESCE(cost_usd, 0)), 0) AS cost_usd, + COALESCE(SUM(duration_ms), 0) AS duration_ms, + COALESCE(SUM(CASE WHEN status_code >= 200 AND status_code < 400 THEN 1 ELSE 0 END), 0) AS success_count, + COALESCE(SUM(CASE + WHEN total_tokens - output_tokens > 0 THEN MAX(input_tokens, total_tokens - output_tokens) + ELSE input_tokens + cache_read_tokens + cache_write_tokens + END), 0) AS prompt_tokens +`; + +function readUsageTotals(database: SqlDatabase, query: UsageWhereClause): UsageTotals { + const row = queryRows( + database, + ` + SELECT + ${usageTotalsSelect} + FROM usage_events + WHERE ${query.where} + `, + query.params + )[0]; + return usageTotalsFromRow(row); +} + +function readUsageSeries( + database: SqlDatabase, + range: UsageStatsRange, + now: Date, + query: UsageWhereClause +): UsageSeriesPoint[] { + const unit: "day" | "hour" = range === "today" || range === "24h" ? "hour" : "day"; + const bucketExpression = unit === "hour" + ? "strftime('%Y-%m-%d %H:00', created_at, 'localtime')" + : "strftime('%Y-%m-%d', created_at, 'localtime')"; + const rows = queryRows( + database, + ` + SELECT + ${bucketExpression} AS bucket, + ${usageTotalsSelect} + FROM usage_events + WHERE ${query.where} + GROUP BY bucket + `, + query.params + ); + const totalsByBucket = new Map(rows.map((row) => [String(row.bucket ?? ""), usageTotalsFromRow(row)])); + + return buildBuckets(range, now).map(({ key, label }) => ({ + ...(totalsByBucket.get(key) ?? { ...emptyTotals }), + bucket: key, + label + })); +} + +function readModelRows(database: SqlDatabase, query: UsageWhereClause): UsageComparisonRow[] { + const rows = readUsageGroupRows( + database, + query, + "provider, model", + "provider, model, MAX(credential_id) AS credential_id", + 8 + ).map((row) => ({ + ...usageTotalsFromRow(row), + caption: normalizeLabel(String(row.provider ?? ""), "unknown"), + credentialId: normalizeFilterValue(String(row.credential_id ?? "")), + key: `${normalizeLabel(String(row.provider ?? ""), "unknown")}::${normalizeLabel(String(row.model ?? ""), "unknown")}`, + label: normalizeLabel(String(row.model ?? ""), "unknown"), + maxShare: 0, + model: normalizeLabel(String(row.model ?? ""), "unknown"), + provider: normalizeLabel(String(row.provider ?? ""), "unknown") + })); + return applyMaxShare(rows, (row) => row.totalTokens || row.requestCount); +} + +function readClientModelRows(database: SqlDatabase, query: UsageWhereClause): UsageComparisonRow[] { + const rows = readUsageGroupRows( + database, + query, + "client, provider, credential_id, model", + "client, provider, credential_id, model", + 25 + ).map((row) => { + const client = normalizeLabel(String(row.client ?? ""), "unknown"); + const model = normalizeLabel(String(row.model ?? ""), "unknown"); + const provider = normalizeLabel(String(row.provider ?? ""), "unknown"); + const credentialId = normalizeFilterValue(String(row.credential_id ?? "")) ?? ""; + return { + ...usageTotalsFromRow(row), + caption: credentialId ? `${provider} / ${credentialId} / ${model}` : `${provider} / ${model}`, + client, + credentialId: credentialId || undefined, + key: `${client}::${provider}::${credentialId}::${model}`, + label: client, + maxShare: 0, + model, + provider + }; + }); + return applyMaxShare(rows, (row) => row.totalTokens || row.requestCount); +} + +function readProviderModelRows(database: SqlDatabase, query: UsageWhereClause): UsageComparisonRow[] { + const rows = readUsageGroupRows( + database, + query, + "provider, credential_id, model", + "provider, credential_id, model", + 25 + ).map((row) => { + const model = normalizeLabel(String(row.model ?? ""), "unknown"); + const provider = normalizeLabel(String(row.provider ?? ""), "unknown"); + const credentialId = normalizeFilterValue(String(row.credential_id ?? "")) ?? ""; + return { + ...usageTotalsFromRow(row), + caption: credentialId ? `${credentialId} / ${model}` : model, + credentialId: credentialId || undefined, + key: `${provider}::${credentialId}::${model}`, + label: provider, + maxShare: 0, + model, + provider + }; + }); + return applyMaxShare(rows, (row) => row.totalTokens || row.requestCount); +} + +function readUsageGroupRows( + database: SqlDatabase, + query: UsageWhereClause, + groupBy: string, + selectColumns: string, + limit: number +): Record[] { + return queryRows( + database, + ` + SELECT + ${selectColumns}, + ${usageTotalsSelect} + FROM usage_events + WHERE ${query.where} + GROUP BY ${groupBy} + ORDER BY computed_total_tokens DESC, request_count DESC + LIMIT ? + `, + [...query.params, limit] + ); +} + +function readRecentRequestRows(database: SqlDatabase, query: UsageWhereClause): UsageComparisonRow[] { + const events = queryRows( + database, + ` + SELECT + id, + created_at, + request_id, + client, + method, + path, + model, + provider, + credential_id, + status_code, + duration_ms, + input_tokens, + output_tokens, + cache_read_tokens, + cache_write_tokens, + total_tokens, + cost_usd, + cost_source + FROM usage_events + WHERE ${query.where} + ORDER BY created_at DESC, id DESC + LIMIT 10 + `, + query.params + ).map(toStoredUsageEvent).reverse(); + return buildRecentRequestRows(events); +} + +function usageTotalsFromRow(row: Record | undefined): UsageTotals { + const requestCount = normalizeCount(row?.request_count); + if (requestCount === 0) { + return { ...emptyTotals }; + } + const successfulRequests = normalizeCount(row?.success_count); + const promptTokens = normalizeCount(row?.prompt_tokens); + const cacheTokens = normalizeCount(row?.cache_read_tokens); + return { + avgDurationMs: Math.round(normalizeCount(row?.duration_ms) / requestCount), + cacheRatio: ratio(cacheTokens, promptTokens), + cacheTokens, + costUsd: normalizeCost(row?.cost_usd), + errorCount: requestCount - successfulRequests, + inputTokens: normalizeCount(row?.input_tokens), + outputTokens: normalizeCount(row?.output_tokens), + requestCount, + successRate: successfulRequests / requestCount, + totalTokens: normalizeCount(row?.computed_total_tokens) + }; +} + function buildSeries(range: UsageStatsRange, now: Date, events: StoredUsageEvent[]): UsageSeriesPoint[] { const buckets = buildBuckets(range, now); const grouped = new Map(); diff --git a/packages/core/src/web/management-server.ts b/packages/core/src/web/management-server.ts new file mode 100644 index 0000000..966a087 --- /dev/null +++ b/packages/core/src/web/management-server.ts @@ -0,0 +1,1165 @@ +import { spawn } from "node:child_process"; +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import packageJson from "../../package.json"; +import { loadPersistedAppSetting, replacePersistedAppSetting } from "@ccr/core/config/app-config-store"; +import { scanBotHandoffBluetoothTargets, scanBotHandoffWifiTargets } from "@ccr/core/agents/bot-gateway/handoff-scan-service"; +import { cancelBotGatewayQrLogin, startBotGatewayQrLogin, waitBotGatewayQrLogin } from "@ccr/core/agents/bot-gateway/qr-login-service"; +import { syncClaudeAppGatewayConfig, restoreClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service"; +import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "@ccr/core/config/config"; +import { API_KEYS_DB_FILE, APP_CONFIG_DB_FILE, APP_NAME, CONFIGDIR, CONFIG_FILE, DATADIR, GATEWAY_CONFIG_FILE, LEGACY_CONFIG_FILE, ONBOARDING_FINISHED_FILE, PROXY_CA_CERT_FILE, REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "@ccr/core/config/constants"; +import { detectProviderIcon } from "@ccr/core/providers/icons"; +import { fetchProviderManifest } from "@ccr/core/providers/manifest-service"; +import { getLocalAgentProviderCandidates, importLocalAgentProvider } from "@ccr/core/agents/local-providers/service"; +import { getProviderCatalogModels } from "@ccr/core/providers/model-catalog"; +import { getProviderPresets } from "@ccr/core/providers/presets/index"; +import { checkGatewayProviderConnectivity, probeGatewayProvider, probeGatewayProviderCandidates } from "@ccr/core/providers/probe"; +import { applyProfileConfig } from "@ccr/core/profiles/service"; +import { getProfileOpenCommand, getProfileRuntimeStatus, openProfileFromCcr, stopProfileFromCcr } from "@ccr/core/profiles/launch-service"; +import { ensureProxyCertificateAuthority } from "@ccr/core/proxy/certificates"; +import { proxyService } from "@ccr/core/proxy/service"; +import { listMcpServerTools } from "@ccr/core/mcp/tool-discovery"; +import { getAgentAnalysis, getAgentTracePayload, getRequestLogDetail, getRequestLogs } from "@ccr/core/observability/request-log-store"; +import { getUsageStats } from "@ccr/core/usage/store"; +import { gatewayService } from "@ccr/core/gateway/service"; +import { shouldRestartGatewayForRuntimeConfigChange } from "@ccr/core/gateway/runtime-change"; +import { getProviderAccountSnapshots, invalidateProviderAccountSnapshotCache, resetCodexRateLimitCredit, testProviderAccountConnector } from "@ccr/core/providers/account-service"; +import type { + AgentAnalysisFilter, + AgentAnalysisTracePayloadRequest, + ApiKeyConfig, + AppConfig, + AppDataExportResult, + AppInfo, + AppSaveConfigOptions, + AppUpdateStatus, + BotGatewayQrLoginCancelRequest, + BotGatewayQrLoginStartRequest, + BotGatewayQrLoginWaitRequest, + BotGatewayQrWindowOpenRequest, + GatewayPluginAppConfig, + GatewayProviderConnectivityCheckRequest, + GatewayProviderProbeCandidatesRequest, + GatewayProviderProbeRequest, + GatewayStatus, + LocalAgentProviderImportRequest, + PluginDependency, + PluginDirectorySelection, + PluginMarketplaceEntry, + ProfileApplyResult, + ProfileOpenRequest, + ProviderAccountResetRequest, + ProviderAccountSnapshotRequestOptions, + ProviderAccountTestRequest, + ProviderCatalogModelsRequest, + ProviderIconDetectionRequest, + ProviderManifestFetchRequest, + RequestLogDetailRequest, + RequestLogListFilter, + UsageStatsFilter, + UsageStatsRange +} from "@ccr/core/contracts/app"; + +export type WebManagementServerOptions = { + authToken?: string; + host?: string; + open?: boolean; + port?: number; + startGateway?: boolean; +}; + +export type WebManagementServerRuntime = { + close: () => Promise; + server: Server; + url: string; +}; + +type RpcRequest = { + args?: unknown[]; + method?: string; +}; + +type RpcHandler = (...args: unknown[]) => Promise | unknown; + +type WebManagementSecurityContext = { + allowIpLiteralHosts: boolean; + allowedHostnames: Set; + authToken: string; + port: number; +}; + +const defaultWebHost = "127.0.0.1"; +const defaultWebPort = 3458; +const onboardingFinishedAtSettingKey = "onboardingFinishedAt"; +const maxRpcBodyBytes = 8 * 1024 * 1024; +const webAuthHeader = "x-ccr-web-auth"; +const webAuthQueryParam = "ccr_web_token"; +const staticRoot = path.resolve(__dirname, "..", "renderer"); +const homeHtmlFile = path.join(staticRoot, "pages", "home", "index.html"); +const rendererAssetsRoot = path.join(staticRoot, "assets"); +const webBridgeScriptTag = ' '; + +const pluginMarketplace: PluginMarketplaceEntry[] = [ + { + capabilities: ["Wrapper runtime", "Claude App proxy", "Claude Design", "Model routing"], + dependencies: [], + description: "Routes Claude App Design traffic through the local CCR wrapper backend with configurable model routing.", + id: "claude-design", + modulePath: path.join(__dirname, "..", "marketplace", "plugins", "claude-design-plugin.cjs"), + name: "Claude Design" + }, + { + capabilities: ["Wrapper runtime", "Proxy mode", "Cursor", "Model routing", "OpenAI/Anthropic/Gemini forwarding"], + dependencies: [], + description: "Routes Cursor-compatible LLM traffic captured by proxy mode into the local CCR gateway.", + id: "cursor-proxy", + modulePath: path.join(__dirname, "..", "marketplace", "plugins", "cursor-proxy-plugin.cjs"), + name: "Cursor Proxy" + } +]; + +export async function startWebManagementServer(options: WebManagementServerOptions = {}): Promise { + const host = options.host?.trim() || readEnvString("CCR_WEB_HOST") || defaultWebHost; + const requestedPort = options.port ?? readEnvPort("CCR_WEB_PORT") ?? defaultWebPort; + const authToken = options.authToken?.trim() || readEnvString("CCR_WEB_AUTH_TOKEN") || randomBytes(32).toString("base64url"); + let security: WebManagementSecurityContext | undefined; + const server = createServer((request, response) => { + if (!security) { + sendJson(response, 503, { error: { message: "CCR web management server is not ready." }, ok: false }); + return; + } + void handleRequest(request, response, security).catch((error) => { + sendJson(response, 500, { error: { message: formatError(error) }, ok: false }); + }); + }); + + const listenedPort = await listenWithFallback(server, requestedPort, host); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : listenedPort; + const baseUrl = `http://${formatListenHost(host)}:${port}/`; + const url = urlWithWebAuthToken(baseUrl, authToken); + security = createWebManagementSecurityContext(host, port, authToken); + + if (options.startGateway !== false) { + await startConfiguredServices("web startup"); + } + if (options.open) { + await openSystemExternal(url).catch((error) => { + console.warn(`[web] Failed to open ${url}: ${formatError(error)}`); + }); + } + + return { + close: async () => { + await closeServer(server); + await stopConfiguredServices(); + }, + server, + url + }; +} + +async function handleRequest(request: IncomingMessage, response: ServerResponse, security: WebManagementSecurityContext): Promise { + if (!isAllowedWebRequestHost(request, security)) { + sendText(response, 403, "Forbidden host"); + return; + } + + const url = requestUrl(request); + if (url.pathname === "/api/ccr/rpc") { + await handleRpcRequest(request, response, security); + return; + } + if (request.method !== "GET" && request.method !== "HEAD") { + sendText(response, 405, "Method not allowed"); + return; + } + if (url.pathname === "/" || url.pathname === "/pages/home/index.html") { + sendHomeHtml(response, request.method === "HEAD"); + return; + } + if (url.pathname.startsWith("/assets/")) { + sendStaticFile(response, rendererAssetsRoot, decodeURIComponent(url.pathname.slice("/assets/".length)), request.method === "HEAD"); + return; + } + sendText(response, 404, "Not found"); +} + +async function handleRpcRequest(request: IncomingMessage, response: ServerResponse, security: WebManagementSecurityContext): Promise { + if (request.method !== "POST") { + sendJson(response, 405, { error: { message: "RPC only supports POST." }, ok: false }); + return; + } + if (!isJsonRequest(request)) { + sendJson(response, 415, { error: { message: "RPC requests must use application/json." }, ok: false }); + return; + } + if (!isAllowedWebRequestOrigin(request, security)) { + sendJson(response, 403, { error: { message: "Forbidden RPC origin." }, ok: false }); + return; + } + if (!hasValidWebAuthToken(request, security)) { + sendJson(response, 401, { error: { message: "CCR web authentication token is missing or invalid." }, ok: false }); + return; + } + + let payload: RpcRequest; + try { + payload = JSON.parse((await readRequestBody(request, maxRpcBodyBytes)).toString("utf8")) as RpcRequest; + } catch (error) { + sendJson(response, 400, { error: { message: `Invalid JSON: ${formatError(error)}` }, ok: false }); + return; + } + + const method = typeof payload.method === "string" ? payload.method.trim() : ""; + const handler = rpcHandlers[method]; + if (!method || !handler) { + sendJson(response, 404, { error: { message: `Unknown CCR web RPC method: ${method || "(empty)"}` }, ok: false }); + return; + } + + try { + const value = await handler(...(Array.isArray(payload.args) ? payload.args : [])); + sendJson(response, 200, { ok: true, value }); + } catch (error) { + sendJson(response, 500, { error: { message: formatError(error) }, ok: false }); + } +} + +const unsupportedUpdateStatus: AppUpdateStatus = { + canCheck: false, + canDownload: false, + canInstall: false, + currentVersion: packageJson.version, + lastError: "Updates are only available in the desktop app.", + state: "idle", + supported: false +}; + +const rpcHandlers: Record = { + applyClaudeAppGateway: async (config?: unknown) => { + const previousConfig = await loadAppConfig(); + const baseConfig = config ? await saveAppConfig(config as AppConfig) : previousConfig; + const synced = await syncClaudeAppGatewayConfig(baseConfig); + const savedConfig = synced.config; + let runtimeStatus = gatewayService.getStatus(); + if (synced.configChanged || shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig) || runtimeStatus.state !== "running") { + runtimeStatus = await gatewayService.start(savedConfig); + } else { + gatewayService.updateConfig(savedConfig); + } + if (config || synced.configChanged) { + invalidateProviderAccountSnapshotCache(); + } + const gatewayDetail = runtimeStatus.state === "running" + ? "CCR gateway is running." + : `CCR gateway did not start: ${runtimeStatus.lastError || "unknown error"}`; + const apiKeyDetail = synced.result.apiKeyGenerated ? "Generated a Claude App API key." : "Reused an existing CCR API key."; + return { + ...synced.result, + message: `${synced.result.message}\n${gatewayDetail}\n${apiKeyDetail}` + }; + }, + applyProfile: async () => applyProfileConfig(await loadAppConfig()), + cancelBotGatewayQrLogin: (request) => cancelBotGatewayQrLogin(request as BotGatewayQrLoginCancelRequest), + checkProviderConnectivity: (request) => checkGatewayProviderConnectivity(request as GatewayProviderConnectivityCheckRequest), + clearProxyNetworkCaptures: () => proxyService.clearNetworkCaptures(), + closeBotGatewayQrWindow: (_request) => ({ closed: false }), + detectProviderIcon: (request) => detectProviderIcon(request as ProviderIconDetectionRequest), + exportData: () => exportAppData(), + fetchProviderManifest: (request) => fetchProviderManifest(request as ProviderManifestFetchRequest), + getAgentAnalysis: (filter) => getAgentAnalysis(filter as AgentAnalysisFilter | undefined), + getAgentTracePayload: (request) => getAgentTracePayload(request as AgentAnalysisTracePayloadRequest), + getAppInfo: () => getCliAppInfo(), + getConfig: () => loadAppConfig(), + getGatewayStatus: () => gatewayService.getStatus(), + getServiceIdentity: (serviceToken) => ({ + pid: process.pid, + serviceTokenConfigured: Boolean(process.env.CCR_SERVICE_INSTANCE_TOKEN?.trim()), + serviceTokenMatches: typeof serviceToken === "string" && + Boolean(serviceToken.trim()) && + serviceToken === process.env.CCR_SERVICE_INSTANCE_TOKEN + }), + getLocalAgentProviderCandidates: () => getLocalAgentProviderCandidates(), + getOnboardingFinished: async () => Boolean(readString(await loadPersistedAppSetting(onboardingFinishedAtSettingKey)) || existsSync(ONBOARDING_FINISHED_FILE)), + getPluginMarketplace: () => pluginMarketplace, + getProfileOpenCommand: async (request) => getProfileOpenCommand(await loadAppConfig(), request as ProfileOpenRequest), + getProfileRuntimeStatus: () => getProfileRuntimeStatus(), + getProviderAccountSnapshots: (provider, options) => getProviderAccountSnapshots(provider as string | undefined, options as ProviderAccountSnapshotRequestOptions | undefined), + getProviderCatalogModels: (request) => getProviderCatalogModels(request as ProviderCatalogModelsRequest), + getProviderPresets: () => getProviderPresets(), + getProxyCertificateStatus: () => proxyService.getCertificateStatus(), + getProxyNetworkCaptures: () => proxyService.getNetworkCaptures(), + getProxyStatus: () => proxyService.getStatus(), + getRequestLogDetail: (request) => getRequestLogDetail(request as RequestLogDetailRequest), + getRequestLogs: (filter) => getRequestLogs(filter as RequestLogListFilter | undefined), + getUpdateStatus: () => unsupportedUpdateStatus, + getUsageStats: (range, filter) => getUsageStats(range as UsageStatsRange | undefined, filter as UsageStatsFilter | undefined), + importLocalAgentProvider: (request) => importLocalAgentProvider(request as LocalAgentProviderImportRequest), + installProxyCertificate: () => proxyService.installCertificate(), + listMcpServerTools: async (serverName) => { + const name = typeof serverName === "string" ? serverName.trim() : ""; + if (!name) { + throw new Error("MCP server name is required."); + } + const config = await loadAppConfig(); + const server = config.agent.mcpServers.find((candidate) => candidate.name === name); + if (!server) { + throw new Error("MCP server must be saved before tool discovery."); + } + return listMcpServerTools(server); + }, + openBotGatewayQrWindow: async (request) => { + const qrRequest = request as BotGatewayQrWindowOpenRequest; + await openSystemExternal(qrRequest.url); + return { + message: "Opened the QR login URL in your default browser.", + observed: false, + opened: true + }; + }, + openBuiltInBrowser: async () => { + const config = await loadAppConfig(); + const appUrl = firstConfiguredBrowserAppUrl(config) || "about:blank"; + if (appUrl === "about:blank") { + throw new Error("No browser app is configured."); + } + await openSystemExternal(appUrl); + }, + openProfile: async (request) => { + const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig()); + const config = syncedClaudeAppConfig.config; + const status = await gatewayService.start(config); + if (status.state !== "running") { + throw new Error(status.lastError || "CCR gateway did not start."); + } + logProfileApplyResult(await applyProfileConfig(config)); + return openProfileFromCcr(config, request as ProfileOpenRequest); + }, + probeProvider: (request) => probeGatewayProvider(request as GatewayProviderProbeRequest), + probeProviderCandidates: (request) => probeGatewayProviderCandidates(request as GatewayProviderProbeCandidatesRequest), + quitApp: async () => { + setTimeout(() => process.kill(process.pid, "SIGTERM"), 50).unref(); + }, + restartGateway: async () => { + const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig()); + const config = syncedClaudeAppConfig.config; + const status = await gatewayService.start(config); + await applyProfileIfServiceRunning(config, status); + return status; + }, + restartProxy: async () => { + const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig()); + const config = syncedClaudeAppConfig.config; + const status = await gatewayService.start(config); + await applyProfileIfServiceRunning(config, status); + return proxyService.getStatus(); + }, + revealProxyCertificate: async () => { + ensureProxyCertificateAuthority(); + await revealFile(PROXY_CA_CERT_FILE); + }, + resetCodexRateLimitCredit: (request) => resetCodexRateLimitCredit(request as ProviderAccountResetRequest), + saveApiKeys: async (apiKeys) => { + const savedConfig = await saveApiKeysConfig(apiKeys as ApiKeyConfig[]); + const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(savedConfig); + const nextConfig = syncedClaudeAppConfig.config; + gatewayService.updateConfig(nextConfig); + logProfileApplyResult(await applyProfileConfig(nextConfig)); + invalidateProviderAccountSnapshotCache(); + return nextConfig; + }, + saveConfig: async (config, options) => { + const previousConfig = await loadAppConfig(); + const nextInput = config as AppConfig; + if (nextInput.proxy.enabled) { + const certificateStatus = await proxyService.getCertificateStatus(); + if (!certificateStatus.trusted) { + throw new Error(certificateStatus.message); + } + } + let savedConfig = await saveAppConfig(nextInput); + const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(savedConfig); + savedConfig = syncedClaudeAppConfig.config; + let runtimeStatus = gatewayService.getStatus(); + if (syncedClaudeAppConfig.configChanged || shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig)) { + runtimeStatus = await gatewayService.start(savedConfig); + } else { + gatewayService.updateConfig(savedConfig); + } + if ((options as AppSaveConfigOptions | undefined)?.applyProfile !== false) { + await applyProfileIfServiceRunning(savedConfig, runtimeStatus); + } + invalidateProviderAccountSnapshotCache(); + return savedConfig; + }, + scanBotHandoffBluetoothTargets: () => scanBotHandoffBluetoothTargets(), + scanBotHandoffWifiTargets: () => scanBotHandoffWifiTargets(), + selectPluginDirectory: (directory) => inspectPluginDirectory(readRequiredString(directory, "Plugin directory path is required.")), + setOnboardingFinished: async () => { + await replacePersistedAppSetting(onboardingFinishedAtSettingKey, new Date().toISOString()); + return true; + }, + setProxyNetworkCaptureEnabled: (enabled) => proxyService.setNetworkCaptureEnabled(Boolean(enabled)), + startBotGatewayQrLogin: (request) => startBotGatewayQrLogin(request as BotGatewayQrLoginStartRequest), + startGateway: async () => { + const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig()); + const config = syncedClaudeAppConfig.config; + const status = await gatewayService.start(config); + await applyProfileIfServiceRunning(config, status); + return status; + }, + stopGateway: () => gatewayService.stop(), + stopProfile: async (request) => stopProfileFromCcr(await loadAppConfig(), request as ProfileOpenRequest), + testProviderAccountConnector: (request) => testProviderAccountConnector(request as ProviderAccountTestRequest), + updateCheck: () => unsupportedUpdateStatus, + updateDownload: () => unsupportedUpdateStatus, + updateInstall: () => { + throw new Error("Updates are only available in the desktop app."); + }, + waitBotGatewayQrLogin: (request) => waitBotGatewayQrLogin(request as BotGatewayQrLoginWaitRequest) +}; + +async function startConfiguredServices(reason: string): Promise { + try { + let config = await loadAppConfig(); + try { + config = (await syncClaudeAppGatewayConfig(config)).config; + } catch (error) { + console.error(`Failed to sync Claude App gateway config during ${reason}: ${formatError(error)}`); + } + const status = await gatewayService.start(config); + if (status.state === "error") { + console.error(`Failed to start gateway during ${reason}: ${status.lastError}`); + } + if (status.state === "running") { + const profileResult = await applyProfileConfig(config); + logProfileApplyResult(profileResult); + } + if (config.proxy.enabled && config.proxy.systemProxy) { + const proxyStatus = await proxyService.ensureSystemProxyActive(); + if (proxyStatus.systemProxy.state !== "active") { + const details = proxyStatus.systemProxy.lastError ? `: ${proxyStatus.systemProxy.lastError}` : ""; + console.error(`Proxy mode is enabled, but system proxy is ${proxyStatus.systemProxy.state} during ${reason}${details}`); + } + } + } catch (error) { + console.error(`Failed to start configured services during ${reason}: ${formatError(error)}`); + } +} + +async function stopConfiguredServices(): Promise { + await gatewayService.stop({ proxyRestoreTimeoutMs: 30_000 }).catch((error) => { + console.error(`Failed to stop gateway: ${formatError(error)}`); + }); + try { + restoreClaudeAppGatewayConfig(); + } catch (error) { + console.error(`Failed to restore Claude App gateway config: ${formatError(error)}`); + } +} + +async function applyProfileIfServiceRunning(config: AppConfig, status: GatewayStatus): Promise { + if (status.state !== "running") { + return; + } + logProfileApplyResult(await applyProfileConfig(config)); +} + +function logProfileApplyResult(result: ProfileApplyResult): void { + for (const client of result.clients) { + if (!client.ok) { + console.warn(`[profile:${client.client}] ${client.message}`); + } + } +} + +function getCliAppInfo(): AppInfo { + return { + appConfigDbFile: APP_CONFIG_DB_FILE, + apiKeysDbFile: API_KEYS_DB_FILE, + configDir: CONFIGDIR, + configFile: CONFIG_FILE, + dataDir: DATADIR, + gatewayConfigFile: GATEWAY_CONFIG_FILE, + launchAtLoginSupported: false, + name: APP_NAME, + platform: process.platform, + requestLogsDbFile: REQUEST_LOGS_DB_FILE, + usageDbFile: USAGE_DB_FILE, + version: packageJson.version + }; +} + +function sendHomeHtml(response: ServerResponse, headOnly: boolean): void { + if (!existsSync(homeHtmlFile)) { + sendText(response, 500, "CCR renderer assets were not found. Run npm run build:assets first."); + return; + } + let html = readFileSync(homeHtmlFile, "utf8"); + if (!html.includes("web-client-bridge.js")) { + html = html.replace(' ', `${webBridgeScriptTag}\n `); + } + sendBuffer(response, 200, Buffer.from(html, "utf8"), "text/html; charset=utf-8", headOnly); +} + +function sendStaticFile(response: ServerResponse, root: string, relativePath: string, headOnly: boolean): void { + const normalizedRelativePath = relativePath.replace(/^[/\\]+/, ""); + const file = path.resolve(root, normalizedRelativePath); + const resolvedRoot = path.resolve(root); + if (!file.startsWith(`${resolvedRoot}${path.sep}`) && file !== resolvedRoot) { + sendText(response, 403, "Forbidden"); + return; + } + if (!isFile(file)) { + sendText(response, 404, "Not found"); + return; + } + sendBuffer(response, 200, readFileSync(file), contentTypeForFile(file), headOnly); +} + +function sendBuffer(response: ServerResponse, status: number, body: Buffer, contentType: string, headOnly: boolean): void { + response.writeHead(status, { + "cache-control": "no-store", + "content-length": body.length, + "content-type": contentType, + "x-content-type-options": "nosniff" + }); + if (headOnly) { + response.end(); + return; + } + response.end(body); +} + +function sendJson(response: ServerResponse, status: number, payload: unknown): void { + const body = Buffer.from(JSON.stringify(payload), "utf8"); + response.writeHead(status, { + "cache-control": "no-store", + "content-length": body.length, + "content-type": "application/json; charset=utf-8", + "x-content-type-options": "nosniff" + }); + response.end(body); +} + +function sendText(response: ServerResponse, status: number, text: string): void { + sendBuffer(response, status, Buffer.from(`${text}\n`, "utf8"), "text/plain; charset=utf-8", false); +} + +function requestUrl(request: IncomingMessage): URL { + return new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`); +} + +function createWebManagementSecurityContext(host: string, port: number, authToken: string): WebManagementSecurityContext { + const normalizedHost = normalizeHostname(host); + const allowedHostnames = new Set(["localhost", "127.0.0.1", "::1", "0:0:0:0:0:0:0:1"]); + if (normalizedHost) { + allowedHostnames.add(normalizedHost); + } + return { + allowIpLiteralHosts: isWildcardBindHost(normalizedHost), + allowedHostnames, + authToken, + port + }; +} + +function urlWithWebAuthToken(value: string, authToken: string): string { + const url = new URL(value); + url.searchParams.set(webAuthQueryParam, authToken); + return url.toString(); +} + +function isAllowedWebRequestHost(request: IncomingMessage, security: WebManagementSecurityContext): boolean { + const hostname = requestHostname(request); + return Boolean(hostname && isAllowedWebHostname(hostname, security)); +} + +function isAllowedWebRequestOrigin(request: IncomingMessage, security: WebManagementSecurityContext): boolean { + const origin = readHeaderValue(request.headers.origin); + if (origin && !isAllowedWebOriginValue(origin, security)) { + return false; + } + + const referer = readHeaderValue(request.headers.referer); + if (!origin && referer && !isAllowedWebOriginValue(referer, security)) { + return false; + } + + return true; +} + +function isAllowedWebOriginValue(value: string, security: WebManagementSecurityContext): boolean { + try { + const url = new URL(value); + const port = url.port ? Number(url.port) : url.protocol === "http:" ? 80 : url.protocol === "https:" ? 443 : undefined; + return url.protocol === "http:" && + port === security.port && + isAllowedWebHostname(normalizeHostname(url.hostname), security); + } catch { + return false; + } +} + +function isAllowedWebHostname(hostname: string, security: WebManagementSecurityContext): boolean { + const normalized = normalizeHostname(hostname); + return security.allowedHostnames.has(normalized) || + (security.allowIpLiteralHosts && Boolean(net.isIP(normalized))); +} + +function requestHostname(request: IncomingMessage): string | undefined { + const host = readHeaderValue(request.headers.host); + if (!host) { + return undefined; + } + try { + return normalizeHostname(new URL(`http://${host}`).hostname); + } catch { + return undefined; + } +} + +function isJsonRequest(request: IncomingMessage): boolean { + const contentType = readHeaderValue(request.headers["content-type"])?.toLowerCase() ?? ""; + return contentType.split(";")[0]?.trim() === "application/json"; +} + +function hasValidWebAuthToken(request: IncomingMessage, security: WebManagementSecurityContext): boolean { + const token = readHeaderValue(request.headers[webAuthHeader]); + return constantTimeEquals(token, security.authToken); +} + +function constantTimeEquals(value: string | undefined, expected: string): boolean { + if (!value) { + return false; + } + const valueBuffer = Buffer.from(value); + const expectedBuffer = Buffer.from(expected); + return valueBuffer.length === expectedBuffer.length && timingSafeEqual(valueBuffer, expectedBuffer); +} + +function readHeaderValue(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) { + return readString(value[0]); + } + return readString(value); +} + +function normalizeHostname(value: string): string { + return value.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, ""); +} + +function isWildcardBindHost(host: string): boolean { + return host === "" || host === "0.0.0.0" || host === "::" || host === "::0"; +} + +function readRequestBody(request: IncomingMessage, maxBytes: number): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let totalBytes = 0; + request.on("data", (chunk: Buffer) => { + totalBytes += chunk.length; + if (totalBytes > maxBytes) { + reject(new Error(`Request body exceeds ${maxBytes} bytes.`)); + request.destroy(); + return; + } + chunks.push(chunk); + }); + request.once("end", () => resolve(Buffer.concat(chunks))); + request.once("error", reject); + }); +} + +async function listenWithFallback(server: Server, port: number, host: string): Promise { + let candidate = port; + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + await listen(server, candidate, host); + return candidate; + } catch (error) { + if (!isAddressInUseError(error) || candidate >= 65535) { + throw error; + } + candidate += 1; + } + } + throw new Error(`No available CCR web management port found starting at ${port}.`); +} + +function listen(server: Server, port: number, host: string): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("listening", onListening); + reject(error); + }; + const onListening = () => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(port, host); + }); +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); +} + +async function exportAppData(): Promise { + const exportedAt = new Date().toISOString(); + const exportDir = defaultExportDir(); + mkdirSync(exportDir, { recursive: true }); + const file = path.join(exportDir, `claude-code-router-data-${fileSafeTimestamp(exportedAt)}.json`); + assertExportTargetIsNotInternalDataFile(file); + const payload = { + app: { + name: APP_NAME, + platform: process.platform, + version: packageJson.version + }, + appState: { + onboardingFinished: Boolean(readString(await loadPersistedAppSetting(onboardingFinishedAtSettingKey)) || existsSync(ONBOARDING_FINISHED_FILE)) + }, + config: await loadAppConfig(), + exportedAt, + files: readDataExportFiles(), + kind: "claude-code-router-data-export", + version: 1 + }; + + writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + return { + canceled: false, + exportedAt, + file + }; +} + +function defaultExportDir(): string { + const downloads = path.join(os.homedir(), "Downloads"); + return existsSync(downloads) ? downloads : CONFIGDIR; +} + +function readDataExportFiles(): Array<{ base64: string; name: string; path: string; sizeBytes: number }> { + const files: Array<{ base64: string; name: string; path: string; sizeBytes: number }> = []; + for (const file of dataExportCandidateFiles()) { + try { + if (!isFile(file)) { + continue; + } + const stat = statSync(file); + files.push({ + base64: readFileSync(file).toString("base64"), + name: path.basename(file), + path: file, + sizeBytes: stat.size + }); + } catch (error) { + console.warn(`[export] Failed to include ${file}: ${formatError(error)}`); + } + } + return files; +} + +function dataExportCandidateFiles(): string[] { + return uniqueStrings([ + ...sqliteDataFiles(APP_CONFIG_DB_FILE), + ...sqliteDataFiles(API_KEYS_DB_FILE), + ...sqliteDataFiles(REQUEST_LOGS_DB_FILE), + ...sqliteDataFiles(USAGE_DB_FILE) + ]); +} + +function sqliteDataFiles(file: string): string[] { + return [file, `${file}-wal`, `${file}-shm`]; +} + +function assertExportTargetIsNotInternalDataFile(file: string): void { + const target = path.resolve(file); + const reserved = new Set([ + CONFIG_FILE, + LEGACY_CONFIG_FILE, + APP_CONFIG_DB_FILE, + API_KEYS_DB_FILE, + REQUEST_LOGS_DB_FILE, + USAGE_DB_FILE, + ...dataExportCandidateFiles() + ].map((item) => path.resolve(item))); + if (reserved.has(target)) { + throw new Error("Choose a different export path. Internal CCR data files cannot be overwritten."); + } +} + +function inspectPluginDirectory(directory: string): PluginDirectorySelection { + const manifest = readFirstJson([ + path.join(directory, "plugin.json"), + path.join(directory, "ccr-plugin.json"), + path.join(directory, ".ccr-plugin", "plugin.json"), + path.join(directory, ".codex-plugin", "plugin.json") + ]); + const packageJsonManifest = readFirstJson([path.join(directory, "package.json")]); + const moduleValue = + readString(manifest?.module) || + readString(manifest?.main) || + readString(manifest?.path) || + readString(readRecord(packageJsonManifest?.ccr)?.module) || + readString(readRecord(packageJsonManifest?.ccrPlugin)?.module) || + readString(packageJsonManifest?.main); + const id = + pluginIdValue(readString(manifest?.id) || readString(manifest?.key) || readString(packageJsonManifest?.name)) || + pluginIdValue(path.basename(directory)) || + "plugin"; + const name = readString(manifest?.name) || readString(packageJsonManifest?.displayName) || readString(packageJsonManifest?.name); + const apps = readPluginApps(manifest, packageJsonManifest); + return { + ...(apps.length ? { apps } : {}), + dependencies: readPluginDependencies(directory, manifest, packageJsonManifest), + directory, + id, + modulePath: resolvePluginDirectoryModule(directory, moduleValue), + ...(name ? { name } : {}) + }; +} + +function readPluginApps( + manifest: Record | undefined, + packageJsonManifest: Record | undefined +): GatewayPluginAppConfig[] { + const values = [ + manifest?.apps, + readRecord(manifest?.ccr)?.apps, + readRecord(manifest?.ccrPlugin)?.apps, + readRecord(packageJsonManifest?.ccr)?.apps, + readRecord(packageJsonManifest?.ccrPlugin)?.apps + ]; + const apps = values.flatMap(parsePluginApps); + const byId = new Map(); + for (const app of apps) { + const key = app.id || `${app.name}:${app.url}`; + if (!byId.has(key)) { + byId.set(key, app); + } + } + return [...byId.values()]; +} + +function parsePluginApps(value: unknown): GatewayPluginAppConfig[] { + if (!Array.isArray(value)) { + return []; + } + return value.map(parsePluginAppItem).filter((item): item is GatewayPluginAppConfig => Boolean(item)); +} + +function parsePluginAppItem(value: unknown): GatewayPluginAppConfig | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + + const record = value as Record; + const name = readString(record.name) || readString(record.title); + const url = readString(record.url) || readString(record.href) || readString(record.target); + if (!name || !url) { + return undefined; + } + const id = pluginIdValue(readString(record.id) || name); + const description = readString(record.description); + const icon = readString(record.icon); + return { + ...(description ? { description } : {}), + ...(icon ? { icon } : {}), + ...(id ? { id } : {}), + name, + url + }; +} + +function readPluginDependencies( + directory: string, + manifest: Record | undefined, + packageJsonManifest: Record | undefined +): PluginDependency[] { + const values = [ + manifest?.dependencies, + manifest?.pluginDependencies, + readRecord(manifest?.ccr)?.dependencies, + readRecord(manifest?.ccrPlugin)?.dependencies, + readRecord(packageJsonManifest?.ccr)?.dependencies, + readRecord(packageJsonManifest?.ccrPlugin)?.dependencies + ]; + const dependencies = values.flatMap((value) => parsePluginDependencies(value, directory)); + const byId = new Map(); + for (const dependency of dependencies) { + if (dependency.id && !byId.has(dependency.id)) { + byId.set(dependency.id, dependency); + } + } + return [...byId.values()]; +} + +function parsePluginDependencies(value: unknown, directory: string): PluginDependency[] { + if (Array.isArray(value)) { + return value.map((item) => parsePluginDependencyItem(item, directory)).filter((item): item is PluginDependency => Boolean(item)); + } + if (value && typeof value === "object" && !Array.isArray(value)) { + return Object.entries(value as Record) + .map(([id, item]) => parsePluginDependencyEntry(id, item, directory)) + .filter((item): item is PluginDependency => Boolean(item)); + } + return []; +} + +function parsePluginDependencyEntry(idValue: string, value: unknown, directory: string): PluginDependency | undefined { + if (value && typeof value === "object" && !Array.isArray(value)) { + return parsePluginDependencyItem({ id: idValue, ...(value as Record) }, directory); + } + + const id = pluginIdValue(idValue); + if (!id) { + return undefined; + } + const specifier = readString(value); + const modulePath = specifier && looksLikeDependencyModulePath(specifier) ? resolveDependencyModulePath(directory, specifier) : undefined; + return { + id, + ...(modulePath ? { modulePath } : {}) + }; +} + +function parsePluginDependencyItem(value: unknown, directory: string): PluginDependency | undefined { + if (typeof value === "string") { + const id = pluginIdValue(value); + return id ? { id } : undefined; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + + const record = value as Record; + const id = pluginIdValue(readString(record.id) || readString(record.key) || readString(record.name)); + if (!id) { + return undefined; + } + const moduleValue = readString(record.module) || readString(record.path) || readString(record.modulePath); + const modulePath = moduleValue ? resolveDependencyModulePath(directory, moduleValue) : undefined; + const name = readString(record.name); + return { + id, + ...(modulePath ? { modulePath } : {}), + ...(name ? { name } : {}) + }; +} + +function resolveDependencyModulePath(directory: string, value: string): string { + if (value === "~" || value.startsWith("~/")) { + return path.join(os.homedir(), value.slice(2)); + } + return path.isAbsolute(value) ? value : path.join(directory, value); +} + +function looksLikeDependencyModulePath(value: string): boolean { + return value.startsWith(".") || value.startsWith("/") || value.startsWith("~"); +} + +function resolvePluginDirectoryModule(directory: string, moduleValue: string | undefined): string { + if (moduleValue) { + return path.isAbsolute(moduleValue) ? moduleValue : path.join(directory, moduleValue); + } + + for (const filename of ["index.cjs", "index.mjs", "index.js", "plugin.cjs", "plugin.mjs", "plugin.js"]) { + const candidate = path.join(directory, filename); + if (isFile(candidate)) { + return candidate; + } + } + + return directory; +} + +function readFirstJson(files: string[]): Record | undefined { + for (const file of files) { + if (!isFile(file)) { + continue; + } + try { + const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // Ignore invalid plugin metadata and fall back to directory inference. + } + } + return undefined; +} + +function readRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; +} + +function firstConfiguredBrowserAppUrl(config: AppConfig): string | undefined { + for (const plugin of config.plugins) { + if (plugin.enabled === false) { + continue; + } + const app = plugin.apps?.find((candidate) => readString(candidate.url)); + if (app?.url) { + return app.url; + } + } + return undefined; +} + +async function revealFile(file: string): Promise { + if (process.platform === "darwin") { + await execDetached("/usr/bin/open", ["-R", file]); + return; + } + if (process.platform === "win32") { + await execDetached("explorer.exe", ["/select,", file]); + return; + } + await execDetached("xdg-open", [path.dirname(file)]); +} + +export function openSystemExternal(target: string): Promise { + const url = normalizeExternalHttpTarget(target); + if (!url) { + return Promise.resolve(); + } + if (process.platform === "darwin") { + return execDetached("/usr/bin/open", [url]); + } + if (process.platform === "win32") { + return execDetached("rundll32.exe", ["url.dll,FileProtocolHandler", url]); + } + return execDetached("xdg-open", [url]); +} + +export function normalizeExternalHttpTarget(target: unknown): string | undefined { + const trimmed = typeof target === "string" ? target.trim() : ""; + if (!trimmed || trimmed === "about:blank") { + return undefined; + } + let url: URL; + try { + url = new URL(trimmed); + } catch { + throw new Error("External URL must be a valid absolute URL."); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Only http and https URLs can be opened."); + } + return url.toString(); +} + +function execDetached(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + detached: true, + stdio: "ignore", + windowsHide: true + }); + child.once("error", reject); + child.once("spawn", () => { + child.unref(); + resolve(); + }); + }); +} + +function contentTypeForFile(file: string): string { + switch (path.extname(file).toLowerCase()) { + case ".css": + return "text/css; charset=utf-8"; + case ".gif": + return "image/gif"; + case ".html": + return "text/html; charset=utf-8"; + case ".ico": + return "image/x-icon"; + case ".js": + return "text/javascript; charset=utf-8"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".png": + return "image/png"; + case ".svg": + return "image/svg+xml"; + case ".webp": + return "image/webp"; + default: + return "application/octet-stream"; + } +} + +function readRequiredString(value: unknown, message: string): string { + const text = readString(value); + if (!text) { + throw new Error(message); + } + return text; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readEnvString(key: string): string | undefined { + return readString(process.env[key]); +} + +function readEnvPort(key: string): number | undefined { + const value = Number(process.env[key]); + return Number.isInteger(value) && value > 0 && value < 65536 ? value : undefined; +} + +function pluginIdValue(value: string | undefined): string { + return value?.toLowerCase().replace(/^@/, "").replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || ""; +} + +function isFile(file: string): boolean { + try { + return existsSync(file) && statSync(file).isFile(); + } catch { + return false; + } +} + +function uniqueStrings(values: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + if (!value || seen.has(value)) { + continue; + } + seen.add(value); + result.push(value); + } + return result; +} + +function fileSafeTimestamp(value: string): string { + return value.replace(/[:.]/g, "-"); +} + +function formatListenHost(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +} + +function isAddressInUseError(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "EADDRINUSE"; +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..1f70006 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src" + }, + "include": ["src/**/*.ts", "src/**/*.d.ts"] +} diff --git a/assets/logo.png b/packages/electron/assets/logo.png similarity index 100% rename from assets/logo.png rename to packages/electron/assets/logo.png diff --git a/assets/tray-cyan.png b/packages/electron/assets/tray-cyan.png similarity index 100% rename from assets/tray-cyan.png rename to packages/electron/assets/tray-cyan.png diff --git a/assets/tray-orange.png b/packages/electron/assets/tray-orange.png similarity index 100% rename from assets/tray-orange.png rename to packages/electron/assets/tray-orange.png diff --git a/assets/tray-violet.png b/packages/electron/assets/tray-violet.png similarity index 100% rename from assets/tray-violet.png rename to packages/electron/assets/tray-violet.png diff --git a/assets/tray.png b/packages/electron/assets/tray.png similarity index 100% rename from assets/tray.png rename to packages/electron/assets/tray.png diff --git a/packages/electron/package.json b/packages/electron/package.json new file mode 100644 index 0000000..9a737cb --- /dev/null +++ b/packages/electron/package.json @@ -0,0 +1,13 @@ +{ + "name": "@claude-code-router/electron", + "version": "3.0.10", + "private": true, + "description": "Claude Code Router Electron desktop shell.", + "main": "dist/main/main.js", + "dependencies": { + "better-sqlite3": "^12.11.1" + }, + "devDependencies": { + "electron-updater": "^6.8.9" + } +} diff --git a/src/main/app-menu.ts b/packages/electron/src/main/app-menu.ts similarity index 53% rename from src/main/app-menu.ts rename to packages/electron/src/main/app-menu.ts index be1c5d7..b601dbc 100644 --- a/src/main/app-menu.ts +++ b/packages/electron/src/main/app-menu.ts @@ -1,8 +1,6 @@ import { app, dialog, Menu, type BrowserWindow, type MenuItemConstructorOptions } from "electron"; -import { APP_NAME, IPC_CHANNELS } from "./constants"; -import { appUpdateService } from "./update-service"; +import { APP_NAME, IPC_CHANNELS } from "@ccr/core/config/constants"; import windowsManager from "./windows"; -import type { AppUpdateStatus } from "../shared/app"; export function setupApplicationMenu(): void { Menu.setApplicationMenu(Menu.buildFromTemplate(createMenuTemplate())); @@ -133,121 +131,7 @@ function showAboutPanel(): void { function checkForUpdatesFromMenu(): void { const window = windowsManager.showMainWindow(); - void checkForUpdatesWithDialog(window); -} - -async function checkForUpdatesWithDialog(window: BrowserWindow): Promise { - try { - await presentUpdateStatus(window, await appUpdateService.checkForUpdates()); - } catch (error) { - await showUpdateDialog(window, { - detail: formatError(error), - message: "Update check failed.", - type: "error" - }); - } -} - -async function presentUpdateStatus(window: BrowserWindow, status: AppUpdateStatus): Promise { - if (status.state === "not-available") { - await showUpdateDialog(window, { - detail: `Version ${status.currentVersion} is currently installed.`, - message: `${APP_NAME} is up to date.`, - type: "info" - }); - return; - } - - if (status.state === "available") { - const result = await showUpdateDialog(window, { - buttons: ["Download", "Later"], - cancelId: 1, - detail: status.availableVersion - ? `Version ${status.availableVersion} is available.` - : "An update is available.", - message: "Update available.", - type: "info" - }); - if (result.response === 0) { - await downloadUpdateFromMenu(window); - } - return; - } - - if (status.state === "downloaded") { - await promptInstallUpdate(window); - return; - } - - if (status.state === "error") { - await showUpdateDialog(window, { - detail: status.lastError || "Unable to check for updates.", - message: "Update check failed.", - type: "error" - }); - } -} - -async function downloadUpdateFromMenu(window: BrowserWindow): Promise { - try { - const status = await appUpdateService.downloadUpdate(); - if (status.state === "downloaded" || status.canInstall) { - await promptInstallUpdate(window); - return; - } - if (status.state === "error") { - await showUpdateDialog(window, { - detail: status.lastError || "Unable to download the update.", - message: "Update download failed.", - type: "error" - }); - } - } catch (error) { - await showUpdateDialog(window, { - detail: formatError(error), - message: "Update download failed.", - type: "error" - }); - } -} - -async function promptInstallUpdate(window: BrowserWindow): Promise { - const result = await showUpdateDialog(window, { - buttons: ["Install and Restart", "Later"], - cancelId: 1, - detail: "The update has been downloaded and is ready to install.", - message: "Update ready to install.", - type: "info" - }); - - if (result.response !== 0) { - return; - } - - try { - await appUpdateService.installUpdate(); - } catch (error) { - await showUpdateDialog(window, { - detail: formatError(error), - message: "Update install failed.", - type: "error" - }); - } -} - -function showUpdateDialog( - window: BrowserWindow, - options: { buttons?: string[]; cancelId?: number; detail: string; message: string; type: "error" | "info" } -): Promise { - return dialog.showMessageBox(window, { - buttons: options.buttons ?? ["OK"], - cancelId: options.cancelId, - defaultId: 0, - detail: options.detail, - message: options.message, - title: "Software Update", - type: options.type - }); + sendWhenReady(window, IPC_CHANNELS.appOpenUpdate); } function sendWhenReady(window: BrowserWindow, channel: string): void { @@ -268,7 +152,3 @@ function sendWhenReady(window: BrowserWindow, channel: string): void { send(); } - -function formatError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/src/main/bot-gateway-qr-window-service.ts b/packages/electron/src/main/bot-gateway-qr-window-service.ts similarity index 98% rename from src/main/bot-gateway-qr-window-service.ts rename to packages/electron/src/main/bot-gateway-qr-window-service.ts index d020130..a65d490 100644 --- a/src/main/bot-gateway-qr-window-service.ts +++ b/packages/electron/src/main/bot-gateway-qr-window-service.ts @@ -4,7 +4,7 @@ import type { BotGatewayQrWindowCloseResult, BotGatewayQrWindowOpenRequest, BotGatewayQrWindowOpenResult -} from "../shared/app"; +} from "@ccr/core/contracts/app"; const qrWindows = new Map(); diff --git a/packages/electron/src/main/browser-automation-mcp.ts b/packages/electron/src/main/browser-automation-mcp.ts new file mode 100644 index 0000000..10f8558 --- /dev/null +++ b/packages/electron/src/main/browser-automation-mcp.ts @@ -0,0 +1,3544 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { randomUUID } from "node:crypto"; +import type { Event as ElectronEvent, WebContents } from "electron"; +import { loadAppConfig } from "@ccr/core/config/config"; +import type { + BuiltInBrowserAutomationHandoff, + BuiltInBrowserAutomationHandoffKind, + BuiltInBrowserState, + BuiltInBrowserTabState +} from "@ccr/core/contracts/app"; +import type { BrowserAutomationMcpIntegration } from "@ccr/core/gateway/service"; +import { BROWSER_AUTOMATION_MCP_PATH } from "@ccr/core/mcp/toolhub-config"; +import { builtInBrowserService, type BrowserAutomationEvent } from "./built-in-browser"; +import { chromeLoginImportService } from "./chrome-login-import"; + +type JsonPrimitive = boolean | null | number | string; +type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +type JsonRpcRequest = { + id?: null | number | string; + jsonrpc?: string; + method?: string; + params?: unknown; +}; + +type JsonRpcResponse = + | { + id: null | number | string; + jsonrpc: "2.0"; + result: JsonValue; + } + | { + error: { + code: number; + data?: JsonValue; + message: string; + }; + id: null | number | string; + jsonrpc: "2.0"; + }; + +type McpTool = { + description: string; + inputSchema: JsonValue; + name: string; + tags?: string[]; + title?: string; +}; + +type ToolCallResult = { + content: Array<{ text: string; type: "text" }>; + isError?: boolean; +}; + +type BrowserTarget = { + axNodeId?: string; + backendNodeId?: number; + exact?: boolean; + index?: number; + ref?: string; + role?: string; + selector?: string; + text?: string; +}; + +type BrowserSessionRef = { + frameId?: string; + sessionId: string; + tabId: string; + userId?: string; +}; + +type SnapshotOptions = { + limit?: number; + maxElements: number; + maxText?: number; + offset?: number; +}; + +type AttachedSession = { + attachedAt: number; + leaseId: string; + observeOnly: boolean; + ref: BrowserSessionRef; +}; + +type EventSubscription = { + channels: Set; + dropped: boolean; + events: BrowserAutomationEvent[]; + ref?: BrowserSessionRef; + redactTextInput: boolean; + sampleMouseMove: boolean; + startedAt: number; + subscriptionId: string; + unsubscribe: () => void; +}; + +type AxSnapshotResult = { + handoff?: BuiltInBrowserAutomationHandoff; + handoffRequired?: boolean; + humanHelp?: Record; + humanHelpRequired?: boolean; + nextAction?: "human_help"; + nodes: Array>; + scope: string; + session: BrowserSessionRef; + title: string; + url: string; +}; + +type BrowserHandoffDetection = { + kind: BuiltInBrowserAutomationHandoffKind; + message: string; + reason: string; +}; + +type ReadinessResult = { + matched: boolean; + matchedEvent?: string; + navigationError?: { + errorCode?: number; + errorDescription?: string; + url?: string; + }; + readinessUrl?: string; + timedOut: boolean; +}; + +type ReadinessEvent = { + errorCode?: number; + errorDescription?: string; + isMainFrame?: boolean; + type: string; + url?: string; +}; + +type NavigationReadinessContext = { + expectedUrl?: string; + previousUrl?: string; +}; + +const protocolVersion = "2024-11-05"; +const automationEventReplayMs = 15_000; +const defaultAxSnapshotLimit = 60; +const defaultSnapshotMaxElements = 80; +const defaultSnapshotMaxText = 3_000; +const defaultEventAwaitTimeoutMs = 10_000; +const defaultJavascriptTimeoutMs = 8_000; +const maxSnapshotTextLimit = 20_000; +const maxSnapshotTextOffset = 1_000_000_000; +const maxMcpRequestBytes = 2 * 1024 * 1024; +const maxSubscriptionEvents = 512; +const maxToolResultChars = 60_000; +const maxToolResultArrayItems = 120; +const maxToolResultStringChars = 2_000; +const maxToolResultObjectKeys = 120; +const maxSnapshotResultElements = 80; +const maxAxSnapshotResultNodes = 80; +const maxBrowserResultTextChars = maxSnapshotTextLimit; +const defaultWaitTimeoutMs = 10_000; + +const sessionSchema = objectSchema({ + frameId: { description: "Optional frame id. CCR currently targets the main frame.", type: "string" }, + sessionId: { description: "Browser automation session id.", type: "string" }, + tabId: { description: "Built-in browser tab id.", type: "string" }, + userId: { description: "Optional logical user id.", type: "string" } +}, ["sessionId", "tabId"]); + +const cursorSchema = objectSchema({ + dropped: { description: "Whether earlier events were dropped before this cursor.", type: "boolean" }, + seq: { description: "Last observed event sequence.", type: "number" }, + subscriptionId: { description: "Event subscription id.", type: "string" }, + ts: { description: "Last observed event timestamp.", type: "number" } +}, ["subscriptionId", "seq"]); + +const targetSchema = objectSchema({ + axNodeId: { description: "Accessibility node id returned by browser_ax_snapshot/query. In CCR this is a stable element ref.", type: "string" }, + backendNodeId: { description: "Reserved for CDP-compatible clients. CSS/ref targeting is preferred in CCR.", type: "number" }, + exact: { description: "Match text/name exactly instead of by substring.", type: "boolean" }, + index: { description: "Zero-based match index when text/role resolves multiple elements.", minimum: 0, type: "number" }, + ref: { description: "Element ref returned by browser_snapshot. Refs are CSS selectors.", type: "string" }, + role: { description: "Accessible or implicit role such as button, link, textbox, combobox, checkbox.", type: "string" }, + selector: { description: "CSS selector. Used directly when provided.", type: "string" }, + text: { description: "Visible text, accessible name, placeholder, label, or value to match.", type: "string" } +}); + +const browserAutomationTools: McpTool[] = [ + ...browserPublicAutomationTools(), + ...browserLegacyAliasTools() +].filter((tool, index, tools) => tools.findIndex((candidate) => candidate.name === tool.name) === index); + +function browserPublicAutomationTools(): McpTool[] { + return [ + { + description: "Open a URL or attach an existing CCR built-in browser tab and create an automation session.", + inputSchema: objectSchema({ + observeOnly: { description: "If true, action tools reject this session.", type: "boolean" }, + tabId: { description: "Existing tab id to attach.", type: "string" }, + timeoutMs: { description: "Optional navigation timeout in milliseconds.", type: "number" }, + url: { description: "Optional URL or search query to open.", type: "string" }, + userId: { description: "Optional logical user id.", type: "string" }, + waitUntil: { description: "Readiness condition. Default/recommended: interactive, which waits until the page is inspectable/actionable and avoids long-lived network requests. network_idle is rarely appropriate for SPAs, Google, mail, chat, auth, or streaming pages.", enum: ["none", "interactive", "domcontentloaded", "load", "network_idle"], type: "string" }, + windowId: { description: "Ignored in CCR; included for agentic-browser compatibility.", type: "string" } + }), + name: "browser_session_open", + tags: ["browser", "automation", "session", "open", "tab"], + title: "Open Browser Automation Session" + }, + { + description: "Detach and release a browser automation session. Does not close the tab.", + inputSchema: objectSchema({ session: sessionSchema }, ["session"]), + name: "browser_session_close", + tags: ["browser", "automation", "session", "close"], + title: "Close Browser Automation Session" + }, + { + description: "Create a new CCR built-in browser tab. Accepts an existing session or the fixed CCR browser window id.", + inputSchema: objectSchema({ + activate: { description: "Whether the new tab should become active. Defaults to true.", type: "boolean" }, + session: sessionSchema, + url: { description: "Optional URL or search query.", type: "string" }, + windowId: { description: "Ignored in CCR; included for compatibility.", type: "string" } + }), + name: "browser_tab_create", + tags: ["browser", "automation", "tab", "create"], + title: "Create Browser Tab" + }, + { + description: "List all CCR built-in browser tabs with active, loading, URL, title, and navigation state.", + inputSchema: objectSchema({ + session: sessionSchema, + windowId: { description: "Ignored in CCR; included for compatibility.", type: "string" } + }), + name: "browser_tab_list", + tags: ["browser", "automation", "tab", "list"], + title: "List Browser Tabs" + }, + { + description: "Activate an existing CCR built-in browser tab.", + inputSchema: objectSchema({ + session: sessionSchema, + tabId: { description: "Tab id to activate. Defaults to session.tabId.", type: "string" } + }), + name: "browser_tab_activate", + tags: ["browser", "automation", "tab", "activate", "focus"], + title: "Activate Browser Tab" + }, + { + description: "Close a CCR built-in browser tab.", + inputSchema: objectSchema({ + session: sessionSchema, + tabId: { description: "Tab id to close. Defaults to session.tabId.", type: "string" } + }), + name: "browser_tab_close", + tags: ["browser", "automation", "tab", "close"], + title: "Close Browser Tab" + }, + { + description: "Navigate the session tab or a specified tab to a URL or search query.", + inputSchema: objectSchema({ + session: sessionSchema, + tabId: { description: "Optional tab id. Defaults to session.tabId or active tab.", type: "string" }, + timeoutMs: { description: "Optional navigation timeout in milliseconds.", maximum: 120000, minimum: 100, type: "number" }, + url: { description: "URL or search query to load.", type: "string" }, + waitUntil: { description: "Readiness condition. Default/recommended: interactive, which waits until the page is inspectable/actionable and avoids long-lived network requests. network_idle is rarely appropriate for SPAs, Google, mail, chat, auth, or streaming pages.", enum: ["none", "interactive", "domcontentloaded", "load", "network_idle"], type: "string" } + }, ["url"]), + name: "browser_navigate", + tags: ["browser", "automation", "navigate", "tab", "url"], + title: "Navigate Browser Tab" + }, + { + description: "Go back in the session tab or a specified tab.", + inputSchema: objectSchema({ session: sessionSchema, tabId: { type: "string" } }), + name: "browser_tab_go_back", + tags: ["browser", "automation", "tab", "back"], + title: "Browser Tab Back" + }, + { + description: "Go forward in the session tab or a specified tab.", + inputSchema: objectSchema({ session: sessionSchema, tabId: { type: "string" } }), + name: "browser_tab_go_forward", + tags: ["browser", "automation", "tab", "forward"], + title: "Browser Tab Forward" + }, + { + description: "Reload the session tab or a specified tab.", + inputSchema: objectSchema({ session: sessionSchema, tabId: { type: "string" } }), + name: "browser_tab_reload", + tags: ["browser", "automation", "tab", "reload"], + title: "Reload Browser Tab" + }, + { + description: "Read a condensed accessibility-oriented page snapshot. Nodes include axNodeId/ref, role, name, value, text, and rect.", + inputSchema: objectSchema({ + includeIgnored: { description: "Included for compatibility; CCR returns visible/high-signal nodes.", type: "boolean" }, + limit: { description: "Maximum nodes to return.", maximum: 300, minimum: 1, type: "number" }, + maxDepth: { description: "Included for compatibility.", type: "number" }, + rootAxNodeId: { description: "Optional root node/ref to scope the snapshot.", type: "string" }, + scope: { description: "full or outline. CCR returns the same compact shape for both.", enum: ["full", "outline"], type: "string" }, + session: sessionSchema + }, ["session"]), + name: "browser_ax_snapshot", + tags: ["browser", "automation", "accessibility", "snapshot", "dom", "page"], + title: "Browser Accessibility Snapshot" + }, + { + description: "Search the page accessibility outline by role, accessible name, visible text, label, placeholder, or value.", + inputSchema: objectSchema({ + includeIgnored: { description: "Included for compatibility; CCR searches visible/high-signal nodes.", type: "boolean" }, + limit: { description: "Maximum matches.", maximum: 300, minimum: 1, type: "number" }, + name: { description: "Accessible name filter.", type: "string" }, + role: { description: "Role filter such as button, link, textbox, combobox.", type: "string" }, + rootAxNodeId: { description: "Optional root node/ref to scope the query.", type: "string" }, + session: sessionSchema, + text: { description: "Visible text/value/description filter.", type: "string" } + }, ["session"]), + name: "browser_ax_query", + tags: ["browser", "automation", "accessibility", "query", "find"], + title: "Query Browser Accessibility Tree" + }, + { + description: "Click an element resolved by axNodeId/ref, CSS selector, role, or text.", + inputSchema: objectSchema({ force: { type: "boolean" }, session: sessionSchema, target: targetSchema }, ["session", "target"]), + name: "browser_element_click", + tags: ["browser", "automation", "element", "click"], + title: "Click Browser Element" + }, + { + description: "Set text into an input, textarea, contenteditable, or textbox-like element.", + inputSchema: objectSchema({ + replace: { description: "Replace existing text. Defaults to true.", type: "boolean" }, + session: sessionSchema, + target: targetSchema, + text: { type: "string" } + }, ["session", "target", "text"]), + name: "browser_element_input", + tags: ["browser", "automation", "element", "input", "type", "form"], + title: "Input Browser Element" + }, + { + description: "Select an option on a native select by value or visible label.", + inputSchema: objectSchema({ + exact: { type: "boolean" }, + session: sessionSchema, + target: targetSchema, + value: { description: "Requested option value or visible label.", type: "string" } + }, ["session", "target", "value"]), + name: "browser_element_select", + tags: ["browser", "automation", "element", "select", "form"], + title: "Select Browser Element Option" + }, + { + description: "Press a keyboard key against a resolved target or the current focused element.", + inputSchema: objectSchema({ + key: { description: "Keyboard key, e.g. Enter, Tab, Escape, ArrowDown.", type: "string" }, + session: sessionSchema, + target: targetSchema + }, ["session", "key"]), + name: "browser_element_press", + tags: ["browser", "automation", "element", "press", "keyboard"], + title: "Press Browser Element Key" + }, + { + description: "Scroll the page or a target element.", + inputSchema: objectSchema({ + amount: { description: "Scroll amount in CSS pixels.", type: "number" }, + direction: { description: "Scroll direction.", enum: ["up", "down"], type: "string" }, + session: sessionSchema, + target: targetSchema + }, ["session"]), + name: "browser_element_scroll", + tags: ["browser", "automation", "element", "scroll"], + title: "Scroll Browser Element" + }, + { + description: "Subscribe to browser automation events such as tab, navigation, DOM-ready, runtime, and loading signals.", + inputSchema: objectSchema({ + channels: { description: "Event channels: tab, navigation, dom, runtime, dialog, download, input, focus, selection, handoff.", items: { type: "string" }, type: "array" }, + redactTextInput: { type: "boolean" }, + sampleMouseMove: { type: "boolean" }, + session: sessionSchema + }, ["session", "channels"]), + name: "browser_events_subscribe", + tags: ["browser", "automation", "events", "subscribe"], + title: "Subscribe Browser Events" + }, + { + description: "Read buffered browser automation events from a subscription.", + inputSchema: objectSchema({ + cursor: cursorSchema, + limit: { maximum: 200, minimum: 1, type: "number" }, + subscriptionId: { type: "string" } + }, ["subscriptionId"]), + name: "browser_events_read", + tags: ["browser", "automation", "events", "read"], + title: "Read Browser Events" + }, + { + description: "Wait for browser automation events matching kind, tabId, URL pattern, title pattern, or summary pattern.", + inputSchema: objectSchema({ + coalesceMs: { description: "Small delay after a first match to collect related events.", type: "number" }, + cursor: cursorSchema, + kinds: { items: { type: "string" }, type: "array" }, + maxEvents: { maximum: 50, minimum: 1, type: "number" }, + summaryPattern: { type: "string" }, + tabId: { type: "string" }, + timeoutMs: { maximum: 120000, minimum: 100, type: "number" }, + titlePattern: { type: "string" }, + urlPattern: { type: "string" }, + subscriptionId: { type: "string" } + }, ["subscriptionId"]), + name: "browser_events_await", + tags: ["browser", "automation", "events", "await", "wait"], + title: "Await Browser Events" + }, + { + description: "Unsubscribe from browser automation events and release the subscription.", + inputSchema: objectSchema({ subscriptionId: { type: "string" } }, ["subscriptionId"]), + name: "browser_events_unsubscribe", + tags: ["browser", "automation", "events", "unsubscribe"], + title: "Unsubscribe Browser Events" + }, + { + description: "Handle a currently open JavaScript alert/confirm/prompt dialog using Chromium CDP.", + inputSchema: objectSchema({ + accept: { type: "boolean" }, + promptText: { type: "string" }, + session: sessionSchema + }, ["session", "accept"]), + name: "browser_dialog_handle", + tags: ["browser", "automation", "dialog", "alert", "confirm", "prompt"], + title: "Handle Browser Dialog" + }, + { + description: "Request human intervention for the current browser task. Shows the hidden browser window and displays the requested action in the top toolbar.", + inputSchema: objectSchema({ + kind: { + description: "Type of help needed.", + enum: ["login_required", "verification_code", "human_verification", "blocked", "other"], + type: "string" + }, + message: { description: "Short instruction shown in the top toolbar.", type: "string" }, + reason: { description: "Why automation is blocked.", type: "string" }, + session: sessionSchema, + tabId: { description: "Optional tab id. Defaults to session.tabId or active tab.", type: "string" } + }, ["reason"]), + name: "browser_handoff_request", + tags: ["browser", "automation", "human", "handoff", "intervention"], + title: "Request Browser Human Handoff" + }, + { + description: "Read the current browser human handoff status.", + inputSchema: objectSchema({}), + name: "browser_handoff_status", + tags: ["browser", "automation", "human", "handoff"], + title: "Browser Handoff Status" + }, + { + description: "Wait until the user clicks Done or Hide on the current browser handoff toolbar. Use after browser_handoff_request or a tool result with humanHelpRequired.", + inputSchema: objectSchema({ + handoffId: { description: "Optional handoff id returned by browser_handoff_request or humanHelp.handoff.", type: "string" }, + session: sessionSchema, + tabId: { description: "Optional tab id. Defaults to session.tabId.", type: "string" }, + timeoutMs: { description: "Maximum wait time in milliseconds.", maximum: 600000, minimum: 100, type: "number" } + }), + name: "browser_handoff_wait", + tags: ["browser", "automation", "human", "handoff", "wait"], + title: "Wait For Browser Human Handoff" + }, + { + description: "Clear the current browser human handoff prompt.", + inputSchema: objectSchema({ + status: { enum: ["completed", "dismissed"], type: "string" } + }), + name: "browser_handoff_clear", + tags: ["browser", "automation", "human", "handoff"], + title: "Clear Browser Handoff" + }, + { + description: "Ask the user to confirm importing Chrome cookies and localStorage for selected domains into CCR's in-app browser. Opens a browser confirmation page; the Chrome extension performs the import after user confirmation.", + inputSchema: objectSchema({ + domain: { description: "Single domain to import, such as github.com. Used with domains if both are provided.", type: "string" }, + domains: { description: "Domains to import. If omitted, CCR derives the current tab hostname.", items: { type: "string" }, type: "array" }, + openConfirmationPage: { description: "Open the system browser confirmation page. Defaults to true.", type: "boolean" }, + session: sessionSchema, + tabId: { description: "Optional tab id used to derive a domain when domain/domains are omitted.", type: "string" }, + target: { description: "Target CCR browser storage partition.", enum: ["browser", "browser-and-web-search"], type: "string" } + }), + name: "browser_chrome_login_import", + tags: ["browser", "automation", "chrome", "login", "import", "cookies", "localStorage"], + title: "Import Chrome Login State" + }, + { + description: "Read the status of a Chrome login import job created by browser_chrome_login_import.", + inputSchema: objectSchema({ + jobId: { description: "Chrome login import job id.", type: "string" } + }, ["jobId"]), + name: "browser_chrome_login_import_status", + tags: ["browser", "automation", "chrome", "login", "import", "status"], + title: "Chrome Login Import Status" + } + ]; +} + +function browserLegacyAliasTools(): McpTool[] { + return [ + { + description: "Open or focus the CCR built-in browser window. Optionally navigate the active tab to a URL.", + inputSchema: objectSchema({ + url: { description: "Optional URL or search query to load in the active tab.", type: "string" } + }), + name: "browser_open", + tags: ["browser", "automation", "open", "focus"], + title: "Open Browser" + }, + { + description: "List CCR built-in browser tabs, including active tab, URL, title, and loading state.", + inputSchema: objectSchema({}), + name: "browser_tabs", + tags: ["browser", "automation", "tabs"], + title: "List Browser Tabs" + }, + { + description: "Open a new CCR built-in browser tab. Optionally load a URL or search query.", + inputSchema: objectSchema({ + url: { description: "Optional URL or search query to load in the new tab.", type: "string" } + }), + name: "browser_tab_new", + tags: ["browser", "automation", "tab"], + title: "New Browser Tab" + }, + { + description: "Focus a CCR built-in browser tab by tabId.", + inputSchema: objectSchema({ + tabId: { description: "Tab id returned by browser_tabs or browser_tab_new.", type: "string" } + }, ["tabId"]), + name: "browser_tab_activate", + tags: ["browser", "automation", "tab", "focus"], + title: "Activate Browser Tab" + }, + { + description: "Close a CCR built-in browser tab by tabId.", + inputSchema: objectSchema({ + tabId: { description: "Tab id returned by browser_tabs or browser_tab_new.", type: "string" } + }, ["tabId"]), + name: "browser_tab_close", + tags: ["browser", "automation", "tab"], + title: "Close Browser Tab" + }, + { + description: "Navigate a CCR built-in browser tab to a URL or search query.", + inputSchema: objectSchema({ + tabId: { description: "Optional tab id. Defaults to the active tab.", type: "string" }, + url: { description: "URL or search query to load.", type: "string" } + }, ["url"]), + name: "browser_navigate", + tags: ["browser", "automation", "navigate", "url"], + title: "Navigate Browser" + }, + { + description: "Capture page text plus interactable element refs for browser automation. Use returned refs for browser_click, browser_type, browser_select, browser_press_key, or browser_scroll.", + inputSchema: objectSchema({ + limit: { description: "Maximum page text characters to include. Overrides maxText when both are provided.", maximum: maxSnapshotTextLimit, minimum: 0, type: "number" }, + maxElements: { description: "Maximum interactable elements to include.", maximum: 300, minimum: 1, type: "number" }, + maxText: { description: "Legacy alias for limit.", maximum: maxSnapshotTextLimit, minimum: 0, type: "number" }, + offset: { description: "Starting character offset into the normalized page text.", maximum: maxSnapshotTextOffset, minimum: 0, type: "number" }, + tabId: { description: "Optional tab id. Defaults to the active tab.", type: "string" } + }), + name: "browser_snapshot", + tags: ["browser", "automation", "snapshot", "accessibility", "dom", "page"], + title: "Browser Page Snapshot" + }, + { + description: "Click an element in the CCR built-in browser by ref, CSS selector, role, or visible text.", + inputSchema: objectSchema({ + tabId: { description: "Optional tab id. Defaults to the active tab.", type: "string" }, + target: targetSchema + }, ["target"]), + name: "browser_click", + tags: ["browser", "automation", "click", "element"], + title: "Click Browser Element" + }, + { + description: "Type text into an input, textarea, select-like textbox, or contenteditable element in the CCR built-in browser.", + inputSchema: objectSchema({ + replaceExisting: { description: "Replace existing text. Defaults to true.", type: "boolean" }, + tabId: { description: "Optional tab id. Defaults to the active tab.", type: "string" }, + target: targetSchema, + text: { description: "Text to type.", type: "string" } + }, ["target", "text"]), + name: "browser_type", + tags: ["browser", "automation", "type", "input", "form"], + title: "Type In Browser Element" + }, + { + description: "Choose an option in a select element by value or visible label.", + inputSchema: objectSchema({ + exact: { description: "Match label exactly instead of by substring.", type: "boolean" }, + label: { description: "Visible option label to choose.", type: "string" }, + tabId: { description: "Optional tab id. Defaults to the active tab.", type: "string" }, + target: targetSchema, + value: { description: "Option value to choose.", type: "string" } + }, ["target"]), + name: "browser_select", + tags: ["browser", "automation", "select", "form"], + title: "Select Browser Option" + }, + { + description: "Press a keyboard key in the CCR built-in browser. Optionally focus an element first.", + inputSchema: objectSchema({ + key: { description: "Electron keyCode, for example Enter, Tab, Escape, Backspace, ArrowDown.", type: "string" }, + tabId: { description: "Optional tab id. Defaults to the active tab.", type: "string" }, + target: targetSchema + }, ["key"]), + name: "browser_press_key", + tags: ["browser", "automation", "keyboard", "press"], + title: "Press Browser Key" + }, + { + description: "Scroll the page or a target element in the CCR built-in browser.", + inputSchema: objectSchema({ + deltaX: { description: "Horizontal scroll delta in pixels.", type: "number" }, + deltaY: { description: "Vertical scroll delta in pixels.", type: "number" }, + tabId: { description: "Optional tab id. Defaults to the active tab.", type: "string" }, + target: targetSchema + }), + name: "browser_scroll", + tags: ["browser", "automation", "scroll"], + title: "Scroll Browser" + }, + { + description: "Wait until a browser page condition is true: URL includes/matches, selector is visible, or text is visible.", + inputSchema: objectSchema({ + selector: { description: "CSS selector that must be visible.", type: "string" }, + tabId: { description: "Optional tab id. Defaults to the active tab.", type: "string" }, + text: { description: "Text that must be visible in document body.", type: "string" }, + timeoutMs: { description: "Maximum wait time in milliseconds.", maximum: 120000, minimum: 100, type: "number" }, + urlIncludes: { description: "Substring that must appear in the URL.", type: "string" }, + urlMatches: { description: "JavaScript regular expression that must match the URL.", type: "string" } + }), + name: "browser_wait_for", + tags: ["browser", "automation", "wait", "url", "selector", "text"], + title: "Wait For Browser Page" + }, + { + description: "Agentic-browser-compatible alias for browser_handoff_request. Request human help for login, verification, CAPTCHA, or other manual-only blockers.", + inputSchema: objectSchema({ + browserSessionId: { type: "string" }, + kind: { enum: ["login_required", "verification_code", "human_verification", "blocked", "other"], type: "string" }, + message: { type: "string" }, + reason: { type: "string" }, + tabId: { type: "string" } + }, ["reason", "kind"]), + name: "askHumanHelp", + tags: ["browser", "automation", "human", "handoff", "intervention"], + title: "Ask Human Help" + } + ]; +} + +class BrowserAutomationMcpService implements BrowserAutomationMcpIntegration { + private readonly sessions = new Map(); + private readonly subscriptions = new Map(); + + async handleBrowserAutomationMcpRequest(request: IncomingMessage, response: ServerResponse): Promise { + response.setHeader("MCP-Protocol-Version", protocolVersion); + const path = request.url ? new URL(request.url, "http://127.0.0.1").pathname : "/"; + + if (request.method === "GET" && (path === BROWSER_AUTOMATION_MCP_PATH || path === `${BROWSER_AUTOMATION_MCP_PATH}/`)) { + sendJson(response, 200, { + endpoint: BROWSER_AUTOMATION_MCP_PATH, + name: "ccr-browser-automation", + protocol: "mcp", + transport: "streamable-http" + }); + return; + } + + if (request.method !== "POST") { + sendJson(response, 405, { error: { message: "Browser automation MCP endpoint only supports GET and POST." } }); + return; + } + + let payload: unknown; + try { + payload = JSON.parse((await readRequestBody(request, maxMcpRequestBytes)).toString("utf8")) as unknown; + } catch (error) { + sendJson(response, 400, jsonRpcError(null, -32700, `Invalid JSON-RPC request: ${formatError(error)}`)); + return; + } + + const requests = Array.isArray(payload) ? payload : [payload]; + const responses = await Promise.all(requests.map((item) => this.handleJsonRpcRequest(item))); + const filtered = responses.filter((item): item is JsonRpcResponse => Boolean(item)); + if (filtered.length === 0) { + response.writeHead(204); + response.end(); + return; + } + + sendJson(response, 200, Array.isArray(payload) ? filtered : filtered[0]); + } + + async stopBrowserAutomationMcpServer(): Promise { + for (const subscription of this.subscriptions.values()) { + subscription.unsubscribe(); + } + this.subscriptions.clear(); + this.sessions.clear(); + // The gateway owns this MCP route. Browser tabs remain user-visible state and are + // intentionally not closed when the gateway restarts. + } + + private async handleJsonRpcRequest(payload: unknown): Promise { + if (!isRecord(payload)) { + return jsonRpcError(null, -32600, "JSON-RPC request must be an object."); + } + + const request = payload as JsonRpcRequest; + const id = request.id ?? null; + if (request.id === undefined && request.method?.startsWith("notifications/")) { + return undefined; + } + if (request.jsonrpc !== "2.0" || !request.method) { + return jsonRpcError(id, -32600, "Invalid JSON-RPC 2.0 request."); + } + + try { + switch (request.method) { + case "initialize": + return jsonRpcResult(id, { + capabilities: { + tools: {} + }, + protocolVersion, + serverInfo: { + name: "ccr-browser-automation", + title: "CCR Browser Automation", + version: "1.0.0" + } + }); + case "ping": + return jsonRpcResult(id, {}); + case "tools/list": + return jsonRpcResult(id, { tools: browserAutomationTools as unknown as JsonValue }); + case "tools/call": + return jsonRpcResult(id, await this.callTool(request.params) as unknown as JsonValue); + default: + return jsonRpcError(id, -32601, `Unsupported MCP method: ${request.method}`); + } + } catch (error) { + return jsonRpcError(id, -32603, formatError(error)); + } + } + + private async callTool(params: unknown): Promise { + if (!isRecord(params) || typeof params.name !== "string") { + throw new Error("tools/call params must include a tool name."); + } + const args = isRecord(params.arguments) ? params.arguments : {}; + try { + const result = await this.runTool(params.name, args); + return textResult(formatToolResult(params.name, result)); + } catch (error) { + return { + ...textResult(formatError(error)), + isError: true + }; + } + } + + private async runTool(name: string, args: Record): Promise { + switch (name) { + case "browser_session_open": + return await this.openSession(args); + case "browser_session_close": + return this.closeSession(args); + case "browser_tab_create": + return await this.createTab(args); + case "browser_tab_list": + await this.ensureBrowserOpen(); + return browserWindowState(); + case "browser_tab_activate": { + await this.ensureBrowserOpen(); + const session = this.resolveOptionalSession(args); + const tabId = readString(args.tabId) || session?.ref.tabId; + if (!tabId) { + throw new Error("browser_tab_activate requires tabId or session."); + } + const state = builtInBrowserService.selectAutomationTab(tabId); + return { + ...browserWindowState(state), + tab: summarizeTab(requiredTabState(state, tabId), state.activeTabId) + }; + } + case "browser_tab_close": { + await this.ensureBrowserOpen(); + const session = this.resolveOptionalSession(args); + this.assertCanMutate(session); + const tabId = readString(args.tabId) || session?.ref.tabId; + if (!tabId) { + throw new Error("browser_tab_close requires tabId or session."); + } + const state = builtInBrowserService.closeAutomationTab(tabId); + this.removeSessionsForTab(tabId); + return browserWindowState(state); + } + case "browser_navigate": { + await this.ensureBrowserOpen(); + const session = this.resolveOptionalSession(args); + this.assertCanMutate(session); + const tabId = readString(args.tabId) || session?.ref.tabId || builtInBrowserService.getAutomationState().activeTabId; + if (!tabId) { + throw new Error("browser_navigate could not resolve an active tab."); + } + const url = requiredString(args.url, "browser_navigate requires url."); + const waitUntil = normalizeWaitUntil(readString(args.waitUntil), "interactive"); + const readiness = await waitForReadiness( + builtInBrowserService.getAutomationWebContents(tabId), + waitUntil, + clampInteger(readNumber(args.timeoutMs) ?? defaultWaitTimeoutMs, 100, 120000), + () => builtInBrowserService.navigateAutomationTab(url, tabId), + url + ); + const state = builtInBrowserService.getAutomationState(); + const webContents = builtInBrowserService.getAutomationWebContents(tabId); + const handoff = await maybeRequestHandoffAfterNavigation(webContents, readiness, { + requestedUrl: url, + session: session?.ref, + tabId, + waitUntil + }); + return { + ...browserWindowState(state), + ...(handoff ? humanHelpResultFields(handoff) : {}), + session: session?.ref, + tab: summarizeTab(requiredTabState(state, tabId), state.activeTabId), + ...readiness + }; + } + case "browser_tab_go_back": { + await this.ensureBrowserOpen(); + const session = this.resolveOptionalSession(args); + this.assertCanMutate(session); + return browserWindowState(builtInBrowserService.goBackAutomationTab(readString(args.tabId) || session?.ref.tabId)); + } + case "browser_tab_go_forward": { + await this.ensureBrowserOpen(); + const session = this.resolveOptionalSession(args); + this.assertCanMutate(session); + return browserWindowState(builtInBrowserService.goForwardAutomationTab(readString(args.tabId) || session?.ref.tabId)); + } + case "browser_tab_reload": { + await this.ensureBrowserOpen(); + const session = this.resolveOptionalSession(args); + this.assertCanMutate(session); + return browserWindowState(builtInBrowserService.reloadAutomationTab(readString(args.tabId) || session?.ref.tabId)); + } + case "browser_ax_snapshot": { + await this.ensureBrowserOpen(); + const session = this.requireSession(args); + return await captureAxSnapshot( + builtInBrowserService.getAutomationWebContents(session.ref.tabId), + session.ref, + { + limit: clampInteger(readNumber(args.limit) ?? defaultAxSnapshotLimit, 1, 300), + rootAxNodeId: readString(args.rootAxNodeId), + scope: readString(args.scope) === "outline" ? "outline" : "full" + } + ); + } + case "browser_ax_query": { + await this.ensureBrowserOpen(); + const session = this.requireSession(args); + return await queryAxSnapshot( + builtInBrowserService.getAutomationWebContents(session.ref.tabId), + session.ref, + { + limit: clampInteger(readNumber(args.limit) ?? 50, 1, 300), + name: readString(args.name), + role: readString(args.role), + rootAxNodeId: readString(args.rootAxNodeId), + text: readString(args.text) + } + ); + } + case "browser_element_click": { + await this.ensureBrowserOpen(); + const session = this.requireSession(args); + this.assertCanMutate(session); + const webContents = builtInBrowserService.getAutomationWebContents(session.ref.tabId); + const result = await runTargetAction(webContents, "click", readTarget(args.target)); + return await pageActionResult(webContents, session.ref, "click", result); + } + case "browser_element_input": { + await this.ensureBrowserOpen(); + const session = this.requireSession(args); + this.assertCanMutate(session); + const webContents = builtInBrowserService.getAutomationWebContents(session.ref.tabId); + const result = await runTargetAction(webContents, "type", readTarget(args.target), { + replaceExisting: args.replace !== false, + text: typeof args.text === "string" ? args.text : "" + }); + return await pageActionResult(webContents, session.ref, "input", result); + } + case "browser_element_select": { + await this.ensureBrowserOpen(); + const session = this.requireSession(args); + this.assertCanMutate(session); + const value = requiredString(args.value, "browser_element_select requires value."); + const webContents = builtInBrowserService.getAutomationWebContents(session.ref.tabId); + const result = await runTargetAction(webContents, "select", readTarget(args.target), { + exact: args.exact === true, + label: value, + value + }); + return await pageActionResult(webContents, session.ref, "select", result); + } + case "browser_element_press": { + await this.ensureBrowserOpen(); + const session = this.requireSession(args); + this.assertCanMutate(session); + const webContents = builtInBrowserService.getAutomationWebContents(session.ref.tabId); + const result = await pressKey( + webContents, + requiredString(args.key, "browser_element_press requires key."), + isRecord(args.target) ? readTarget(args.target) : undefined + ); + return await pageActionResult(webContents, session.ref, "press", result); + } + case "browser_element_scroll": { + await this.ensureBrowserOpen(); + const session = this.requireSession(args); + this.assertCanMutate(session); + const amount = Math.abs(readNumber(args.amount) ?? 700); + const direction = readString(args.direction) === "up" ? "up" : "down"; + const webContents = builtInBrowserService.getAutomationWebContents(session.ref.tabId); + const result = await runTargetAction( + webContents, + "scroll", + isRecord(args.target) ? readTarget(args.target) : undefined, + { + deltaX: 0, + deltaY: direction === "up" ? -amount : amount + } + ); + return await pageActionResult(webContents, session.ref, "scroll", result); + } + case "browser_events_subscribe": + await this.ensureBrowserOpen(); + return this.subscribeEvents(args); + case "browser_events_read": + return this.readEvents(args); + case "browser_events_await": + return await this.awaitEvents(args); + case "browser_events_unsubscribe": + return this.unsubscribeEvents(args); + case "browser_dialog_handle": { + await this.ensureBrowserOpen(); + const session = this.requireSession(args); + this.assertCanMutate(session); + const webContents = builtInBrowserService.getAutomationWebContents(session.ref.tabId); + return await handleJavaScriptDialog(webContents, args.accept === true, readString(args.promptText), session.ref); + } + case "browser_handoff_request": + case "askHumanHelp": + await this.ensureBrowserOpen(); + return this.requestHandoff(args); + case "browser_handoff_status": + await this.ensureBrowserOpen(); + return { + handoff: builtInBrowserService.getAutomationState().automationHandoff, + state: browserWindowState() + }; + case "browser_handoff_wait": + await this.ensureBrowserOpen(); + return await this.waitForHandoff(args); + case "browser_handoff_clear": + return { + handoff: undefined, + state: builtInBrowserService.resolveAutomationHandoff(readString(args.status) === "dismissed" ? "dismissed" : "completed") + }; + case "browser_chrome_login_import": { + await this.ensureBrowserOpen(); + const session = this.resolveOptionalSession(args); + const domains = resolveChromeLoginImportDomains(args, session?.ref.tabId); + const job = await chromeLoginImportService.createJob({ + domains, + openConfirmationPage: args.openConfirmationPage !== false, + target: readString(args.target) === "browser-and-web-search" ? "browser-and-web-search" : "browser" + }); + return { + humanHelpRequired: true, + job, + nextAction: "user_confirm_chrome_import", + state: browserWindowState(), + summary: `Opened Chrome login import confirmation for ${job.domains.join(", ")}.` + }; + } + case "browser_chrome_login_import_status": { + const jobId = requiredString(args.jobId, "browser_chrome_login_import_status requires jobId."); + const job = chromeLoginImportService.getJob(jobId); + if (!job) { + throw new Error(`Chrome login import job was not found: ${jobId}`); + } + return { job }; + } + case "browser_open": { + const state = await this.ensureBrowserVisible(); + const url = readString(args.url); + return url ? await builtInBrowserService.navigateAutomationTab(url, state.activeTabId) : builtInBrowserService.getAutomationState(); + } + case "browser_tabs": + await this.ensureBrowserOpen(); + return builtInBrowserService.getAutomationState(); + case "browser_tab_new": { + await this.ensureBrowserOpen(); + const tab = builtInBrowserService.createAutomationTab(readString(args.url) || undefined); + return { state: builtInBrowserService.getAutomationState(), tab }; + } + case "browser_snapshot": + await this.ensureBrowserOpen(); + return await captureSnapshotWithHandoff( + builtInBrowserService.getAutomationWebContents(readString(args.tabId)), + readString(args.tabId), + { + limit: clampInteger(readNumber(args.limit) ?? readNumber(args.maxText) ?? defaultSnapshotMaxText, 0, maxSnapshotTextLimit), + maxElements: clampInteger(readNumber(args.maxElements) ?? defaultSnapshotMaxElements, 1, 300), + offset: clampInteger(readNumber(args.offset) ?? 0, 0, maxSnapshotTextOffset) + } + ); + case "browser_click": + await this.ensureBrowserOpen(); + return await runTargetAction( + builtInBrowserService.getAutomationWebContents(readString(args.tabId)), + "click", + readTarget(args.target) + ); + case "browser_type": + await this.ensureBrowserOpen(); + return await runTargetAction( + builtInBrowserService.getAutomationWebContents(readString(args.tabId)), + "type", + readTarget(args.target), + { + replaceExisting: args.replaceExisting !== false, + text: typeof args.text === "string" ? args.text : "" + } + ); + case "browser_select": + await this.ensureBrowserOpen(); + return await runTargetAction( + builtInBrowserService.getAutomationWebContents(readString(args.tabId)), + "select", + readTarget(args.target), + { + exact: args.exact === true, + label: readString(args.label), + value: readString(args.value) + } + ); + case "browser_press_key": + await this.ensureBrowserOpen(); + return await pressKey( + builtInBrowserService.getAutomationWebContents(readString(args.tabId)), + requiredString(args.key, "browser_press_key requires key."), + isRecord(args.target) ? readTarget(args.target) : undefined + ); + case "browser_scroll": + await this.ensureBrowserOpen(); + return await runTargetAction( + builtInBrowserService.getAutomationWebContents(readString(args.tabId)), + "scroll", + isRecord(args.target) ? readTarget(args.target) : undefined, + { + deltaX: readNumber(args.deltaX) ?? 0, + deltaY: readNumber(args.deltaY) ?? 700 + } + ); + case "browser_wait_for": + await this.ensureBrowserOpen(); + return await waitForPageCondition( + builtInBrowserService.getAutomationWebContents(readString(args.tabId)), + { + selector: readString(args.selector), + text: readString(args.text), + timeoutMs: clampInteger(readNumber(args.timeoutMs) ?? defaultWaitTimeoutMs, 100, 120000), + urlIncludes: readString(args.urlIncludes), + urlMatches: readString(args.urlMatches) + } + ); + default: + throw new Error(`Unknown browser automation tool: ${name}`); + } + } + + private async ensureBrowserOpen(): Promise { + await builtInBrowserService.openHidden(await loadAppConfig()); + return builtInBrowserService.getAutomationState(); + } + + private async ensureBrowserVisible(): Promise { + await builtInBrowserService.open(await loadAppConfig()); + return builtInBrowserService.getAutomationState(); + } + + private async openSession(args: Record): Promise { + let state = await this.ensureBrowserOpen(); + const url = readString(args.url); + const requestedTabId = readString(args.tabId); + const previousActiveTabId = state.activeTabId; + let tabId = requestedTabId; + let createdTab = false; + + if (tabId && !state.tabs.some((tab) => tab.id === tabId)) { + throw new Error(`Browser tab was not found: ${tabId}`); + } + + if (!tabId && url) { + const tab = builtInBrowserService.createAutomationTab(); + tabId = tab.id; + createdTab = true; + } else if (!tabId) { + tabId = state.activeTabId || builtInBrowserService.createAutomationTab().id; + createdTab = !state.activeTabId; + } else if (state.activeTabId !== tabId) { + builtInBrowserService.selectAutomationTab(tabId); + } + + if (!tabId) { + throw new Error("Unable to resolve browser tab for automation session."); + } + + const waitUntil = normalizeWaitUntil(readString(args.waitUntil), url ? "interactive" : "none"); + const timeoutMs = clampInteger(readNumber(args.timeoutMs) ?? defaultWaitTimeoutMs, 100, 120000); + const webContents = builtInBrowserService.getAutomationWebContents(tabId); + const readiness = await waitForReadiness( + webContents, + waitUntil, + timeoutMs, + url ? () => builtInBrowserService.navigateAutomationTab(url, tabId) : undefined, + url + ); + state = builtInBrowserService.getAutomationState(); + const tab = requiredTabState(state, tabId); + const ref: BrowserSessionRef = { + sessionId: randomUUID(), + tabId, + ...(readString(args.userId) ? { userId: readString(args.userId) } : {}) + }; + const attached: AttachedSession = { + attachedAt: Date.now(), + leaseId: randomUUID(), + observeOnly: args.observeOnly === true, + ref + }; + this.sessions.set(ref.sessionId, attached); + const handoff = await maybeRequestHandoffAfterNavigation(webContents, readiness, { + requestedUrl: url, + session: ref, + tabId, + waitUntil + }); + + return { + session: ref, + attachedAt: attached.attachedAt, + createdTab, + ...(handoff ? humanHelpResultFields(handoff) : {}), + matched: readiness.matched, + matchedEvent: readiness.matchedEvent, + ...(readiness.navigationError ? { navigationError: readiness.navigationError } : {}), + previousActiveTabId, + readinessUrl: readiness.readinessUrl, + tabId, + timedOut: readiness.timedOut, + title: tab.title, + url: tab.url, + waitUntil, + windowId: builtInBrowserService.getAutomationWindowId() + }; + } + + private closeSession(args: Record): unknown { + const session = this.requireSession(args); + this.sessions.delete(session.ref.sessionId); + for (const [subscriptionId, subscription] of this.subscriptions) { + if (subscription.ref?.sessionId === session.ref.sessionId) { + subscription.unsubscribe(); + this.subscriptions.delete(subscriptionId); + } + } + return { success: true }; + } + + private async createTab(args: Record): Promise { + const state = await this.ensureBrowserOpen(); + const session = this.resolveOptionalSession(args); + this.assertCanMutate(session); + const previousActiveTabId = state.activeTabId; + const tab = builtInBrowserService.createAutomationTab(readString(args.url) || undefined); + if (args.activate === false && previousActiveTabId) { + builtInBrowserService.selectAutomationTab(previousActiveTabId); + } + const nextState = builtInBrowserService.getAutomationState(); + return summarizeTab(requiredTabState(nextState, tab.id), nextState.activeTabId); + } + + private requireSession(args: Record): AttachedSession { + const session = this.resolveOptionalSession(args); + if (!session) { + throw new Error("A valid browser automation session is required."); + } + return session; + } + + private resolveOptionalSession(args: Record): AttachedSession | undefined { + const ref = readSessionRef(args.session); + if (!ref) { + return undefined; + } + const existing = this.sessions.get(ref.sessionId); + if (existing) { + if (existing.ref.tabId !== ref.tabId) { + throw new Error("Browser automation session tabId does not match the attached session."); + } + return existing; + } + const state = builtInBrowserService.getAutomationState(); + if (!state.tabs.some((tab) => tab.id === ref.tabId)) { + throw new Error(`Browser automation session tab was not found: ${ref.tabId}`); + } + const restored: AttachedSession = { + attachedAt: Date.now(), + leaseId: randomUUID(), + observeOnly: false, + ref + }; + this.sessions.set(ref.sessionId, restored); + return restored; + } + + private assertCanMutate(session?: AttachedSession): void { + if (session?.observeOnly) { + throw new Error("This browser automation session is observeOnly and cannot mutate browser state."); + } + } + + private removeSessionsForTab(tabId: string): void { + for (const [sessionId, session] of this.sessions) { + if (session.ref.tabId === tabId) { + this.sessions.delete(sessionId); + } + } + for (const [subscriptionId, subscription] of this.subscriptions) { + if (subscription.ref?.tabId === tabId) { + subscription.unsubscribe(); + this.subscriptions.delete(subscriptionId); + } + } + } + + private subscribeEvents(args: Record): unknown { + const session = this.requireSession(args); + const channels = normalizeEventChannels(readStringArray(args.channels)); + const subscription: EventSubscription = { + channels, + dropped: false, + events: [], + ref: session.ref, + redactTextInput: args.redactTextInput !== false, + sampleMouseMove: args.sampleMouseMove === true, + startedAt: Date.now(), + subscriptionId: randomUUID(), + unsubscribe: () => undefined + }; + const listener = (event: BrowserAutomationEvent) => { + if (!matchesSubscriptionEvent(subscription, event)) { + return; + } + subscription.events.push(event); + if (subscription.events.length > maxSubscriptionEvents) { + subscription.events.splice(0, subscription.events.length - maxSubscriptionEvents); + subscription.dropped = true; + } + }; + subscription.unsubscribe = builtInBrowserService.subscribeAutomationEvents(listener, { + replayRecentMs: automationEventReplayMs + }); + this.subscriptions.set(subscription.subscriptionId, subscription); + + return { + subscription: subscriptionDescriptor(subscription) + }; + } + + private readEvents(args: Record): unknown { + const subscription = this.requireSubscription(args); + const limit = clampInteger(readNumber(args.limit) ?? 100, 1, 200); + const cursorSeq = readCursorSeq(args.cursor); + const events = eventsAfterCursor(subscription, cursorSeq).slice(0, limit); + return { + dropped: subscription.dropped || cursorDropped(subscription, cursorSeq), + events, + nextCursor: makeEventCursor(subscription, events.at(-1)?.seq ?? cursorSeq), + subscriptionId: subscription.subscriptionId + }; + } + + private async awaitEvents(args: Record): Promise { + const subscription = this.requireSubscription(args); + const timeoutMs = clampInteger(readNumber(args.timeoutMs) ?? defaultEventAwaitTimeoutMs, 100, 120000); + const maxEvents = clampInteger(readNumber(args.maxEvents) ?? 10, 1, 50); + const coalesceMs = clampInteger(readNumber(args.coalesceMs) ?? 100, 0, 2000); + const cursorSeq = readCursorSeq(args.cursor); + const deadline = Date.now() + timeoutMs; + let matched: BrowserAutomationEvent[] = []; + + while (Date.now() <= deadline) { + matched = eventsAfterCursor(subscription, cursorSeq) + .filter((event) => matchesAwaitFilter(event, args)) + .slice(0, maxEvents); + if (matched.length > 0) { + if (coalesceMs > 0) { + await sleep(coalesceMs); + matched = eventsAfterCursor(subscription, cursorSeq) + .filter((event) => matchesAwaitFilter(event, args)) + .slice(0, maxEvents); + } + break; + } + await sleep(100); + } + + return { + dropped: subscription.dropped || cursorDropped(subscription, cursorSeq), + event: matched[0], + events: matched, + matched: matched.length > 0, + nextCursor: makeEventCursor(subscription, matched.at(-1)?.seq ?? cursorSeq), + subscriptionId: subscription.subscriptionId, + timedOut: matched.length === 0 + }; + } + + private unsubscribeEvents(args: Record): unknown { + const subscription = this.requireSubscription(args); + subscription.unsubscribe(); + this.subscriptions.delete(subscription.subscriptionId); + return { + ok: true, + subscriptionId: subscription.subscriptionId + }; + } + + private requireSubscription(args: Record): EventSubscription { + const subscriptionId = requiredString(args.subscriptionId, "Browser event subscriptionId is required."); + const subscription = this.subscriptions.get(subscriptionId); + if (!subscription) { + throw new Error(`Browser event subscription was not found: ${subscriptionId}`); + } + return subscription; + } + + private requestHandoff(args: Record): unknown { + const session = this.resolveOptionalSession(args); + const sessionId = session?.ref.sessionId || readString(args.browserSessionId); + const tabId = readString(args.tabId) || session?.ref.tabId; + const handoff = builtInBrowserService.requestAutomationHandoff({ + kind: readHandoffKind(args.kind), + message: readString(args.message), + reason: requiredString(args.reason, "browser_handoff_request requires reason."), + sessionId, + tabId + }); + return { + ...humanHelpResultFields(handoff), + state: browserWindowState() + }; + } + + private async waitForHandoff(args: Record): Promise { + const session = this.resolveOptionalSession(args); + const handoffId = readString(args.handoffId); + const tabId = readString(args.tabId) || session?.ref.tabId; + const timeoutMs = clampInteger(readNumber(args.timeoutMs) ?? 300000, 100, 600000); + + const current = builtInBrowserService.getAutomationState().automationHandoff; + if (!handoffMatches(current, { handoffId, tabId })) { + const recentResolution = findRecentHandoffResolution({ handoffId, tabId }); + if (recentResolution) { + return handoffResolutionResult(recentResolution, undefined, false); + } + return { + handoff: current, + matched: false, + reason: current ? "A browser handoff is pending, but it does not match the requested handoffId/tabId." : "No browser handoff is currently pending.", + state: browserWindowState(), + timedOut: false + }; + } + + return await new Promise((resolve) => { + let settled = false; + const finish = (result: Record) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + unsubscribe(); + resolve(result); + }; + const unsubscribe = builtInBrowserService.subscribeAutomationEvents((event) => { + if (event.kind !== "handoff.completed" && event.kind !== "handoff.dismissed") { + return; + } + if (handoffId && event.handoffId !== handoffId) { + return; + } + if (tabId && event.tabId !== tabId) { + return; + } + finish(handoffResolutionResult(event, current, false)); + }); + const timer = setTimeout(() => { + finish({ + handoff: current, + matched: false, + state: browserWindowState(), + timedOut: true + }); + }, timeoutMs); + }); + } +} + +export const browserAutomationMcpService = new BrowserAutomationMcpService(); + +function browserWindowState(state = builtInBrowserService.getAutomationState()): Record { + return { + activeTabId: state.activeTabId, + tabs: state.tabs.map((tab) => summarizeTab(tab, state.activeTabId)), + windowId: builtInBrowserService.getAutomationWindowId() + }; +} + +function resolveChromeLoginImportDomains(args: Record, fallbackTabId?: string): string[] { + const explicit = uniqueStrings([ + ...readStringArray(args.domains), + ...(readString(args.domain) ? [readString(args.domain) as string] : []) + ].map(normalizeChromeLoginImportDomain).filter((domain): domain is string => Boolean(domain))); + if (explicit.length > 0) { + return explicit; + } + + const state = builtInBrowserService.getAutomationState(); + const tabId = readString(args.tabId) || fallbackTabId || state.activeTabId; + const tab = state.tabs.find((candidate) => candidate.id === tabId) || state.tabs.find((candidate) => candidate.id === state.activeTabId); + const domain = normalizeChromeLoginImportDomain(tab?.url); + if (!domain) { + throw new Error("browser_chrome_login_import requires domains or an active http(s) tab."); + } + return [domain]; +} + +function normalizeChromeLoginImportDomain(value: unknown): string | undefined { + const raw = readString(value)?.toLowerCase(); + if (!raw) { + return undefined; + } + try { + const url = new URL(raw.includes("://") ? raw : `https://${raw}`); + return url.hostname.replace(/^\*\./, "").replace(/^\./, ""); + } catch { + const domain = raw.replace(/^\*\./, "").replace(/^\./, "").split("/")[0]; + return domain && !domain.includes(" ") ? domain : undefined; + } +} + +function summarizeTab(tab: BuiltInBrowserTabState, activeTabId?: string): Record { + return { + canGoBack: tab.canGoBack, + canGoForward: tab.canGoForward, + displayUrl: tab.url, + isActive: tab.id === activeTabId, + isLoading: tab.isLoading, + tabId: tab.id, + title: tab.title, + url: tab.url, + windowId: builtInBrowserService.getAutomationWindowId() + }; +} + +function requiredTabState(state: BuiltInBrowserState, tabId: string): BuiltInBrowserTabState { + const tab = state.tabs.find((candidate) => candidate.id === tabId); + if (!tab) { + throw new Error(`Browser tab was not found: ${tabId}`); + } + return tab; +} + +function readSessionRef(value: unknown): BrowserSessionRef | undefined { + if (!isRecord(value)) { + return undefined; + } + const sessionId = readString(value.sessionId); + const tabId = readString(value.tabId); + if (!sessionId || !tabId) { + return undefined; + } + return { + sessionId, + tabId, + ...(readString(value.frameId) ? { frameId: readString(value.frameId) } : {}), + ...(readString(value.userId) ? { userId: readString(value.userId) } : {}) + }; +} + +async function pageActionResult( + webContents: WebContents, + session: BrowserSessionRef, + action: string, + result: unknown +): Promise> { + const handoff = await maybeRequestPageHandoff(webContents, { + session, + tabId: session.tabId + }); + return { + action, + ...(handoff ? humanHelpResultFields(handoff) : {}), + result, + session, + title: await webContents.getTitle(), + url: webContents.getURL() + }; +} + +async function captureAxSnapshot( + webContents: WebContents, + session: BrowserSessionRef, + options: { limit: number; rootAxNodeId?: string; scope: string } +): Promise { + const snapshot = await captureSnapshot(webContents, { + maxElements: options.limit, + maxText: options.scope === "outline" ? 0 : defaultSnapshotMaxText + }); + const record = isRecord(snapshot) ? snapshot : {}; + const title = readString(record.title) || await webContents.getTitle(); + const url = readString(record.url) || webContents.getURL(); + const elements = Array.isArray(record.elements) ? record.elements.filter(isRecord) : []; + const elementNodes = elements.map((element, index) => axNodeFromSnapshotElement(element, index)); + const rootNode = { + axNodeId: "document", + childAxNodeIds: elementNodes.map((node) => String(node.axNodeId)), + ignored: false, + name: title || url, + role: "document", + url + }; + let nodes: Array> = [rootNode, ...elementNodes]; + if (options.rootAxNodeId && options.rootAxNodeId !== "document") { + nodes = nodes.filter((node) => node.axNodeId === options.rootAxNodeId || node.ref === options.rootAxNodeId); + } + const result: AxSnapshotResult = { + nodes: nodes.slice(0, options.limit), + scope: options.scope, + session, + title, + url + }; + const handoff = await maybeRequestPageHandoff(webContents, { + session, + snapshot, + tabId: session.tabId + }); + return handoff ? { ...result, ...humanHelpResultFields(handoff) } : result; +} + +async function queryAxSnapshot( + webContents: WebContents, + session: BrowserSessionRef, + options: { limit: number; name?: string; role?: string; rootAxNodeId?: string; text?: string } +): Promise> { + const snapshot = await captureAxSnapshot(webContents, session, { + limit: 300, + rootAxNodeId: options.rootAxNodeId, + scope: "outline" + }); + const role = options.role?.toLowerCase(); + const name = options.name?.toLowerCase(); + const text = options.text?.toLowerCase(); + const matches = snapshot.nodes + .filter((node) => node.role !== "document") + .filter((node) => { + if (role && String(node.role || "").toLowerCase() !== role) { + return false; + } + if (name && !String(node.name || "").toLowerCase().includes(name)) { + return false; + } + if (text) { + const haystack = [ + node.name, + node.value, + node.description, + node.text, + node.placeholder + ].join(" ").toLowerCase(); + if (!haystack.includes(text)) { + return false; + } + } + return true; + }) + .slice(0, options.limit); + return { + ...(snapshot.handoff ? humanHelpResultFields(snapshot.handoff) : {}), + matches, + session, + title: snapshot.title, + url: snapshot.url + }; +} + +function axNodeFromSnapshotElement(element: Record, index: number): Record { + const ref = readString(element.ref) || `element-${index}`; + const text = readString(element.text); + const name = readString(element.name) || text || readString(element.placeholder) || ref; + const description = text && text !== name ? text : undefined; + return { + axNodeId: ref, + backendNodeId: readNumber(element.backendNodeId), + checked: element.checked === true ? true : undefined, + description, + disabled: element.disabled === true ? true : undefined, + href: readString(element.href), + ignored: false, + index, + name, + placeholder: readString(element.placeholder), + rect: isRecord(element.rect) ? element.rect : undefined, + ref, + role: readString(element.role) || readString(element.tag) || "generic", + tag: readString(element.tag), + text: description, + value: readString(element.value) + }; +} + +async function waitForReadiness( + webContents: WebContents, + waitUntil: string, + timeoutMs: number, + requestNavigation?: () => Promise, + expectedUrl?: string +): Promise { + if (waitUntil === "none") { + if (requestNavigation) { + await requestNavigation(); + } + return { matched: true, matchedEvent: "none", readinessUrl: safeWebContentsUrl(webContents), timedOut: false }; + } + if (webContents.isDestroyed()) { + return { matched: false, matchedEvent: "destroyed", timedOut: false }; + } + if (requestNavigation) { + const readiness = waitForNextReadinessEvent(webContents, waitUntil, timeoutMs, { + expectedUrl: normalizeNavigationUrlForMatch(expectedUrl), + previousUrl: safeWebContentsUrl(webContents) + }); + try { + await requestNavigation(); + } catch (error) { + return navigationFailureResult("navigation_request_failed", { + errorDescription: formatError(error), + url: normalizeNavigationUrlForMatch(expectedUrl) || safeWebContentsUrl(webContents) + }); + } + return await readiness; + } + + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + if (webContents.isDestroyed()) { + return { matched: false, matchedEvent: "destroyed", timedOut: false }; + } + + if (!webContents.isLoading()) { + const readinessUrl = safeWebContentsUrl(webContents); + if (isChromiumErrorPage(readinessUrl)) { + return navigationFailureResult("chrome-error", { + errorDescription: "Chromium displayed an internal error page.", + url: readinessUrl + }); + } + if (waitUntil === "interactive") { + const interactive = await interactiveReadinessResult(webContents, readinessUrl); + if (interactive) { + return interactive; + } + } + if (waitUntil === "domcontentloaded") { + return { matched: true, matchedEvent: "domcontentloaded", readinessUrl, timedOut: false }; + } + if (waitUntil === "load") { + return { matched: true, matchedEvent: "load", readinessUrl, timedOut: false }; + } + if (waitUntil === "network_idle") { + const idle = await waitForNoLoadingStart(webContents, Math.min(500, Math.max(0, deadline - Date.now()))); + if (idle && !webContents.isDestroyed() && !webContents.isLoading()) { + return { matched: true, matchedEvent: "network_idle", readinessUrl: safeWebContentsUrl(webContents), timedOut: false }; + } + } + } + + const event = await waitForReadinessEvent(webContents, Math.max(0, deadline - Date.now())); + if (event.type === "timeout") { + break; + } + if (event.type === "destroyed") { + return { matched: false, matchedEvent: "destroyed", timedOut: false }; + } + if (event.type === "did-fail-load" && event.errorCode !== -3) { + return navigationFailureResult("did-fail-load", event); + } + const eventUrl = event.url || safeWebContentsUrl(webContents); + if (isChromiumErrorPage(eventUrl)) { + return navigationFailureResult("chrome-error", { + errorDescription: "Chromium displayed an internal error page.", + url: eventUrl + }); + } + if (waitUntil === "interactive" && isInteractiveReadinessEvent(event)) { + const interactive = await interactiveReadinessResult(webContents, eventUrl); + if (interactive) { + return interactive; + } + } + if (waitUntil === "domcontentloaded" && event.type === "dom-ready") { + return { matched: true, matchedEvent: "domcontentloaded", readinessUrl: eventUrl, timedOut: false }; + } + if (waitUntil === "load" && (event.type === "did-finish-load" || event.type === "did-stop-loading") && !webContents.isDestroyed() && !webContents.isLoading()) { + return { matched: true, matchedEvent: "load", readinessUrl: eventUrl, timedOut: false }; + } + if (waitUntil === "network_idle" && (event.type === "did-finish-load" || event.type === "did-stop-loading") && !webContents.isDestroyed() && !webContents.isLoading()) { + const idle = await waitForNoLoadingStart(webContents, Math.min(500, Math.max(0, deadline - Date.now()))); + if (idle && !webContents.isDestroyed() && !webContents.isLoading()) { + return { matched: true, matchedEvent: "network_idle", readinessUrl: safeWebContentsUrl(webContents), timedOut: false }; + } + } + } + if (waitUntil === "interactive" || waitUntil === "network_idle") { + const fallback = await interactiveReadinessResult( + webContents, + undefined, + waitUntil === "network_idle" ? "interactive_after_network_idle_timeout" : "interactive" + ); + if (fallback) { + return fallback; + } + } + return { + matched: false, + matchedEvent: webContents.isDestroyed() ? "destroyed" : undefined, + timedOut: !webContents.isDestroyed() + }; +} + +async function waitForNextReadinessEvent( + webContents: WebContents, + waitUntil: string, + timeoutMs: number, + context: NavigationReadinessContext +): Promise { + const deadline = Date.now() + timeoutMs; + let navigationStarted = false; + let lastNavigationUrl: string | undefined; + + while (Date.now() <= deadline) { + if (webContents.isDestroyed()) { + return { matched: false, matchedEvent: "destroyed", timedOut: false }; + } + + const event = await waitForReadinessEvent(webContents, Math.max(0, deadline - Date.now())); + if (event.type === "timeout") { + break; + } + if (event.type === "destroyed") { + return { matched: false, matchedEvent: "destroyed", timedOut: false }; + } + + const eventUrl = event.url || safeWebContentsUrl(webContents); + if (isNavigationStartEvent(event)) { + if (event.isMainFrame !== false && isRelevantNavigationUrl(eventUrl, context)) { + navigationStarted = true; + lastNavigationUrl = eventUrl; + } + continue; + } + + if (event.type === "did-fail-load") { + if (event.errorCode === -3) { + continue; + } + if (event.isMainFrame !== false && (navigationStarted || isRelevantNavigationUrl(eventUrl, context))) { + return navigationFailureResult("did-fail-load", event); + } + continue; + } + + if (!navigationStarted) { + continue; + } + + if (isChromiumErrorPage(eventUrl)) { + return navigationFailureResult("chrome-error", { + errorDescription: "Chromium displayed an internal error page.", + url: eventUrl + }); + } + if (waitUntil === "interactive" && isInteractiveReadinessEvent(event)) { + const interactive = await interactiveReadinessResult(webContents, eventUrl || lastNavigationUrl); + if (interactive) { + return interactive; + } + } + if (waitUntil === "domcontentloaded" && event.type === "dom-ready") { + return { matched: true, matchedEvent: "domcontentloaded", readinessUrl: eventUrl || lastNavigationUrl, timedOut: false }; + } + if (waitUntil === "load" && (event.type === "did-finish-load" || event.type === "did-stop-loading") && !webContents.isDestroyed() && !webContents.isLoading()) { + return { matched: true, matchedEvent: "load", readinessUrl: eventUrl || lastNavigationUrl, timedOut: false }; + } + if (waitUntil === "network_idle" && (event.type === "did-finish-load" || event.type === "did-stop-loading") && !webContents.isDestroyed() && !webContents.isLoading()) { + const idle = await waitForNoLoadingStart(webContents, Math.min(500, Math.max(0, deadline - Date.now()))); + if (idle && !webContents.isDestroyed() && !webContents.isLoading()) { + return { matched: true, matchedEvent: "network_idle", readinessUrl: safeWebContentsUrl(webContents) || lastNavigationUrl, timedOut: false }; + } + } + } + if (waitUntil === "interactive" || waitUntil === "network_idle") { + const fallback = await interactiveReadinessResult( + webContents, + lastNavigationUrl, + waitUntil === "network_idle" ? "interactive_after_network_idle_timeout" : "interactive" + ); + if (fallback && (navigationStarted || isRelevantNavigationUrl(fallback.readinessUrl, context))) { + return fallback; + } + } + return { + matched: false, + matchedEvent: webContents.isDestroyed() ? "destroyed" : undefined, + timedOut: !webContents.isDestroyed() + }; +} + +function waitForReadinessEvent(webContents: WebContents, timeoutMs: number): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (event: ReadinessEvent) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + webContents.off("did-navigate", onDidNavigate); + webContents.off("did-redirect-navigation", onDidRedirectNavigation); + webContents.off("did-fail-load", onDidFailLoad); + webContents.off("did-finish-load", onDidFinishLoad); + webContents.off("did-start-navigation", onDidStartNavigation); + webContents.off("did-start-loading", onDidStartLoading); + webContents.off("did-stop-loading", onDidStopLoading); + webContents.off("dom-ready", onDomReady); + webContents.off("destroyed", onDestroyed); + resolve(event); + }; + const onDidFailLoad = ( + _event: ElectronEvent, + errorCode: number, + errorDescription: string, + validatedUrl: string, + isMainFrame?: boolean + ) => finish({ + errorCode, + errorDescription, + isMainFrame, + type: "did-fail-load", + url: validatedUrl || safeWebContentsUrl(webContents) + }); + const onDidFinishLoad = () => finish({ type: "did-finish-load", url: safeWebContentsUrl(webContents) }); + const onDidNavigate = (_event: ElectronEvent, url: string) => finish({ isMainFrame: true, type: "did-navigate", url }); + const onDidRedirectNavigation = (_event: ElectronEvent, url: string, isInPlace?: boolean, isMainFrame?: boolean) => { + finish({ isMainFrame, type: isInPlace ? "did-navigate-in-page" : "did-redirect-navigation", url }); + }; + const onDidStartNavigation = (_event: ElectronEvent, url: string, isInPlace?: boolean, isMainFrame?: boolean) => { + finish({ isMainFrame, type: isInPlace ? "did-navigate-in-page" : "did-start-navigation", url }); + }; + const onDidStartLoading = () => finish({ type: "did-start-loading", url: safeWebContentsUrl(webContents) }); + const onDidStopLoading = () => finish({ type: "did-stop-loading", url: safeWebContentsUrl(webContents) }); + const onDomReady = () => finish({ type: "dom-ready", url: safeWebContentsUrl(webContents) }); + const onDestroyed = () => finish({ type: "destroyed" }); + const timer = setTimeout(() => finish({ type: "timeout" }), Math.max(0, timeoutMs)); + + webContents.once("did-navigate", onDidNavigate); + webContents.once("did-redirect-navigation", onDidRedirectNavigation); + webContents.once("did-fail-load", onDidFailLoad); + webContents.once("did-finish-load", onDidFinishLoad); + webContents.once("did-start-navigation", onDidStartNavigation); + webContents.once("did-start-loading", onDidStartLoading); + webContents.once("did-stop-loading", onDidStopLoading); + webContents.once("dom-ready", onDomReady); + webContents.once("destroyed", onDestroyed); + }); +} + +function waitForNoLoadingStart(webContents: WebContents, timeoutMs: number): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (idle: boolean) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + webContents.off("did-start-loading", onDidStartLoading); + webContents.off("destroyed", onDestroyed); + resolve(idle); + }; + const onDidStartLoading = () => finish(false); + const onDestroyed = () => finish(false); + const timer = setTimeout(() => finish(true), Math.max(0, timeoutMs)); + webContents.once("did-start-loading", onDidStartLoading); + webContents.once("destroyed", onDestroyed); + }); +} + +async function interactiveReadinessResult( + webContents: WebContents, + fallbackUrl?: string, + matchedEvent = "interactive" +): Promise { + if (webContents.isDestroyed()) { + return undefined; + } + const readinessUrl = safeWebContentsUrl(webContents) || fallbackUrl; + if (!isUsableInteractiveUrl(readinessUrl)) { + return undefined; + } + const readyState = await documentReadyState(webContents); + if (readyState === "interactive" || readyState === "complete" || !webContents.isLoading()) { + return { matched: true, matchedEvent, readinessUrl, timedOut: false }; + } + return undefined; +} + +async function documentReadyState(webContents: WebContents): Promise { + try { + const value = await executeJavaScriptWithTimeout( + webContents, + "document.readyState", + 1000, + "Document readiness check" + ); + return typeof value === "string" ? value : undefined; + } catch { + return undefined; + } +} + +function isInteractiveReadinessEvent(event: ReadinessEvent): boolean { + return event.type === "dom-ready" || + event.type === "did-finish-load" || + event.type === "did-stop-loading"; +} + +function isUsableInteractiveUrl(value: string | undefined): value is string { + return Boolean(value) && !isBlankPageUrl(value) && !isChromiumErrorPage(value); +} + +function navigationFailureResult(matchedEvent: string, event: Pick): ReadinessResult { + return { + matched: false, + matchedEvent, + navigationError: { + ...(event.errorCode !== undefined ? { errorCode: event.errorCode } : {}), + ...(event.errorDescription ? { errorDescription: event.errorDescription } : {}), + ...(event.url ? { url: event.url } : {}) + }, + readinessUrl: event.url, + timedOut: false + }; +} + +function isNavigationStartEvent(event: ReadinessEvent): boolean { + return event.type === "did-start-navigation" || + event.type === "did-redirect-navigation" || + event.type === "did-navigate"; +} + +function isRelevantNavigationUrl(url: string | undefined, context: NavigationReadinessContext): boolean { + if (!url) { + return false; + } + const normalizedUrl = normalizeUrlForComparison(url); + if (!normalizedUrl) { + return false; + } + if (context.expectedUrl && urlsMatchForNavigation(normalizedUrl, context.expectedUrl)) { + return true; + } + if (isChromiumErrorPage(normalizedUrl)) { + return true; + } + if (isBlankPageUrl(normalizedUrl)) { + return isBlankPageUrl(context.expectedUrl); + } + if (!context.previousUrl) { + return true; + } + return !urlsMatchForNavigation(normalizedUrl, context.previousUrl); +} + +function normalizeNavigationUrlForMatch(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + if (isBlankPageUrl(trimmed) || isChromiumErrorPage(trimmed)) { + return trimmed; + } + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { + return normalizeUrlForComparison(trimmed); + } + if (/^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/|$)/i.test(trimmed) || trimmed.includes(".")) { + return normalizeUrlForComparison(`https://${trimmed}`); + } + return normalizeUrlForComparison(`https://www.google.com/search?q=${encodeURIComponent(trimmed)}`); +} + +function normalizeUrlForComparison(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + try { + const url = new URL(trimmed); + url.hash = ""; + return url.toString(); + } catch { + return trimmed; + } +} + +function urlsMatchForNavigation(left: string | undefined, right: string | undefined): boolean { + if (!left || !right) { + return false; + } + const normalizedLeft = normalizeUrlForComparison(left); + const normalizedRight = normalizeUrlForComparison(right); + if (!normalizedLeft || !normalizedRight) { + return false; + } + if (normalizedLeft === normalizedRight) { + return true; + } + try { + const leftUrl = new URL(normalizedLeft); + const rightUrl = new URL(normalizedRight); + return leftUrl.protocol === rightUrl.protocol && + leftUrl.hostname === rightUrl.hostname && + leftUrl.pathname === rightUrl.pathname && + leftUrl.search === rightUrl.search; + } catch { + return normalizedLeft === normalizedRight; + } +} + +function isBlankPageUrl(value: string | undefined): boolean { + return value === "about:blank"; +} + +function isChromiumErrorPage(value: string | undefined): boolean { + return value === "chrome-error://chromewebdata/"; +} + +function safeWebContentsUrl(webContents: WebContents): string | undefined { + if (webContents.isDestroyed()) { + return undefined; + } + try { + return webContents.getURL(); + } catch { + return undefined; + } +} + +function normalizeWaitUntil(value: string | undefined, fallback: string): string { + const normalized = (value || fallback).toLowerCase().replace(/[-\s]/g, "_"); + if (normalized === "auto" || normalized === "interactive" || normalized === "ready") { + return "interactive"; + } + if (normalized === "dom_ready" || normalized === "domcontentloaded") { + return "domcontentloaded"; + } + if (normalized === "networkidle" || normalized === "network_idle") { + return "network_idle"; + } + if (normalized === "none" || normalized === "load") { + return normalized; + } + return fallback; +} + +function readHandoffKind(value: unknown): BuiltInBrowserAutomationHandoffKind { + const kind = readString(value); + if ( + kind === "blocked" || + kind === "human_verification" || + kind === "login_required" || + kind === "other" || + kind === "verification_code" + ) { + return kind; + } + return "other"; +} + +function normalizeEventChannels(values: string[]): Set { + const normalized = values + .map((value) => value.toLowerCase().trim()) + .filter(Boolean); + if (normalized.length === 0) { + return new Set(["tab", "navigation", "dom", "runtime", "dialog", "download", "input", "focus", "selection", "handoff"]); + } + return new Set(normalized); +} + +function matchesSubscriptionEvent(subscription: EventSubscription, event: BrowserAutomationEvent): boolean { + const channel = eventChannel(event.kind); + if (!subscription.channels.has(channel) && !subscription.channels.has(event.kind) && !subscription.channels.has("*")) { + return false; + } + if (!subscription.ref) { + return true; + } + if (!event.tabId || event.tabId === subscription.ref.tabId) { + return true; + } + return channel === "tab"; +} + +function eventChannel(kind: string): string { + if (kind.startsWith("tab.")) return "tab"; + if (kind.startsWith("page.navigation") || kind.startsWith("page.loading")) return "navigation"; + if (kind === "page.dom_ready") return "dom"; + if (kind.startsWith("runtime.")) return "runtime"; + if (kind.startsWith("dialog.")) return "dialog"; + if (kind.startsWith("download.")) return "download"; + if (kind.startsWith("handoff.")) return "handoff"; + if (kind.startsWith("input.")) return "input"; + if (kind.startsWith("focus.")) return "focus"; + if (kind.startsWith("selection.")) return "selection"; + return kind.split(".")[0] || kind; +} + +function subscriptionDescriptor(subscription: EventSubscription): Record { + return { + channels: [...subscription.channels], + cursor: makeEventCursor(subscription), + redactTextInput: subscription.redactTextInput, + sampleMouseMove: subscription.sampleMouseMove, + session: subscription.ref, + startedAt: subscription.startedAt, + subscriptionId: subscription.subscriptionId + }; +} + +function findRecentHandoffResolution(filter: { handoffId?: string; tabId?: string }): BrowserAutomationEvent | undefined { + const events = builtInBrowserService.getAutomationEvents({ replayRecentMs: automationEventReplayMs }); + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event.kind !== "handoff.completed" && event.kind !== "handoff.dismissed") { + continue; + } + if (filter.handoffId && event.handoffId !== filter.handoffId) { + continue; + } + if (filter.tabId && event.tabId !== filter.tabId) { + continue; + } + return event; + } + return undefined; +} + +function handoffResolutionResult( + event: BrowserAutomationEvent, + handoff: BuiltInBrowserAutomationHandoff | undefined, + timedOut: boolean +): Record { + return { + event, + handoff, + matched: !timedOut, + state: browserWindowState(), + status: event.handoffStatus || (event.kind === "handoff.completed" ? "completed" : "dismissed"), + timedOut, + title: event.title, + url: event.url + }; +} + +function eventsAfterCursor(subscription: EventSubscription, cursorSeq?: number): BrowserAutomationEvent[] { + const seq = cursorSeq ?? 0; + return subscription.events.filter((event) => event.seq > seq); +} + +function cursorDropped(subscription: EventSubscription, cursorSeq?: number): boolean { + if (cursorSeq === undefined || subscription.events.length === 0) { + return subscription.dropped; + } + return subscription.dropped && cursorSeq < subscription.events[0].seq - 1; +} + +function makeEventCursor(subscription: EventSubscription, seq?: number): Record { + const lastEvent = subscription.events.findLast((event) => seq === undefined || event.seq <= seq) || subscription.events.at(-1); + return { + dropped: subscription.dropped, + seq: seq ?? lastEvent?.seq ?? 0, + subscriptionId: subscription.subscriptionId, + ts: lastEvent?.ts ?? subscription.startedAt + }; +} + +function readCursorSeq(value: unknown): number | undefined { + if (!isRecord(value)) { + return undefined; + } + return readNumber(value.seq); +} + +function matchesAwaitFilter(event: BrowserAutomationEvent, args: Record): boolean { + const match = isRecord(args.match) ? args.match : args; + const kinds = readStringArray(match.kinds); + const kind = readString(match.kind); + if (kind && event.kind !== kind) { + return false; + } + if (kinds.length > 0 && !kinds.includes(event.kind)) { + return false; + } + const tabId = readString(match.tabId); + if (tabId && event.tabId !== tabId) { + return false; + } + const windowId = readString(match.windowId); + if (windowId && event.windowId !== windowId) { + return false; + } + return stringMatchesPattern(event.url, readString(match.urlPattern)) + && stringMatchesPattern(event.title, readString(match.titlePattern)) + && stringMatchesPattern(event.summary, readString(match.summaryPattern)); +} + +function stringMatchesPattern(value: string | undefined, pattern: string | undefined): boolean { + if (!pattern) { + return true; + } + const haystack = value || ""; + try { + return new RegExp(pattern).test(haystack); + } catch { + return haystack.toLowerCase().includes(pattern.toLowerCase()); + } +} + +async function handleJavaScriptDialog( + webContents: WebContents, + accept: boolean, + promptText: string | undefined, + session: BrowserSessionRef +): Promise> { + let attachedHere = false; + try { + if (!webContents.debugger.isAttached()) { + webContents.debugger.attach("1.3"); + attachedHere = true; + } + await withTimeout(webContents.debugger.sendCommand("Page.enable"), defaultJavascriptTimeoutMs, "Timed out enabling browser dialog handling."); + await withTimeout( + webContents.debugger.sendCommand("Page.handleJavaScriptDialog", { + accept, + ...(promptText !== undefined ? { promptText } : {}) + }), + defaultJavascriptTimeoutMs, + "Timed out handling browser dialog." + ); + return { + accepted: accept, + ok: true, + promptText, + session, + title: await webContents.getTitle(), + url: webContents.getURL() + }; + } catch (error) { + throw new Error(`Failed to handle browser dialog: ${formatError(error)}`); + } finally { + if (attachedHere && webContents.debugger.isAttached()) { + try { + webContents.debugger.detach(); + } catch { + // Ignore detach errors after the dialog is resolved. + } + } + } +} + +async function executeJavaScriptWithTimeout( + webContents: WebContents, + script: string, + timeoutMs: number, + label: string +): Promise { + if (webContents.isDestroyed()) { + throw new Error(`${label} failed because the browser tab is destroyed.`); + } + return await withTimeout( + webContents.executeJavaScript(script, true) as Promise, + timeoutMs, + `${label} timed out after ${timeoutMs}ms.` + ); +} + +async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), Math.max(0, timeoutMs)); + }) + ]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + +async function captureSnapshot(webContents: WebContents, options: SnapshotOptions): Promise { + return await executeJavaScriptWithTimeout( + webContents, + `(${snapshotScript})(${JSON.stringify(options)})`, + defaultJavascriptTimeoutMs, + "Browser snapshot" + ) as unknown; +} + +async function captureSnapshotWithHandoff( + webContents: WebContents, + tabId: string | undefined, + options: SnapshotOptions +): Promise { + const snapshot = await captureSnapshot(webContents, options); + const handoff = await maybeRequestPageHandoff(webContents, { + snapshot, + tabId + }); + if (!handoff || !isRecord(snapshot)) { + return snapshot; + } + return { + ...snapshot, + ...humanHelpResultFields(handoff) + }; +} + +async function maybeRequestHandoffAfterNavigation( + webContents: WebContents, + readiness: ReadinessResult, + options: { + requestedUrl?: string; + session?: BrowserSessionRef; + tabId?: string; + waitUntil?: string; + } +): Promise { + if (webContents.isDestroyed()) { + return undefined; + } + + if (!readiness.navigationError) { + const pageHandoff = await maybeRequestPageHandoff(webContents, { + session: options.session, + tabId: options.tabId + }); + if (pageHandoff) { + return pageHandoff; + } + } + + return maybeRequestNavigationHandoff(webContents, readiness, options); +} + +function maybeRequestNavigationHandoff( + webContents: WebContents, + readiness: ReadinessResult, + options: { + requestedUrl?: string; + session?: BrowserSessionRef; + tabId?: string; + waitUntil?: string; + } +): BuiltInBrowserAutomationHandoff | undefined { + if (readiness.matched || webContents.isDestroyed()) { + return undefined; + } + + const tabId = options.tabId || options.session?.tabId; + const existing = existingHandoffForTab(tabId); + if (existing) { + return existing; + } + + const currentUrl = safeWebContentsUrl(webContents); + const target = options.requestedUrl || readiness.readinessUrl || currentUrl || "the requested page"; + const error = readiness.navigationError; + const reason = error + ? `Browser navigation was blocked or failed while opening ${target}${error.errorCode !== undefined ? ` (error ${error.errorCode})` : ""}${error.errorDescription ? `: ${error.errorDescription}` : "."}` + : `Browser automation did not reach ${options.waitUntil || "the requested readiness state"} while opening ${target}.`; + + return builtInBrowserService.requestAutomationHandoff({ + kind: "blocked", + message: "Please inspect this browser page and complete or retry the blocked step, then click Done.", + reason, + sessionId: options.session?.sessionId, + tabId + }); +} + +async function maybeRequestPageHandoff( + webContents: WebContents, + options: { + session?: BrowserSessionRef; + snapshot?: unknown; + tabId?: string; + } = {} +): Promise { + if (webContents.isDestroyed()) { + return undefined; + } + + let snapshot = options.snapshot; + if (snapshot === undefined) { + try { + snapshot = await captureSnapshot(webContents, { + maxElements: 20, + maxText: 1200 + }); + } catch { + snapshot = undefined; + } + } + + const detection = detectPageHandoff(snapshot, { + title: await webContents.getTitle(), + url: webContents.getURL() + }); + if (!detection) { + return undefined; + } + + const tabId = options.tabId || options.session?.tabId; + const existing = existingHandoffForTab(tabId); + if (existing) { + return existing; + } + + return builtInBrowserService.requestAutomationHandoff({ + kind: detection.kind, + message: detection.message, + reason: detection.reason, + sessionId: options.session?.sessionId, + tabId + }); +} + +function existingHandoffForTab(tabId: string | undefined): BuiltInBrowserAutomationHandoff | undefined { + const existing = builtInBrowserService.getAutomationState().automationHandoff; + if (existing && (!tabId || existing.tabId === tabId)) { + return existing; + } + return undefined; +} + +function handoffMatches( + handoff: BuiltInBrowserAutomationHandoff | undefined, + filter: { handoffId?: string; tabId?: string } +): boolean { + if (!handoff) { + return false; + } + if (filter.handoffId && handoff.id !== filter.handoffId) { + return false; + } + if (filter.tabId && handoff.tabId !== filter.tabId) { + return false; + } + return true; +} + +function detectPageHandoff(snapshot: unknown, fallback: { title?: string; url?: string }): BrowserHandoffDetection | undefined { + const record = isRecord(snapshot) ? snapshot : {}; + const title = readString(record.title) || fallback.title || ""; + const url = readString(record.url) || fallback.url || ""; + const signal = pageHandoffSignal(record, title, url); + if (isChromiumErrorPage(url)) { + return { + kind: "blocked", + message: "Please inspect this browser error page and retry or complete the blocked step, then click Done.", + reason: "Chromium displayed an internal error page for this navigation." + }; + } + if (!signal) { + return undefined; + } + + const verificationCode = /verification code|security code|one[-\s]?time code|2[-\s]?step|two[-\s]?step|authenticator|check your (?:phone|email)|enter the code/.test(signal); + if (verificationCode) { + return { + kind: "verification_code", + message: "Please enter the verification code in this browser window, then click Done.", + reason: `The page appears to require a verification code: ${title || url || "current page"}.` + }; + } + + const cloudflare = signal.includes("cloudflare") || /\bray id\s*:/.test(signal); + const botVerification = /performing security verification|security verification|verif(?:y|ies|ying)[^.\n]{0,80}(?:not a bot|human)|not a bot|not a robot|human verification|are you human|checking your browser|security check/.test(signal); + const challenge = /captcha|hcaptcha|recaptcha|turnstile|cf-challenge|cf-turnstile/.test(signal); + const justAMoment = title.trim().toLowerCase() === "just a moment..." || signal.includes("just a moment..."); + if (challenge || (cloudflare && (botVerification || justAMoment))) { + return { + kind: "human_verification", + message: "Please complete the security verification in this browser window, then click Done.", + reason: `The page appears to be a human verification or bot-protection challenge: ${title || url || "current page"}.` + }; + } + + const loginUrl = /(?:^|[/.?=&_-])(?:login|signin|sign-in|accounts)(?:[/.?=&_-]|$)/.test(url.toLowerCase()) || + /accounts\.google\.com|mail\.google\.com/.test(url.toLowerCase()); + const loginTitle = /\bsign in\b|\blog in\b|\blogin\b|google accounts|account login/.test(title.toLowerCase()); + const credentialFields = /email or phone|email address|username|password|forgot (?:email|password)|create account|\bnext\b/.test(signal); + if ((loginUrl || loginTitle) && credentialFields) { + return { + kind: "login_required", + message: "Please sign in in this browser window, then click Done.", + reason: `The page appears to require a human login: ${title || url || "current page"}.` + }; + } + + return undefined; +} + +function humanHelpResultFields(handoff: BuiltInBrowserAutomationHandoff): Record { + return { + handoff, + handoffRequired: true, + humanHelp: { + aliasTool: "askHumanHelp", + instruction: "Browser automation needs human help. The browser window has been shown with a top toolbar instruction. Call browser_handoff_wait to receive the user's Done/Hide result before continuing automation.", + handoffId: handoff.id, + message: handoff.message, + reason: handoff.reason, + required: true, + status: "requested", + tool: "browser_handoff_wait" + }, + humanHelpRequired: true, + nextAction: "human_help" + }; +} + +function pageHandoffSignal(record: Record, title: string, url: string): string { + const parts = [title, url, rawString(record.text)]; + const activeElement = isRecord(record.activeElement) ? record.activeElement : undefined; + if (activeElement) { + parts.push(rawString(activeElement.name), rawString(activeElement.text), rawString(activeElement.role)); + } + const elements = Array.isArray(record.elements) ? record.elements : []; + for (const element of elements.slice(0, 40)) { + if (!isRecord(element)) { + continue; + } + parts.push( + rawString(element.name), + rawString(element.text), + rawString(element.role), + rawString(element.tag), + rawString(element.href) + ); + } + return parts + .filter((part): part is string => Boolean(part)) + .join("\n") + .replace(/\s+/g, " ") + .toLowerCase(); +} + +async function runTargetAction( + webContents: WebContents, + action: "click" | "focus" | "scroll" | "select" | "type", + target?: BrowserTarget, + value: Record = {} +): Promise { + const result = await executeJavaScriptWithTimeout( + webContents, + `(${targetActionScript})(${JSON.stringify({ action, target, value })})`, + defaultJavascriptTimeoutMs, + `Browser target action ${action}` + ) as { error?: string; ok?: boolean }; + if (!result?.ok) { + throw new Error(result?.error || `Browser target action failed: ${action}`); + } + return result; +} + +async function pressKey(webContents: WebContents, key: string, target?: BrowserTarget): Promise { + if (target) { + await runTargetAction(webContents, "focus", target); + } + webContents.sendInputEvent({ keyCode: key, type: "keyDown" }); + webContents.sendInputEvent({ keyCode: key, type: "keyUp" }); + return { + key, + ok: true, + title: await webContents.getTitle(), + url: webContents.getURL() + }; +} + +async function waitForPageCondition( + webContents: WebContents, + condition: { + selector?: string; + text?: string; + timeoutMs: number; + urlIncludes?: string; + urlMatches?: string; + } +): Promise { + const deadline = Date.now() + condition.timeoutMs; + let last: unknown; + while (Date.now() <= deadline) { + try { + last = await executeJavaScriptWithTimeout( + webContents, + `(${waitForConditionScript})(${JSON.stringify(condition)})`, + Math.max(100, Math.min(1000, deadline - Date.now())), + "Browser wait condition check" + ) as unknown; + if (isRecord(last) && last.matched === true) { + return last; + } + } catch (error) { + last = { + error: formatError(error), + matched: false + }; + } + await sleep(150); + } + return { + last, + matched: false, + timedOut: true, + title: await webContents.getTitle(), + url: webContents.getURL() + }; +} + +const snapshotScript = function(options: { limit?: number; maxElements: number; maxText?: number; offset?: number }) { + const maxElements = Math.max(1, Math.min(300, Math.floor(options.maxElements || 80))); + const textLimitInput = options.limit ?? options.maxText ?? 3000; + const textLimit = Math.max(0, Math.min(20000, Math.floor(textLimitInput))); + const requestedTextOffset = Math.max(0, Math.floor(options.offset || 0)); + const refAttribute = "data-ccr-browser-ref"; + const elementSelector = [ + "a[href]", + "button", + "input", + "textarea", + "select", + "summary", + "[role]", + "[contenteditable='true']", + "[contenteditable='plaintext-only']", + "[tabindex]:not([tabindex='-1'])" + ].join(","); + + function visible(el: Element) { + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== "none" && + style.visibility !== "hidden" && + Number(style.opacity || "1") > 0 && + rect.width > 0 && + rect.height > 0; + } + + function cssEscape(value: string) { + return window.CSS && typeof window.CSS.escape === "function" + ? window.CSS.escape(value) + : value.replace(/[^a-zA-Z0-9_-]/g, "\\$&"); + } + + function uniqueIdSelector(id: string) { + if (!id) return ""; + const selector = `#${cssEscape(id)}`; + try { + return document.querySelectorAll(selector).length === 1 ? selector : ""; + } catch { + return ""; + } + } + + function refFor(el: Element) { + const win = window as Window & { __ccrBrowserRefSeq?: number }; + const existing = el.getAttribute(refAttribute); + if (existing) { + return `[${refAttribute}="${cssEscape(existing)}"]`; + } + for (let attempt = 0; attempt < 1000; attempt += 1) { + win.__ccrBrowserRefSeq = (win.__ccrBrowserRefSeq || 0) + 1; + const nextRef = `ccr-${win.__ccrBrowserRefSeq}`; + const selector = `[${refAttribute}="${cssEscape(nextRef)}"]`; + if (!document.querySelector(selector)) { + el.setAttribute(refAttribute, nextRef); + return selector; + } + } + + const idSelector = uniqueIdSelector((el as HTMLElement).id || ""); + if (idSelector) return idSelector; + const parts: string[] = []; + let current: Element | null = el; + while (current && current.nodeType === 1 && current !== document.documentElement && parts.length < 8) { + const tag = current.tagName.toLowerCase(); + const parent: HTMLElement | null = current.parentElement; + if (!parent) { + parts.unshift(tag); + break; + } + const parentIdSelector = uniqueIdSelector(parent.id || ""); + const sameTag = Array.from(parent.children).filter((child): child is Element => child instanceof Element && child.tagName === current!.tagName); + const index = sameTag.indexOf(current) + 1; + parts.unshift(`${tag}:nth-of-type(${Math.max(index, 1)})`); + if (parentIdSelector) { + parts.unshift(parentIdSelector); + break; + } + current = parent; + } + return parts.join(" > "); + } + + function text(el: Element) { + return ((el as HTMLElement).innerText || el.textContent || "").replace(/\s+/g, " ").trim(); + } + + function labelText(el: Element) { + const labelledBy = el.getAttribute("aria-labelledby"); + if (labelledBy) { + const value = labelledBy + .split(/\s+/) + .map((id) => document.getElementById(id)?.innerText || "") + .join(" ") + .replace(/\s+/g, " ") + .trim(); + if (value) return value; + } + const id = (el as HTMLInputElement).id; + if (id) { + const label = document.querySelector(`label[for="${cssEscape(id)}"]`); + if (label) return text(label); + } + const wrappingLabel = el.closest("label"); + return wrappingLabel ? text(wrappingLabel) : ""; + } + + function unsafeField(el: Element) { + const input = el as HTMLInputElement; + const haystack = [ + input.type, + input.name, + input.id, + input.autocomplete, + el.getAttribute("aria-label"), + labelText(el) + ].join(" ").toLowerCase(); + return /\b(password|secret|token|api[-_ ]?key|bearer|credential)\b/.test(haystack); + } + + function valueOf(el: Element) { + if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement)) { + return ""; + } + if (unsafeField(el)) { + return ""; + } + return el.value || ""; + } + + function roleOf(el: Element) { + const explicit = el.getAttribute("role"); + if (explicit) return explicit; + const tag = el.tagName.toLowerCase(); + const input = el as HTMLInputElement; + if (tag === "a") return "link"; + if (tag === "button") return "button"; + if (tag === "textarea") return "textbox"; + if (tag === "select") return "combobox"; + if (tag === "summary") return "button"; + if (tag === "input") { + if (["button", "submit", "reset"].includes(input.type)) return "button"; + if (input.type === "checkbox") return "checkbox"; + if (input.type === "radio") return "radio"; + if (input.type === "range") return "slider"; + return "textbox"; + } + if (el.getAttribute("contenteditable")) return "textbox"; + return ""; + } + + function nameOf(el: Element) { + const input = el as HTMLInputElement; + return ( + el.getAttribute("aria-label") || + labelText(el) || + el.getAttribute("alt") || + el.getAttribute("title") || + input.placeholder || + (input.type && ["button", "submit", "reset"].includes(input.type) ? input.value : "") || + text(el) + ).replace(/\s+/g, " ").trim(); + } + + const elements = Array.from(document.querySelectorAll(elementSelector)) + .filter(visible) + .slice(0, maxElements) + .map((el, index) => { + const rect = el.getBoundingClientRect(); + return { + checked: el instanceof HTMLInputElement && ["checkbox", "radio"].includes(el.type) ? el.checked : undefined, + disabled: (el as HTMLButtonElement | HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement).disabled || undefined, + href: el instanceof HTMLAnchorElement ? el.href : undefined, + index, + name: nameOf(el).slice(0, 240), + placeholder: (el as HTMLInputElement).placeholder || undefined, + rect: { + height: Math.round(rect.height), + width: Math.round(rect.width), + x: Math.round(rect.x), + y: Math.round(rect.y) + }, + ref: refFor(el), + role: roleOf(el), + tag: el.tagName.toLowerCase(), + text: text(el).slice(0, 180), + value: valueOf(el) || undefined + }; + }); + + const pageText = (document.body?.innerText || "").replace(/\s+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim(); + const textOffset = Math.min(requestedTextOffset, pageText.length); + const textWindow = pageText.slice(textOffset, textOffset + textLimit); + const textNextOffset = textOffset + textWindow.length; + + return { + activeElement: document.activeElement ? { + name: nameOf(document.activeElement).slice(0, 240), + ref: refFor(document.activeElement), + role: roleOf(document.activeElement), + tag: document.activeElement.tagName.toLowerCase() + } : undefined, + elements, + text: textWindow, + textHasMore: textNextOffset < pageText.length, + textLength: pageText.length, + textLimit, + textNextOffset, + textOffset, + textRemaining: Math.max(0, pageText.length - textNextOffset), + textReturned: textWindow.length, + textRequestedOffset: requestedTextOffset !== textOffset ? requestedTextOffset : undefined, + title: document.title, + url: location.href + }; +}; + +const targetActionScript = function(request: { + action: "click" | "focus" | "scroll" | "select" | "type"; + target?: BrowserTarget; + value?: Record; +}) { + function visible(el: Element) { + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; + } + function text(el: Element) { + return ((el as HTMLElement).innerText || el.textContent || "").replace(/\s+/g, " ").trim(); + } + function cssEscape(value: string) { + return window.CSS && typeof window.CSS.escape === "function" + ? window.CSS.escape(value) + : value.replace(/[^a-zA-Z0-9_-]/g, "\\$&"); + } + function labelText(el: Element) { + const id = (el as HTMLInputElement).id; + if (id) { + const label = document.querySelector(`label[for="${cssEscape(id)}"]`); + if (label) return text(label); + } + const wrappingLabel = el.closest("label"); + return wrappingLabel ? text(wrappingLabel) : ""; + } + function roleOf(el: Element) { + const explicit = el.getAttribute("role"); + if (explicit) return explicit.toLowerCase(); + const tag = el.tagName.toLowerCase(); + const input = el as HTMLInputElement; + if (tag === "a") return "link"; + if (tag === "button") return "button"; + if (tag === "textarea") return "textbox"; + if (tag === "select") return "combobox"; + if (tag === "summary") return "button"; + if (tag === "input") { + if (["button", "submit", "reset"].includes(input.type)) return "button"; + if (input.type === "checkbox") return "checkbox"; + if (input.type === "radio") return "radio"; + return "textbox"; + } + if (el.getAttribute("contenteditable")) return "textbox"; + return ""; + } + function nameOf(el: Element) { + const input = el as HTMLInputElement; + return ( + el.getAttribute("aria-label") || + labelText(el) || + el.getAttribute("alt") || + el.getAttribute("title") || + input.placeholder || + input.value || + text(el) + ).replace(/\s+/g, " ").trim(); + } + function summarize(el: Element) { + const rect = el.getBoundingClientRect(); + return { + name: nameOf(el).slice(0, 240), + rect: { + height: Math.round(rect.height), + width: Math.round(rect.width), + x: Math.round(rect.x), + y: Math.round(rect.y) + }, + role: roleOf(el), + tag: el.tagName.toLowerCase(), + text: text(el).slice(0, 180) + }; + } + function selectorElement(selector: string) { + try { + return document.querySelector(selector); + } catch { + return null; + } + } + function matchesText(value: string, needle: string, exact?: boolean) { + const left = value.trim().toLowerCase(); + const right = needle.trim().toLowerCase(); + return exact ? left === right : left.includes(right); + } + function findTarget(target?: BrowserTarget) { + if (!target && request.action !== "scroll") return null; + const directSelector = target?.selector || target?.ref || target?.axNodeId; + if (directSelector) { + return selectorElement(directSelector); + } + const selector = [ + "a[href]", + "button", + "input", + "textarea", + "select", + "summary", + "[role]", + "[contenteditable='true']", + "[contenteditable='plaintext-only']", + "[tabindex]:not([tabindex='-1'])" + ].join(","); + const candidates = Array.from(document.querySelectorAll(selector)).filter(visible); + const role = target?.role?.trim().toLowerCase(); + const needle = target?.text?.trim(); + const matches = candidates.filter((el) => { + if (role && roleOf(el) !== role) return false; + if (!needle) return true; + const haystack = [ + nameOf(el), + text(el), + (el as HTMLInputElement).placeholder || "", + (el as HTMLInputElement).value || "" + ].join(" "); + return matchesText(haystack, needle, target?.exact); + }); + return matches[Math.max(0, Math.floor(target?.index || 0))] || null; + } + function dispatchValueEvents(el: Element) { + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + } + + const el = findTarget(request.target); + if (!el && request.action !== "scroll") { + return { error: "No matching browser element found.", ok: false }; + } + if (el) { + el.scrollIntoView({ block: "center", inline: "center" }); + if (el instanceof HTMLElement) { + el.focus({ preventScroll: true }); + } + } + + if (request.action === "focus") { + return { element: el ? summarize(el) : undefined, ok: true }; + } + + if (request.action === "click") { + if (!(el instanceof HTMLElement)) { + return { error: "Matched element is not clickable.", ok: false }; + } + el.click(); + return { element: summarize(el), ok: true }; + } + + if (request.action === "type") { + const nextText = typeof request.value?.text === "string" ? request.value.text : ""; + const replaceExisting = request.value?.replaceExisting !== false; + if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { + el.value = replaceExisting ? nextText : `${el.value}${nextText}`; + dispatchValueEvents(el); + return { element: summarize(el), ok: true, value: el.type === "password" ? "" : el.value }; + } + if (el instanceof HTMLElement && el.isContentEditable) { + if (replaceExisting) { + el.textContent = nextText; + } else { + el.textContent = `${el.textContent || ""}${nextText}`; + } + dispatchValueEvents(el); + return { element: summarize(el), ok: true }; + } + return { error: "Matched element cannot receive typed text.", ok: false }; + } + + if (request.action === "select") { + if (!(el instanceof HTMLSelectElement)) { + return { error: "Matched element is not a select.", ok: false }; + } + const requestedValue = typeof request.value?.value === "string" ? request.value.value : ""; + const requestedLabel = typeof request.value?.label === "string" ? request.value.label : ""; + const exact = request.value?.exact === true; + const option = Array.from(el.options).find((candidate) => { + if (requestedValue && candidate.value === requestedValue) return true; + if (!requestedLabel) return false; + return matchesText(candidate.textContent || "", requestedLabel, exact); + }); + if (!option) { + return { error: "No matching select option found.", ok: false }; + } + el.value = option.value; + dispatchValueEvents(el); + return { element: summarize(el), label: option.textContent || "", ok: true, value: option.value }; + } + + if (request.action === "scroll") { + const deltaX = typeof request.value?.deltaX === "number" ? request.value.deltaX : 0; + const deltaY = typeof request.value?.deltaY === "number" ? request.value.deltaY : 700; + if (el instanceof HTMLElement) { + el.scrollBy({ behavior: "auto", left: deltaX, top: deltaY }); + return { element: summarize(el), ok: true }; + } + window.scrollBy({ behavior: "auto", left: deltaX, top: deltaY }); + return { ok: true, scrollX: window.scrollX, scrollY: window.scrollY }; + } + + return { error: "Unsupported browser target action.", ok: false }; +}; + +const waitForConditionScript = function(condition: { + selector?: string; + text?: string; + urlIncludes?: string; + urlMatches?: string; +}) { + const checks: Array<{ matched: boolean; type: string }> = []; + if (condition.urlIncludes) { + checks.push({ matched: location.href.includes(condition.urlIncludes), type: "urlIncludes" }); + } + if (condition.urlMatches) { + let matched = false; + try { + matched = new RegExp(condition.urlMatches).test(location.href); + } catch { + matched = false; + } + checks.push({ matched, type: "urlMatches" }); + } + if (condition.selector) { + let matched = false; + try { + const el = document.querySelector(condition.selector); + if (el) { + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + matched = style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; + } + } catch { + matched = false; + } + checks.push({ matched, type: "selector" }); + } + if (condition.text) { + const pageText = (document.body?.innerText || "").toLowerCase(); + checks.push({ matched: pageText.includes(condition.text.toLowerCase()), type: "text" }); + } + return { + checks, + matched: checks.length > 0 && checks.every((check) => check.matched), + title: document.title, + url: location.href + }; +}; + +function readTarget(value: unknown): BrowserTarget { + if (!isRecord(value)) { + throw new Error("A browser target object is required."); + } + return { + axNodeId: readString(value.axNodeId), + backendNodeId: readNumber(value.backendNodeId), + exact: value.exact === true, + index: clampInteger(readNumber(value.index) ?? 0, 0, 10000), + ref: readString(value.ref) || readString(value.axNodeId), + role: readString(value.role), + selector: readString(value.selector), + text: readString(value.text) + }; +} + +function requiredString(value: unknown, message: string): string { + const text = readString(value); + if (!text) { + throw new Error(message); + } + return text; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : []; +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values)]; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function clampInteger(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, Math.trunc(value))); +} + +function objectSchema(properties: Record, required: string[] = []): JsonValue { + return { + additionalProperties: false, + properties, + required, + type: "object" + }; +} + +type CompactResultOptions = { + maxArrayItems: number; + maxDepth: number; + maxObjectKeys: number; + maxStringChars: number; +}; + +function formatToolResult(toolName: string, result: unknown): string { + const compacted = compactToolResult(toolName, result); + const serialized = stringifyToolResult(compacted); + if (serialized.length <= maxToolResultChars) { + return serialized; + } + + const tighter = compactJsonValue(compacted, { + maxArrayItems: 50, + maxDepth: 6, + maxObjectKeys: 80, + maxStringChars: 600 + }); + const tightSerialized = stringifyToolResult({ + guidance: largeResultGuidance(toolName), + maxChars: maxToolResultChars, + originalChars: serialized.length, + result: tighter, + tool: toolName, + truncated: true + }); + if (tightSerialized.length <= maxToolResultChars) { + return tightSerialized; + } + + return stringifyToolResult({ + guidance: largeResultGuidance(toolName), + maxChars: maxToolResultChars, + originalChars: serialized.length, + preview: truncateString(tightSerialized, 12_000), + tool: toolName, + truncated: true + }); +} + +function compactToolResult(toolName: string, result: unknown): unknown { + if (toolName === "browser_snapshot") { + return compactSnapshotResult(result); + } + if (toolName === "browser_ax_snapshot") { + return compactAxSnapshotResult(result); + } + if (toolName === "browser_ax_query") { + return compactArrayResult(result, "matches", 80, 600); + } + if (toolName === "browser_events_read" || toolName === "browser_events_await") { + return compactArrayResult(result, "events", 80, 800); + } + return compactJsonValue(result, defaultCompactResultOptions()); +} + +function compactSnapshotResult(result: unknown): unknown { + if (!isRecord(result)) { + return compactJsonValue(result, defaultCompactResultOptions()); + } + + const elements = Array.isArray(result.elements) ? result.elements : []; + const text = rawString(result.text); + const compacted: Record = {}; + for (const [key, value] of Object.entries(result)) { + if (key === "elements" || key === "text") { + continue; + } + compacted[key] = compactJsonValue(value, { + maxArrayItems: 40, + maxDepth: 5, + maxObjectKeys: 60, + maxStringChars: 600 + }); + } + + if (text !== undefined) { + compacted.text = truncateString(text, maxBrowserResultTextChars); + } + compacted.elements = elements + .slice(0, maxSnapshotResultElements) + .map((element) => compactJsonValue(element, { + maxArrayItems: 20, + maxDepth: 4, + maxObjectKeys: 40, + maxStringChars: 500 + })); + compacted.elementCount = elements.length; + + const truncated: Record = {}; + if (text !== undefined && text.length > maxBrowserResultTextChars) { + truncated.textChars = text.length - maxBrowserResultTextChars; + } + if (elements.length > maxSnapshotResultElements) { + truncated.elements = elements.length - maxSnapshotResultElements; + } + if (Object.keys(truncated).length > 0) { + compacted.truncated = truncated; + } + return compacted; +} + +function compactAxSnapshotResult(result: unknown): unknown { + if (!isRecord(result)) { + return compactJsonValue(result, defaultCompactResultOptions()); + } + + const nodes = Array.isArray(result.nodes) ? result.nodes : []; + const compacted: Record = {}; + for (const [key, value] of Object.entries(result)) { + if (key === "nodes") { + continue; + } + compacted[key] = compactJsonValue(value, { + maxArrayItems: 40, + maxDepth: 5, + maxObjectKeys: 60, + maxStringChars: 400 + }); + } + compacted.nodes = nodes + .slice(0, maxAxSnapshotResultNodes) + .map((node) => compactJsonValue(node, { + maxArrayItems: 80, + maxDepth: 5, + maxObjectKeys: 40, + maxStringChars: 260 + })); + compacted.nodeCount = nodes.length; + if (nodes.length > maxAxSnapshotResultNodes) { + compacted.truncated = { + nodes: nodes.length - maxAxSnapshotResultNodes + }; + } + return compacted; +} + +function compactArrayResult(result: unknown, key: string, maxItems: number, maxStringChars: number): unknown { + if (!isRecord(result)) { + return compactJsonValue(result, defaultCompactResultOptions()); + } + + const items = Array.isArray(result[key]) ? result[key] : []; + const compacted: Record = {}; + for (const [entryKey, value] of Object.entries(result)) { + if (entryKey === key) { + continue; + } + compacted[entryKey] = compactJsonValue(value, { + maxArrayItems: 40, + maxDepth: 5, + maxObjectKeys: 60, + maxStringChars + }); + } + compacted[key] = items + .slice(0, maxItems) + .map((item) => compactJsonValue(item, { + maxArrayItems: 40, + maxDepth: 5, + maxObjectKeys: 60, + maxStringChars + })); + compacted[`${key}Count`] = items.length; + if (items.length > maxItems) { + compacted.truncated = { + [key]: items.length - maxItems + }; + } + return compacted; +} + +function compactJsonValue( + value: unknown, + options: CompactResultOptions, + depth = 0, + seen: WeakSet = new WeakSet() +): unknown { + if (value === null || typeof value === "boolean" || typeof value === "number") { + return value; + } + if (typeof value === "string") { + return truncateString(value, options.maxStringChars); + } + if (typeof value === "bigint") { + return value.toString(); + } + if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol") { + return undefined; + } + if (typeof value !== "object") { + return String(value); + } + if (seen.has(value)) { + return "[Circular]"; + } + if (depth >= options.maxDepth) { + return { + reason: "Maximum result depth reached.", + truncated: true + }; + } + + seen.add(value); + if (Array.isArray(value)) { + const limit = Math.max(0, options.maxArrayItems); + const items = value + .slice(0, limit) + .map((item) => compactJsonValue(item, options, depth + 1, seen)); + if (value.length > limit) { + items.push({ + omittedItems: value.length - limit, + truncated: true + }); + } + seen.delete(value); + return items; + } + + const entries = Object.entries(value); + const compacted: Record = {}; + for (const [key, entryValue] of entries.slice(0, options.maxObjectKeys)) { + compacted[key] = compactJsonValue(entryValue, options, depth + 1, seen); + } + if (entries.length > options.maxObjectKeys) { + compacted.truncatedKeys = entries.length - options.maxObjectKeys; + } + seen.delete(value); + return compacted; +} + +function defaultCompactResultOptions(): CompactResultOptions { + return { + maxArrayItems: maxToolResultArrayItems, + maxDepth: 8, + maxObjectKeys: maxToolResultObjectKeys, + maxStringChars: maxToolResultStringChars + }; +} + +function stringifyToolResult(value: unknown): string { + return JSON.stringify(value, null, 2) || "null"; +} + +function rawString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function truncateString(value: string, maxChars: number): string { + if (value.length <= maxChars) { + return value; + } + let suffix = "\n[truncated]"; + for (let index = 0; index < 2; index += 1) { + const keep = Math.max(0, maxChars - suffix.length); + suffix = `\n[truncated ${value.length - keep} chars]`; + } + return `${value.slice(0, Math.max(0, maxChars - suffix.length))}${suffix}`; +} + +function largeResultGuidance(toolName: string): string { + if (toolName === "browser_snapshot") { + return "Result was compacted. Re-run browser_snapshot with lower maxElements/limit, page text with offset/limit, or use browser_ax_query to fetch targeted elements."; + } + if (toolName === "browser_ax_snapshot") { + return "Result was compacted. Re-run browser_ax_snapshot with lower limit or scope=outline, or use browser_ax_query with role/name/text filters."; + } + if (toolName === "browser_ax_query") { + return "Result was compacted. Re-run browser_ax_query with a lower limit or narrower role/name/text filters."; + } + if (toolName === "browser_events_read" || toolName === "browser_events_await") { + return "Result was compacted. Read events with a lower limit/maxEvents or a more specific cursor/filter."; + } + return "Result was compacted before returning to the MCP client. Use narrower arguments or lower limits for more detail."; +} + +function textResult(text: string): ToolCallResult { + return { + content: [{ text, type: "text" }] + }; +} + +function jsonRpcResult(id: null | number | string, result: JsonValue): JsonRpcResponse { + return { + id, + jsonrpc: "2.0", + result + }; +} + +function jsonRpcError(id: null | number | string, code: number, message: string): JsonRpcResponse { + return { + error: { + code, + message + }, + id, + jsonrpc: "2.0" + }; +} + +function sendJson(response: ServerResponse, status: number, payload: unknown): void { + const body = `${JSON.stringify(payload)}\n`; + response.writeHead(status, { + "content-length": Buffer.byteLength(body), + "content-type": "application/json; charset=utf-8" + }); + response.end(body); +} + +function readRequestBody(request: IncomingMessage, maxBytes: number): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + request.on("data", (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buffer.byteLength; + if (total > maxBytes) { + reject(new Error("MCP request body is too large.")); + request.destroy(); + return; + } + chunks.push(buffer); + }); + request.on("end", () => resolve(Buffer.concat(chunks))); + request.on("error", reject); + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/browser-preload.ts b/packages/electron/src/main/browser-preload.ts similarity index 67% rename from src/main/browser-preload.ts rename to packages/electron/src/main/browser-preload.ts index f479d88..9d571ff 100644 --- a/src/main/browser-preload.ts +++ b/packages/electron/src/main/browser-preload.ts @@ -1,16 +1,19 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron"; -import { IPC_CHANNELS } from "../shared/ipc-channels"; -import type { BuiltInBrowserState } from "../shared/app"; +import { IPC_CHANNELS } from "@ccr/core/contracts/ipc-channels"; +import type { BuiltInBrowserState, ChromeLoginImportJob, ChromeLoginImportRequest } from "@ccr/core/contracts/app"; contextBridge.exposeInMainWorld("ccrBrowser", { back: (tabId?: string) => ipcRenderer.invoke(IPC_CHANNELS.browserBack, tabId) as Promise, closeTab: (tabId: string) => ipcRenderer.invoke(IPC_CHANNELS.browserCloseTab, tabId) as Promise, forward: (tabId?: string) => ipcRenderer.invoke(IPC_CHANNELS.browserForward, tabId) as Promise, + getChromeLoginImport: (id: string) => ipcRenderer.invoke(IPC_CHANNELS.browserGetChromeLoginImport, id) as Promise, getState: () => ipcRenderer.invoke(IPC_CHANNELS.browserGetState) as Promise, navigate: (url: string, tabId?: string) => ipcRenderer.invoke(IPC_CHANNELS.browserNavigate, url, tabId) as Promise, newTab: (url?: string) => ipcRenderer.invoke(IPC_CHANNELS.browserNewTab, url) as Promise, reload: (tabId?: string) => ipcRenderer.invoke(IPC_CHANNELS.browserReload, tabId) as Promise, + resolveAutomationHandoff: (status: "completed" | "dismissed") => ipcRenderer.invoke(IPC_CHANNELS.browserResolveAutomationHandoff, status) as Promise, selectTab: (tabId: string) => ipcRenderer.invoke(IPC_CHANNELS.browserSelectTab, tabId) as Promise, + startChromeLoginImport: (request: ChromeLoginImportRequest) => ipcRenderer.invoke(IPC_CHANNELS.browserStartChromeLoginImport, request) as Promise, onStateChanged: (callback: (state: BuiltInBrowserState) => void) => { const handler = (_event: IpcRendererEvent, state: BuiltInBrowserState) => callback(state); ipcRenderer.on(IPC_CHANNELS.browserStateChanged, handler); diff --git a/src/main/built-in-browser.ts b/packages/electron/src/main/built-in-browser.ts similarity index 52% rename from src/main/built-in-browser.ts rename to packages/electron/src/main/built-in-browser.ts index d845d5a..c941016 100644 --- a/src/main/built-in-browser.ts +++ b/packages/electron/src/main/built-in-browser.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { EventEmitter } from "node:events"; import { BrowserWindow, clipboard, @@ -10,28 +11,64 @@ import { WebContentsView, type ContextMenuParams, type IpcMainInvokeEvent, - type MenuItemConstructorOptions + type MenuItemConstructorOptions, + type WebContents } from "electron"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import type { AppConfig, BuiltInBrowserState, BuiltInBrowserTabState, GatewayPluginAppConfig, InstalledBrowserApp } from "../shared/app"; -import { IPC_CHANNELS } from "../shared/ipc-channels"; -import { APP_NAME } from "./constants"; -import { pluginService } from "./plugins/service"; -import { proxyService } from "../server/proxy/service"; +import type { + AppConfig, + BuiltInBrowserAutomationHandoff, + BuiltInBrowserAutomationHandoffKind, + BuiltInBrowserState, + BuiltInBrowserTabState, + ChromeLoginImportRequest, + GatewayPluginAppConfig, + InstalledBrowserApp +} from "@ccr/core/contracts/app"; +import { IPC_CHANNELS } from "@ccr/core/contracts/ipc-channels"; +import { APP_NAME } from "@ccr/core/config/constants"; +import { pluginService } from "@ccr/core/plugins/service"; +import { chromeLoginImportService } from "./chrome-login-import"; type BrowserTab = BuiltInBrowserTabState & { view: WebContentsView; }; -const browserChromeHeight = 82; +export type BrowserAutomationEvent = { + errorCode?: number; + errorDescription?: string; + handoffId?: string; + handoffStatus?: "completed" | "dismissed"; + kind: string; + seq: number; + summary?: string; + tabId?: string; + title?: string; + ts: number; + url?: string; + windowId?: string; +}; + +export type BrowserAutomationEventListener = (event: BrowserAutomationEvent) => void; + +const browserChromeBaseHeight = 82; +const browserHandoffToolbarHeight = 44; const browserHomeUrl = "about:blank"; const browserPartition = "persist:ccr-built-in-browser"; +const browserAutomationWindowId = "ccr-built-in-browser"; +const maxAutomationEventHistory = 512; +const maxAutomationEventAgeMs = 60_000; const titleBarHeight = 46; class BuiltInBrowserService { private activeTabId?: string; private apps: InstalledBrowserApp[] = []; + private automationHandoff?: BuiltInBrowserAutomationHandoff; + private automationEventSeq = 0; + private readonly automationEvents = new EventEmitter(); + private readonly automationEventHistory: BrowserAutomationEvent[] = []; + private hideWindowAfterAutomationHandoff = false; private proxyConfigKey = ""; private tabOrder: string[] = []; private tabs = new Map(); @@ -44,10 +81,7 @@ class BuiltInBrowserService { async open(config: AppConfig): Promise { await this.syncProxy(config); - const window = this.window && !this.window.isDestroyed() ? this.window : this.createWindow(); - if (this.tabs.size === 0) { - this.createTab(browserHomeUrl); - } + const window = this.ensureWindow(); if (window.isMinimized()) { window.restore(); } @@ -57,28 +91,18 @@ class BuiltInBrowserService { this.sendState(); } + async openHidden(config: AppConfig): Promise { + await this.syncProxy(config); + this.ensureWindow(); + this.layoutActiveView(); + this.sendState(); + } + async syncProxy(config: AppConfig): Promise { this.syncApps(config); const browserSession = session.fromPartition(browserPartition); - if (config.proxy.enabled) { - await proxyService.refreshUpstreamProxyFromCurrentSystem(); - } - const proxyStatus = proxyService.getStatus(); - const shouldUseProxy = Boolean( - config.proxy.enabled && - proxyStatus.state === "running" && - proxyStatus.endpoint - ); - const proxyConfig = shouldUseProxy - ? { - mode: "fixed_servers" as const, - proxyBypassRules: "<-loopback>", - proxyRules: electronProxyRules(proxyStatus.endpoint) - } - : { - mode: "direct" as const - }; + const proxyConfig = { mode: "system" as const }; const nextKey = JSON.stringify(proxyConfig); if (nextKey === this.proxyConfigKey) { return; @@ -100,9 +124,211 @@ class BuiltInBrowserService { async clearProxy(): Promise { const browserSession = session.fromPartition(browserPartition); - await browserSession.setProxy({ mode: "direct" }); + const proxyConfig = { mode: "system" as const }; + await browserSession.setProxy(proxyConfig); await browserSession.forceReloadProxyConfig(); - this.proxyConfigKey = JSON.stringify({ mode: "direct" }); + this.proxyConfigKey = JSON.stringify(proxyConfig); + } + + getAutomationState(): BuiltInBrowserState { + return this.getState(); + } + + getAutomationWindowId(): string { + return browserAutomationWindowId; + } + + getAutomationEvents(options: { replayRecentMs?: number } = {}): BrowserAutomationEvent[] { + const replayRecentMs = Math.max(0, Math.floor(options.replayRecentMs ?? 0)); + const cutoff = replayRecentMs > 0 ? Date.now() - replayRecentMs : 0; + this.pruneAutomationEventHistory(); + return this.automationEventHistory + .filter((event) => event.ts >= cutoff) + .map((event) => ({ ...event })); + } + + requestAutomationHandoff(request: { + kind?: BuiltInBrowserAutomationHandoffKind; + message?: string; + reason?: string; + sessionId?: string; + tabId?: string; + }): BuiltInBrowserAutomationHandoff { + const existingWindow = this.window && !this.window.isDestroyed() ? this.window : undefined; + if (!this.automationHandoff) { + this.hideWindowAfterAutomationHandoff = !existingWindow || !existingWindow.isVisible() || existingWindow.isMinimized(); + } + const tab = this.getTab(request.tabId); + if (request.tabId && tab?.id) { + this.selectTab(request.tabId); + } + const targetTab = tab || this.getTab(); + const handoff: BuiltInBrowserAutomationHandoff = { + id: randomUUID(), + kind: request.kind || "other", + message: request.message?.trim() || defaultAutomationHandoffMessage(request.kind), + ...(request.reason?.trim() ? { reason: request.reason.trim() } : {}), + requestedAt: Date.now(), + ...(request.sessionId?.trim() ? { sessionId: request.sessionId.trim() } : {}), + status: "pending", + tabId: targetTab?.id || request.tabId || this.activeTabId + }; + this.automationHandoff = handoff; + this.layoutActiveView(); + this.showAutomationWindow(); + this.sendState(); + this.emitAutomationEvent({ + handoffId: handoff.id, + kind: "handoff.requested", + summary: handoff.message, + tabId: handoff.tabId, + title: targetTab?.title, + url: targetTab?.url + }); + return handoff; + } + + resolveAutomationHandoff(status: "completed" | "dismissed" = "completed"): BuiltInBrowserState { + const handoff = this.automationHandoff; + const shouldHideWindow = this.hideWindowAfterAutomationHandoff; + this.automationHandoff = undefined; + this.hideWindowAfterAutomationHandoff = false; + this.layoutActiveView(); + this.sendState(); + if (handoff) { + const tab = this.getTab(handoff.tabId); + if (shouldHideWindow) { + this.hideAutomationWindow(); + } + this.emitAutomationEvent({ + handoffId: handoff.id, + handoffStatus: status, + kind: status === "completed" ? "handoff.completed" : "handoff.dismissed", + summary: status === "completed" ? "User completed the requested browser handoff." : "User dismissed the requested browser handoff.", + tabId: handoff.tabId, + title: tab?.title, + url: tab?.url + }); + } + return this.getState(); + } + + subscribeAutomationEvents( + listener: BrowserAutomationEventListener, + options: { replayRecentMs?: number } = {} + ): () => void { + this.automationEvents.on("event", listener); + const replayRecentMs = Math.max(0, Math.floor(options.replayRecentMs ?? 0)); + if (replayRecentMs > 0) { + const cutoff = Date.now() - replayRecentMs; + this.pruneAutomationEventHistory(); + for (const event of this.automationEventHistory) { + if (event.ts >= cutoff) { + listener(event); + } + } + } + return () => this.automationEvents.off("event", listener); + } + + createAutomationTab(url = browserHomeUrl): BuiltInBrowserTabState { + const tab = this.createTab(url); + const { view: _view, ...state } = tab; + return state; + } + + selectAutomationTab(tabId: string): BuiltInBrowserState { + this.selectTab(tabId); + return this.getState(); + } + + closeAutomationTab(tabId: string): BuiltInBrowserState { + this.closeTab(tabId); + return this.getState(); + } + + async navigateAutomationTab(url: string, tabId?: string): Promise { + const tab = this.getTab(tabId); + if (!tab) { + throw new Error("Browser tab was not found."); + } + + const nextUrl = normalizeBrowserUrl(url); + tab.url = nextUrl; + if (tab.id === this.activeTabId) { + this.layoutActiveView(); + } + this.sendState(); + void tab.view.webContents.loadURL(nextUrl).catch((error) => { + this.emitAutomationEvent({ + kind: "page.load_failed", + summary: `Navigation request failed: ${formatError(error)}`, + tabId: tab.id, + title: tab.title, + url: nextUrl + }); + }); + this.emitAutomationEvent({ + kind: "page.navigation_requested", + summary: `Navigation requested: ${nextUrl}`, + tabId: tab.id, + title: tab.title, + url: nextUrl + }); + return this.getState(); + } + + goBackAutomationTab(tabId?: string): BuiltInBrowserState { + const tab = this.getTab(tabId); + tab?.view.webContents.navigationHistory.goBack(); + if (tab) { + this.emitAutomationEvent({ + kind: "tab.go_back", + summary: `Go back in tab ${tab.id}.`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); + } + return this.getState(); + } + + goForwardAutomationTab(tabId?: string): BuiltInBrowserState { + const tab = this.getTab(tabId); + tab?.view.webContents.navigationHistory.goForward(); + if (tab) { + this.emitAutomationEvent({ + kind: "tab.go_forward", + summary: `Go forward in tab ${tab.id}.`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); + } + return this.getState(); + } + + reloadAutomationTab(tabId?: string): BuiltInBrowserState { + const tab = this.getTab(tabId); + tab?.view.webContents.reload(); + if (tab) { + this.emitAutomationEvent({ + kind: "tab.reload", + summary: `Reload tab ${tab.id}.`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); + } + return this.getState(); + } + + getAutomationWebContents(tabId?: string): WebContents { + const tab = this.getTab(tabId); + if (!tab || tab.view.webContents.isDestroyed()) { + throw new Error("Browser tab is unavailable."); + } + return tab.view.webContents; } private registerIpcHandlers(): void { @@ -110,6 +336,14 @@ class BuiltInBrowserService { this.assertBrowserSender(event); return this.getState(); }); + ipcMain.handle(IPC_CHANNELS.browserStartChromeLoginImport, async (event, request: ChromeLoginImportRequest) => { + this.assertBrowserSender(event); + return await chromeLoginImportService.createJob(request); + }); + ipcMain.handle(IPC_CHANNELS.browserGetChromeLoginImport, (event, id: string) => { + this.assertBrowserSender(event); + return chromeLoginImportService.getJob(typeof id === "string" ? id : ""); + }); ipcMain.handle(IPC_CHANNELS.browserNewTab, (event, url?: string) => { this.assertBrowserSender(event); this.createTab(url); @@ -145,6 +379,34 @@ class BuiltInBrowserService { this.getTab(tabId)?.view.webContents.reload(); return this.getState(); }); + ipcMain.handle(IPC_CHANNELS.browserResolveAutomationHandoff, (event, status?: string) => { + this.assertBrowserSender(event); + return this.resolveAutomationHandoff(status === "dismissed" ? "dismissed" : "completed"); + }); + } + + private ensureWindow(): BrowserWindow { + const window = this.window && !this.window.isDestroyed() ? this.window : this.createWindow(); + if (this.tabs.size === 0) { + this.createTab(browserHomeUrl); + } + return window; + } + + private showAutomationWindow(): void { + const window = this.ensureWindow(); + if (window.isMinimized()) { + window.restore(); + } + window.show(); + window.focus(); + } + + private hideAutomationWindow(): void { + const window = this.window; + if (window && !window.isDestroyed()) { + window.hide(); + } } private createWindow(): BrowserWindow { @@ -187,15 +449,12 @@ class BuiltInBrowserService { this.window = undefined; } }); - window.once("ready-to-show", () => { - if (!window.isDestroyed()) { - window.show(); - } - }); window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); window.webContents.on("did-finish-load", () => this.sendState()); - void window.loadURL(this.resolveRendererUrl("pages/browser/index.html")); + void window.loadURL(this.resolveRendererUrl("pages/browser/index.html")).catch((error) => { + console.warn(`[browser] Failed to load browser chrome: ${formatError(error)}`); + }); return window; } @@ -225,8 +484,23 @@ class BuiltInBrowserService { this.window?.contentView.addChildView(view); view.setVisible(false); this.selectTab(tab.id); - void view.webContents.loadURL(tab.url); + void view.webContents.loadURL(tab.url).catch((error) => { + this.emitAutomationEvent({ + kind: "page.load_failed", + summary: `Initial tab load failed: ${formatError(error)}`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); + }); this.sendState(); + this.emitAutomationEvent({ + kind: "tab.created", + summary: `Created tab ${tab.id}.`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); return tab; } @@ -234,7 +508,14 @@ class BuiltInBrowserService { const { webContents } = tab.view; webContents.setWindowOpenHandler(({ url }) => { if (isHttpUrl(url)) { - this.createTab(url); + const opened = this.createTab(url); + this.emitAutomationEvent({ + kind: "tab.opened", + summary: `Opened new tab from window.open: ${url}`, + tabId: opened.id, + title: opened.title, + url: opened.url + }); } return { action: "deny" }; }); @@ -244,14 +525,35 @@ class BuiltInBrowserService { webContents.on("page-title-updated", (_event, title) => { tab.title = title || titleFromUrl(tab.url); this.sendState(); + this.emitAutomationEvent({ + kind: "page.title", + summary: `Title updated: ${tab.title}`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); }); webContents.on("did-start-loading", () => { tab.isLoading = true; this.updateTabNavigationState(tab); + this.emitAutomationEvent({ + kind: "page.loading_started", + summary: `Loading started: ${tab.url}`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); }); webContents.on("did-stop-loading", () => { tab.isLoading = false; this.updateTabNavigationState(tab); + this.emitAutomationEvent({ + kind: "page.loading_stopped", + summary: `Loading stopped: ${tab.url}`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); }); webContents.on("did-navigate", (_event, url) => { tab.url = url; @@ -260,6 +562,13 @@ class BuiltInBrowserService { this.layoutActiveView(); } this.updateTabNavigationState(tab); + this.emitAutomationEvent({ + kind: "page.navigation", + summary: `Navigated to ${url}`, + tabId: tab.id, + title: tab.title, + url + }); }); webContents.on("did-navigate-in-page", (_event, url) => { tab.url = url; @@ -267,8 +576,15 @@ class BuiltInBrowserService { this.layoutActiveView(); } this.updateTabNavigationState(tab); + this.emitAutomationEvent({ + kind: "page.navigation_in_page", + summary: `In-page navigation to ${url}`, + tabId: tab.id, + title: tab.title, + url + }); }); - webContents.on("did-fail-load", (_event, errorCode, _errorDescription, validatedUrl) => { + webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedUrl) => { if (errorCode !== -3) { tab.isLoading = false; tab.url = validatedUrl || tab.url; @@ -276,6 +592,35 @@ class BuiltInBrowserService { this.layoutActiveView(); } this.updateTabNavigationState(tab); + this.emitAutomationEvent({ + errorCode, + errorDescription, + kind: "page.load_failed", + summary: `Load failed (${errorCode}): ${errorDescription}`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); + } + }); + webContents.on("dom-ready", () => { + this.emitAutomationEvent({ + kind: "page.dom_ready", + summary: `DOM ready: ${tab.url}`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); + }); + webContents.on("console-message", (_event, level, message) => { + if (level >= 2) { + this.emitAutomationEvent({ + kind: "runtime.console", + summary: message, + tabId: tab.id, + title: tab.title, + url: tab.url + }); } }); webContents.on("destroyed", () => { @@ -286,6 +631,13 @@ class BuiltInBrowserService { this.activeTabId = this.tabOrder[0]; } this.sendState(); + this.emitAutomationEvent({ + kind: "tab.destroyed", + summary: `Destroyed tab ${tab.id}.`, + tabId: tab.id, + title: tab.title, + url: tab.url + }); } }); } @@ -403,6 +755,13 @@ class BuiltInBrowserService { selected.view.webContents.focus(); } this.sendState(); + this.emitAutomationEvent({ + kind: "tab.activated", + summary: `Activated tab ${tabId}.`, + tabId, + title: selected.title, + url: selected.url + }); } private closeTab(tabId: string): void { @@ -415,6 +774,13 @@ class BuiltInBrowserService { this.tabs.delete(tabId); this.tabOrder = this.tabOrder.filter((id) => id !== tabId); tab.view.webContents.close({ waitForBeforeUnload: false }); + this.emitAutomationEvent({ + kind: "tab.closed", + summary: `Closed tab ${tabId}.`, + tabId, + title: tab.title, + url: tab.url + }); if (this.tabOrder.length === 0) { this.createTab(browserHomeUrl); @@ -440,8 +806,23 @@ class BuiltInBrowserService { if (tab.id === this.activeTabId) { this.layoutActiveView(); } - void tab.view.webContents.loadURL(nextUrl); + void tab.view.webContents.loadURL(nextUrl).catch((error) => { + this.emitAutomationEvent({ + kind: "page.load_failed", + summary: `Navigation request failed: ${formatError(error)}`, + tabId: tab.id, + title: tab.title, + url: nextUrl + }); + }); this.sendState(); + this.emitAutomationEvent({ + kind: "page.navigation_requested", + summary: `Navigation requested: ${nextUrl}`, + tabId: tab.id, + title: tab.title, + url: nextUrl + }); } private getTab(tabId?: string): BrowserTab | undefined { @@ -469,17 +850,22 @@ class BuiltInBrowserService { const { height, width } = window.getContentBounds(); activeTab.view.setVisible(true); activeTab.view.setBounds({ - height: Math.max(0, height - browserChromeHeight), + height: Math.max(0, height - this.browserChromeHeight()), width, x: 0, - y: browserChromeHeight + y: this.browserChromeHeight() }); } + private browserChromeHeight(): number { + return browserChromeBaseHeight + (this.automationHandoff ? browserHandoffToolbarHeight : 0); + } + private getState(): BuiltInBrowserState { return { activeTabId: this.activeTabId, apps: this.apps.map((app) => ({ ...app })), + ...(this.automationHandoff ? { automationHandoff: { ...this.automationHandoff } } : {}), tabs: this.tabOrder .map((id) => this.tabs.get(id)) .filter((tab): tab is BrowserTab => Boolean(tab)) @@ -512,6 +898,27 @@ class BuiltInBrowserService { this.activeTabId = undefined; } + private emitAutomationEvent(event: Omit): void { + const nextEvent: BrowserAutomationEvent = { + ...event, + seq: ++this.automationEventSeq, + ts: Date.now(), + windowId: browserAutomationWindowId + }; + this.automationEventHistory.push(nextEvent); + this.pruneAutomationEventHistory(nextEvent.ts); + this.automationEvents.emit("event", nextEvent); + } + + private pruneAutomationEventHistory(now = Date.now()): void { + while (this.automationEventHistory.length > 0 && now - this.automationEventHistory[0].ts > maxAutomationEventAgeMs) { + this.automationEventHistory.shift(); + } + if (this.automationEventHistory.length > maxAutomationEventHistory) { + this.automationEventHistory.splice(0, this.automationEventHistory.length - maxAutomationEventHistory); + } + } + private resolveRendererUrl(relativeHtmlPath: string): string { return pathToFileURL(path.join(__dirname, "../renderer", relativeHtmlPath)).toString(); } @@ -619,9 +1026,22 @@ function titleFromUrl(value: string): string { } } -function electronProxyRules(endpoint: string): string { - const parsed = new URL(endpoint); - const host = parsed.hostname.includes(":") ? `[${parsed.hostname}]` : parsed.hostname; - const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80"); - return `http=${host}:${port};https=${host}:${port}`; +function defaultAutomationHandoffMessage(kind?: BuiltInBrowserAutomationHandoffKind): string { + if (kind === "login_required") { + return "Please sign in on this page, then click Done."; + } + if (kind === "verification_code") { + return "Please enter the verification code, then click Done."; + } + if (kind === "human_verification") { + return "Please complete the human verification, then click Done."; + } + if (kind === "blocked") { + return "Please resolve the blocker on this page, then click Done."; + } + return "Please complete the requested browser step, then click Done."; +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); } diff --git a/packages/electron/src/main/chrome-login-import.ts b/packages/electron/src/main/chrome-login-import.ts new file mode 100644 index 0000000..850dd17 --- /dev/null +++ b/packages/electron/src/main/chrome-login-import.ts @@ -0,0 +1,593 @@ +import { randomUUID } from "node:crypto"; +import http, { type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { BrowserWindow, session, shell } from "electron"; +import type { + ChromeLoginImportCookie, + ChromeLoginImportJob, + ChromeLoginImportLocalStorage, + ChromeLoginImportRequest, + ChromeLoginImportResult, + ChromeLoginImportTarget +} from "@ccr/core/contracts/app"; + +type ServerInfo = { + server: Server; + url: string; +}; + +type StoredChromeLoginImportJob = ChromeLoginImportJob; + +type CookieSetDetails = Parameters[0]; + +const browserPartition = "persist:ccr-built-in-browser"; +const webSearchPartition = "persist:ccr-browser-web-search-mcp"; +const importJobTtlMs = 5 * 60_000; +const maxImportRequestBytes = 16 * 1024 * 1024; +const maxStoredErrors = 20; +const localStorageWriteTimeoutMs = 15_000; + +class ChromeLoginImportService { + private readonly jobs = new Map(); + private serverInfo?: ServerInfo; + private serverStartPromise?: Promise; + + async createJob(request: ChromeLoginImportRequest): Promise { + const domains = uniqueStrings(request.domains.map(normalizeImportDomain).filter((item): item is string => Boolean(item))); + if (domains.length === 0) { + throw new Error("At least one Chrome import domain is required."); + } + + const target = normalizeImportTarget(request.target); + const serverInfo = await this.ensureServer(); + const id = randomUUID(); + const now = Date.now(); + const job: StoredChromeLoginImportJob = { + confirmUrl: `${serverInfo.url}/chrome-import/jobs/${id}/confirm`, + createdAt: now, + domains, + endpointUrl: serverInfo.url, + expiresAt: now + importJobTtlMs, + id, + importUrl: `${serverInfo.url}/chrome-import/jobs/${id}`, + status: "pending", + target + }; + this.jobs.set(id, job); + this.pruneJobs(); + if (request.openConfirmationPage) { + await this.openConfirmationPage(job.id); + } + return cloneJob(job); + } + + getJob(id: string): ChromeLoginImportJob | undefined { + const job = this.jobs.get(id); + if (!job) { + return undefined; + } + this.refreshExpiredJob(job); + return cloneJob(job); + } + + async openConfirmationPage(id: string): Promise { + const job = this.jobs.get(id); + if (!job) { + throw new Error("Chrome login import job was not found."); + } + this.refreshExpiredJob(job); + if (job.status !== "pending") { + throw new Error(`Chrome login import job is ${job.status}.`); + } + await shell.openExternal(job.confirmUrl); + return cloneJob(job); + } + + private async ensureServer(): Promise { + if (this.serverInfo) { + return this.serverInfo; + } + if (this.serverStartPromise) { + return this.serverStartPromise; + } + + this.serverStartPromise = new Promise((resolve, reject) => { + const server = http.createServer((request, response) => { + void this.handleRequest(request, response).catch((error) => { + sendJson(response, 500, { error: { message: formatError(error) } }); + }); + }); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Chrome login import server failed to start.")); + return; + } + const info = { + server, + url: `http://127.0.0.1:${address.port}` + }; + this.serverInfo = info; + resolve(info); + }); + }).finally(() => { + this.serverStartPromise = undefined; + }); + return this.serverStartPromise; + } + + private async handleRequest(request: IncomingMessage, response: ServerResponse): Promise { + setCorsHeaders(response); + if (request.method === "OPTIONS") { + response.writeHead(204); + response.end(); + return; + } + + const path = request.url ? new URL(request.url, "http://127.0.0.1").pathname : "/"; + const match = path.match(/^\/chrome-import\/jobs\/([^/]+)(?:\/(confirm|cookies))?$/); + if (!match) { + sendJson(response, 404, { error: { message: "Chrome login import endpoint not found." } }); + return; + } + + const id = decodeURIComponent(match[1]); + const job = this.jobs.get(id); + if (!job) { + sendJson(response, 404, { error: { message: "Chrome login import job was not found." } }); + return; + } + this.refreshExpiredJob(job); + if (job.status === "expired") { + sendJson(response, 410, { error: { message: "Chrome login import job expired." }, job: cloneJob(job) }); + return; + } + + const action = match[2]; + if (request.method === "GET" && action === "confirm") { + sendHtml(response, 200, confirmationPageHtml(job)); + return; + } + + if (request.method === "GET" && !action) { + sendJson(response, 200, { job: cloneJob(job) }); + return; + } + + if (request.method === "POST" && action === "cookies") { + if (job.status !== "pending") { + sendJson(response, 409, { + error: { + message: `Chrome login import job is ${job.status}.` + }, + job: cloneJob(job) + }); + return; + } + const payload = await readJsonRequest(request); + const result = await this.importLoginState(job, payload); + sendJson(response, 200, { job: cloneJob(job), result }); + return; + } + + sendJson(response, 405, { error: { message: "Unsupported Chrome login import method." } }); + } + + private async importLoginState(job: StoredChromeLoginImportJob, payload: unknown): Promise { + const importPayload = readImportPayload(payload); + const partitions = targetPartitions(job.target); + const result: ChromeLoginImportResult = { + completedAt: Date.now(), + cookieImported: 0, + cookieSkipped: 0, + domains: [...job.domains], + imported: 0, + localStorageImported: 0, + localStorageSkipped: 0, + partitions, + skipped: 0 + }; + const errors: string[] = []; + + for (const cookie of importPayload.cookies) { + const normalized = normalizeChromeCookie(cookie, job.domains); + if (!normalized) { + result.cookieSkipped += 1; + continue; + } + + try { + await Promise.all(partitions.map((partition) => session.fromPartition(partition).cookies.set(normalized))); + result.cookieImported += 1; + } catch (error) { + result.cookieSkipped += 1; + if (errors.length < maxStoredErrors) { + errors.push(`${normalized.domain || new URL(normalized.url).hostname}:${normalized.name}: ${formatError(error)}`); + } + } + } + + await Promise.all(partitions.map((partition) => session.fromPartition(partition).cookies.flushStore())); + + for (const localStorageEntry of importPayload.localStorage) { + const normalized = normalizeLocalStorageEntry(localStorageEntry, job.domains); + if (!normalized) { + result.localStorageSkipped += 1; + continue; + } + + const itemCount = Object.keys(normalized.items).length; + if (itemCount === 0) { + result.localStorageSkipped += 1; + continue; + } + + try { + await Promise.all(partitions.map((partition) => writeLocalStorage(partition, normalized.origin, normalized.items))); + result.localStorageImported += itemCount; + } catch (error) { + result.localStorageSkipped += itemCount; + if (errors.length < maxStoredErrors) { + errors.push(`${normalized.origin}: localStorage: ${formatError(error)}`); + } + } + } + + result.imported = result.cookieImported + result.localStorageImported; + result.skipped = result.cookieSkipped + result.localStorageSkipped; + if (errors.length > 0) { + result.errors = errors; + } + job.result = result; + job.status = result.imported > 0 || result.skipped > 0 ? "completed" : "failed"; + return result; + } + + private refreshExpiredJob(job: StoredChromeLoginImportJob): void { + if (job.status === "pending" && Date.now() > job.expiresAt) { + job.status = "expired"; + } + } + + private pruneJobs(): void { + const cutoff = Date.now() - importJobTtlMs; + for (const [id, job] of this.jobs) { + this.refreshExpiredJob(job); + if (job.expiresAt < cutoff) { + this.jobs.delete(id); + } + } + } +} + +export const chromeLoginImportService = new ChromeLoginImportService(); + +function targetPartitions(target: ChromeLoginImportTarget): string[] { + return target === "browser-and-web-search" + ? [browserPartition, webSearchPartition] + : [browserPartition]; +} + +function normalizeChromeCookie(cookie: ChromeLoginImportCookie, allowedDomains: string[]): CookieSetDetails | undefined { + if (!isRecord(cookie) || typeof cookie.name !== "string" || typeof cookie.value !== "string") { + return undefined; + } + if (cookie.partitionKey !== undefined) { + return undefined; + } + + const rawDomain = readString(cookie.domain).toLowerCase(); + const cookieHost = normalizeCookieHost(rawDomain); + if (!cookieHost || !allowedDomains.some((domain) => cookieDomainMatches(cookieHost, domain))) { + return undefined; + } + + const path = normalizeCookiePath(cookie.path); + const expirationDate = typeof cookie.expirationDate === "number" && Number.isFinite(cookie.expirationDate) + ? cookie.expirationDate + : undefined; + if (expirationDate !== undefined && expirationDate <= Date.now() / 1000) { + return undefined; + } + + const secure = cookie.secure === true; + const details: CookieSetDetails = { + httpOnly: cookie.httpOnly === true, + name: cookie.name, + path, + secure, + url: cookieUrl(cookieHost, path, secure), + value: cookie.value + }; + if (cookie.hostOnly !== true) { + details.domain = rawDomain || cookieHost; + } + if (expirationDate !== undefined && cookie.session !== true) { + details.expirationDate = expirationDate; + } + const sameSite = normalizeSameSite(cookie.sameSite); + if (sameSite) { + details.sameSite = sameSite; + } + return details; +} + +function normalizeLocalStorageEntry( + entry: ChromeLoginImportLocalStorage, + allowedDomains: string[] +): ChromeLoginImportLocalStorage | undefined { + if (!isRecord(entry) || !isRecord(entry.items)) { + return undefined; + } + + let origin: URL; + try { + origin = new URL(readString(entry.origin)); + } catch { + return undefined; + } + + if (!["http:", "https:"].includes(origin.protocol)) { + return undefined; + } + if (!allowedDomains.some((domain) => cookieDomainMatches(origin.hostname.toLowerCase(), domain))) { + return undefined; + } + + const items: Record = {}; + for (const [key, value] of Object.entries(entry.items)) { + if (typeof key === "string" && typeof value === "string") { + items[key] = value; + } + } + return { + items, + origin: origin.origin + }; +} + +async function writeLocalStorage(partition: string, origin: string, items: Record): Promise { + const window = new BrowserWindow({ + height: 480, + paintWhenInitiallyHidden: true, + show: false, + skipTaskbar: true, + title: "CCR Chrome Login Import", + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + partition, + sandbox: true, + webSecurity: true + }, + width: 640 + }); + try { + await withTimeout(window.webContents.loadURL(`${origin}/`), localStorageWriteTimeoutMs, "Timed out loading localStorage origin."); + const loadedOrigin = new URL(window.webContents.getURL()).origin; + if (loadedOrigin !== origin) { + throw new Error(`Origin redirected to ${loadedOrigin}.`); + } + const entries = Object.entries(items); + await window.webContents.executeJavaScript( + `(() => { + const entries = ${JSON.stringify(entries)}; + for (const [key, value] of entries) { + window.localStorage.setItem(key, value); + } + return entries.length; + })()`, + true + ); + } finally { + if (!window.isDestroyed()) { + window.destroy(); + } + } +} + +function normalizeImportTarget(value: unknown): ChromeLoginImportTarget { + return value === "browser-and-web-search" ? "browser-and-web-search" : "browser"; +} + +function normalizeImportDomain(value: unknown): string | undefined { + const raw = readString(value).toLowerCase(); + if (!raw) { + return undefined; + } + try { + const parsed = new URL(raw.includes("://") ? raw : `https://${raw}`); + return normalizeCookieHost(parsed.hostname); + } catch { + return normalizeCookieHost(raw.replace(/^\*\./, "").split("/")[0] || ""); + } +} + +function normalizeCookieHost(value: string): string | undefined { + const host = value.trim().replace(/^\./, "").toLowerCase(); + if (!host || host.includes("*") || host.includes("/") || host.includes(" ")) { + return undefined; + } + return host; +} + +function normalizeCookiePath(value: unknown): string { + const path = readString(value); + return path.startsWith("/") ? path : "/"; +} + +function cookieUrl(host: string, path: string, secure: boolean): string { + const normalizedPath = path.startsWith("/") ? path : "/"; + return `${secure ? "https" : "http"}://${host}${normalizedPath}`; +} + +function cookieDomainMatches(cookieHost: string, allowedDomain: string): boolean { + return cookieHost === allowedDomain || cookieHost.endsWith(`.${allowedDomain}`); +} + +function normalizeSameSite(value: unknown): CookieSetDetails["sameSite"] | undefined { + return value === "lax" || value === "strict" || value === "no_restriction" || value === "unspecified" + ? value + : undefined; +} + +function readImportPayload(payload: unknown): { + cookies: ChromeLoginImportCookie[]; + localStorage: ChromeLoginImportLocalStorage[]; +} { + if (!isRecord(payload)) { + throw new Error("Chrome login import payload must be an object."); + } + const cookies = Array.isArray(payload.cookies) + ? payload.cookies.filter(isRecord) as ChromeLoginImportCookie[] + : []; + const localStorage = Array.isArray(payload.localStorage) + ? payload.localStorage.filter(isRecord) as ChromeLoginImportLocalStorage[] + : []; + if (cookies.length === 0 && localStorage.length === 0) { + throw new Error("Chrome login import payload must include cookies or localStorage."); + } + return { cookies, localStorage }; +} + +function cloneJob(job: StoredChromeLoginImportJob): ChromeLoginImportJob { + return { + ...job, + domains: [...job.domains], + ...(job.result + ? { + result: { + ...job.result, + domains: [...job.result.domains], + ...(job.result.errors ? { errors: [...job.result.errors] } : {}), + partitions: [...job.result.partitions] + } + } + : {}) + }; +} + +async function readJsonRequest(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buffer.byteLength; + if (total > maxImportRequestBytes) { + throw new Error("Chrome login import payload is too large."); + } + chunks.push(buffer); + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown; +} + +function sendJson(response: ServerResponse, statusCode: number, body: unknown): void { + if (!response.headersSent) { + response.writeHead(statusCode, { "content-type": "application/json" }); + } + response.end(`${JSON.stringify(body)}\n`); +} + +function sendHtml(response: ServerResponse, statusCode: number, body: string): void { + if (!response.headersSent) { + response.writeHead(statusCode, { + "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'none'; connect-src http://127.0.0.1:* http://localhost:*", + "content-type": "text/html; charset=utf-8" + }); + } + response.end(body); +} + +function confirmationPageHtml(job: StoredChromeLoginImportJob): string { + const domains = job.domains.map((domain) => `
    • ${escapeHtml(domain)}
    • `).join(""); + return ` + + + + + CCR Chrome Login Import + + + +
      +

      Import Chrome Login State into CCR

      +

      CCR is requesting permission to import cookies and localStorage for these domains into the in-app browser.

      +
        ${domains}
      + +
      Install or enable the CCR Login Import extension in Chrome to continue.
      +
      + +`; +} + +function setCorsHeaders(response: ServerResponse): void { + response.setHeader("access-control-allow-headers", "content-type, x-ccr-login-import"); + response.setHeader("access-control-allow-methods", "GET, POST, OPTIONS"); + response.setHeader("access-control-allow-origin", "*"); +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values)]; +} + +function readString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (char) => { + switch (char) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case "\"": + return """; + default: + return "'"; + } + }); +} + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timeout: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + return Promise.race([promise, timeoutPromise]).finally(() => { + if (timeout) { + clearTimeout(timeout); + } + }); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/deep-link.ts b/packages/electron/src/main/deep-link.ts similarity index 88% rename from src/main/deep-link.ts rename to packages/electron/src/main/deep-link.ts index 9304f50..6d6f1cf 100644 --- a/src/main/deep-link.ts +++ b/packages/electron/src/main/deep-link.ts @@ -1,9 +1,9 @@ import { app } from "electron"; import path from "node:path"; -import { appDeepLinkProtocol, createProviderDeepLinkRequest as createSharedProviderDeepLinkRequest, isAppDeepLinkUrl } from "../shared/deep-link"; -import type { ProviderDeepLinkRequest } from "../shared/app"; -import { IPC_CHANNELS } from "./constants"; -import { providerIdentitySafetyIssue } from "./presets"; +import { appDeepLinkProtocol, createProviderDeepLinkRequest as createSharedProviderDeepLinkRequest, isAppDeepLinkUrl } from "@ccr/core/contracts/deep-link"; +import type { ProviderDeepLinkRequest } from "@ccr/core/contracts/app"; +import { IPC_CHANNELS } from "@ccr/core/config/constants"; +import { providerIdentitySafetyIssue } from "@ccr/core/providers/presets/index"; import windowsManager from "./windows"; class DeepLinkService { diff --git a/packages/electron/src/main/electron-web-search-mcp.ts b/packages/electron/src/main/electron-web-search-mcp.ts new file mode 100644 index 0000000..e59ec3a --- /dev/null +++ b/packages/electron/src/main/electron-web-search-mcp.ts @@ -0,0 +1,1316 @@ +import { BrowserWindow, WebContentsView, app, session, type WebContents } from "electron"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { join as pathJoin } from "node:path"; +import { backendService } from "@ccr/core/plugins/backend-service"; +import type { GatewayMcpServerConfig } from "@ccr/core/contracts/app"; +import type { + BrowserWebSearchMcpIntegration, + BrowserWebSearchMcpRegistration, + BrowserWebSearchProtocolRecord +} from "@ccr/core/gateway/service"; + +type JsonPrimitive = boolean | null | number | string; +type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +type JsonRpcRequest = { + id?: null | number | string; + jsonrpc?: string; + method?: string; + params?: unknown; +}; + +type JsonRpcResponse = + | { + id: null | number | string; + jsonrpc: "2.0"; + result: JsonValue; + } + | { + error: { + code: number; + data?: JsonValue; + message: string; + }; + id: null | number | string; + jsonrpc: "2.0"; + }; + +type ToolCallResult = { + content: Array<{ text: string; type: "text" }>; + isError?: boolean; +}; + +type BrowserSearchEngine = "bing" | "duckduckgo" | "google"; +type BrowserSearchFreshness = "day" | "month" | "week" | "year"; + +type BrowserSearchInput = { + after?: string; + anyTerms: string[]; + before?: string; + count: number; + country?: string; + engine: BrowserSearchEngine; + exactPhrase?: string; + excludeDomains: string[]; + excludeTerms: string[]; + freshness?: BrowserSearchFreshness; + includeDomains: string[]; + includeRaw: boolean; + keywords: string[]; + language?: string; + prompt: string; + safeSearch?: "moderate" | "off" | "strict"; + timeoutMs: number; +}; + +type BrowserSearchRequest = BrowserSearchInput & { + query: string; + searchUrl: string; +}; + +type BrowserSearchResult = { + content?: string; + diagnostics?: string[]; + snippet?: string; + title: string; + url: string; +}; + +type BrowserSearchPageResult = { + blocked?: string; + results?: BrowserSearchResult[]; + title?: string; +}; + +type BrowserSearchResponse = { + engine: BrowserSearchEngine; + query: string; + results: BrowserSearchResult[]; + searchUrl: string; +}; + +type BrowserSearchWorker = { + busy: boolean; + view: WebContentsView; +}; + +type BrowserSearchQueueEntry = { + reject: (error: Error) => void; + resolve: () => void; +}; + +const ownerId = "ccr-browser-web-search-mcp"; +const protocolVersion = "2024-11-05"; +const maxMcpRequestBytes = 2 * 1024 * 1024; +const defaultResultCount = 5; +const defaultTimeoutMs = 30_000; +const maxSearchResultCount = 20; +const searchPartition = "persist:ccr-browser-web-search-mcp"; +const defaultEngine: BrowserSearchEngine = "bing"; +const desktopUserAgent = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; + +function bundledBrowserWebSearchProxyMcpEntryPath(): string { + return pathJoin(__dirname, "browser-web-search-proxy-mcp.js"); +} + +class BrowserWebSearchMcpService implements BrowserWebSearchMcpIntegration { + private readonly recentResults: BrowserWebSearchProtocolRecord[] = []; + private readonly servers = new Map(); + private readonly searchPool = new HiddenBrowserSearchPool(); + + async registerBrowserWebSearchMcpServer(options: BrowserWebSearchMcpRegistration): Promise { + await app.whenReady(); + const existing = this.servers.get(options.toolName); + if (existing) { + return existing.mcpConfig(); + } + + const server = new BrowserWebSearchToolServer(options, this.searchPool, (record) => this.recordSearchResult(record)); + const backend = await backendService.registerHttpBackend(ownerId, { + handler: (request, response) => server.handleRequest(request, response), + id: `${ownerId}:${options.name}` + }); + server.setUrl(`${backend.url}/mcp`); + this.servers.set(options.toolName, server); + return server.mcpConfig(); + } + + recentBrowserWebSearchResults(options: { sinceMs: number; toolName?: string }): BrowserWebSearchProtocolRecord[] { + this.pruneRecentResults(); + return this.recentResults.filter((record) => { + if (record.completedAtMs < options.sinceMs) { + return false; + } + return !options.toolName || record.toolName === options.toolName; + }); + } + + async runBrowserWebSearch(options: { count?: number; prompt: string; timeoutMs?: number; toolName?: string }): Promise { + const server = options.toolName ? this.servers.get(options.toolName) : this.servers.values().next().value; + return await server?.searchForProtocolResult(options); + } + + async stopBrowserWebSearchMcpServers(): Promise { + this.servers.clear(); + this.recentResults.splice(0); + await backendService.stopOwner(ownerId); + this.searchPool.destroy(); + } + + private recordSearchResult(record: BrowserWebSearchProtocolRecord): void { + this.recentResults.push(record); + this.pruneRecentResults(); + } + + private pruneRecentResults(): void { + const cutoff = Date.now() - 5 * 60_000; + while (this.recentResults.length > 0 && (this.recentResults[0].completedAtMs < cutoff || this.recentResults.length > 50)) { + this.recentResults.shift(); + } + } +} + +class BrowserWebSearchToolServer { + private readonly defaultCount: number; + private readonly defaultEngine: BrowserSearchEngine; + private readonly defaultLanguage?: string; + private readonly defaultCountry?: string; + private readonly defaultSafeSearch?: "moderate" | "off" | "strict"; + private readonly defaultTimeoutMs: number; + private readonly toolName: string; + private url = ""; + + constructor( + options: BrowserWebSearchMcpRegistration, + private readonly searchPool: HiddenBrowserSearchPool, + private readonly recordSearchResult: (record: BrowserWebSearchProtocolRecord) => void + ) { + const env = options.env ?? {}; + this.toolName = options.toolName; + this.defaultCount = clampInteger( + options.resultCount ?? readNumber(env.BROWSER_SEARCH_RESULT_COUNT) ?? readNumber(env.SEARCH_RESULT_COUNT) ?? defaultResultCount, + 1, + maxSearchResultCount + ); + this.defaultEngine = parseSearchEngine(env.BROWSER_SEARCH_ENGINE || env.SEARCH_ENGINE) ?? defaultEngine; + this.defaultLanguage = readString(env.BROWSER_SEARCH_LANGUAGE || env.SEARCH_LANGUAGE); + this.defaultCountry = readString(env.BROWSER_SEARCH_COUNTRY || env.SEARCH_COUNTRY); + this.defaultSafeSearch = parseSafeSearch(env.BROWSER_SEARCH_SAFE_SEARCH || env.SEARCH_SAFE_SEARCH); + this.defaultTimeoutMs = clampInteger( + options.timeoutMs ?? readNumber(env.BROWSER_SEARCH_TIMEOUT_MS) ?? readNumber(env.SEARCH_TIMEOUT_MS) ?? defaultTimeoutMs, + 100, + 600_000 + ); + } + + setUrl(url: string): void { + this.url = url; + } + + mcpConfig(): GatewayMcpServerConfig { + const requestTimeoutMs = clampInteger(this.defaultTimeoutMs + 5_000, 1_000, 600_000); + return { + args: [bundledBrowserWebSearchProxyMcpEntryPath()], + command: process.execPath, + env: { + BROWSER_WEB_SEARCH_MCP_URL: this.url, + BROWSER_WEB_SEARCH_PROXY_TIMEOUT_MS: String(requestTimeoutMs), + ELECTRON_RUN_AS_NODE: "1" + }, + name: this.toolName, + protocolVersion, + requestTimeoutMs, + startupTimeoutMs: 60_000, + stdioMessageMode: "content-length", + transport: "stdio" + }; + } + + async handleRequest(request: IncomingMessage, response: ServerResponse): Promise { + response.setHeader("MCP-Protocol-Version", protocolVersion); + const path = request.url ? new URL(request.url, this.url || "http://127.0.0.1").pathname : "/"; + + if (request.method === "GET" && (path === "/mcp" || path === "/mcp/")) { + sendJson(response, 200, { + endpoint: "/mcp", + name: this.toolName, + protocol: "mcp", + transport: "streamable-http" + }); + return; + } + + if (request.method !== "POST" || (path !== "/mcp" && path !== "/mcp/")) { + sendJson(response, 404, { error: { message: "In-app browser web search MCP endpoint not found." } }); + return; + } + + let payload: unknown; + try { + payload = JSON.parse((await readRequestBody(request, maxMcpRequestBytes)).toString("utf8")) as unknown; + } catch (error) { + sendJson(response, 400, jsonRpcError(null, -32700, `Invalid JSON-RPC request: ${formatError(error)}`)); + return; + } + + const requests = Array.isArray(payload) ? payload : [payload]; + const responses = await Promise.all(requests.map((item) => this.handleJsonRpcRequest(item))); + const filtered = responses.filter((item): item is JsonRpcResponse => Boolean(item)); + if (filtered.length === 0) { + response.writeHead(204); + response.end(); + return; + } + + sendJson(response, 200, Array.isArray(payload) ? filtered : filtered[0]); + } + + private async handleJsonRpcRequest(payload: unknown): Promise { + if (!isRecord(payload)) { + return jsonRpcError(null, -32600, "JSON-RPC request must be an object."); + } + + const request = payload as JsonRpcRequest; + const id = request.id ?? null; + if (request.id === undefined && request.method?.startsWith("notifications/")) { + return undefined; + } + if (request.jsonrpc !== "2.0" || !request.method) { + return jsonRpcError(id, -32600, "Invalid JSON-RPC 2.0 request."); + } + + try { + switch (request.method) { + case "initialize": + return jsonRpcResult(id, { + capabilities: { + tools: {} + }, + protocolVersion, + serverInfo: { + name: "ccr-browser-web-search", + title: "CCR In-app Browser Web Search", + version: "1.0.0" + } + }); + case "ping": + return jsonRpcResult(id, {}); + case "tools/list": + return jsonRpcResult(id, { tools: [this.tool()] as unknown as JsonValue }); + case "tools/call": + return jsonRpcResult(id, await this.callTool(request.params) as unknown as JsonValue); + default: + return jsonRpcError(id, -32601, `Unsupported MCP method: ${request.method}`); + } + } catch (error) { + return jsonRpcError(id, -32603, formatError(error)); + } + } + + private tool(): JsonValue { + return { + description: + "Search the web through a hidden in-app browser window. " + + "Supports Google, Bing, and DuckDuckGo plus advanced query controls such as domains, keywords, phrases, excluded terms, and date filters.", + inputSchema: objectSchema({ + after: { description: "Earliest result date as YYYY-MM-DD. Also added to the engine query as after:YYYY-MM-DD.", type: "string" }, + allowedDomains: { description: "Alias for includeDomains; compatible with Anthropic web search declarations.", items: { type: "string" }, type: "array" }, + allowed_domains: { description: "Alias for includeDomains; compatible with Anthropic web_search_20250305.", items: { type: "string" }, type: "array" }, + anyTerms: { description: "Terms where any one may match; encoded as OR terms.", items: { type: "string" }, type: "array" }, + before: { description: "Latest result date as YYYY-MM-DD. Also added to the engine query as before:YYYY-MM-DD.", type: "string" }, + blockedDomains: { description: "Alias for excludeDomains; compatible with Anthropic web search declarations.", items: { type: "string" }, type: "array" }, + blocked_domains: { description: "Alias for excludeDomains; compatible with Anthropic web_search_20250305.", items: { type: "string" }, type: "array" }, + count: { maximum: maxSearchResultCount, minimum: 1, type: "number" }, + country: { description: "Country/region hint such as US, CN, GB.", type: "string" }, + domains: { description: "Alias for includeDomains.", items: { type: "string" }, type: "array" }, + engine: { enum: ["auto", "bing", "google", "duckduckgo"], type: "string" }, + exactPhrase: { description: "Phrase to search exactly.", type: "string" }, + excludeDomain: { description: "Single domain alias for excludeDomains.", type: "string" }, + excludeDomains: { description: "Domains to exclude with -site:domain.", items: { type: "string" }, type: "array" }, + excludeTerms: { description: "Terms to exclude with a leading minus.", items: { type: "string" }, type: "array" }, + freshness: { description: "Relative time filter.", enum: ["day", "week", "month", "year"], type: "string" }, + includeDomains: { description: "Domains to restrict with site:domain.", items: { type: "string" }, type: "array" }, + includeRaw: { description: "Include the engine search URL in the text response.", type: "boolean" }, + keywords: { description: "Additional required keywords. A comma-separated string is also accepted.", items: { type: "string" }, type: "array" }, + language: { description: "Language hint such as en, zh-CN, ja.", type: "string" }, + prompt: { description: "Natural-language query. Search engine operators already present here are preserved.", type: "string" }, + query: { description: "Alias for prompt.", type: "string" }, + safeSearch: { enum: ["off", "moderate", "strict"], type: "string" }, + site: { description: "Single domain alias for includeDomains.", type: "string" }, + timeRange: { description: "Alias for freshness.", enum: ["day", "week", "month", "year"], type: "string" }, + timeoutMs: { minimum: 100, type: "number" } + }, ["prompt"]), + name: this.toolName, + title: "In-app Browser Web Search" + }; + } + + private async callTool(params: unknown): Promise { + if (!isRecord(params) || typeof params.name !== "string") { + throw new Error("tools/call params must include a tool name."); + } + if (params.name !== this.toolName) { + throw new Error(`Unknown in-app browser web search tool: ${params.name}`); + } + + const args = isRecord(params.arguments) ? params.arguments : {}; + try { + const input = this.readSearchInput(args); + const record = await this.search(input); + return textResult(formatSearchResponse(record, input.includeRaw)); + } catch (error) { + return { + ...textResult(formatError(error)), + isError: true + }; + } + } + + async searchForProtocolResult(options: { count?: number; prompt: string; timeoutMs?: number }): Promise { + return await this.search(this.readSearchInput({ + count: options.count, + prompt: options.prompt, + timeoutMs: options.timeoutMs + })); + } + + private async search(input: BrowserSearchInput): Promise { + const response = await this.searchPool.search(input); + const record = { + completedAtMs: Date.now(), + engine: response.engine, + query: response.query, + results: response.results, + searchUrl: response.searchUrl, + toolName: this.toolName + }; + this.recordSearchResult(record); + return record; + } + + private readSearchInput(args: Record): BrowserSearchInput { + const prompt = readString(args.prompt) || readString(args.query); + if (!prompt) { + throw new Error(`${this.toolName} requires prompt.`); + } + + const engine = parseSearchEngine(readString(args.engine)) ?? this.defaultEngine; + const includeDomains = uniqueStrings([ + ...readStringArray(args.includeDomains), + ...readStringArray(args.domains), + ...readStringArray(args.domain), + ...readStringArray(args.site), + ...readStringArray(args.allowedDomains), + ...readStringArray(args.allowed_domains) + ].map(normalizeDomain).filter((item): item is string => Boolean(item))); + const excludeDomains = uniqueStrings([ + ...readStringArray(args.excludeDomains), + ...readStringArray(args.excludeDomain), + ...readStringArray(args.blockedDomains), + ...readStringArray(args.blocked_domains) + ].map(normalizeDomain).filter((item): item is string => Boolean(item))); + + return { + after: normalizeSearchDate(readString(args.after) || readString(args.since) || readString(args.fromDate)), + anyTerms: readStringArray(args.anyTerms), + before: normalizeSearchDate(readString(args.before) || readString(args.until) || readString(args.toDate)), + count: clampInteger(readNumber(args.count) ?? this.defaultCount, 1, maxSearchResultCount), + country: readString(args.country) || this.defaultCountry, + engine, + exactPhrase: readString(args.exactPhrase), + excludeDomains, + excludeTerms: readStringArray(args.excludeTerms), + freshness: parseFreshness(readString(args.freshness) || readString(args.timeRange)), + includeDomains, + includeRaw: args.includeRaw === true, + keywords: readStringArray(args.keywords), + language: readString(args.language) || this.defaultLanguage, + prompt, + safeSearch: parseSafeSearch(readString(args.safeSearch)) ?? this.defaultSafeSearch, + timeoutMs: clampInteger(readNumber(args.timeoutMs) ?? this.defaultTimeoutMs, 100, 600_000) + }; + } +} + +class HiddenBrowserSearchPool { + private configuredSession = false; + private queue: BrowserSearchQueueEntry[] = []; + private window?: BrowserWindow; + private workers: BrowserSearchWorker[] = []; + + async search(input: BrowserSearchInput): Promise { + const request = buildBrowserSearchRequest(input); + const worker = await this.acquire(request.timeoutMs); + let page: BrowserSearchPageResult; + try { + page = await runSearchInWorker(worker, request); + } finally { + this.release(worker); + } + if (page.blocked) { + throw new Error(page.blocked); + } + const results = uniqueSearchResults(page.results ?? []).slice(0, request.count); + return { + engine: request.engine, + query: request.query, + results: await this.enrichResults(results, request), + searchUrl: request.searchUrl + }; + } + + destroy(): void { + this.queue.splice(0).forEach((entry) => entry.reject(new Error("Browser search service stopped."))); + this.queue = []; + for (const worker of this.workers) { + if (!worker.view.webContents.isDestroyed()) { + worker.view.webContents.close({ waitForBeforeUnload: false }); + } + } + this.workers = []; + if (this.window && !this.window.isDestroyed()) { + this.window.destroy(); + } + this.window = undefined; + this.configuredSession = false; + } + + private async acquire(timeoutMs: number): Promise { + await app.whenReady(); + this.ensureWindow(); + const maxWorkers = browserSearchConcurrency(); + for (;;) { + const idle = this.workers.find((worker) => !worker.busy); + if (idle) { + idle.busy = true; + return idle; + } + if (this.workers.length < maxWorkers) { + const worker = this.createWorker(); + worker.busy = true; + return worker; + } + await waitForQueueTurn(this.queue, timeoutMs); + } + } + + private release(worker: BrowserSearchWorker): void { + worker.busy = false; + const next = this.queue.shift(); + next?.resolve(); + } + + private ensureWindow(): BrowserWindow { + if (this.window && !this.window.isDestroyed()) { + return this.window; + } + this.configureSearchSession(); + this.window = new BrowserWindow({ + height: 900, + paintWhenInitiallyHidden: true, + show: false, + skipTaskbar: true, + title: "CCR In-app Browser Web Search", + webPreferences: { + backgroundThrottling: false, + contextIsolation: true, + images: false, + nodeIntegration: false, + sandbox: true, + webSecurity: true + }, + width: 1280 + }); + this.window.on("closed", () => { + this.workers = []; + this.window = undefined; + }); + return this.window; + } + + private createWorker(): BrowserSearchWorker { + const window = this.ensureWindow(); + const view = new WebContentsView({ + webPreferences: { + backgroundThrottling: false, + contextIsolation: true, + images: false, + nodeIntegration: false, + partition: searchPartition, + sandbox: true, + webSecurity: true + } + }); + view.webContents.setAudioMuted(true); + view.webContents.on("console-message", (event) => { + event.preventDefault(); + }); + view.setBounds({ height: 900, width: 1280, x: 0, y: 0 }); + window.contentView.addChildView(view); + const worker = { busy: false, view }; + this.workers.push(worker); + return worker; + } + + private configureSearchSession(): void { + if (this.configuredSession) { + return; + } + const browserSession = session.fromPartition(searchPartition); + browserSession.setUserAgent(desktopUserAgent); + browserSession.webRequest.onBeforeRequest((details, callback) => { + if (details.resourceType === "image" || details.resourceType === "media" || details.resourceType === "font") { + callback({ cancel: true }); + return; + } + callback({}); + }); + this.configuredSession = true; + } + + private async enrichResults(results: BrowserSearchResult[], request: BrowserSearchRequest): Promise { + const openCount = Math.min(results.length, browserSearchOpenResultCount()); + if (openCount <= 0) { + return results; + } + const enriched = await Promise.all(results.slice(0, openCount).map((result) => this.enrichResult(result, request))); + return results.map((result, index) => enriched[index] ?? result); + } + + private async enrichResult(result: BrowserSearchResult, request: BrowserSearchRequest): Promise { + if (!shouldOpenSearchResult(result.url)) { + return result; + } + const timeoutMs = browserSearchPageTimeoutMs(request.timeoutMs); + let worker: BrowserSearchWorker | undefined; + try { + worker = await this.acquire(timeoutMs); + await loadSearchUrl(worker.view.webContents, result.url, timeoutMs); + const page = await withTimeout( + worker.view.webContents.executeJavaScript(pageContentExtractionScript(), true) as Promise<{ description?: string; text?: string; title?: string }>, + timeoutMs, + "Browser result page extraction timed out." + ); + const content = compactExtractedContent(page.text); + const snippet = result.snippet || compactExtractedContent(page.description); + return { + ...result, + ...(content ? { content } : {}), + ...(!content ? { diagnostics: appendSearchResultDiagnostic(result.diagnostics, "No extractable page content found.") } : {}), + ...(snippet ? { snippet } : {}), + ...(page.title && page.title.length > result.title.length ? { title: page.title.slice(0, 240) } : {}) + }; + } catch (error) { + return { + ...result, + diagnostics: appendSearchResultDiagnostic(result.diagnostics, `Page extraction failed: ${formatError(error)}`) + }; + } finally { + if (worker) { + this.release(worker); + } + } + } +} + +async function runSearchInWorker(worker: BrowserSearchWorker, request: BrowserSearchRequest): Promise { + const webContents = worker.view.webContents; + if (webContents.isDestroyed()) { + throw new Error("Browser search view is unavailable."); + } + + await loadSearchUrl(webContents, request.searchUrl, request.timeoutMs); + return await pollSearchResults(webContents, request); +} + +async function loadSearchUrl(webContents: WebContents, url: string, timeoutMs: number): Promise { + webContents.stop(); + await withTimeout( + webContents.loadURL(url, { + userAgent: desktopUserAgent + }), + timeoutMs, + "Browser search navigation timed out." + ); +} + +async function pollSearchResults(webContents: WebContents, request: BrowserSearchRequest): Promise { + const deadline = Date.now() + request.timeoutMs; + let last: BrowserSearchPageResult = { results: [] }; + while (Date.now() < deadline) { + try { + last = await withTimeout( + webContents.executeJavaScript(searchResultExtractionScript(request.engine, request.count), true) as Promise, + Math.max(100, Math.min(1000, deadline - Date.now())), + "Browser search result extraction timed out." + ); + } catch { + await sleep(150); + continue; + } + if (last.blocked || (last.results?.length ?? 0) >= Math.min(request.count, 3)) { + return last; + } + await sleep(150); + } + return last; +} + +function buildBrowserSearchRequest(input: BrowserSearchInput): BrowserSearchRequest { + const query = buildAdvancedSearchQuery(input); + const searchUrl = searchUrlForEngine(input, query); + return { + ...input, + query, + searchUrl + }; +} + +function searchUrlForEngine(input: BrowserSearchInput, query: string): string { + if (input.engine === "google") { + const url = new URL("https://www.google.com/search"); + url.searchParams.set("q", query); + url.searchParams.set("num", String(Math.min(input.count, 10))); + url.searchParams.set("filter", "0"); + url.searchParams.set("pws", "0"); + url.searchParams.set("udm", "14"); + if (input.language) url.searchParams.set("hl", input.language); + if (input.country) url.searchParams.set("gl", input.country); + if (input.safeSearch) url.searchParams.set("safe", input.safeSearch === "off" ? "off" : "active"); + const tbs = googleTimeFilter(input); + if (tbs) url.searchParams.set("tbs", tbs); + return url.toString(); + } + + if (input.engine === "duckduckgo") { + const url = new URL("https://duckduckgo.com/"); + url.searchParams.set("q", query); + url.searchParams.set("ia", "web"); + if (input.language) url.searchParams.set("kl", input.language); + if (input.safeSearch) url.searchParams.set("kp", input.safeSearch === "off" ? "-2" : "1"); + const df = duckDuckGoTimeFilter(input.freshness); + if (df) url.searchParams.set("df", df); + return url.toString(); + } + + const url = new URL("https://www.bing.com/search"); + url.searchParams.set("q", query); + url.searchParams.set("count", String(input.count)); + url.searchParams.set("qs", "n"); + url.searchParams.set("form", "QBRE"); + if (input.language) url.searchParams.set("setlang", input.language); + if (input.country) url.searchParams.set("cc", input.country); + if (input.safeSearch) url.searchParams.set("safeSearch", bingSafeSearch(input.safeSearch)); + const qft = bingTimeFilter(input.freshness); + if (qft) url.searchParams.set("qft", qft); + return url.toString(); +} + +function buildAdvancedSearchQuery(input: BrowserSearchInput): string { + const parts = [input.prompt.trim()]; + parts.push(...input.keywords.map(searchTerm)); + if (input.exactPhrase) { + parts.push(quoteSearchPhrase(input.exactPhrase)); + } + if (input.anyTerms.length > 0) { + parts.push(`(${input.anyTerms.map(searchTerm).join(" OR ")})`); + } + parts.push(...input.excludeTerms.map((term) => `-${searchTerm(term)}`)); + if (input.includeDomains.length === 1) { + parts.push(`site:${input.includeDomains[0]}`); + } else if (input.includeDomains.length > 1) { + parts.push(`(${input.includeDomains.map((domain) => `site:${domain}`).join(" OR ")})`); + } + parts.push(...input.excludeDomains.map((domain) => `-site:${domain}`)); + if (input.after) { + parts.push(`after:${input.after}`); + } + if (input.before) { + parts.push(`before:${input.before}`); + } + return parts.filter(Boolean).join(" ").replace(/\s+/g, " ").trim(); +} + +function searchTerm(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + return /\s/.test(trimmed) && !/^".*"$/.test(trimmed) ? quoteSearchPhrase(trimmed) : trimmed; +} + +function quoteSearchPhrase(value: string): string { + return `"${value.trim().replace(/"/g, " ")}"`; +} + +function googleTimeFilter(input: BrowserSearchInput): string | undefined { + if (input.after || input.before) { + const parts = ["cdr:1"]; + if (input.after) parts.push(`cd_min:${dateToGoogleDate(input.after)}`); + if (input.before) parts.push(`cd_max:${dateToGoogleDate(input.before)}`); + return parts.join(","); + } + if (input.freshness === "day") return "qdr:d"; + if (input.freshness === "week") return "qdr:w"; + if (input.freshness === "month") return "qdr:m"; + if (input.freshness === "year") return "qdr:y"; + return undefined; +} + +function bingTimeFilter(value: BrowserSearchFreshness | undefined): string | undefined { + if (value === "day") return "+filterui:age-lt1440"; + if (value === "week") return "+filterui:age-lt10080"; + if (value === "month") return "+filterui:age-lt43200"; + if (value === "year") return "+filterui:age-lt525600"; + return undefined; +} + +function duckDuckGoTimeFilter(value: BrowserSearchFreshness | undefined): string | undefined { + if (value === "day") return "d"; + if (value === "week") return "w"; + if (value === "month") return "m"; + if (value === "year") return "y"; + return undefined; +} + +function bingSafeSearch(value: "moderate" | "off" | "strict"): string { + if (value === "strict") return "Strict"; + if (value === "off") return "Off"; + return "Moderate"; +} + +function dateToGoogleDate(value: string): string { + const [year, month, day] = value.split("-"); + return `${month}/${day}/${year}`; +} + +function searchResultExtractionScript(engine: BrowserSearchEngine, count: number): string { + return `(() => { + const engine = ${JSON.stringify(engine)}; + const maxResults = ${JSON.stringify(count)}; + const results = []; + const seen = new Set(); + const blockedText = detectBlocked(); + if (blockedText) return { blocked: blockedText, results, title: document.title || "" }; + + function compact(value) { + return String(value || "").replace(/\\s+/g, " ").trim(); + } + function absoluteUrl(href) { + try { return new URL(href, location.href); } catch (_) { return undefined; } + } + function decodeMaybeBase64(value) { + if (!value) return ""; + let raw = value.replace(/-/g, "+").replace(/_/g, "/"); + if (raw.startsWith("a1")) raw = raw.slice(2); + try { + const decoded = atob(raw); + return /^https?:\\/\\//i.test(decoded) ? decoded : ""; + } catch (_) { + return ""; + } + } + function normalizeResultUrl(href) { + const url = absoluteUrl(href); + if (!url || !/^https?:$/i.test(url.protocol)) return ""; + const host = url.hostname.toLowerCase(); + if (host.includes("google.") && url.pathname === "/url") { + const q = url.searchParams.get("q") || url.searchParams.get("url"); + if (q) return normalizeResultUrl(q); + } + if (host.endsWith("bing.com") && url.pathname.startsWith("/ck/")) { + const raw = url.searchParams.get("u"); + const decoded = decodeMaybeBase64(raw || ""); + if (decoded) return normalizeResultUrl(decoded); + } + if (host.endsWith("duckduckgo.com") && url.pathname.startsWith("/l/")) { + const target = url.searchParams.get("uddg"); + if (target) return normalizeResultUrl(target); + } + if (isSearchChromeUrl(url)) return ""; + url.hash = ""; + return url.toString(); + } + function isSearchChromeUrl(url) { + const host = url.hostname.toLowerCase(); + const path = url.pathname.toLowerCase(); + if (host === location.hostname.toLowerCase() && (path === "/" || path === "/search" || path.startsWith("/search/"))) return true; + if (host.includes("google.") && /\\/(preferences|advanced_search|intl|policies|support|sorry|search|aclk|imgres)/.test(path)) return true; + if (host.endsWith("bing.com") && /\\/(search|account|profile|images|videos|maps|news|aclick)/.test(path)) return true; + if (host.endsWith("bing.com") && path.startsWith("/ck/")) return true; + if (host.endsWith("duckduckgo.com") && (path === "/" || path.startsWith("/settings") || path.startsWith("/y.js"))) return true; + return false; + } + function resultContainer(anchor, title) { + const root = anchor && anchor.closest("li.b_algo, li.b_ad, div.g, div[data-sokoban-container], div[data-testid='result'], article, .result, .result__body, .web-result"); + if (root) return root; + let node = anchor; + for (let depth = 0; node && depth < 8; depth += 1, node = node.parentElement) { + if (node.matches && node.matches("nav, header, footer, form, aside")) return undefined; + if (node.matches && node.matches("main, #search, #b_results, #rso")) break; + const text = compact(node.textContent || ""); + if (text.length > title.length + 40) return node; + } + return undefined; + } + function isLikelyOrganicResult(anchor, container) { + if (!anchor || !container) return false; + if (anchor.closest("nav, header, footer, form, aside")) return false; + if (anchor.closest("li.b_algo, div.g, div[data-sokoban-container], div[data-testid='result'], article, .result, .result__body, .web-result")) return true; + const heading = anchor.closest("h1, h2, h3, [role='heading']"); + const searchRegion = anchor.closest("main, #search, #b_results, #rso"); + return Boolean(heading && searchRegion); + } + function isAdOrUtilityResult(anchor, container) { + let node = anchor; + for (let depth = 0; node && depth < 6; depth += 1, node = node.parentElement) { + const attributes = [ + node.getAttribute && node.getAttribute("aria-label"), + node.getAttribute && node.getAttribute("data-text-ad"), + node.getAttribute && node.getAttribute("data-testid"), + node.className + ].filter(Boolean).join(" "); + if (/\\b(?:ad|ads|sponsored|promo|shopping)\\b|广告|赞助|推廣/i.test(attributes)) return true; + } + const firstText = compact(container && container.textContent).slice(0, 180); + if (/^(?:ad|ads|sponsored|promo|shopping)\\b|^(?:广告|赞助|推廣)/i.test(firstText)) return true; + return false; + } + function cleanSnippetCandidate(value, title, url) { + const host = (() => { try { return new URL(url).hostname.replace(/^www\\./, ""); } catch (_) { return ""; } })(); + return compact(String(value || "") + .replace(title, " ") + .replace(url, " ") + .replace(host, " ") + .replace(/https?:\\/\\/\\S+/g, " ") + .replace(/\\b(?:Cached|Translate this page|Similar)\\b/gi, " ")); + } + function breadcrumbLike(value) { + const text = compact(value); + if (!text) return true; + if (/https?:\\/\\//i.test(text)) return true; + if (text.length < 35 && /[›/]|^https?:|^www\\./i.test(text)) return true; + if (/[›]/.test(text) && !/[.!?,。!?]/.test(text)) return true; + if (!/[.!?:;,。!?:;]/.test(text) && /^[\\w\\s.:-]+(?:[›/]\\s*[\\w\\s.:-]+)+$/i.test(text)) return true; + return false; + } + function snippetFor(anchor, container, title, url) { + const preferred = container && container.querySelector(".b_caption p, .b_snippet, [data-sncf], .VwiC3b, .IsZvec, [data-result='snippet'], .result__snippet, p"); + const preferredText = cleanSnippetCandidate(preferred && preferred.textContent, title, url); + if (preferredText && !breadcrumbLike(preferredText)) return preferredText.slice(0, 700); + const candidates = container + ? Array.from(container.querySelectorAll("p, span, div")) + .map((node) => cleanSnippetCandidate(node.textContent, title, url)) + .filter((text) => text.length >= 35 && !breadcrumbLike(text)) + : []; + const text = candidates.find((candidate) => candidate.length >= 50) || candidates[0] || ""; + if (!text) return ""; + return text.slice(0, 700); + } + function titleFor(anchor, titleOverride) { + const explicit = compact(titleOverride); + if (explicit && explicit.length >= 2) return explicit; + const heading = anchor && anchor.querySelector("h1, h2, h3, [role='heading']"); + return compact((heading && heading.textContent) || (anchor && (anchor.textContent || anchor.innerText))); + } + function add(anchor, titleOverride) { + if (!anchor || results.length >= maxResults) return; + const href = anchor.getAttribute("href") || ""; + const url = normalizeResultUrl(href); + if (!url || seen.has(url)) return; + const title = titleFor(anchor, titleOverride); + if (!title || title.length < 2) return; + const container = resultContainer(anchor, title); + if (!isLikelyOrganicResult(anchor, container) || isAdOrUtilityResult(anchor, container)) return; + seen.add(url); + results.push({ + snippet: snippetFor(anchor, container, title, url), + title: title.slice(0, 240), + url + }); + } + function collect(selector) { + document.querySelectorAll(selector).forEach((node) => { + const anchor = node.matches && node.matches("a[href]") ? node : node.closest && node.closest("a[href]"); + add(anchor, node.matches && node.matches("h1, h2, h3, [role='heading']") ? node.textContent : ""); + }); + } + function collectGoogle() { + collect("#search h3"); + collect("#rso h3"); + collect("a[href] h3"); + } + function collectBing() { + collect("li.b_algo h2 a[href]"); + collect("main ol li h2 a[href]"); + } + function collectDuckDuckGo() { + collect("a[data-testid='result-title-a']"); + collect(".result__a"); + collect("article h2 a[href]"); + } + function collectGenericHeadings() { + collect("main h1 a[href], main h2 a[href], main h3 a[href], #search h3, #b_results h2 a[href], #rso h3, article h2 a[href], article h3 a[href]"); + } + if (engine === "google") collectGoogle(); + if (engine === "bing") collectBing(); + if (engine === "duckduckgo") collectDuckDuckGo(); + collectGenericHeadings(); + return { results, title: document.title || "" }; + + function detectBlocked() { + const text = compact(document.body && document.body.innerText).toLowerCase(); + if (!text) return ""; + if (text.includes("unusual traffic") || text.includes("detected unusual") || text.includes("not a robot") || text.includes("captcha") || text.includes("验证码") || text.includes("人机验证")) { + return "Search engine returned an anti-bot or CAPTCHA page."; + } + if (text.includes("before you continue to google") || text.includes("consent.google") || text.includes("cookie consent")) { + return "Google returned a consent page instead of search results."; + } + return ""; + } + })();`; +} + +function pageContentExtractionScript(): string { + return `(() => { + function compact(value) { + return String(value || "").replace(/[\\t\\f\\v ]+/g, " ").replace(/\\n\\s*\\n+/g, "\\n").trim(); + } + function textFrom(node) { + if (!node) return ""; + const clone = node.cloneNode(true); + clone.querySelectorAll("script, style, noscript, svg, nav, header, footer, aside, form, button, input, select, textarea, iframe, [aria-hidden='true']").forEach((item) => item.remove()); + const lines = String(clone.innerText || clone.textContent || "") + .split(/\\n+/) + .map((line) => compact(line)) + .filter((line) => line.length >= 20) + .filter((line, index, array) => array.indexOf(line) === index); + return lines.join("\\n"); + } + function scoreText(text) { + const length = text.length; + const sentenceMarks = (text.match(/[.!?。!?]/g) || []).length; + return length + sentenceMarks * 80; + } + const title = compact( + document.querySelector("meta[property='og:title']")?.getAttribute("content") || + document.querySelector("h1")?.textContent || + document.title || + "" + ); + const description = compact( + document.querySelector("meta[name='description']")?.getAttribute("content") || + document.querySelector("meta[property='og:description']")?.getAttribute("content") || + "" + ); + const candidates = [ + ...document.querySelectorAll("article, main, [role='main'], .article, .article-content, .post, .post-content, .entry-content, .content, #content, .main-content") + ].map(textFrom).filter(Boolean); + const fallback = textFrom(document.body); + const text = [...candidates, fallback] + .filter(Boolean) + .sort((left, right) => scoreText(right) - scoreText(left))[0] || ""; + return { + description, + text: text.slice(0, 5000), + title + }; + })();`; +} + +function formatSearchResponse( + response: Pick & { engine: string }, + includeRaw: boolean +): string { + if (response.results.length === 0) { + return [ + `Search engine: ${response.engine}`, + `Query: ${response.query}`, + includeRaw ? `Search URL: ${response.searchUrl}` : "", + "No results." + ].filter(Boolean).join("\n"); + } + + return [ + `Search engine: ${response.engine}`, + `Query: ${response.query}`, + includeRaw ? `Search URL: ${response.searchUrl}` : "", + ...response.results.map((result, index) => [ + `${index + 1}. ${result.title}`, + `URL: ${result.url}`, + result.snippet ? `Snippet: ${result.snippet}` : "", + result.content ? `Extracted content: ${result.content}` : "", + result.diagnostics?.length ? `Diagnostics: ${result.diagnostics.join("; ")}` : "" + ].filter(Boolean).join("\n")) + ].filter(Boolean).join("\n\n"); +} + +function appendSearchResultDiagnostic(existing: string[] | undefined, message: string): string[] { + return uniqueStrings([...(existing ?? []), message]); +} + +function uniqueSearchResults(results: BrowserSearchResult[]): BrowserSearchResult[] { + const seen = new Set(); + const unique: BrowserSearchResult[] = []; + for (const result of results) { + if (!result.url || seen.has(result.url)) { + continue; + } + seen.add(result.url); + unique.push(result); + } + return unique; +} + +function normalizeDomain(value: string): string | undefined { + const trimmed = value.trim().replace(/^site:/i, ""); + if (!trimmed) { + return undefined; + } + try { + const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`); + return url.hostname.replace(/^www\./i, "").toLowerCase(); + } catch { + return trimmed.replace(/^www\./i, "").replace(/\/.*$/, "").toLowerCase(); + } +} + +function normalizeSearchDate(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (/^\\d{4}-\\d{2}-\\d{2}$/.test(trimmed)) { + return trimmed; + } + const time = Date.parse(trimmed); + return Number.isFinite(time) ? new Date(time).toISOString().slice(0, 10) : undefined; +} + +function parseSearchEngine(value: string | undefined): BrowserSearchEngine | undefined { + const normalized = value?.trim().toLowerCase().replace(/[_\s]+/g, "-"); + if (!normalized || normalized === "auto") { + return undefined; + } + if (normalized === "google") return "google"; + if (normalized === "bing") return "bing"; + if (normalized === "duckduckgo" || normalized === "duck-duck-go" || normalized === "ddg") return "duckduckgo"; + return undefined; +} + +function parseFreshness(value: string | undefined): BrowserSearchFreshness | undefined { + const normalized = value?.trim().toLowerCase(); + if (normalized === "day" || normalized === "24h" || normalized === "d") return "day"; + if (normalized === "week" || normalized === "7d" || normalized === "w") return "week"; + if (normalized === "month" || normalized === "30d" || normalized === "m") return "month"; + if (normalized === "year" || normalized === "365d" || normalized === "y") return "year"; + return undefined; +} + +function parseSafeSearch(value: string | undefined): "moderate" | "off" | "strict" | undefined { + const normalized = value?.trim().toLowerCase(); + if (normalized === "off" || normalized === "false" || normalized === "0") return "off"; + if (normalized === "strict") return "strict"; + if (normalized === "moderate" || normalized === "medium" || normalized === "on" || normalized === "true" || normalized === "1") return "moderate"; + return undefined; +} + +function browserSearchConcurrency(): number { + return clampInteger(readNumber(process.env.CCR_BROWSER_SEARCH_CONCURRENCY) ?? 2, 1, 4); +} + +function browserSearchOpenResultCount(): number { + return clampInteger(readNumber(process.env.CCR_BROWSER_SEARCH_OPEN_RESULT_COUNT) ?? 3, 0, 8); +} + +function browserSearchPageTimeoutMs(requestTimeoutMs: number): number { + return clampInteger( + readNumber(process.env.CCR_BROWSER_SEARCH_PAGE_TIMEOUT_MS) ?? Math.min(requestTimeoutMs, 8_000), + 500, + 30_000 + ); +} + +function shouldOpenSearchResult(url: string): boolean { + try { + const parsed = new URL(url); + if (!/^https?:$/i.test(parsed.protocol)) { + return false; + } + return !/\.(?:avi|dmg|docx?|exe|gif|jpe?g|mov|mp3|mp4|pdf|png|pptx?|rar|webp|xlsx?|zip)$/i.test(parsed.pathname); + } catch { + return false; + } +} + +function compactExtractedContent(value: string | undefined): string | undefined { + const compacted = value?.replace(/\s+/g, " ").trim(); + if (!compacted || compacted.length < 40) { + return undefined; + } + return compacted.slice(0, 2_400); +} + +function waitForQueueTurn(queue: BrowserSearchQueueEntry[], timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const entry = { + reject: (error: Error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + reject(error); + }, + resolve: () => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve(); + } + }; + const timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + const index = queue.indexOf(entry); + if (index >= 0) { + queue.splice(index, 1); + } + reject(new Error("Browser search worker queue timed out.")); + }, timeoutMs); + queue.push(entry); + }); +} + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), timeoutMs); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error); + } + ); + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function textResult(text: string): ToolCallResult { + return { + content: [{ text, type: "text" }] + }; +} + +function objectSchema(properties: Record, required: string[] = []): JsonValue { + return { + additionalProperties: true, + properties, + ...(required.length ? { required } : {}), + type: "object" + }; +} + +function jsonRpcResult(id: null | number | string, result: JsonValue): JsonRpcResponse { + return { + id, + jsonrpc: "2.0", + result + }; +} + +function jsonRpcError(id: null | number | string, code: number, message: string): JsonRpcResponse { + return { + error: { + code, + message + }, + id, + jsonrpc: "2.0" + }; +} + +function readRequestBody(request: IncomingMessage, maxBytes: number): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + request.on("data", (chunk: Buffer) => { + total += chunk.byteLength; + if (total > maxBytes) { + reject(new Error(`Request body exceeds ${maxBytes} bytes.`)); + request.destroy(); + return; + } + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + request.on("end", () => resolve(Buffer.concat(chunks))); + request.on("error", reject); + }); +} + +function sendJson(response: ServerResponse, statusCode: number, payload: unknown): void { + const body = `${JSON.stringify(payload)}\n`; + response.writeHead(statusCode, { + "cache-control": "no-store", + "content-length": Buffer.byteLength(body), + "content-type": "application/json; charset=utf-8" + }); + response.end(body); +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function readStringArray(value: unknown): string[] { + if (typeof value === "string") { + return value.split(",").map((item) => item.trim()).filter(Boolean); + } + if (!Array.isArray(value)) { + return []; + } + return value.map(readString).filter((item): item is string => Boolean(item)); +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} + +function clampInteger(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, Math.round(value))); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export const browserWebSearchMcpService = new BrowserWebSearchMcpService(); diff --git a/src/main/ipc.ts b/packages/electron/src/main/ipc.ts similarity index 56% rename from src/main/ipc.ts rename to packages/electron/src/main/ipc.ts index 94b24b0..a639fbc 100644 --- a/src/main/ipc.ts +++ b/packages/electron/src/main/ipc.ts @@ -1,35 +1,38 @@ -import { app, BrowserWindow, dialog, ipcMain, shell, type OpenDialogOptions, type SaveDialogOptions } from "electron"; -import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; -import net from "node:net"; +import { app, BrowserWindow, dialog, ipcMain, nativeImage, shell, type OpenDialogOptions, type Rectangle, type SaveDialogOptions } from "electron"; +import { randomUUID } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import path from "node:path"; -import { loadPersistedAppSetting, replacePersistedAppSetting } from "./app-config-store"; +import { deflateSync, inflateSync } from "node:zlib"; +import { loadPersistedAppSetting, replacePersistedAppSetting } from "@ccr/core/config/app-config-store"; import { builtInBrowserService } from "./built-in-browser"; -import { scanBotHandoffBluetoothTargets, scanBotHandoffWifiTargets } from "./bot-handoff-scan-service"; -import { cancelBotGatewayQrLogin, startBotGatewayQrLogin, waitBotGatewayQrLogin } from "./bot-gateway-qr-login-service"; +import { scanBotHandoffBluetoothTargets, scanBotHandoffWifiTargets } from "@ccr/core/agents/bot-gateway/handoff-scan-service"; +import { cancelBotGatewayQrLogin, startBotGatewayQrLogin, waitBotGatewayQrLogin } from "@ccr/core/agents/bot-gateway/qr-login-service"; import { closeBotGatewayQrWindow, openBotGatewayQrWindow } from "./bot-gateway-qr-window-service"; -import { syncClaudeAppGatewayConfig } from "./claude-app-gateway-service"; -import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "./config"; -import { API_KEYS_DB_FILE, APP_CONFIG_DB_FILE, APP_NAME, CONFIGDIR, CONFIG_FILE, DATADIR, GATEWAY_CONFIG_FILE, IPC_CHANNELS, LEGACY_CONFIG_FILE, ONBOARDING_FINISHED_FILE, PROXY_CA_CERT_FILE, REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "./constants"; +import { syncClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service"; +import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "@ccr/core/config/config"; +import { API_KEYS_DB_FILE, APP_CONFIG_DB_FILE, APP_NAME, CONFIGDIR, CONFIG_FILE, DATADIR, GATEWAY_CONFIG_FILE, IPC_CHANNELS, LEGACY_CONFIG_FILE, ONBOARDING_FINISHED_FILE, PROXY_CA_CERT_FILE, REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "@ccr/core/config/constants"; import { deepLinkService } from "./deep-link"; -import { gatewayService } from "../server/gateway/service"; -import { getProviderAccountSnapshots, invalidateProviderAccountSnapshotCache, testProviderAccountConnector } from "./provider-account-service"; -import { detectProviderIcon } from "./provider-icons"; -import { fetchProviderManifest } from "./provider-manifest-service"; -import { getLocalAgentProviderCandidates, importLocalAgentProvider } from "./local-agent-provider-service"; -import { getProviderCatalogModels } from "./provider-model-catalog"; -import { getProviderPresets } from "./presets"; -import { checkGatewayProviderConnectivity, probeGatewayProvider, probeGatewayProviderCandidates } from "./provider-probe"; -import { applyProfileConfig } from "./profile-service"; -import { getProfileOpenCommand, getProfileRuntimeStatus, openProfileFromCcr, stopProfileFromCcr } from "./profile-launch-service"; -import { ensureProxyCertificateAuthority } from "../server/proxy/certificates"; -import { proxyService } from "../server/proxy/service"; -import { listMcpServerTools } from "../server/mcp/tool-discovery"; -import { getAgentAnalysis, getAgentTracePayload, getRequestLogs } from "./request-log-store"; +import { gatewayService } from "@ccr/core/gateway/service"; +import { shouldRestartGatewayForRuntimeConfigChange } from "@ccr/core/gateway/runtime-change"; +import { getProviderAccountSnapshots, invalidateProviderAccountSnapshotCache, resetCodexRateLimitCredit, testProviderAccountConnector } from "@ccr/core/providers/account-service"; +import { detectProviderIcon } from "@ccr/core/providers/icons"; +import { fetchProviderManifest } from "@ccr/core/providers/manifest-service"; +import { getLocalAgentProviderCandidates, importLocalAgentProvider } from "@ccr/core/agents/local-providers/service"; +import { isLaunchAtLoginSupported, syncLaunchAtLogin } from "./launch-at-login"; +import { getProviderCatalogModels } from "@ccr/core/providers/model-catalog"; +import { getProviderPresets } from "@ccr/core/providers/presets/index"; +import { checkGatewayProviderConnectivity, probeGatewayProvider, probeGatewayProviderCandidates } from "@ccr/core/providers/probe"; +import { applyProfileConfig } from "@ccr/core/profiles/service"; +import { desktopCliCommandName, getProfileOpenCommand, getProfileRuntimeStatus, openProfileFromCcr, stopProfileFromCcr } from "@ccr/core/profiles/launch-service"; +import { ensureProxyCertificateAuthority } from "@ccr/core/proxy/certificates"; +import { proxyService } from "@ccr/core/proxy/service"; +import { listMcpServerTools } from "@ccr/core/mcp/tool-discovery"; +import { getAgentAnalysis, getAgentTracePayload, getRequestLogDetail, getRequestLogs } from "@ccr/core/observability/request-log-store"; import trayController from "./tray-controller"; import { appUpdateService } from "./update-service"; -import { getUsageStats } from "./usage-store"; +import { getUsageStats } from "@ccr/core/usage/store"; import windowsManager from "./windows"; -import type { AgentAnalysisFilter, AgentAnalysisTracePayloadRequest, ApiKeyConfig, AppConfig, AppDataExportResult, AppInfo, AppSaveConfigOptions, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, BotGatewayQrWindowCloseRequest, BotGatewayQrWindowOpenRequest, GatewayPluginAppConfig, GatewayProviderConnectivityCheckRequest, GatewayProviderProbeCandidatesRequest, GatewayProviderProbeRequest, GatewayStatus, LocalAgentProviderImportRequest, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountSnapshotRequestOptions, ProviderAccountTestRequest, ProviderCatalogModelsRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app"; +import type { AgentAnalysisFilter, AgentAnalysisTracePayloadRequest, ApiKeyConfig, AppCaptureElementPngRequest, AppCaptureElementPngResult, AppConfig, AppDataExportResult, AppImageExportTargetRequest, AppImageExportTargetResult, AppInfo, AppRenderHtmlPngRequest, AppRenderHtmlPngResult, AppSaveConfigOptions, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginStartRequest, BotGatewayQrLoginWaitRequest, BotGatewayQrWindowCloseRequest, BotGatewayQrWindowOpenRequest, GatewayPluginAppConfig, GatewayProviderConnectivityCheckRequest, GatewayProviderProbeCandidatesRequest, GatewayProviderProbeRequest, GatewayStatus, LocalAgentProviderImportRequest, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountResetRequest, ProviderAccountSnapshotRequestOptions, ProviderAccountTestRequest, ProviderCatalogModelsRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "@ccr/core/contracts/app"; const pluginMarketplace: PluginMarketplaceEntry[] = [ { @@ -50,6 +53,7 @@ const pluginMarketplace: PluginMarketplaceEntry[] = [ } ]; const onboardingFinishedAtSettingKey = "onboardingFinishedAt"; +const imageExportTargets = new Map(); ipcMain.handle(IPC_CHANNELS.appGetInfo, () => { return { @@ -59,6 +63,7 @@ ipcMain.handle(IPC_CHANNELS.appGetInfo, () => { configFile: CONFIG_FILE, dataDir: DATADIR, gatewayConfigFile: GATEWAY_CONFIG_FILE, + launchAtLoginSupported: isLaunchAtLoginSupported(), name: APP_NAME, platform: process.platform, requestLogsDbFile: REQUEST_LOGS_DB_FILE, @@ -70,6 +75,15 @@ ipcMain.handle(IPC_CHANNELS.appGetInfo, () => { ipcMain.handle(IPC_CHANNELS.appExportData, async (event): Promise => { return exportAppData(BrowserWindow.fromWebContents(event.sender)); }); +ipcMain.handle(IPC_CHANNELS.appCaptureElementPng, async (event, request: AppCaptureElementPngRequest): Promise => { + return captureElementPng(BrowserWindow.fromWebContents(event.sender), request); +}); +ipcMain.handle(IPC_CHANNELS.appPrepareImageExportTarget, async (event, request: AppImageExportTargetRequest): Promise => { + return prepareImageExportTarget(BrowserWindow.fromWebContents(event.sender), request); +}); +ipcMain.handle(IPC_CHANNELS.appRenderHtmlPng, async (event, request: AppRenderHtmlPngRequest): Promise => { + return renderHtmlPng(BrowserWindow.fromWebContents(event.sender), request); +}); ipcMain.handle(IPC_CHANNELS.appGetConfig, () => loadAppConfig()); ipcMain.handle(IPC_CHANNELS.appGetOnboardingFinished, async () => { @@ -79,7 +93,10 @@ ipcMain.handle(IPC_CHANNELS.appGetOnboardingFinished, async () => { ipcMain.handle(IPC_CHANNELS.appGetPendingProviderDeepLinks, () => deepLinkService.consumePendingProviderRequests()); ipcMain.handle(IPC_CHANNELS.appGetLocalAgentProviderCandidates, () => getLocalAgentProviderCandidates()); ipcMain.handle(IPC_CHANNELS.appGetProfileOpenCommand, async (_event, request: ProfileOpenRequest) => { - return getProfileOpenCommand(await loadAppConfig(), request); + return getProfileOpenCommand(await loadAppConfig(), request, { + commandName: desktopCliCommandName, + ensureLauncher: true + }); }); ipcMain.handle(IPC_CHANNELS.appGetProfileRuntimeStatus, () => { return getProfileRuntimeStatus(); @@ -94,6 +111,7 @@ ipcMain.handle(IPC_CHANNELS.appGetProxyCertificateStatus, () => proxyService.get ipcMain.handle(IPC_CHANNELS.appGetProxyNetworkCaptures, () => proxyService.getNetworkCaptures()); ipcMain.handle(IPC_CHANNELS.appGetProxyStatus, () => proxyService.getStatus()); ipcMain.handle(IPC_CHANNELS.appGetPluginMarketplace, () => pluginMarketplace); +ipcMain.handle(IPC_CHANNELS.appGetRequestLogDetail, (_event, request) => getRequestLogDetail(request)); ipcMain.handle(IPC_CHANNELS.appGetRequestLogs, (_event, filter?: RequestLogListFilter) => getRequestLogs(filter)); ipcMain.handle(IPC_CHANNELS.appGetUpdateStatus, () => appUpdateService.getStatus()); ipcMain.handle(IPC_CHANNELS.appGetUsageStats, (_event, range?: UsageStatsRange, filter?: UsageStatsFilter) => getUsageStats(range, filter)); @@ -114,7 +132,6 @@ ipcMain.handle(IPC_CHANNELS.appListMcpServerTools, async (_event, serverName: st }); ipcMain.handle(IPC_CHANNELS.appOpenBuiltInBrowser, async () => { const config = await loadAppConfig(); - await ensureBuiltInBrowserProxyReady(config); await builtInBrowserService.open(config); }); ipcMain.handle(IPC_CHANNELS.appCloseTray, () => { @@ -164,7 +181,7 @@ ipcMain.handle(IPC_CHANNELS.appApplyClaudeAppGateway, async (_event, config?: Ap const savedConfig = synced.config; let runtimeStatus = gatewayService.getStatus(); - if (synced.configChanged || shouldRestartForRuntimeChange(previousConfig, savedConfig) || runtimeStatus.state !== "running") { + if (synced.configChanged || shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig) || runtimeStatus.state !== "running") { runtimeStatus = await gatewayService.start(savedConfig); } else { gatewayService.updateConfig(savedConfig); @@ -219,6 +236,9 @@ ipcMain.handle(IPC_CHANNELS.appProbeProvider, (_event, request: GatewayProviderP ipcMain.handle(IPC_CHANNELS.appProbeProviderCandidates, (_event, request: GatewayProviderProbeCandidatesRequest) => { return probeGatewayProviderCandidates(request); }); +ipcMain.handle(IPC_CHANNELS.appResetCodexRateLimitCredit, (_event, request: ProviderAccountResetRequest) => { + return resetCodexRateLimitCredit(request); +}); ipcMain.handle(IPC_CHANNELS.appTestProviderAccountConnector, (_event, request: ProviderAccountTestRequest) => { return testProviderAccountConnector(request); }); @@ -240,11 +260,23 @@ ipcMain.handle(IPC_CHANNELS.appSaveConfig, async (_event, config: AppConfig, opt throw new Error(certificateStatus.message); } } + const launchAtLoginChanged = Boolean(config.launchAtLogin) !== Boolean(previousConfig.launchAtLogin); let savedConfig = await saveAppConfig(config); + if (launchAtLoginChanged) { + try { + syncLaunchAtLogin(savedConfig); + } catch (error) { + await saveAppConfig({ + ...savedConfig, + launchAtLogin: previousConfig.launchAtLogin + }); + throw error; + } + } const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(savedConfig); savedConfig = syncedClaudeAppConfig.config; let runtimeStatus = gatewayService.getStatus(); - if (syncedClaudeAppConfig.configChanged || shouldRestartForRuntimeChange(previousConfig, savedConfig)) { + if (syncedClaudeAppConfig.configChanged || shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig)) { runtimeStatus = await gatewayService.start(savedConfig); } else { gatewayService.updateConfig(savedConfig); @@ -327,178 +359,6 @@ function logProfileApplyResult(result: ProfileApplyResult): void { } } -type ProxyConnectProbeResult = { - detail?: string; - ok: boolean; -}; - -const browserProxyConnectProbeTarget = "claude.ai:443"; -const proxyConnectProbeTimeoutMs = 3000; - -async function ensureBuiltInBrowserProxyReady(config: AppConfig): Promise { - if (!config.proxy.enabled) { - return; - } - - let proxyStatus = proxyService.getStatus(); - if (proxyStatus.state === "running" && proxyStatus.endpoint) { - const probe = await probeProxyConnect(proxyStatus.endpoint); - if (probe.ok) { - return; - } - console.warn(`[browser] Proxy CONNECT probe failed at ${proxyStatus.endpoint}; restarting proxy: ${probe.detail || "unknown error"}`); - } - - const status = await gatewayService.start(config); - if (status.state === "error") { - throw new Error(status.lastError || "Failed to start proxy mode."); - } - - proxyStatus = proxyService.getStatus(); - if (proxyStatus.state !== "running" || !proxyStatus.endpoint) { - throw new Error(proxyStatus.lastError || "Proxy mode is not running."); - } - - const probe = await probeProxyConnect(proxyStatus.endpoint); - if (probe.ok) { - return; - } - - console.warn( - `[browser] Shared proxy endpoint ${proxyStatus.endpoint} still does not accept CONNECT after restart; starting dedicated proxy endpoint: ${probe.detail || "unknown error"}` - ); - const dedicatedProxyConfig = await createBuiltInBrowserProxyConfig(config); - const dedicatedProxyStatus = await proxyService.start(dedicatedProxyConfig); - if (dedicatedProxyStatus.state !== "running" || !dedicatedProxyStatus.endpoint) { - throw new Error(dedicatedProxyStatus.lastError || "Failed to start the dedicated proxy endpoint for the built-in browser."); - } - - const dedicatedProbe = await probeProxyConnect(dedicatedProxyStatus.endpoint); - if (!dedicatedProbe.ok) { - const detail = dedicatedProbe.detail ? `: ${dedicatedProbe.detail}` : ""; - throw new Error(`Proxy mode is running at ${dedicatedProxyStatus.endpoint}, but HTTPS CONNECT is not available${detail}.`); - } -} - -async function createBuiltInBrowserProxyConfig(config: AppConfig): Promise { - return { - ...config, - proxy: { - ...config.proxy, - host: "127.0.0.1", - port: await findAvailableLoopbackPort(), - systemProxy: false - } - }; -} - -function findAvailableLoopbackPort(): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - server.close((error) => { - if (error) { - reject(error); - return; - } - if (!address || typeof address === "string") { - reject(new Error("Failed to allocate a local proxy port.")); - return; - } - resolve(address.port); - }); - }); - }); -} - -function probeProxyConnect(endpoint: string): Promise { - return new Promise((resolve) => { - let parsed: URL; - try { - parsed = new URL(endpoint); - } catch { - resolve({ detail: `Invalid proxy endpoint: ${endpoint}`, ok: false }); - return; - } - - const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)); - if (!Number.isInteger(port) || port <= 0 || port > 65535) { - resolve({ detail: `Invalid proxy port in endpoint: ${endpoint}`, ok: false }); - return; - } - - const host = parsed.hostname.replace(/^\[|\]$/g, ""); - const socket = net.connect({ host, port }); - let response = ""; - let settled = false; - - const finish = (result: ProxyConnectProbeResult) => { - if (settled) { - return; - } - settled = true; - socket.destroy(); - resolve(result); - }; - const parseResponse = (): ProxyConnectProbeResult | undefined => { - const firstLine = response.split(/\r?\n/, 1)[0]?.trim(); - if (!firstLine) { - return undefined; - } - if (/^HTTP\/1\.[01]\s+200\b/i.test(firstLine)) { - return { ok: true }; - } - if (/^HTTP\/1\.[01]\s+\d{3}\b/i.test(firstLine)) { - return { detail: firstLine, ok: false }; - } - return { detail: `Unexpected response: ${firstLine}`, ok: false }; - }; - - socket.setTimeout(proxyConnectProbeTimeoutMs, () => { - finish({ detail: `Timed out after ${proxyConnectProbeTimeoutMs}ms`, ok: false }); - }); - socket.once("error", (error) => { - finish({ detail: formatError(error), ok: false }); - }); - socket.once("connect", () => { - socket.write(`CONNECT ${browserProxyConnectProbeTarget} HTTP/1.1\r\nHost: ${browserProxyConnectProbeTarget}\r\n\r\n`); - }); - socket.on("data", (chunk) => { - response += chunk.toString("latin1"); - const result = parseResponse(); - if (result) { - finish(result); - } - }); - socket.once("close", () => { - finish(parseResponse() ?? { detail: "Connection closed without a CONNECT response", ok: false }); - }); - }); -} - -function shouldRestartForRuntimeChange(previousConfig: AppConfig, nextConfig: AppConfig): boolean { - return ( - previousConfig.gateway.enabled !== nextConfig.gateway.enabled || - previousConfig.gateway.host !== nextConfig.gateway.host || - previousConfig.gateway.port !== nextConfig.gateway.port || - previousConfig.gateway.coreHost !== nextConfig.gateway.coreHost || - previousConfig.gateway.corePort !== nextConfig.gateway.corePort || - previousConfig.proxy.enabled !== nextConfig.proxy.enabled || - previousConfig.proxy.host !== nextConfig.proxy.host || - previousConfig.proxy.mode !== nextConfig.proxy.mode || - previousConfig.proxy.port !== nextConfig.proxy.port || - previousConfig.proxy.systemProxy !== nextConfig.proxy.systemProxy || - JSON.stringify(previousConfig.proxy.targets) !== JSON.stringify(nextConfig.proxy.targets) || - JSON.stringify(previousConfig.agent) !== JSON.stringify(nextConfig.agent) || - JSON.stringify(previousConfig.Providers) !== JSON.stringify(nextConfig.Providers) || - JSON.stringify(previousConfig.plugins) !== JSON.stringify(nextConfig.plugins) || - JSON.stringify(previousConfig.providerPlugins) !== JSON.stringify(nextConfig.providerPlugins) || - JSON.stringify(previousConfig.virtualModelProfiles) !== JSON.stringify(nextConfig.virtualModelProfiles) - ); -} - async function exportAppData(window: BrowserWindow | null): Promise { const exportedAt = new Date().toISOString(); const result = window @@ -538,6 +398,105 @@ async function exportAppData(window: BrowserWindow | null): Promise { + if (!window) { + throw new Error("Window is unavailable."); + } + + const rect = sanitizeCaptureRect(request.rect); + const result = await imageExportFile(window, request.fileName, request.exportId); + if (result.canceled || !result.filePath) { + return { canceled: true }; + } + + const image = await window.webContents.capturePage(rect); + const png = image.toPNG(); + writeFileSync(result.filePath, pngWithExportProcessing(png, request, rect.width), { mode: 0o600 }); + return { canceled: false, file: result.filePath }; +} + +async function renderHtmlPng(window: BrowserWindow | null, request: AppRenderHtmlPngRequest): Promise { + const html = typeof request.html === "string" ? request.html : ""; + if (!html.trim()) { + throw new Error("Export HTML is empty."); + } + + const size = sanitizeRenderSize(request.size); + const result = await imageExportFile(window, request.fileName, request.exportId); + if (result.canceled || !result.filePath) { + return { canceled: true }; + } + + const renderWindow = new BrowserWindow({ + backgroundColor: "#00000000", + frame: false, + height: size.height, + paintWhenInitiallyHidden: true, + resizable: false, + show: false, + skipTaskbar: true, + transparent: true, + useContentSize: true, + webPreferences: { + backgroundThrottling: false, + contextIsolation: true, + nodeIntegration: false, + offscreen: true, + sandbox: true + }, + width: size.width + }); + + const tempDir = mkdtempSync(path.join(app.getPath("temp"), "ccr-export-")); + const tempHtmlFile = path.join(tempDir, "export.html"); + writeFileSync(tempHtmlFile, html, { encoding: "utf8", mode: 0o600 }); + + try { + await renderWindow.loadFile(tempHtmlFile); + await waitForExportWindowPaint(renderWindow); + const image = await renderWindow.webContents.capturePage({ + height: size.height, + width: size.width, + x: 0, + y: 0 + }); + const png = image.toPNG(); + writeFileSync(result.filePath, pngWithExportProcessing(png, request, size.width), { mode: 0o600 }); + return { canceled: false, file: result.filePath }; + } finally { + if (!renderWindow.isDestroyed()) { + renderWindow.destroy(); + } + rmSync(tempDir, { force: true, recursive: true }); + } +} + +async function prepareImageExportTarget(window: BrowserWindow | null, request: AppImageExportTargetRequest): Promise { + const result = window + ? await dialog.showSaveDialog(window, shareCardSaveDialogOptions(request.fileName)) + : await dialog.showSaveDialog(shareCardSaveDialogOptions(request.fileName)); + if (result.canceled || !result.filePath) { + return { canceled: true }; + } + + const exportId = randomUUID(); + imageExportTargets.set(exportId, result.filePath); + return { + canceled: false, + exportId, + file: result.filePath + }; +} + +async function imageExportFile(window: BrowserWindow | null, fileName: string, exportId?: string): Promise<{ canceled: boolean; filePath?: string }> { + const targetFile = exportId ? consumeImageExportTarget(exportId) : undefined; + return targetFile + ? { canceled: false, filePath: targetFile } + : window + ? await dialog.showSaveDialog(window, shareCardSaveDialogOptions(fileName)) + : await dialog.showSaveDialog(shareCardSaveDialogOptions(fileName)); +} + function dataExportSaveDialogOptions(exportedAt: string): SaveDialogOptions { return { buttonLabel: "Export", @@ -549,6 +508,372 @@ function dataExportSaveDialogOptions(exportedAt: string): SaveDialogOptions { }; } +function shareCardSaveDialogOptions(fileName: string): SaveDialogOptions { + return { + buttonLabel: "Save image", + defaultPath: path.join(app.getPath("downloads"), safePngFileName(fileName)), + filters: [ + { extensions: ["png"], name: "PNG image" } + ], + title: "Save image" + }; +} + +function sanitizeCaptureRect(rect: AppCaptureElementPngRequest["rect"]): Rectangle { + const x = finiteNumber(rect?.x, "capture x"); + const y = finiteNumber(rect?.y, "capture y"); + const width = finiteNumber(rect?.width, "capture width"); + const height = finiteNumber(rect?.height, "capture height"); + if (width <= 0 || height <= 0) { + throw new Error("Capture area must not be empty."); + } + return { + height: Math.ceil(height), + width: Math.ceil(width), + x: Math.max(0, Math.floor(x)), + y: Math.max(0, Math.floor(y)) + }; +} + +function sanitizeRenderSize(size: AppRenderHtmlPngRequest["size"]): { height: number; width: number } { + const width = finiteNumber(size?.width, "render width"); + const height = finiteNumber(size?.height, "render height"); + if (width <= 0 || height <= 0 || width > 4096 || height > 4096) { + throw new Error("Render size is out of range."); + } + return { + height: Math.ceil(height), + width: Math.ceil(width) + }; +} + +async function waitForExportWindowPaint(window: BrowserWindow): Promise { + await window.webContents.executeJavaScript(` + new Promise((resolve) => { + const fontsReady = document.fonts && document.fonts.ready ? document.fonts.ready.catch(() => undefined) : Promise.resolve(); + fontsReady.then(() => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))); + }); + }); + `); +} + +function finiteNumber(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`Invalid ${label}.`); + } + return value; +} + +function safePngFileName(value: string): string { + const raw = typeof value === "string" ? value : ""; + const safe = path.basename(raw).replace(/[<>:"/\\|?*\x00-\x1f]/g, "-").trim() || "ccr-share-card.png"; + return safe.toLowerCase().endsWith(".png") ? safe : `${safe}.png`; +} + +function consumeImageExportTarget(exportId: string): string { + const target = imageExportTargets.get(exportId); + imageExportTargets.delete(exportId); + if (!target) { + throw new Error("Image export target is unavailable."); + } + return target; +} + +const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +type PngExportProcessingRequest = { + borderRadius?: number; + output?: { + height: number; + width: number; + }; +}; + +type DecodedPngPixels = { + bitDepth: number; + colorType: 2 | 6; + height: number; + pixels: Uint8Array; + width: number; +}; + +function pngWithExportProcessing(png: Buffer, request: PngExportProcessingRequest, cssWidth: number): Buffer { + const radius = typeof request.borderRadius === "number" && Number.isFinite(request.borderRadius) ? Math.max(0, request.borderRadius) : 0; + const outputWidth = sanitizePngOutputDimension(request.output?.width); + const outputHeight = sanitizePngOutputDimension(request.output?.height); + if (radius <= 0 && (!outputWidth || !outputHeight)) { + return png; + } + + try { + const decoded = decodePngPixels(png); + let width = decoded.width; + let height = decoded.height; + let rgba = pngPixelsToRgba(decoded); + if (outputWidth && outputHeight && (outputWidth !== width || outputHeight !== height)) { + rgba = resizeRgbaBilinear(rgba, width, height, outputWidth, outputHeight); + width = outputWidth; + height = outputHeight; + } + if (radius > 0 && cssWidth > 0) { + const pixelRadius = Math.min(width / 2, height / 2, radius * width / cssWidth); + applyRoundedAlphaMask(rgba, width, height, pixelRadius); + } + return encodeRgbaPng(width, height, rgba); + } catch (error) { + console.warn(`[export] Failed to process exported PNG: ${formatError(error)}`); + const resized = resizePngWithNativeImage(png, outputWidth, outputHeight); + if (resized) { + return resized; + } + return png; + } +} + +function resizePngWithNativeImage(png: Buffer, width?: number, height?: number): Buffer | undefined { + if (!width || !height) { + return undefined; + } + try { + const image = nativeImage.createFromBuffer(png); + if (image.isEmpty()) { + return undefined; + } + return image.resize({ height, width }).toPNG(); + } catch (error) { + console.warn(`[export] Failed to resize exported PNG fallback: ${formatError(error)}`); + return undefined; + } +} + +function sanitizePngOutputDimension(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + const rounded = Math.round(value); + return rounded > 0 && rounded <= 4096 ? rounded : undefined; +} + +function decodePngPixels(png: Buffer): DecodedPngPixels { + if (png.length < 33 || !png.subarray(0, pngSignature.length).equals(pngSignature)) { + throw new Error("Invalid PNG file."); + } + + let offset = pngSignature.length; + let width = 0; + let height = 0; + let bitDepth = 0; + let colorType = 0; + let interlaceMethod = 0; + const idatChunks: Buffer[] = []; + + while (offset + 12 <= png.length) { + const length = png.readUInt32BE(offset); + offset += 4; + const type = png.toString("ascii", offset, offset + 4); + offset += 4; + const data = png.subarray(offset, offset + length); + offset += length + 4; + + if (type === "IHDR") { + width = data.readUInt32BE(0); + height = data.readUInt32BE(4); + bitDepth = data[8]; + colorType = data[9]; + interlaceMethod = data[12]; + } else if (type === "IDAT") { + idatChunks.push(Buffer.from(data)); + } else if (type === "IEND") { + break; + } + } + + if (width <= 0 || height <= 0 || bitDepth !== 8 || (colorType !== 2 && colorType !== 6) || interlaceMethod !== 0) { + throw new Error("Unsupported PNG format."); + } + + const channels = colorType === 6 ? 4 : 3; + const stride = width * channels; + const inflated = inflateSync(Buffer.concat(idatChunks)); + const pixels = new Uint8Array(width * height * channels); + let sourceOffset = 0; + let previousRow = new Uint8Array(stride); + + for (let y = 0; y < height; y += 1) { + const filter = inflated[sourceOffset]; + sourceOffset += 1; + const row = new Uint8Array(stride); + for (let x = 0; x < stride; x += 1) { + const raw = inflated[sourceOffset + x]; + const left = x >= channels ? row[x - channels] : 0; + const up = previousRow[x] ?? 0; + const upLeft = x >= channels ? previousRow[x - channels] ?? 0 : 0; + row[x] = unfilterPngByte(filter, raw, left, up, upLeft); + } + pixels.set(row, y * stride); + previousRow = row; + sourceOffset += stride; + } + + return { + bitDepth, + colorType: colorType as 2 | 6, + height, + pixels, + width + }; +} + +function unfilterPngByte(filter: number, raw: number, left: number, up: number, upLeft: number): number { + if (filter === 0) return raw; + if (filter === 1) return (raw + left) & 0xff; + if (filter === 2) return (raw + up) & 0xff; + if (filter === 3) return (raw + Math.floor((left + up) / 2)) & 0xff; + if (filter === 4) return (raw + pngPaethPredictor(left, up, upLeft)) & 0xff; + throw new Error(`Unsupported PNG filter: ${filter}`); +} + +function pngPaethPredictor(left: number, up: number, upLeft: number): number { + const estimate = left + up - upLeft; + const leftDistance = Math.abs(estimate - left); + const upDistance = Math.abs(estimate - up); + const upLeftDistance = Math.abs(estimate - upLeft); + if (leftDistance <= upDistance && leftDistance <= upLeftDistance) return left; + if (upDistance <= upLeftDistance) return up; + return upLeft; +} + +function pngPixelsToRgba(decoded: DecodedPngPixels): Uint8Array { + const rgba = new Uint8Array(decoded.width * decoded.height * 4); + const sourceChannels = decoded.colorType === 6 ? 4 : 3; + for (let source = 0, target = 0; source < decoded.pixels.length; source += sourceChannels, target += 4) { + rgba[target] = decoded.pixels[source]; + rgba[target + 1] = decoded.pixels[source + 1]; + rgba[target + 2] = decoded.pixels[source + 2]; + rgba[target + 3] = sourceChannels === 4 ? decoded.pixels[source + 3] : 255; + } + return rgba; +} + +function resizeRgbaBilinear(source: Uint8Array, sourceWidth: number, sourceHeight: number, targetWidth: number, targetHeight: number): Uint8Array { + const target = new Uint8Array(targetWidth * targetHeight * 4); + const xRatio = targetWidth > 1 ? (sourceWidth - 1) / (targetWidth - 1) : 0; + const yRatio = targetHeight > 1 ? (sourceHeight - 1) / (targetHeight - 1) : 0; + + for (let y = 0; y < targetHeight; y += 1) { + const sourceY = y * yRatio; + const y0 = Math.floor(sourceY); + const y1 = Math.min(sourceHeight - 1, y0 + 1); + const yWeight = sourceY - y0; + for (let x = 0; x < targetWidth; x += 1) { + const sourceX = x * xRatio; + const x0 = Math.floor(sourceX); + const x1 = Math.min(sourceWidth - 1, x0 + 1); + const xWeight = sourceX - x0; + const targetOffset = (y * targetWidth + x) * 4; + const topLeft = (y0 * sourceWidth + x0) * 4; + const topRight = (y0 * sourceWidth + x1) * 4; + const bottomLeft = (y1 * sourceWidth + x0) * 4; + const bottomRight = (y1 * sourceWidth + x1) * 4; + for (let channel = 0; channel < 4; channel += 1) { + const top = source[topLeft + channel] * (1 - xWeight) + source[topRight + channel] * xWeight; + const bottom = source[bottomLeft + channel] * (1 - xWeight) + source[bottomRight + channel] * xWeight; + target[targetOffset + channel] = Math.round(top * (1 - yWeight) + bottom * yWeight); + } + } + } + + return target; +} + +function applyRoundedAlphaMask(rgba: Uint8Array, width: number, height: number, radius: number): void { + if (radius <= 0) { + return; + } + + const halfWidth = width / 2; + const halfHeight = height / 2; + const innerHalfWidth = Math.max(0, halfWidth - radius); + const innerHalfHeight = Math.max(0, halfHeight - radius); + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const qx = Math.abs(x + 0.5 - halfWidth) - innerHalfWidth; + const qy = Math.abs(y + 0.5 - halfHeight) - innerHalfHeight; + const outside = Math.hypot(Math.max(qx, 0), Math.max(qy, 0)); + const inside = Math.min(Math.max(qx, qy), 0); + const distance = outside + inside - radius; + const coverage = Math.max(0, Math.min(1, 0.5 - distance)); + if (coverage >= 1) { + continue; + } + const alphaOffset = (y * width + x) * 4 + 3; + rgba[alphaOffset] = Math.round(rgba[alphaOffset] * coverage); + } + } +} + +function encodeRgbaPng(width: number, height: number, rgba: Uint8Array): Buffer { + const stride = width * 4; + const scanlines = Buffer.alloc((stride + 1) * height); + for (let y = 0; y < height; y += 1) { + const target = y * (stride + 1); + scanlines[target] = 0; + scanlines.set(rgba.subarray(y * stride, (y + 1) * stride), target + 1); + } + + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = 6; + ihdr[10] = 0; + ihdr[11] = 0; + ihdr[12] = 0; + + return Buffer.concat([ + pngSignature, + pngChunk("IHDR", ihdr), + pngChunk("IDAT", deflateSync(scanlines)), + pngChunk("IEND", Buffer.alloc(0)) + ]); +} + +function pngChunk(type: string, data: Buffer): Buffer { + const typeBuffer = Buffer.from(type, "ascii"); + const chunk = Buffer.alloc(12 + data.length); + chunk.writeUInt32BE(data.length, 0); + typeBuffer.copy(chunk, 4); + data.copy(chunk, 8); + chunk.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 8 + data.length); + return chunk; +} + +let crc32Table: Uint32Array | undefined; + +function crc32(buffer: Buffer): number { + const table = crc32Table ?? createCrc32Table(); + crc32Table = table; + let crc = 0xffffffff; + for (const byte of buffer) { + crc = table[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function createCrc32Table(): Uint32Array { + const table = new Uint32Array(256); + for (let index = 0; index < table.length; index += 1) { + let value = index; + for (let bit = 0; bit < 8; bit += 1) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; + } + table[index] = value >>> 0; + } + return table; +} + function readDataExportFiles(): Array<{ base64: string; name: string; path: string; sizeBytes: number }> { const files: Array<{ base64: string; name: string; path: string; sizeBytes: number }> = []; for (const file of dataExportCandidateFiles()) { diff --git a/packages/electron/src/main/launch-at-login.ts b/packages/electron/src/main/launch-at-login.ts new file mode 100644 index 0000000..183cfb4 --- /dev/null +++ b/packages/electron/src/main/launch-at-login.ts @@ -0,0 +1,33 @@ +import { app } from "electron"; +import type { AppConfig } from "@ccr/core/contracts/app"; + +export function isLaunchAtLoginSupported(platform = process.platform): boolean { + return platform === "darwin" || platform === "win32"; +} + +export function syncLaunchAtLogin(config: Pick): void { + if (!isLaunchAtLoginSupported()) { + return; + } + const openAtLogin = Boolean(config.launchAtLogin); + const current = app.getLoginItemSettings(); + if (current.openAtLogin === openAtLogin) { + return; + } + app.setLoginItemSettings(loginItemSettings(openAtLogin)); +} + +function loginItemSettings(openAtLogin: boolean): Electron.Settings { + const settings: Electron.Settings = { + openAtLogin + }; + + if (process.platform === "darwin") { + settings.openAsHidden = false; + return settings; + } + + settings.path = process.execPath; + settings.args = process.defaultApp ? [app.getAppPath()] : []; + return settings; +} diff --git a/src/main/main.ts b/packages/electron/src/main/main-app.ts similarity index 69% rename from src/main/main.ts rename to packages/electron/src/main/main-app.ts index 0e96bce..9f3ab9a 100644 --- a/src/main/main.ts +++ b/packages/electron/src/main/main-app.ts @@ -1,14 +1,18 @@ -import { app } from "electron"; +import { app, BrowserWindow, dialog, shell } from "electron"; import { setupApplicationMenu } from "./app-menu"; -import { loadAppConfig } from "./config"; -import { restoreClaudeAppGatewayConfig, syncClaudeAppGatewayConfig } from "./claude-app-gateway-service"; +import { loadAppConfig } from "@ccr/core/config/config"; +import { restoreClaudeAppGatewayConfig, syncClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service"; import { deepLinkService } from "./deep-link"; -import { gatewayService } from "../server/gateway/service"; +import { gatewayService } from "@ccr/core/gateway/service"; import "./ipc"; -import { applyProfileConfig } from "./profile-service"; -import { proxyService } from "../server/proxy/service"; +import { applyProfileConfig } from "@ccr/core/profiles/service"; +import { ensureCcrCliLauncher } from "@ccr/core/profiles/launch-service"; +import { syncLaunchAtLogin } from "./launch-at-login"; +import { proxyService } from "@ccr/core/proxy/service"; import trayController from "./tray-controller"; import { appUpdateService } from "./update-service"; +import { browserAutomationMcpService } from "./browser-automation-mcp"; +import { browserWebSearchMcpService } from "./electron-web-search-mcp"; import windowsManager from "./windows"; const gotTheLock = app.requestSingleInstanceLock(); @@ -26,6 +30,8 @@ if (!gotTheLock) { } function startPrimaryInstance(): void { + gatewayService.setBrowserAutomationMcpIntegration(browserAutomationMcpService); + gatewayService.setBrowserWebSearchMcpIntegration(browserWebSearchMcpService); deepLinkService.register(); deepLinkService.handleArgv(process.argv); @@ -35,7 +41,13 @@ function startPrimaryInstance(): void { queueEnsureConfiguredProxyModeActive("second-instance"); }); - app.whenReady().then(() => { + void app.whenReady().then(() => { + configureProxyDesktopIntegration(); + try { + ensureCcrCliLauncher(); + } catch (error) { + console.error(`Failed to install ccr CLI launcher: ${formatError(error)}`); + } setupApplicationMenu(); windowsManager.createMainWindow(); trayController.start(); @@ -44,9 +56,21 @@ function startPrimaryInstance(): void { void startConfiguredServices("startup"); app.on("activate", () => { - windowsManager.showMainWindow(); + const mainWindow = windowsManager.getWindow("main"); + if (!trayController.consumeMainWindowActivationSuppression() && (!mainWindow || !mainWindow.isVisible())) { + windowsManager.showMainWindow(); + } queueEnsureConfiguredProxyModeActive("activate"); }); + }).catch((error) => { + const detail = formatErrorDetail(error); + console.error(`Failed to initialize ${app.name || "application"}: ${detail}`); + try { + dialog.showErrorBox("Claude Code Router failed to start", detail); + } catch { + // Keep the console log as the fallback diagnostic channel. + } + app.exit(1); }); app.on("before-quit", (event) => { @@ -75,6 +99,31 @@ function startPrimaryInstance(): void { process.once("SIGTERM", () => handleTerminationSignal("SIGTERM")); } +function configureProxyDesktopIntegration(): void { + proxyService.setDesktopIntegration({ + requestMacosCertificateInstallPermission, + revealFile: async (file) => { + shell.showItemInFolder(file); + } + }); +} + +async function requestMacosCertificateInstallPermission(): Promise { + const window = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; + const options = { + buttons: ["Continue", "Cancel"], + cancelId: 1, + defaultId: 0, + detail: + "macOS will ask for administrator credentials. This is required so Chrome can trust HTTPS certificates generated by CCR proxy mode.", + message: "Install CCR Proxy CA into the macOS System keychain?", + noLink: true, + type: "warning" as const + }; + const result = window ? await dialog.showMessageBox(window, options) : await dialog.showMessageBox(options); + return result.response === 0; +} + function prepareAndQuit(): void { if (stoppingForQuit) { return; @@ -136,6 +185,11 @@ function startConfiguredServices(reason: string): Promise { } catch (error) { console.error(`Failed to sync Claude App gateway config during ${reason}: ${formatError(error)}`); } + try { + syncLaunchAtLogin(config); + } catch (error) { + console.error(`Failed to sync launch-at-login setting during ${reason}: ${formatError(error)}`); + } const status = await gatewayService.start(config); if (status.state === "error") { console.error(`Failed to start gateway during ${reason}: ${status.lastError}`); @@ -212,3 +266,7 @@ function logProxySystemProxyIssue(reason: string, status: ReturnType { + process.off("uncaughtException", reportFatalStartupError); + process.off("unhandledRejection", reportFatalStartupError); + }) + .catch((error) => { + reportFatalStartupError(error); + }); + +function reportFatalStartupError(error: unknown): void { + if (fatalStartupErrorReported) { + return; + } + fatalStartupErrorReported = true; + + const detail = formatErrorDetail(error); + console.error(detail); + + try { + dialog.showErrorBox("Claude Code Router failed to start", startupErrorMessage(detail)); + } catch { + // If the platform dialog is unavailable, the console output above still + // preserves the actionable failure for command-line launches. + } + + app.exit(1); +} + +function configureRuntimeUserDataPath(currentUserDataPath: string): string { + const sharedUserDataPath = resolveRuntimeDataDir(); + mkdirSync(sharedUserDataPath, { recursive: true }); + if (sameFilesystemPath(currentUserDataPath, sharedUserDataPath)) { + return currentUserDataPath; + } + + copyMissingDirectoryContents(currentUserDataPath, sharedUserDataPath, "Electron app data directory"); + app.setPath("userData", sharedUserDataPath); + return app.getPath("userData"); +} + +function startupErrorMessage(detail: string): string { + if (isBetterSqliteNativeError(detail)) { + return [ + "The bundled SQLite native module could not be loaded.", + "", + "This usually means the Windows package was built with a missing or incompatible better-sqlite3 binary. Rebuild the app with npm run rebuild:sqlite3 before packaging, or build the Windows artifact on a Windows x64 runner.", + "", + detail + ].join("\n"); + } + + return detail; +} + +function isBetterSqliteNativeError(detail: string): boolean { + return /better[-_]sqlite3|better_sqlite3\.node|NODE_MODULE_VERSION|ERR_DLOPEN_FAILED|Cannot find module ['"]better-sqlite3/i.test(detail); +} + +function formatErrorDetail(error: unknown): string { + if (error instanceof Error) { + return error.stack || error.message; + } + return String(error); +} diff --git a/src/main/preload.ts b/packages/electron/src/main/preload.ts similarity index 88% rename from src/main/preload.ts rename to packages/electron/src/main/preload.ts index 6e6599b..89e10f7 100644 --- a/src/main/preload.ts +++ b/packages/electron/src/main/preload.ts @@ -1,14 +1,20 @@ import { contextBridge, ipcRenderer } from "electron"; -import { browserErrorI18nLanguage, formatLocalizedErrorMessage } from "../shared/i18n"; -import { IPC_CHANNELS } from "../shared/ipc-channels"; +import { browserErrorI18nLanguage, formatLocalizedErrorMessage } from "@ccr/core/contracts/i18n"; +import { IPC_CHANNELS } from "@ccr/core/contracts/ipc-channels"; import type { AgentAnalysisFilter, AgentAnalysisSnapshot, AgentAnalysisTracePayloadFullResult, AgentAnalysisTracePayloadRequest, AppConfig, + AppCaptureElementPngRequest, + AppCaptureElementPngResult, AppDataExportResult, AppInfo, + AppImageExportTargetRequest, + AppImageExportTargetResult, + AppRenderHtmlPngRequest, + AppRenderHtmlPngResult, AppSaveConfigOptions, AppUpdateStatus, ApiKeyConfig, @@ -42,6 +48,8 @@ import type { ProfileOpenResult, ProfileRuntimeStatus, ProfileStopResult, + ProviderAccountResetRequest, + ProviderAccountResetResult, ProviderAccountSnapshotRequestOptions, ProviderAccountTestRequest, ProviderAccountTestResult, @@ -58,13 +66,15 @@ import type { ProxyCertificateStatus, ProxyNetworkSnapshot, ProxyStatus, + RequestLogDetailRequest, + RequestLogEntry, RequestLogListFilter, RequestLogPage, UsageStatsFilter, UsageStatsRange, UsageStatsSnapshot -} from "../shared/app"; -import type { ProviderPreset } from "../shared/provider-presets"; +} from "@ccr/core/contracts/app"; +import type { ProviderPreset } from "@ccr/core/providers/presets/types"; function invoke(channel: string, ...args: unknown[]): Promise { return ipcRenderer.invoke(channel, ...args).catch((error) => { @@ -84,6 +94,7 @@ contextBridge.exposeInMainWorld("ccr", { applyClaudeAppGateway: (config?: AppConfig) => invoke(IPC_CHANNELS.appApplyClaudeAppGateway, config) as Promise, applyProfile: () => invoke(IPC_CHANNELS.appApplyProfile) as Promise, cancelBotGatewayQrLogin: (request: BotGatewayQrLoginCancelRequest) => invoke(IPC_CHANNELS.appBotGatewayQrLoginCancel, request) as Promise, + captureElementPng: (request: AppCaptureElementPngRequest) => invoke(IPC_CHANNELS.appCaptureElementPng, request) as Promise, checkProviderConnectivity: (request: GatewayProviderConnectivityCheckRequest) => invoke(IPC_CHANNELS.appCheckProviderConnectivity, request) as Promise, closeBotGatewayQrWindow: (request: BotGatewayQrWindowCloseRequest) => invoke(IPC_CHANNELS.appBotGatewayQrWindowClose, request) as Promise, clearProxyNetworkCaptures: () => invoke(IPC_CHANNELS.appClearProxyNetworkCaptures) as Promise, @@ -108,6 +119,7 @@ contextBridge.exposeInMainWorld("ccr", { getProxyCertificateStatus: () => invoke(IPC_CHANNELS.appGetProxyCertificateStatus) as Promise, getProxyNetworkCaptures: () => invoke(IPC_CHANNELS.appGetProxyNetworkCaptures) as Promise, getProxyStatus: () => invoke(IPC_CHANNELS.appGetProxyStatus) as Promise, + getRequestLogDetail: (request: RequestLogDetailRequest) => invoke(IPC_CHANNELS.appGetRequestLogDetail, request) as Promise, getRequestLogs: (filter?: RequestLogListFilter) => invoke(IPC_CHANNELS.appGetRequestLogs, filter) as Promise, getUpdateStatus: () => invoke(IPC_CHANNELS.appGetUpdateStatus) as Promise, getUsageStats: (range?: UsageStatsRange, filter?: UsageStatsFilter) => invoke(IPC_CHANNELS.appGetUsageStats, range, filter) as Promise, @@ -118,10 +130,13 @@ contextBridge.exposeInMainWorld("ccr", { openBotGatewayQrWindow: (request: BotGatewayQrWindowOpenRequest) => invoke(IPC_CHANNELS.appBotGatewayQrWindowOpen, request) as Promise, openExternal: (url: string) => invoke(IPC_CHANNELS.appOpenExternal, url) as Promise, openProfile: (request: ProfileOpenRequest) => invoke(IPC_CHANNELS.appOpenProfile, request) as Promise, + prepareImageExportTarget: (request: AppImageExportTargetRequest) => invoke(IPC_CHANNELS.appPrepareImageExportTarget, request) as Promise, probeProviderCandidates: (request: GatewayProviderProbeCandidatesRequest) => invoke(IPC_CHANNELS.appProbeProviderCandidates, request) as Promise, probeProvider: (request: GatewayProviderProbeRequest) => invoke(IPC_CHANNELS.appProbeProvider, request) as Promise, quitApp: () => invoke(IPC_CHANNELS.appQuit) as Promise, revealProxyCertificate: () => invoke(IPC_CHANNELS.appRevealProxyCertificate) as Promise, + renderHtmlPng: (request: AppRenderHtmlPngRequest) => invoke(IPC_CHANNELS.appRenderHtmlPng, request) as Promise, + resetCodexRateLimitCredit: (request: ProviderAccountResetRequest) => invoke(IPC_CHANNELS.appResetCodexRateLimitCredit, request) as Promise, restartGateway: () => invoke(IPC_CHANNELS.appRestartGateway) as Promise, restartProxy: () => invoke(IPC_CHANNELS.appRestartProxy) as Promise, saveApiKeys: (apiKeys: ApiKeyConfig[]) => invoke(IPC_CHANNELS.appSaveApiKeys, apiKeys) as Promise, @@ -157,6 +172,11 @@ contextBridge.exposeInMainWorld("ccr", { ipcRenderer.on(IPC_CHANNELS.appOpenSettings, handler); return () => ipcRenderer.removeListener(IPC_CHANNELS.appOpenSettings, handler); }, + onOpenUpdateRequest: (callback: () => void) => { + const handler = () => callback(); + ipcRenderer.on(IPC_CHANNELS.appOpenUpdate, handler); + return () => ipcRenderer.removeListener(IPC_CHANNELS.appOpenUpdate, handler); + }, onUpdateStatusChanged: (callback: (status: AppUpdateStatus) => void) => { const handler = (_event: Electron.IpcRendererEvent, status: AppUpdateStatus) => callback(status); ipcRenderer.on(IPC_CHANNELS.appUpdateStatusChanged, handler); diff --git a/src/main/tray-controller.ts b/packages/electron/src/main/tray-controller.ts similarity index 94% rename from src/main/tray-controller.ts rename to packages/electron/src/main/tray-controller.ts index 0bcf23f..b3d287c 100644 --- a/src/main/tray-controller.ts +++ b/packages/electron/src/main/tray-controller.ts @@ -2,11 +2,12 @@ import { BrowserWindow, Menu, Tray, app, nativeImage, screen } from "electron"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { deflateSync } from "node:zlib"; -import { loadAppConfig } from "./config"; -import { APP_NAME } from "./constants"; -import { getProviderAccountSnapshots } from "./provider-account-service"; -import { getTodayUsageTotals, onUsageRecorded } from "./usage-store"; -import type { AppConfig, ProviderAccountMeter, TrayBalanceProgressConfig, TrayIconPreference } from "../shared/app"; +import { loadAppConfig } from "@ccr/core/config/config"; +import { APP_NAME } from "@ccr/core/config/constants"; +import { getProviderAccountSnapshots } from "@ccr/core/providers/account-service"; +import { getTodayUsageTotals, onUsageRecorded } from "@ccr/core/usage/store"; +import windowsManager from "./windows"; +import type { AppConfig, ProviderAccountMeter, TrayBalanceProgressConfig, TrayIconPreference } from "@ccr/core/contracts/app"; const popoverMenuWidth = 420; const popoverPreferredHeight = 740; @@ -14,6 +15,7 @@ const popoverDetailGap = 12; const popoverDetailTopOffset = 0; const popoverDetailWidth = 420; const popoverMargin = 8; +const trayActivationSuppressMs = 750; const trayMenuBarIconSize = 20; const trayWindowBackgroundColor = "#020617"; const trayTokenFallbackTitle = "0 tokens"; @@ -38,6 +40,7 @@ class TrayController { private randomTrayIconDateKey?: string; private resolvedRandomTrayIcon?: TrayMascotIconId; private refreshTimer?: NodeJS.Timeout; + private suppressMainWindowActivationUntil = 0; private tray?: Tray; private trayBalanceProgress?: TrayBalanceProgressConfig; private trayIconPreference: TrayIconPreference = "random"; @@ -52,8 +55,18 @@ class TrayController { const icon = createTrayIcon(this.resolveTrayIconId("random")); this.tray = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon); this.applyTrayTitle(trayTokenFallbackTitle); - this.tray.on("click", () => this.togglePopover()); - this.tray.on("right-click", () => this.showContextMenu()); + this.tray.on("click", () => { + this.suppressMainWindowActivation(); + this.togglePopover(); + }); + this.tray.on("double-click", () => { + this.suppressMainWindowActivation(); + this.showMainWindow(); + }); + this.tray.on("right-click", () => { + this.suppressMainWindowActivation(); + this.showContextMenu(); + }); void this.refreshIconFromConfig(); this.unsubscribeUsageUpdates = onUsageRecorded(() => { @@ -102,6 +115,14 @@ class TrayController { void this.refreshTrayTitle(); } + consumeMainWindowActivationSuppression(): boolean { + if (process.platform !== "darwin" || Date.now() > this.suppressMainWindowActivationUntil) { + return false; + } + this.suppressMainWindowActivationUntil = 0; + return true; + } + async refreshIconFromConfig(config?: AppConfig): Promise { if (!supportsTrayPlatform() || !this.tray) { return; @@ -140,6 +161,12 @@ class TrayController { this.showPopover(); } + private suppressMainWindowActivation(): void { + if (process.platform === "darwin") { + this.suppressMainWindowActivationUntil = Date.now() + trayActivationSuppressMs; + } + } + private showPopover(): void { const popover = this.ensurePopover(); this.clearDetailCloseTimer(); @@ -253,6 +280,10 @@ class TrayController { label: formatTokenTitle(this.trayTotalTokens) }, { type: "separator" }, + { + click: () => this.showMainWindow(), + label: `Open ${APP_NAME}` + }, { click: () => this.showPopover(), label: "Show Usage" @@ -267,6 +298,11 @@ class TrayController { this.tray?.popUpContextMenu(menu); } + private showMainWindow(): void { + this.hidePopover(); + windowsManager.showMainWindow(); + } + private showDetailPopover(provider?: string): void { if (!this.popover || this.popover.isDestroyed() || !this.popover.isVisible()) { return; diff --git a/src/main/update-service.ts b/packages/electron/src/main/update-service.ts similarity index 98% rename from src/main/update-service.ts rename to packages/electron/src/main/update-service.ts index 290ba6a..a507e60 100644 --- a/src/main/update-service.ts +++ b/packages/electron/src/main/update-service.ts @@ -1,8 +1,8 @@ import { app } from "electron"; import { autoUpdater, type ProgressInfo, type UpdateDownloadedEvent, type UpdateInfo } from "electron-updater"; -import { IPC_CHANNELS } from "./constants"; +import { IPC_CHANNELS } from "@ccr/core/config/constants"; import windowsManager from "./windows"; -import type { AppUpdateStatus } from "../shared/app"; +import type { AppUpdateStatus } from "@ccr/core/contracts/app"; type InstallPreparation = () => Promise; type UpdateCheckOptions = { diff --git a/src/main/windows.ts b/packages/electron/src/main/windows.ts similarity index 92% rename from src/main/windows.ts rename to packages/electron/src/main/windows.ts index e010725..81f7070 100644 --- a/src/main/windows.ts +++ b/packages/electron/src/main/windows.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, screen } from "electron"; import { existsSync } from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { APP_NAME, IPC_CHANNELS, ONBOARDING_FINISHED_FILE } from "./constants"; +import { APP_NAME, IPC_CHANNELS, ONBOARDING_FINISHED_FILE } from "@ccr/core/config/constants"; type WindowName = "main" | string; type WindowBounds = { height: number; width: number; x?: number; y?: number }; @@ -57,6 +57,13 @@ class WindowsManager { window.show(); } }); + window.on("close", (event) => { + if (!shouldHideMainWindowOnClose()) { + return; + } + event.preventDefault(); + window.hide(); + }); window.on("closed", () => this.windows.delete("main")); window.webContents.on("page-title-updated", (_event, title) => { window.setTitle(title || APP_NAME); @@ -115,10 +122,17 @@ const windowsManager = new WindowsManager(); export default windowsManager; +let appIsQuitting = false; + app.on("before-quit", () => { + appIsQuitting = true; windowsManager.broadcast(IPC_CHANNELS.appBeforeQuit); }); +function shouldHideMainWindowOnClose(): boolean { + return process.platform === "win32" && !appIsQuitting; +} + function fitWindowSize(preferred: number, minimum: number, available: number): number { return Math.max(minimum, Math.min(preferred, available > 0 ? available : preferred)); } diff --git a/packages/electron/tsconfig.json b/packages/electron/tsconfig.json new file mode 100644 index 0000000..1f70006 --- /dev/null +++ b/packages/electron/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src" + }, + "include": ["src/**/*.ts", "src/**/*.d.ts"] +} diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..3208c30 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,21 @@ +{ + "name": "@claude-code-router/ui", + "version": "3.0.10", + "private": true, + "description": "Claude Code Router web management UI.", + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "baseui": "^16.1.1", + "clsx": "^2.1.1", + "lucide-react": "^1.17.0", + "motion": "^12.40.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "recharts": "^3.8.1", + "styletron-engine-atomic": "^1.6.2", + "styletron-react": "^6.1.1", + "tailwind-merge": "^3.6.0" + } +} diff --git a/src/renderer/assets/agent-logos/claude-code.png b/packages/ui/src/assets/agent-logos/claude-code.png similarity index 100% rename from src/renderer/assets/agent-logos/claude-code.png rename to packages/ui/src/assets/agent-logos/claude-code.png diff --git a/src/renderer/assets/agent-logos/codex.png b/packages/ui/src/assets/agent-logos/codex.png similarity index 100% rename from src/renderer/assets/agent-logos/codex.png rename to packages/ui/src/assets/agent-logos/codex.png diff --git a/src/renderer/assets/agent-logos/zcode.png b/packages/ui/src/assets/agent-logos/zcode.png similarity index 100% rename from src/renderer/assets/agent-logos/zcode.png rename to packages/ui/src/assets/agent-logos/zcode.png diff --git a/packages/ui/src/assets/logo.png b/packages/ui/src/assets/logo.png new file mode 100644 index 0000000..76801d1 Binary files /dev/null and b/packages/ui/src/assets/logo.png differ diff --git a/src/renderer/assets/onboarding/mascot-transition.svg b/packages/ui/src/assets/onboarding/mascot-transition.svg similarity index 100% rename from src/renderer/assets/onboarding/mascot-transition.svg rename to packages/ui/src/assets/onboarding/mascot-transition.svg diff --git a/packages/ui/src/assets/provider-icons/anthropic.png b/packages/ui/src/assets/provider-icons/anthropic.png new file mode 100644 index 0000000..c884fe1 Binary files /dev/null and b/packages/ui/src/assets/provider-icons/anthropic.png differ diff --git a/packages/ui/src/assets/provider-icons/bailian.ico b/packages/ui/src/assets/provider-icons/bailian.ico new file mode 100644 index 0000000..f525bfa Binary files /dev/null and b/packages/ui/src/assets/provider-icons/bailian.ico differ diff --git a/packages/ui/src/assets/provider-icons/claudeapi.png b/packages/ui/src/assets/provider-icons/claudeapi.png new file mode 100644 index 0000000..776ced8 Binary files /dev/null and b/packages/ui/src/assets/provider-icons/claudeapi.png differ diff --git a/packages/ui/src/assets/provider-icons/code0.png b/packages/ui/src/assets/provider-icons/code0.png new file mode 100644 index 0000000..32d81c1 Binary files /dev/null and b/packages/ui/src/assets/provider-icons/code0.png differ diff --git a/packages/ui/src/assets/provider-icons/deepseek.ico b/packages/ui/src/assets/provider-icons/deepseek.ico new file mode 100644 index 0000000..8e78c0b Binary files /dev/null and b/packages/ui/src/assets/provider-icons/deepseek.ico differ diff --git a/packages/ui/src/assets/provider-icons/gemini.svg b/packages/ui/src/assets/provider-icons/gemini.svg new file mode 100644 index 0000000..38f5625 --- /dev/null +++ b/packages/ui/src/assets/provider-icons/gemini.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/assets/provider-icons/mistral.webp b/packages/ui/src/assets/provider-icons/mistral.webp new file mode 100644 index 0000000..74e3817 Binary files /dev/null and b/packages/ui/src/assets/provider-icons/mistral.webp differ diff --git a/packages/ui/src/assets/provider-icons/moonshot.ico b/packages/ui/src/assets/provider-icons/moonshot.ico new file mode 100644 index 0000000..9b4870b Binary files /dev/null and b/packages/ui/src/assets/provider-icons/moonshot.ico differ diff --git a/packages/ui/src/assets/provider-icons/openai.png b/packages/ui/src/assets/provider-icons/openai.png new file mode 100644 index 0000000..16896d2 Binary files /dev/null and b/packages/ui/src/assets/provider-icons/openai.png differ diff --git a/packages/ui/src/assets/provider-icons/openrouter.ico b/packages/ui/src/assets/provider-icons/openrouter.ico new file mode 100644 index 0000000..5d47b23 Binary files /dev/null and b/packages/ui/src/assets/provider-icons/openrouter.ico differ diff --git a/packages/ui/src/assets/provider-icons/runapi.jpg b/packages/ui/src/assets/provider-icons/runapi.jpg new file mode 100644 index 0000000..cbd5e94 Binary files /dev/null and b/packages/ui/src/assets/provider-icons/runapi.jpg differ diff --git a/packages/ui/src/assets/provider-icons/siliconflow.png b/packages/ui/src/assets/provider-icons/siliconflow.png new file mode 100644 index 0000000..a4fd985 Binary files /dev/null and b/packages/ui/src/assets/provider-icons/siliconflow.png differ diff --git a/packages/ui/src/assets/provider-icons/teamorouter.png b/packages/ui/src/assets/provider-icons/teamorouter.png new file mode 100644 index 0000000..55f0c06 Binary files /dev/null and b/packages/ui/src/assets/provider-icons/teamorouter.png differ diff --git a/packages/ui/src/assets/provider-icons/zai-global-coding.svg b/packages/ui/src/assets/provider-icons/zai-global-coding.svg new file mode 100644 index 0000000..4f511bd --- /dev/null +++ b/packages/ui/src/assets/provider-icons/zai-global-coding.svg @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui/src/assets/provider-icons/zai-global-general.svg b/packages/ui/src/assets/provider-icons/zai-global-general.svg new file mode 100644 index 0000000..4f511bd --- /dev/null +++ b/packages/ui/src/assets/provider-icons/zai-global-general.svg @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ui/src/assets/provider-icons/zhipu-cn-coding.png b/packages/ui/src/assets/provider-icons/zhipu-cn-coding.png new file mode 100644 index 0000000..0cc7d36 Binary files /dev/null and b/packages/ui/src/assets/provider-icons/zhipu-cn-coding.png differ diff --git a/packages/ui/src/assets/provider-icons/zhipu-cn-general.png b/packages/ui/src/assets/provider-icons/zhipu-cn-general.png new file mode 100644 index 0000000..0cc7d36 Binary files /dev/null and b/packages/ui/src/assets/provider-icons/zhipu-cn-general.png differ diff --git a/packages/ui/src/assets/tray-cyan.png b/packages/ui/src/assets/tray-cyan.png new file mode 100644 index 0000000..b259c8a Binary files /dev/null and b/packages/ui/src/assets/tray-cyan.png differ diff --git a/packages/ui/src/assets/tray-orange.png b/packages/ui/src/assets/tray-orange.png new file mode 100644 index 0000000..8b7b544 Binary files /dev/null and b/packages/ui/src/assets/tray-orange.png differ diff --git a/packages/ui/src/assets/tray-violet.png b/packages/ui/src/assets/tray-violet.png new file mode 100644 index 0000000..65e5d81 Binary files /dev/null and b/packages/ui/src/assets/tray-violet.png differ diff --git a/src/renderer/components/ui/badge.tsx b/packages/ui/src/components/ui/badge.tsx similarity index 100% rename from src/renderer/components/ui/badge.tsx rename to packages/ui/src/components/ui/badge.tsx diff --git a/src/renderer/components/ui/button.tsx b/packages/ui/src/components/ui/button.tsx similarity index 100% rename from src/renderer/components/ui/button.tsx rename to packages/ui/src/components/ui/button.tsx diff --git a/src/renderer/components/ui/card.tsx b/packages/ui/src/components/ui/card.tsx similarity index 100% rename from src/renderer/components/ui/card.tsx rename to packages/ui/src/components/ui/card.tsx diff --git a/src/renderer/components/ui/checkbox.tsx b/packages/ui/src/components/ui/checkbox.tsx similarity index 86% rename from src/renderer/components/ui/checkbox.tsx rename to packages/ui/src/components/ui/checkbox.tsx index b0bc256..008e744 100644 --- a/src/renderer/components/ui/checkbox.tsx +++ b/packages/ui/src/components/ui/checkbox.tsx @@ -22,7 +22,7 @@ const Checkbox = React.forwardRef( type="checkbox" {...props} /> - + ) ); diff --git a/src/renderer/components/ui/dialog.tsx b/packages/ui/src/components/ui/dialog.tsx similarity index 97% rename from src/renderer/components/ui/dialog.tsx rename to packages/ui/src/components/ui/dialog.tsx index 3f28c94..1d971dd 100644 --- a/src/renderer/components/ui/dialog.tsx +++ b/packages/ui/src/components/ui/dialog.tsx @@ -46,7 +46,7 @@ function Dialog({ return ( { diff --git a/src/renderer/components/ui/input.tsx b/packages/ui/src/components/ui/input.tsx similarity index 100% rename from src/renderer/components/ui/input.tsx rename to packages/ui/src/components/ui/input.tsx diff --git a/src/renderer/components/ui/label.tsx b/packages/ui/src/components/ui/label.tsx similarity index 100% rename from src/renderer/components/ui/label.tsx rename to packages/ui/src/components/ui/label.tsx diff --git a/src/renderer/components/ui/popover.tsx b/packages/ui/src/components/ui/popover.tsx similarity index 100% rename from src/renderer/components/ui/popover.tsx rename to packages/ui/src/components/ui/popover.tsx diff --git a/src/renderer/components/ui/select.tsx b/packages/ui/src/components/ui/select.tsx similarity index 100% rename from src/renderer/components/ui/select.tsx rename to packages/ui/src/components/ui/select.tsx diff --git a/src/renderer/components/ui/switch.tsx b/packages/ui/src/components/ui/switch.tsx similarity index 94% rename from src/renderer/components/ui/switch.tsx rename to packages/ui/src/components/ui/switch.tsx index 5440546..616f640 100644 --- a/src/renderer/components/ui/switch.tsx +++ b/packages/ui/src/components/ui/switch.tsx @@ -6,8 +6,8 @@ export interface SwitchProps extends Omit( - ({ checked = false, className, disabled, onChange, onCheckedChange, ...props }, ref) => ( - + ({ checked = false, className, disabled, onChange, onCheckedChange, title, ...props }, ref) => ( + ( }} ref={ref} role="switch" + title={title} type="checkbox" {...props} /> diff --git a/src/renderer/components/ui/textarea.tsx b/packages/ui/src/components/ui/textarea.tsx similarity index 100% rename from src/renderer/components/ui/textarea.tsx rename to packages/ui/src/components/ui/textarea.tsx diff --git a/src/renderer/lib/baseui-provider.tsx b/packages/ui/src/lib/baseui-provider.tsx similarity index 100% rename from src/renderer/lib/baseui-provider.tsx rename to packages/ui/src/lib/baseui-provider.tsx diff --git a/src/renderer/lib/usage-activity.ts b/packages/ui/src/lib/usage-activity.ts similarity index 99% rename from src/renderer/lib/usage-activity.ts rename to packages/ui/src/lib/usage-activity.ts index 4af96aa..bbaae29 100644 --- a/src/renderer/lib/usage-activity.ts +++ b/packages/ui/src/lib/usage-activity.ts @@ -1,4 +1,4 @@ -import type { UsageSeriesPoint } from "../../shared/app"; +import type { UsageSeriesPoint } from "@ccr/core/contracts/app"; export type TokenActivityCell = { date: Date; diff --git a/src/renderer/lib/utils.ts b/packages/ui/src/lib/utils.ts similarity index 100% rename from src/renderer/lib/utils.ts rename to packages/ui/src/lib/utils.ts diff --git a/src/renderer/pages/browser/index.html b/packages/ui/src/pages/browser/index.html similarity index 100% rename from src/renderer/pages/browser/index.html rename to packages/ui/src/pages/browser/index.html diff --git a/src/renderer/pages/browser/main.tsx b/packages/ui/src/pages/browser/main.tsx similarity index 62% rename from src/renderer/pages/browser/main.tsx rename to packages/ui/src/pages/browser/main.tsx index bcb44d6..11b5e90 100644 --- a/src/renderer/pages/browser/main.tsx +++ b/packages/ui/src/pages/browser/main.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState, type FormEvent } from "react"; import { createRoot } from "react-dom/client"; -import { ArrowLeft, ArrowRight, Globe2, LoaderCircle, Plus, RotateCw, Search, X } from "lucide-react"; -import type { BuiltInBrowserState } from "../../../shared/app"; +import { ArrowLeft, ArrowRight, Check, KeyRound, LoaderCircle, Plus, RotateCw, Search, UserRound, X } from "lucide-react"; +import type { BuiltInBrowserState, ChromeLoginImportJob, ChromeLoginImportRequest } from "@ccr/core/contracts/app"; declare global { interface Window { @@ -9,11 +9,14 @@ declare global { back: (tabId?: string) => Promise; closeTab: (tabId: string) => Promise; forward: (tabId?: string) => Promise; + getChromeLoginImport: (id: string) => Promise; getState: () => Promise; navigate: (url: string, tabId?: string) => Promise; newTab: (url?: string) => Promise; reload: (tabId?: string) => Promise; + resolveAutomationHandoff: (status: "completed" | "dismissed") => Promise; selectTab: (tabId: string) => Promise; + startChromeLoginImport: (request: ChromeLoginImportRequest) => Promise; onStateChanged: (callback: (state: BuiltInBrowserState) => void) => () => void; }; } @@ -29,10 +32,12 @@ function BrowserChrome() { const [state, setState] = useState(emptyState); const [addressDraft, setAddressDraft] = useState(""); const [homeDraft, setHomeDraft] = useState(""); + const [chromeImportJob, setChromeImportJob] = useState(); const activeTab = useMemo( () => state.tabs.find((tab) => tab.id === state.activeTabId), [state.activeTabId, state.tabs] ); + const handoff = state.automationHandoff; const homeVisible = activeTab?.url === browserHomeUrl; useEffect(() => { @@ -57,6 +62,25 @@ function BrowserChrome() { setHomeDraft(""); }, [activeTab?.id]); + useEffect(() => { + if (!chromeImportJob || chromeImportJob.status !== "pending") { + return; + } + const interval = window.setInterval(() => { + void window.ccrBrowser?.getChromeLoginImport(chromeImportJob.id).then((job) => { + if (!job) { + setChromeImportJob(undefined); + return; + } + setChromeImportJob(job); + if (job.status === "completed" && activeTab?.id && activeTabMatchesImport(activeTab.url, job.domains)) { + void run(window.ccrBrowser?.reload(activeTab.id)); + } + }); + }, 2000); + return () => window.clearInterval(interval); + }, [activeTab?.id, activeTab?.url, chromeImportJob]); + async function run(action: Promise | undefined): Promise { if (!action) { return; @@ -79,8 +103,53 @@ function BrowserChrome() { void run(window.ccrBrowser?.navigate(url, activeTab?.id)); } + async function startChromeLoginImport() { + const defaultDomain = activeTabDomain(activeTab?.url); + const rawDomains = window.prompt("Chrome domain(s) to import. Separate multiple domains with commas.", defaultDomain); + if (!rawDomains) { + return; + } + const domains = parseImportDomains(rawDomains); + if (domains.length === 0) { + return; + } + const job = await window.ccrBrowser?.startChromeLoginImport({ domains, openConfirmationPage: true }); + if (!job) { + return; + } + setChromeImportJob(job); + } + + function chromeImportTitle(): string { + if (!chromeImportJob) { + return "Import login from Chrome"; + } + if (chromeImportJob.status === "completed") { + return `Chrome import complete: ${chromeImportJob.result?.imported ?? 0} imported, ${chromeImportJob.result?.skipped ?? 0} skipped`; + } + if (chromeImportJob.status === "expired") { + return "Chrome import expired"; + } + if (chromeImportJob.status === "failed") { + return "Chrome import failed"; + } + return "Chrome import pending. Confirm it in the browser window."; + } + + async function handleChromeImportButton() { + if (chromeImportJob?.status === "pending") { + try { + await navigator.clipboard.writeText(chromeImportJob.confirmUrl); + } catch { + window.prompt("Open this Chrome import confirmation URL.", chromeImportJob.confirmUrl); + } + return; + } + await startChromeLoginImport(); + } + return ( -
      +
      @@ -149,8 +218,45 @@ function BrowserChrome() { spellCheck={false} value={addressDraft} /> + + {handoff ? ( +
      +
      + + {handoff.message} + {handoff.reason ? {handoff.reason} : null} +
      +
      + + +
      +
      + ) : null} + {homeVisible ? (
      @@ -189,6 +295,42 @@ function BrowserChrome() { const root = createRoot(document.getElementById("root") as HTMLElement); root.render(); +function activeTabDomain(url: string | undefined): string { + if (!url || url === browserHomeUrl) { + return ""; + } + try { + return new URL(url).hostname; + } catch { + return ""; + } +} + +function activeTabMatchesImport(url: string | undefined, domains: string[]): boolean { + const host = activeTabDomain(url).toLowerCase(); + return Boolean(host && domains.some((domain) => host === domain || host.endsWith(`.${domain}`))); +} + +function parseImportDomains(value: string): string[] { + return [...new Set(value + .split(/[,\n]/) + .map((item) => normalizeImportDomain(item)) + .filter((item): item is string => Boolean(item)))]; +} + +function normalizeImportDomain(value: string): string | undefined { + const raw = value.trim().toLowerCase(); + if (!raw) { + return undefined; + } + try { + return new URL(raw.includes("://") ? raw : `https://${raw}`).hostname; + } catch { + const domain = raw.replace(/^\*\./, "").split("/")[0]; + return domain && !domain.includes(" ") ? domain : undefined; + } +} + const style = document.createElement("style"); style.textContent = ` :root { @@ -231,6 +373,10 @@ style.textContent = ` width: 100%; } + .browser-shell.has-handoff { + grid-template-rows: 38px 44px 44px minmax(0, 1fr); + } + .tabs-row { -webkit-app-region: drag; align-items: end; @@ -325,7 +471,7 @@ style.textContent = ` border-bottom: 1px solid color-mix(in srgb, CanvasText 12%, transparent); display: grid; gap: 4px; - grid-template-columns: 32px 32px 32px minmax(0, 1fr); + grid-template-columns: 32px 32px 32px minmax(0, 1fr) 32px; padding: 6px 10px; } @@ -339,6 +485,11 @@ style.textContent = ` opacity: 0.4; } + .icon-button.active-import { + background: color-mix(in srgb, #0f766e 16%, transparent); + color: color-mix(in srgb, #0f766e 82%, CanvasText); + } + input { background: color-mix(in srgb, CanvasText 4%, Canvas); border: 1px solid color-mix(in srgb, CanvasText 12%, transparent); @@ -355,6 +506,79 @@ style.textContent = ` border-color: color-mix(in srgb, #2563eb 70%, CanvasText 30%); } + .automation-handoff { + -webkit-app-region: drag; + align-items: center; + background: color-mix(in srgb, #f59e0b 16%, Canvas); + border-bottom: 1px solid color-mix(in srgb, #92400e 24%, transparent); + display: grid; + gap: 10px; + grid-template-columns: minmax(0, 1fr) auto; + min-width: 0; + padding: 6px 10px; + } + + .handoff-copy { + align-items: center; + color: color-mix(in srgb, #78350f 76%, CanvasText); + display: flex; + gap: 8px; + min-width: 0; + } + + .handoff-message, + .handoff-reason { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .handoff-message { + font-size: 13px; + font-weight: 700; + } + + .handoff-reason { + color: color-mix(in srgb, CanvasText 58%, transparent); + font-size: 12px; + } + + .handoff-actions { + -webkit-app-region: no-drag; + align-items: center; + display: flex; + flex: 0 0 auto; + gap: 6px; + } + + .handoff-button { + align-items: center; + background: color-mix(in srgb, CanvasText 5%, Canvas); + border: 1px solid color-mix(in srgb, CanvasText 12%, transparent); + border-radius: 7px; + display: inline-flex; + gap: 5px; + height: 30px; + justify-content: center; + min-width: 0; + padding: 0 9px; + white-space: nowrap; + } + + .handoff-button:hover { + background: color-mix(in srgb, CanvasText 9%, Canvas); + } + + .handoff-button.primary { + background: #166534; + border-color: #166534; + color: white; + } + + .handoff-button.primary:hover { + background: #14532d; + } + .home-page { align-items: flex-start; background: @@ -487,6 +711,24 @@ style.textContent = ` .installed-apps { grid-template-columns: 1fr; } + + .automation-handoff { + gap: 6px; + grid-template-columns: minmax(0, 1fr) auto; + } + + .handoff-reason { + display: none; + } + + .handoff-button span { + display: none; + } + + .handoff-button { + padding: 0 8px; + width: 32px; + } } @keyframes spin { diff --git a/src/renderer/pages/home/App.tsx b/packages/ui/src/pages/home/App.tsx similarity index 88% rename from src/renderer/pages/home/App.tsx rename to packages/ui/src/pages/home/App.tsx index e58dfd1..18e8160 100644 --- a/src/renderer/pages/home/App.tsx +++ b/packages/ui/src/pages/home/App.tsx @@ -1,43 +1,43 @@ import { AddApiKeyDraft, AddProfileDraft, AddProviderDraft, AddRoutingRuleDraft, AgentAnalysisSessionSelection, AgentAnalysisSnapshot, AgentFilterValue, - ApiKeyConfig, AppConfig, appCopy, AppI18nContext, AppInfo, AppSaveConfigOptions, + ApiKeyConfig, AppConfig, appCopy, AppI18nContext, AppInfo, AppSaveConfigOptions, AppUpdateStatus, AppLanguagePreference, applyProviderProbeResult, AppToast, BotGatewaySavedConfig, buildExtensionList, claudeDesignRoutingConfigFromDraft, buildRouterConditionPath, ClaudeDesignRoutingDraft, ClaudeDesignRoutingRuleDraft, cloneConfig, createApiKeyDraft, createApiKeyEditDraft, createApiKeyList, createClaudeDesignRoutingDraft, createClaudeDesignRoutingRuleDraft, createCursorProxyRoutingDraft, createCursorProxyRoutingRuleDraft, createEmptyAgentAnalysis, copyTextToClipboard, createEmptyRequestLogPage, createEmptyUsageStats, createExtensionInstallDraft, createGeneratedApiKey, createPluginSettingsDraft, createProfileDraft, - createProfileDraftFromProfile, createProviderConfigFromDeepLink, createProviderDraft, createProviderDraftFromProvider, createRoutingRuleDraft, createRoutingRuleDraftFromRule, + createProfileDraftFromProfile, createProviderDraft, createProviderDraftFromDeepLinkPayload, createProviderDraftFromProvider, createRoutingRuleDraft, createRoutingRuleDraftFromRule, createVirtualModelDraft, createVirtualModelDraftFromProfile, customProviderPresetId, DEFAULT_TRAY_WIDGETS, detectSystemLanguage, detectSystemTheme, enforceSingleEnabledGlobalProfilePerAgent, ExtensionConfigTarget, ExtensionDeleteTarget, ExtensionInstallDraft, ExtensionSource, fallbackAgentAnalysis, fallbackConfig, fallbackGatewayStatus, fallbackInfo, fallbackProxyCertificateStatus, fallbackProxyNetworkSnapshot, fallbackProxyStatus, fallbackRequestLogPage, - fallbackUsageStats, formatAppError, formatJson, formatProxyCertificateInstallMessage, GatewayProviderConfig, + fallbackUpdateStatus, fallbackUsageStats, formatAppError, formatProxyCertificateInstallMessage, GatewayProviderConfig, fusionCustomMcpServerFromDraft, fusionCustomToolConfigFromProfile, GatewayProviderProbeResult, gatewayServiceMessage, GatewayStatus, getDefaultOnboardingStep, isClaudeDesignPluginConfig, isClaudeDesignRoutingDraftValid, isCursorProxyPluginConfig, isMacPlatform, isPlainRecord, isProfileDraftSubmittable, isProviderNameDuplicate, isProviderProbeCandidateReady, isTraySupportedPlatform, isRoutingRewriteDraftRowValid, - LayoutGroup, mergeProviderCapabilities, mergeProviderModelLists, + LayoutGroup, mergeModelDisplayNames, mergeProviderModelLists, modelDescriptionsForModels, modelDisplayNamesForModels, navigation, NavigationId, normalizeApiKeys, normalizeBotGatewaySavedConfigs, normalizeConfig, normalizeLanguagePreference, normalizeObservabilityConfig, normalizeOverviewWidgets, - normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference, + normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterBuiltInRules, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeToolHubConfig, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference, normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder, OverviewWidgetConfig, parsePluginAppsSettingsText, parsePluginConfigSettingsText, parseProviderAccountDraft, providerCredentialsFromDraft, persistLanguagePreference, PluginMarketplaceEntry, PluginRoutingConfigTarget, pluginSettingsConfigFromDraft, PluginSettingsDraft, presetCapabilitiesFromDraft, probeProviderCandidates, probeProviderDeepLinkPayload, profileAgentLabel, profileEnvRowsForAgent, ProfileConfig, ProfileOpenSurface, ProfileRuntimeStatus, profileConfigFromDraft, providerAccountApiKeySafetyIssue, - providerDeepLinkDisplayIcon, - profileOpenCommandFallback, profileOpenSurfaces, ProviderAccountSnapshot, providerApiKeySafetyIssue, ProviderConnectivityCheckReport, ProviderDeepLinkRequest, providerIdentitySafetyIssue, providerProbeCandidates, - providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, providerSelectableProtocolsFromProbe, ProxyCertificateStatus, ProxyNetworkSnapshot, proxyRestartMessage, + profileOpenCommandFallback, profileOpenSurfaces, ProviderAccountSnapshot, providerApiKeySafetyIssue, ProviderConnectivityCheckReport, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerIdentitySafetyIssue, providerProbeCandidates, + providerCapabilitiesForProtocols, providerGlobalBaseUrlForProbe, providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, providerSelectableProtocolsFromProbe, ProxyCertificateStatus, ProxyNetworkSnapshot, proxyRestartMessage, ProxyStatus, readLanguagePreference, RequestLogListFilter, RequestLogPage, ResolvedLanguage, - ResolvedTheme, resolvePluginInstallPlan, resolveProviderDeepLinkCatalogModels, resolveProviderDeepLinkIcon, RouterRule, ServerActionBusy, SettingsPageId, + ResolvedTheme, resolvePluginInstallPlan, resolveProviderDeepLinkCatalogModels, RouterRule, ServerActionBusy, SettingsPageId, routingRewriteFromDraftRow, setProviderPresets, splitLines, translateAppErrorMessage, translateProxyCertificateMessage, translateText, TrayBalanceProgressConfig, TrayWidgetConfig, uniqueRoutingRuleId, updateApiKeyEditableConfig, UsageStatsFilter, UsageStatsRange, UsageStatsSnapshot, useEffect, useMemo, useReducedMotion, useRef, useState, validateVirtualModelDraft, ViewId, VirtualModelDraft, virtualModelProfileFromDraft -} from "./shared"; +} from "./shared/index"; +import { startVisiblePolling } from "./shared/polling"; import { AppDialogStack, LightToast, MainLayout, OnboardingLayout -} from "./components"; +} from "./components/index"; type ProfileOpenDialogState = { busy?: "" | "cli" | "app"; @@ -60,12 +60,17 @@ const localAgentProviderApiKey = "ccr-local-agent-login"; function materializeProviderPluginTemplates( templates: unknown[], providerName: string, - protocol: GatewayProviderConfig["type"] + protocol: GatewayProviderConfig["type"], + providerId: string ): unknown[] { if (templates.length === 0) { return []; } - const internalName = protocol ? `${providerName}::${protocol}` : providerName; + // The core gateway matches provider plugins against the provider's runtime + // identifier (provider.id, or its slug), not the human-readable display name + // — the internal name here must mirror providerCapabilityInternalName() in + // gateway/service.ts or the plugin's auth-header override silently never applies. + const internalName = protocol ? `${providerId}::${protocol}` : providerId; const replacements: Record = { [providerInternalNamePlaceholder]: internalName, [providerNamePlaceholder]: providerName, @@ -148,6 +153,13 @@ function providerNameSlug(value: string): string { .replace(/^-+|-+$/g, "") || "provider"; } +async function loadProviderAccountSnapshots(forceRefresh = false): Promise { + if (!window.ccr) { + return []; + } + return window.ccr.getProviderAccountSnapshots(undefined, forceRefresh ? { forceRefresh: true } : undefined); +} + function extensionActionIndexes(index: number, groupIndexes?: number[]): number[] { const indexes = groupIndexes?.length ? groupIndexes : [index]; return [...new Set(indexes.filter((item) => Number.isInteger(item) && item >= 0))]; @@ -168,6 +180,10 @@ function App() { const [proxyNetworkSnapshot, setProxyNetworkSnapshot] = useState(fallbackProxyNetworkSnapshot); const [proxyStatus, setProxyStatus] = useState(fallbackProxyStatus); const [actionBusy, setActionBusy] = useState(""); + const [updateActionBusy, setUpdateActionBusy] = useState<"" | "check" | "download" | "install">(""); + const [updateActionError, setUpdateActionError] = useState(""); + const [updateDialogOpen, setUpdateDialogOpen] = useState(false); + const [updateDialogStatus, setUpdateDialogStatus] = useState(fallbackUpdateStatus); const [gatewayActionBusy, setGatewayActionBusy] = useState(false); const [actionMessage, setActionMessage] = useState(""); const [actionError, setActionError] = useState(""); @@ -195,11 +211,11 @@ function App() { const [providerProbeLoading, setProviderProbeLoading] = useState(false); const [providerConnectivityProbe, setProviderConnectivityProbe] = useState(); const [providerConnectivityLoading, setProviderConnectivityLoading] = useState(false); + const [providerImportOpen, setProviderImportOpen] = useState(false); + const [providerImportPayload, setProviderImportPayload] = useState(); const [providerDeepLinkRequest, setProviderDeepLinkRequest] = useState(); const [providerDeepLinkBusy, setProviderDeepLinkBusy] = useState(false); const [providerDeepLinkError, setProviderDeepLinkError] = useState(""); - const [providerDeepLinkIconLoading, setProviderDeepLinkIconLoading] = useState(false); - const [providerDeepLinkModelsLoading, setProviderDeepLinkModelsLoading] = useState(false); const [proxyCertificateChecking, setProxyCertificateChecking] = useState(false); const [proxyEnablePending, setProxyEnablePending] = useState(false); const [providerProbeError, setProviderProbeError] = useState(""); @@ -249,6 +265,8 @@ function App() { const [usageRange, setUsageRange] = useState("7d"); const [usageStats, setUsageStats] = useState(fallbackUsageStats); const [providerAccountSnapshots, setProviderAccountSnapshots] = useState([]); + const [providerAccountRefreshing, setProviderAccountRefreshing] = useState(false); + const updateActionBusyRef = useRef(false); const resolvedLanguage = languagePreference === "system" ? systemLanguage : languagePreference; const copy = appCopy[resolvedLanguage]; const t = useMemo(() => (value: string) => translateText(copy, value), [copy]); @@ -318,19 +336,50 @@ function App() { void window.ccr.getPluginMarketplace().then(setPluginMarketplace).catch(() => setPluginMarketplace([])); void window.ccr.getProxyCertificateStatus().then(setProxyCertificateStatus); const unsubscribeOpenSettings = window.ccr.onOpenSettingsRequest(openSettingsDialog); + const unsubscribeOpenUpdate = window.ccr.onOpenUpdateRequest(openUpdateDialog); const refreshRuntimeStatus = () => { void window.ccr?.getGatewayStatus().then(setGatewayStatus); void window.ccr?.getProxyStatus().then(setProxyStatus); void refreshProfileRuntimeStatus(); }; - refreshRuntimeStatus(); - const timer = window.setInterval(refreshRuntimeStatus, 2000); + const stopPolling = startVisiblePolling(refreshRuntimeStatus, 2000); return () => { - window.clearInterval(timer); + stopPolling(); unsubscribeOpenSettings(); + unsubscribeOpenUpdate(); }; }, []); + useEffect(() => { + if (!window.ccr) { + return; + } + + let disposed = false; + void window.ccr.getUpdateStatus() + .then((status) => { + if (!disposed) { + setUpdateDialogStatus(status); + } + }) + .catch(() => { + if (!disposed) { + setUpdateDialogStatus(fallbackUpdateStatus); + } + }); + + const unsubscribe = window.ccr.onUpdateStatusChanged((status) => { + if (!disposed) { + setUpdateDialogStatus(status); + } + }); + + return () => { + disposed = true; + unsubscribe(); + }; + }, [appInfo.version]); + useEffect(() => { if (!window.ccr) { return; @@ -340,6 +389,8 @@ function App() { providerProbeRequestId.current += 1; providerConnectivityRequestId.current += 1; setProviderAddOpen(false); + setProviderImportOpen(false); + setProviderImportPayload(undefined); setProviderEditIndex(undefined); setProviderProbe(undefined); setProviderConnectivityProbe(undefined); @@ -349,7 +400,6 @@ function App() { setProviderDeepLinkRequest(request); setProviderDeepLinkError(""); setProviderDeepLinkBusy(false); - setProviderDeepLinkModelsLoading(false); setActiveView("providers"); }; @@ -368,89 +418,12 @@ function App() { }, []); useEffect(() => { - const request = providerDeepLinkRequest; - const payload = request?.provider; - providerDeepLinkIconRequestId.current += 1; - const requestId = providerDeepLinkIconRequestId.current; - - if (!request || !payload || !providerPresetsLoaded || providerDeepLinkDisplayIcon(payload)) { - setProviderDeepLinkIconLoading(false); + const payload = providerDeepLinkRequest?.provider; + if (!payload || !configLoaded || !providerPresetsLoaded) { return; } - - setProviderDeepLinkIconLoading(true); - void resolveProviderDeepLinkIcon(payload) - .then((resolution) => { - if (providerDeepLinkIconRequestId.current !== requestId || !resolution.persistentIcon) { - return; - } - setProviderDeepLinkRequest((current) => { - if (!current?.provider || current.id !== request.id) { - return current; - } - if (current.provider.icon?.trim()) { - return current; - } - return { - ...current, - provider: { - ...current.provider, - icon: resolution.persistentIcon - } - }; - }); - }) - .finally(() => { - if (providerDeepLinkIconRequestId.current === requestId) { - setProviderDeepLinkIconLoading(false); - } - }); - }, [providerDeepLinkRequest?.id, providerDeepLinkRequest?.provider?.baseUrl, providerDeepLinkRequest?.provider?.icon, providerPresetsLoaded]); - - useEffect(() => { - const request = providerDeepLinkRequest; - const payload = request?.provider; - providerDeepLinkCatalogModelsRequestId.current += 1; - const requestId = providerDeepLinkCatalogModelsRequestId.current; - const hasApiKey = Boolean(payload?.apiKey?.trim()); - - if (!request || !payload || (!hasApiKey && payload.models.length > 0) || !providerPresetsLoaded) { - setProviderDeepLinkModelsLoading(false); - return; - } - - const modelsPromise = hasApiKey - ? probeProviderDeepLinkPayload(payload).then((probe) => mergeProviderModelLists(probe?.models ?? [])) - : resolveProviderDeepLinkCatalogModels(payload); - - setProviderDeepLinkModelsLoading(true); - void modelsPromise - .then((models) => { - if (providerDeepLinkCatalogModelsRequestId.current !== requestId || models.length === 0) { - return; - } - setProviderDeepLinkRequest((current) => { - if (!current?.provider || current.id !== request.id || (!hasApiKey && current.provider.models.length > 0)) { - return current; - } - return { - ...current, - provider: { - ...current.provider, - models - } - }; - }); - }) - .catch(() => { - // Model discovery is optional; importing performs the same resolution again. - }) - .finally(() => { - if (providerDeepLinkCatalogModelsRequestId.current === requestId) { - setProviderDeepLinkModelsLoading(false); - } - }); - }, [providerDeepLinkRequest?.id, providerDeepLinkRequest?.provider?.apiKey, providerDeepLinkRequest?.provider?.baseUrl, providerDeepLinkRequest?.provider?.name, providerPresetsLoaded]); + void openImportProviderDialog(payload); + }, [configLoaded, providerDeepLinkRequest?.id, providerDeepLinkRequest?.provider, providerPresetsLoaded]); useEffect(() => { if (!window.ccr) { @@ -467,11 +440,10 @@ function App() { } }); }; - refreshUsageStats(); - const timer = window.setInterval(refreshUsageStats, 5000); + const stopPolling = startVisiblePolling(refreshUsageStats, 5000); return () => { cancelled = true; - window.clearInterval(timer); + stopPolling(); }; }, [usageRange]); @@ -483,7 +455,7 @@ function App() { let cancelled = false; const refreshProviderAccounts = () => { - void window.ccr?.getProviderAccountSnapshots() + void loadProviderAccountSnapshots() .then((snapshots) => { if (!cancelled) { setProviderAccountSnapshots(snapshots); @@ -495,14 +467,27 @@ function App() { } }); }; - refreshProviderAccounts(); - const timer = window.setInterval(refreshProviderAccounts, 30000); + const stopPolling = startVisiblePolling(refreshProviderAccounts, 30000); return () => { cancelled = true; - window.clearInterval(timer); + stopPolling(); }; }, [draftConfig.Providers]); + async function refreshProviderAccountsNow() { + if (providerAccountRefreshing) { + return; + } + setProviderAccountRefreshing(true); + try { + setProviderAccountSnapshots(await loadProviderAccountSnapshots(true)); + } catch { + setProviderAccountSnapshots([]); + } finally { + setProviderAccountRefreshing(false); + } + } + const requestLogsEnabled = Boolean(draftConfig.observability.requestLogs); const agentAnalysisEnabled = Boolean(draftConfig.observability.agentAnalysis); const agentAnalysisFilterKey = JSON.stringify({ @@ -556,11 +541,11 @@ function App() { }); }; + const stopPolling = startVisiblePolling(() => refreshAgentAnalysis(), 5000, { immediate: false }); refreshAgentAnalysis(true); - const timer = window.setInterval(() => refreshAgentAnalysis(), 5000); return () => { cancelled = true; - window.clearInterval(timer); + stopPolling(); }; }, [activeView, agentAnalysisEnabled, agentAnalysisFilterKey]); @@ -605,11 +590,11 @@ function App() { }); }; + const stopPolling = startVisiblePolling(() => refreshRequestLogs(), 5000, { immediate: false }); refreshRequestLogs(true); - const timer = window.setInterval(() => refreshRequestLogs(), 5000); return () => { cancelled = true; - window.clearInterval(timer); + stopPolling(); }; }, [activeView, requestLogsEnabled, requestLogFilterKey]); @@ -630,15 +615,14 @@ function App() { } }); }; - refreshNetworkCaptures(); - const timer = window.setInterval(refreshNetworkCaptures, 1500); + const stopPolling = startVisiblePolling(refreshNetworkCaptures, 1500); return () => { cancelled = true; - window.clearInterval(timer); + stopPolling(); }; }, [activeView, draftConfig.proxy.captureNetwork]); - const dirty = useMemo(() => formatJson(savedConfig) !== formatJson(draftConfig), [draftConfig, savedConfig]); + const dirty = draftConfig !== savedConfig; const apiKeys = useMemo(() => createApiKeyList(draftConfig), [draftConfig.APIKEY, draftConfig.APIKEYS]); const apiKeyEditItem = apiKeyEditIndex === undefined ? undefined : apiKeys.find((apiKey) => apiKey.index === apiKeyEditIndex); const providerDeleteItem = providerDeleteIndex === undefined ? undefined : draftConfig.Providers[providerDeleteIndex]; @@ -680,8 +664,6 @@ function App() { const onboardingProfileDraftSource = useRef(""); const providerProbeRequestId = useRef(0); const providerConnectivityRequestId = useRef(0); - const providerDeepLinkCatalogModelsRequestId = useRef(0); - const providerDeepLinkIconRequestId = useRef(0); const toastTimer = useRef(); const shouldReduceMotion = useReducedMotion(); @@ -844,6 +826,93 @@ function App() { }, 1800); } + function openUpdateDialog() { + setUpdateDialogOpen(true); + setUpdateActionError(""); + void checkForAppUpdate(); + } + + function openSidebarUpdateDialog() { + setUpdateDialogOpen(true); + setUpdateActionError(""); + if (updateDialogStatus.canDownload || updateDialogStatus.state === "available") { + void downloadAppUpdate(); + return; + } + if ( + updateDialogStatus.canInstall || + updateDialogStatus.state === "checking" || + updateDialogStatus.state === "downloading" || + updateDialogStatus.state === "downloaded" || + updateDialogStatus.state === "installing" + ) { + return; + } + void checkForAppUpdate(); + } + + async function checkForAppUpdate() { + if (updateActionBusyRef.current) { + return; + } + if (!window.ccr?.updateCheck) { + setUpdateDialogStatus({ + ...fallbackUpdateStatus, + currentVersion: appInfo.version, + lastError: t("Updates are only available in packaged builds."), + state: "error" + }); + return; + } + + updateActionBusyRef.current = true; + setUpdateActionBusy("check"); + setUpdateActionError(""); + try { + setUpdateDialogStatus(await window.ccr.updateCheck()); + } catch (error) { + setUpdateActionError(formatError(error)); + } finally { + updateActionBusyRef.current = false; + setUpdateActionBusy(""); + } + } + + async function downloadAppUpdate() { + if (updateActionBusyRef.current || !window.ccr?.updateDownload) { + return; + } + + updateActionBusyRef.current = true; + setUpdateActionBusy("download"); + setUpdateActionError(""); + try { + setUpdateDialogStatus(await window.ccr.updateDownload()); + } catch (error) { + setUpdateActionError(formatError(error)); + } finally { + updateActionBusyRef.current = false; + setUpdateActionBusy(""); + } + } + + async function installAppUpdate() { + if (updateActionBusyRef.current || !window.ccr?.updateInstall) { + return; + } + + updateActionBusyRef.current = true; + setUpdateActionBusy("install"); + setUpdateActionError(""); + try { + await window.ccr.updateInstall(); + } catch (error) { + setUpdateActionError(formatError(error)); + updateActionBusyRef.current = false; + setUpdateActionBusy(""); + } + } + function updateConfig(mutator: (config: AppConfig) => AppConfig) { setDraftConfig((current) => { const next = normalizeConfig(mutator(cloneConfig(current))); @@ -992,6 +1061,8 @@ function App() { providerProbeRequestId.current += 1; providerConnectivityRequestId.current += 1; setProviderEditIndex(undefined); + setProviderImportOpen(false); + setProviderImportPayload(undefined); setProviderDraft(createProviderDraft(draftConfig.Providers)); setProviderProbe(undefined); setProviderConnectivityProbe(undefined); @@ -1012,6 +1083,8 @@ function App() { providerProbeRequestId.current += 1; providerConnectivityRequestId.current += 1; setProviderEditIndex(index); + setProviderImportOpen(false); + setProviderImportPayload(undefined); setProviderDraft(createProviderDraftFromProvider(provider)); setProviderProbe(undefined); setProviderConnectivityProbe(undefined); @@ -1021,6 +1094,63 @@ function App() { setProviderAddOpen(true); } + async function openImportProviderDialog(payload: ProviderDeepLinkPayload) { + if (!providerPresetsLoaded) { + return; + } + const requestId = providerProbeRequestId.current + 1; + providerProbeRequestId.current = requestId; + providerConnectivityRequestId.current += 1; + setProviderDeepLinkBusy(true); + let nextPayload = payload; + let catalogModelDisplayNames: Record | undefined; + let probe: GatewayProviderProbeResult | undefined; + if (nextPayload.models.length === 0) { + const catalogModels = await resolveProviderDeepLinkCatalogModels(nextPayload); + if (providerProbeRequestId.current !== requestId) { + setProviderDeepLinkBusy(false); + return; + } + catalogModelDisplayNames = catalogModels.modelDisplayNames; + if (catalogModels.models.length > 0) { + nextPayload = { + ...nextPayload, + models: catalogModels.models + }; + } + } + probe = await probeProviderDeepLinkPayload(nextPayload); + if (providerProbeRequestId.current !== requestId) { + setProviderDeepLinkBusy(false); + return; + } + if (nextPayload.apiKey?.trim() && probe?.models.length) { + nextPayload = { + ...nextPayload, + models: probe.models + }; + } + + const initialDraftFromPayload = createProviderDraftFromDeepLinkPayload(nextPayload, draftConfig.Providers); + const initialDraft = { + ...initialDraftFromPayload, + modelDisplayNames: mergeModelDisplayNames(initialDraftFromPayload.modelDisplayNames, catalogModelDisplayNames) + }; + setProviderEditIndex(undefined); + setProviderImportOpen(true); + setProviderImportPayload(nextPayload); + setProviderDraft(probe ? applyProviderProbeResult(initialDraft, probe) : initialDraft); + setProviderProbe(probe); + setProviderConnectivityProbe(undefined); + setProviderProbeError(""); + setProviderProbeLoading(false); + setProviderConnectivityLoading(false); + setProviderDeepLinkRequest(undefined); + setProviderDeepLinkError(""); + setProviderDeepLinkBusy(false); + setProviderAddOpen(true); + } + function updateProviderDraft(patch: Partial, resetProbe = false) { const shouldResetProtocolProbe = resetProbe && (patch.baseUrl !== undefined || patch.presetId !== undefined || patch.protocol !== undefined); const shouldResetConnectivityProbe = resetProbe || @@ -1043,6 +1173,8 @@ function App() { return { ...next, + modelDescriptions: patch.modelDescriptions ?? current.modelDescriptions, + modelDisplayNames: patch.modelDisplayNames, modelsText: mergeProviderModelLists(current.selectedModels, splitLines(next.modelsText)).join("\n"), selectedModels: [], selectedProtocols: patch.selectedProtocols ?? current.selectedProtocols @@ -1241,34 +1373,28 @@ function App() { setProviderProbeError(translateAppErrorMessage(copy, credentials)); return false; } - const fallbackProtocol = probe?.detectedProtocol ?? providerDraft.protocol; - const fallbackBaseUrl = probe?.normalizedBaseUrl || providerDraft.baseUrl; const selectableProtocols = providerSelectableProtocolsFromProbe(probe); const selectedProtocols = providerDraft.selectedProtocols.length > 0 - ? providerDraft.selectedProtocols.filter((protocol) => selectableProtocols.length === 0 || selectableProtocols.includes(protocol)) + ? providerDraft.selectedProtocols.filter((protocol) => !probe || selectableProtocols.includes(protocol)) : []; if (selectableProtocols.length > 0 && selectedProtocols.length === 0) { setProviderProbeError(t("Select at least one protocol.")); return false; } - const protocolsToSave = selectedProtocols.length > 0 ? selectedProtocols : [fallbackProtocol]; - const selectedProtocolSet = new Set(protocolsToSave); - const capabilityCandidates = mergeProviderCapabilities( - presetCapabilitiesFromDraft(providerDraft), - probe?.capabilities ?? [], - protocolsToSave.map((type) => ({ - baseUrl: fallbackBaseUrl, - source: probe?.detectedProtocol ? ("detected" as const) : ("preset" as const), - type - })) - ); - const capabilities = capabilityCandidates.filter((capability) => selectedProtocolSet.has(capability.type)); + const protocolsToSave = selectedProtocols.length > 0 ? selectedProtocols : [probe?.detectedProtocol ?? providerDraft.protocol]; + const fallbackProtocol = protocolsToSave.includes(providerDraft.protocol) + ? providerDraft.protocol + : protocolsToSave[0] ?? probe?.detectedProtocol ?? providerDraft.protocol; + const fallbackBaseUrl = providerGlobalBaseUrlForProbe(providerDraft.baseUrl, probe, protocolsToSave); + const modelDescriptions = modelDescriptionsForModels(providerDraft.modelDescriptions, models); + const modelDisplayNames = modelDisplayNamesForModels(providerDraft.modelDisplayNames, models); + const capabilities = providerCapabilitiesForProtocols(providerDraft.baseUrl, protocolsToSave, probe, presetCapabilitiesFromDraft(providerDraft)); const primaryCapability = capabilities.find((capability) => capability.type === fallbackProtocol) ?? capabilities[0]; const protocol = primaryCapability?.type ?? fallbackProtocol; - const baseUrl = primaryCapability?.baseUrl ?? fallbackBaseUrl; + const baseUrl = fallbackBaseUrl; const keySafetyIssue = providerApiKeySafetyIssue({ apiKey: providerDraft.apiKey, @@ -1313,18 +1439,24 @@ function App() { return false; } + const existingProvider = providerEditIndex !== undefined ? draftConfig.Providers[providerEditIndex] : undefined; + const providerId = existingProvider?.id ?? providerNameSlug(providerName); const provider: GatewayProviderConfig = { - api_base_url: normalizeProviderBaseUrl(baseUrl, protocol), + api_base_url: normalizeProviderBaseUrl(baseUrl), api_key: providerDraft.apiKey.trim(), capabilities: capabilities.length > 0 ? capabilities : undefined, account: accountConfig, credentials: credentials.length > 0 ? credentials : undefined, icon: providerDraft.icon.trim() || undefined, + id: providerId, + modelDescriptions, + modelDisplayNames, models, name: providerName, type: protocol }; - const importedProviderPlugins = materializeProviderPluginTemplates(providerDraft.providerPlugins, providerName, protocol); + const importedProviderPlugins = materializeProviderPluginTemplates(providerDraft.providerPlugins, providerName, protocol, providerId); + const wasImport = providerImportOpen; const next = buildConfigUpdate((config) => { if (providerEditIndex === undefined) { @@ -1341,7 +1473,12 @@ function App() { setConfigDraft(next); if (await persistConfig(next, setProviderProbeError)) { setProviderEditIndex(undefined); + setProviderImportOpen(false); + setProviderImportPayload(undefined); setProviderAddOpen(false); + if (wasImport) { + showToast(`${copy.text["Imported provider"] ?? "Imported provider"} ${provider.name}`.trim()); + } if (activeView === "onboarding") { setOnboardingStep(getDefaultOnboardingStep(next, onboardingReadiness)); } @@ -1356,10 +1493,15 @@ function App() { return; } + if (request.provider) { + await openImportProviderDialog(request.provider); + return; + } + setProviderDeepLinkBusy(true); setProviderDeepLinkError(""); try { - if (!request.provider && request.manifest) { + if (request.manifest) { if (!window.ccr?.fetchProviderManifest) { throw new Error("Request failed."); } @@ -1372,61 +1514,7 @@ function App() { return; } - let payload = request.provider; - if (!payload) { - setProviderDeepLinkBusy(false); - return; - } - const identityIssue = providerIdentitySafetyIssue({ - baseUrl: payload.baseUrl, - name: payload.name - }); - if (identityIssue) { - throw new Error(identityIssue.message); - } - const iconResolution = await resolveProviderDeepLinkIcon(payload); - if (iconResolution.persistentIcon && iconResolution.persistentIcon !== payload.icon?.trim()) { - payload = { - ...payload, - icon: iconResolution.persistentIcon - }; - } - if (payload.models.length === 0) { - const catalogModels = await resolveProviderDeepLinkCatalogModels(payload); - if (catalogModels.length > 0) { - payload = { - ...payload, - models: catalogModels - }; - } - } - const probe = await probeProviderDeepLinkPayload(payload); - if (payload.apiKey?.trim() && probe?.models.length) { - payload = { - ...payload, - models: probe.models - }; - } - let importedProviderName = payload.name?.trim() || ""; - const next = buildConfigUpdate((config) => { - const provider = createProviderConfigFromDeepLink(payload, config.Providers, probe); - importedProviderName = provider.name; - config.Providers.push(provider); - if (!config.preferredProvider) { - config.preferredProvider = provider.name; - } - return config; - }); - setConfigDraft(next); - const saved = await persistConfig(next, setProviderDeepLinkError); setProviderDeepLinkBusy(false); - if (saved) { - setProviderDeepLinkRequest(undefined); - showToast(`${copy.text["Imported provider"] ?? "Imported provider"} ${importedProviderName}`.trim()); - if (activeView === "onboarding") { - setOnboardingStep(getDefaultOnboardingStep(next, onboardingReadiness)); - } - } } catch (error) { setProviderDeepLinkError(formatError(error)); setProviderDeepLinkBusy(false); @@ -1444,6 +1532,27 @@ function App() { return persistConfig(next, setActionError); } + function updateProviderModelDescription(providerIndex: number, model: string, description: string) { + const next = buildConfigUpdate((config) => { + const provider = config.Providers[providerIndex]; + const models = provider ? mergeProviderModelLists(provider.models) : []; + if (!provider || !models.includes(model)) { + return config; + } + const descriptions = { ...(provider.modelDescriptions ?? {}) }; + const trimmed = description.trim(); + if (trimmed) { + descriptions[model] = trimmed; + } else { + delete descriptions[model]; + } + provider.modelDescriptions = modelDescriptionsForModels(descriptions, models); + return config; + }); + setConfigDraft(next); + void persistConfig(next, setActionError); + } + async function confirmProviderDelete() { if (providerDeleteIndex === undefined) { return; @@ -1942,6 +2051,13 @@ function App() { })); } + function changeLaunchAtLogin(launchAtLogin: boolean) { + updateConfig((config) => ({ + ...config, + launchAtLogin + })); + } + function changeTrayIconPreference(value: string) { const trayIcon = normalizeTrayIconPreference(value); if (trayIcon === "progress" && !normalizeTrayBalanceProgressConfig(draftConfig.trayBalanceProgress)) { @@ -1998,6 +2114,20 @@ function App() { })); } + function changeToolHubConfig(patch: Partial) { + updateConfig((config) => ({ + ...config, + toolHub: normalizeToolHubConfig({ + ...config.toolHub, + ...patch, + llm: { + ...config.toolHub.llm, + ...(patch.llm ?? {}) + } + }) + })); + } + function openBotSettingsWithAddDialog() { setSettingsInitialPage("bots"); setSettingsBotAddRequestKey((current) => current + 1); @@ -2697,6 +2827,7 @@ function App() { isMac={isMac} needsTrafficLightSafeArea={needsTrafficLightSafeArea} networkCaptureEnabled={networkCaptureEnabled} + onOpenUpdate={openSidebarUpdateDialog} onOpenSettings={openSettingsDialog} onSelectNavigationItem={selectNavigationItem} onToggleSidebar={() => setSidebarOpen((current) => !current)} @@ -2704,6 +2835,8 @@ function App() { shouldReduceMotion={shouldReduceMotion} sidebarOpen={sidebarOpen} toggleGatewayService={toggleGatewayService} + updateActionBusy={Boolean(updateActionBusy)} + updateStatus={updateDialogStatus} visibleNavigation={visibleNavigation} viewProps={{ apiKeys: { @@ -2730,7 +2863,8 @@ function App() { updateFilter: updateRequestLogFilter }, models: { - config: draftConfig + config: draftConfig, + updateModelDescription: updateProviderModelDescription }, networking: { clearCaptures: () => void clearProxyNetworkCaptures(), @@ -2755,6 +2889,8 @@ function App() { onWidgetsChange: changeOverviewWidgets, overviewWidgets: normalizeOverviewWidgets(draftConfig.overviewWidgets), providerAccounts: providerAccountSnapshots, + providerAccountRefreshing, + refreshProviderAccounts: () => void refreshProviderAccountsNow(), setUsageRange, usageRange, usageStats @@ -2787,6 +2923,21 @@ function App() { moveRule: moveRoutingRule, providers: draftConfig.Providers, removeRule: setRoutingDeleteIndex, + updateBuiltInRule: (agent, patch) => updateConfig((config) => { + config.Router.builtInRules = normalizeRouterBuiltInRules(config.Router.builtInRules); + if (agent === "claude-code") { + config.Router.builtInRules["claude-code"] = { + ...config.Router.builtInRules["claude-code"], + ...patch + }; + } else { + config.Router.builtInRules.codex = { + ...config.Router.builtInRules.codex, + ...patch + }; + } + return config; + }), updateFallback: (fallback) => updateConfig((config) => { config.Router.fallback = normalizeRouterFallbackConfig(fallback); return config; @@ -2937,17 +3088,16 @@ function App() { onStopApp: () => void stopProfileApp(profileOpenDialog.profile), profile: profileOpenDialog.profile } : undefined} - providerDeepLink={providerDeepLinkRequest ? { + providerDeepLink={providerDeepLinkRequest && !providerDeepLinkRequest.provider ? { busy: providerDeepLinkBusy, error: providerDeepLinkError, - iconLoading: providerDeepLinkIconLoading, onClose: () => { if (!providerDeepLinkBusy) { setProviderDeepLinkRequest(undefined); } }, onSubmit: confirmProviderDeepLinkImport, - modelsLoading: providerDeepLinkModelsLoading, + presetsLoaded: providerPresetsLoaded, request: providerDeepLinkRequest } : undefined} providerDelete={providerDeleteItem ? { @@ -2961,18 +3111,23 @@ function App() { connectivityProbe: providerConnectivityProbe, draft: providerDraft, error: providerProbeError, + importProvider: providerImportOpen ? providerImportPayload : undefined, onChange: updateProviderDraft, mode: providerEditIndex === undefined ? "add" : "edit", onClose: () => { setProviderAddOpen(false); setProviderEditIndex(undefined); + setProviderImportOpen(false); + setProviderImportPayload(undefined); }, onCheck: checkProviderDraft, onSubmit: submitProviderDraft, probe: providerProbe, probeLoading: providerProbeLoading, providerPlugins: draftConfig.providerPlugins ?? [], - providers: draftConfig.Providers + providers: draftConfig.Providers, + submitLabel: providerImportOpen ? t("Import") : undefined, + title: providerImportOpen ? t("Import Provider") : undefined } : undefined} routingDelete={routingDeleteRule ? { onClose: () => setRoutingDeleteIndex(undefined), @@ -2997,26 +3152,46 @@ function App() { botConfigs: draftConfig.botConfigs, copy, initialPage: settingsInitialPage, - traySupported, languagePreference, + launchAtLogin: Boolean(draftConfig.launchAtLogin), onChangeBotConfigs: changeBotConfigs, + onChangeLaunchAtLogin: changeLaunchAtLogin, onChangeLanguage: changeLanguagePreference, onChangeObservability: changeObservabilityConfig, onChangeTheme: changeThemePreference, + onChangeToolHub: changeToolHubConfig, onChangeTrayBalanceProgress: changeTrayBalanceProgress, onChangeTrayIcon: changeTrayIconPreference, onChangeTrayWidgets: changeTrayWidgets, onClose: () => setSettingsOpen(false), observability: draftConfig.observability, profiles: draftConfig.profile.profiles, + providers: draftConfig.Providers, systemLanguage, systemTheme, themePreference: draftConfig.theme || "system", + toolHub: draftConfig.toolHub, providerAccountSnapshots, trayBalanceProgress: normalizeTrayBalanceProgressConfig(draftConfig.trayBalanceProgress), trayIconPreference: draftConfig.trayIcon || "random", + traySupported, trayWidgets: normalizeTrayWidgets(draftConfig.trayWidgets ?? DEFAULT_TRAY_WIDGETS, draftConfig.trayWindowModules, draftConfig.trayComponentVariants) } : undefined} + update={updateDialogOpen ? { + actionBusy: updateActionBusy, + actionError: updateActionError, + copy, + onCheck: checkForAppUpdate, + onClose: () => { + if (updateActionBusy !== "install") { + setUpdateDialogOpen(false); + setUpdateActionError(""); + } + }, + onDownload: downloadAppUpdate, + onInstall: installAppUpdate, + status: updateDialogStatus + } : undefined} virtualModelUpsert={virtualModelDialogOpen ? { canSubmit: canSubmitVirtualModel, draft: virtualModelDraft, diff --git a/src/renderer/pages/home/components/api-keys.tsx b/packages/ui/src/pages/home/components/api-keys.tsx similarity index 99% rename from src/renderer/pages/home/components/api-keys.tsx rename to packages/ui/src/pages/home/components/api-keys.tsx index 231ab72..d1b6d44 100644 --- a/src/renderer/pages/home/components/api-keys.tsx +++ b/packages/ui/src/pages/home/components/api-keys.tsx @@ -7,7 +7,7 @@ import { formatApiKeyExpiration, formatApiKeyLimits, Input, KeyRound, limitWindowOptions, LimitWindowPreset, motion, Pencil, Plus, Search, SelectControl, translateOptions, Trash2, useAppText, useMemo, useState, X -} from "../shared"; +} from "../shared/index"; export function ApiKeysView({ addApiKey, apiKeys, diff --git a/src/renderer/pages/home/components/dashboard.tsx b/packages/ui/src/pages/home/components/dashboard.tsx similarity index 80% rename from src/renderer/pages/home/components/dashboard.tsx rename to packages/ui/src/pages/home/components/dashboard.tsx index ec214d7..310d111 100644 --- a/src/renderer/pages/home/components/dashboard.tsx +++ b/packages/ui/src/pages/home/components/dashboard.tsx @@ -1,18 +1,19 @@ import { agentAnalysisRangeOptions, AgentAnalysisSessionSelection, AgentAnalysisSnapshot, AgentAnalysisTracePayloadFullResult, AgentAnalysisTracePayloadRequest, AgentAnalysisTraceRun, agentFilterOptions, AgentFilterValue, agentKindLabel, + AnimatePresence, AnimatedDisclosure, AnimatedIconSwap, Area, arrayMove, Badge, Bar, BarChart, Button, Card, CardContent, CardHeader, CardTitle, CartesianGrid, Cell, constrainOverviewWidgetSize, Check, ChevronDown, ChevronLeft, ChevronRight, CircleAlert, cn, compactId, compactUserAgent, compareProviderAccountSnapshots, ComposedChart, CSS, DEFAULT_OVERVIEW_WIDGETS, DndContext, Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle, DragEndEvent, DragOverEvent, DragOverlay, DragStartEvent, Field, formatAxisNumber, formatBytes, - formatCompactNumber, formatDuration, formatLogDateTime, formatPercent, formatProviderAccountMeterTitle, formatProviderAccountMeterValue, + formatCompactNumber, formatDuration, formatLogDateTime, formatPercent, formatProviderAccountDetailDate, formatProviderAccountMeterTitle, formatProviderAccountMeterValue, formatStatusBucketDate, formatStatusCodeCounts, formatSystemStatusRange, formatToolCounts, formatUsdCost, KeyboardSensor, - LabelList, LayoutGroup, Line, MeasuringStrategy, MetricCard, MetricTone, + LabelList, LayoutGroup, Line, LoaderCircle, MeasuringStrategy, MetricCard, MetricTone, metricToneBar, metricToneStroke, motion, normalizeAgentFilterValue, normalizeOverviewWidget, normalizeOverviewWidgets, OverviewMetricKind, overviewMetricOptions, overviewWidgetCollisionDetection, OverviewWidgetConfig, OverviewWidgetSize, overviewWidgetSizeOptions, OverviewWidgetType, OverviewWidgetVariant, Pencil, Pie, PieChart, Plus, - PointerSensor, primaryProviderAccountMeter, providerAccountBadgeVariant, providerAccountMeterProgress, providerAccountMetersForDisplay, providerAccountProgressClass, + PointerSensor, primaryProviderAccountMeter, providerAccountMeterDetailValidityProgress, providerAccountMeterProgress, providerAccountMetersForDisplay, providerAccountProgressClass, isProviderAccountManualResetMeter, providerAccountSnapshotKey, providerAccountSnapshotLabel, ProviderAccountMeter, ProviderAccountSnapshot, ReactNode, ReactPointerEvent, rectSortingStrategy, RefreshCw, Select, SelectControl, SortableContext, sortableKeyboardCoordinates, systemStatusIconClass, systemStatusPointTooltip, systemStatusSegmentClass, @@ -20,12 +21,16 @@ import { UsageSeriesPoint, UsageStatsRange, UsageStatsSnapshot, usageStatusTone, UsageTotals, useAppText, useEffect, useMemo, useRef, useSensor, useSensors, useSortable, useState, X, XAxis, YAxis -} from "../shared"; +} from "../shared/index"; import { buildTokenActivity, type TokenActivityCell } from "@/lib/usage-activity"; +import { ShareCardWidget } from "./share-cards"; +import { Cloud, Rocket } from "lucide-react"; export function OverviewView({ onWidgetsChange, overviewWidgets, providerAccounts, + providerAccountRefreshing = false, + refreshProviderAccounts, setUsageRange, usageRange, usageStats @@ -33,6 +38,8 @@ export function OverviewView({ onWidgetsChange: (widgets: OverviewWidgetConfig[]) => void; overviewWidgets: OverviewWidgetConfig[]; providerAccounts: ProviderAccountSnapshot[]; + providerAccountRefreshing?: boolean; + refreshProviderAccounts?: () => void | Promise; setUsageRange: (range: UsageStatsRange) => void; usageRange: UsageStatsRange; usageStats: UsageStatsSnapshot; @@ -219,6 +226,17 @@ export function OverviewView({ }); } + function changeWidgetShareData(id: string, type: ShareOverviewWidgetType) { + const current = widgets.find((widget) => widget.id === id); + if (!current) { + return; + } + updateWidget(id, { + type, + variant: overviewWidgetVariantOptions(type)[0]?.value ?? current.variant + }); + } + function resetLayout() { onWidgetsChange(DEFAULT_OVERVIEW_WIDGETS.map((widget) => ({ ...widget }))); setSelectedWidgetId(undefined); @@ -248,6 +266,8 @@ export function OverviewView({ > selectedWidget ? changeWidgetBreakdownData(selectedWidget.id, type) : undefined} onChangeCategory={(category) => selectedWidget ? changeWidgetCategory(selectedWidget.id, category) : undefined} onChangeMetric={(metric) => selectedWidget ? updateWidget(selectedWidget.id, { metric }) : undefined} + onChangeShareData={(type) => selectedWidget ? changeWidgetShareData(selectedWidget.id, type) : undefined} onChangeSize={(size) => selectedWidget ? updateWidget(selectedWidget.id, { size }) : undefined} onChangeVariant={(variant) => selectedWidget ? updateWidget(selectedWidget.id, { variant }) : undefined} onRemove={() => selectedWidget ? removeWidget(selectedWidget.id) : undefined} @@ -346,6 +369,7 @@ export function OverviewView({ ) : ( widgetGrid )} + ); } @@ -411,8 +435,8 @@ function OverviewWidgetPalette({ >
      -
      {t(overviewWidgetCategoryLabel(overviewWidgetCategory(template.type)))}
      -
      {t(overviewWidgetCategoryDescription(overviewWidgetCategory(template.type)))}
      +
      {t(overviewWidgetPaletteTitle(template))}
      +
      {t(overviewWidgetPaletteDescription(template))}
      ))} @@ -428,6 +452,7 @@ function OverviewWidgetProperties({ onChangeBreakdownData, onChangeCategory, onChangeMetric, + onChangeShareData, onChangeSize, onChangeVariant, onRemove @@ -439,6 +464,7 @@ function OverviewWidgetProperties({ onChangeBreakdownData: (type: "model-distribution" | "token-mix") => void; onChangeCategory: (category: OverviewWidgetCategory) => void; onChangeMetric: (metric: OverviewMetricKind) => void; + onChangeShareData: (type: ShareOverviewWidgetType) => void; onChangeSize: (size: OverviewWidgetSize) => void; onChangeVariant: (variant: OverviewWidgetVariant) => void; onRemove: () => void; @@ -472,6 +498,9 @@ function OverviewWidgetProperties({ if (category === "breakdown") { onChangeBreakdownData(value as "model-distribution" | "token-mix"); } + if (category === "share-card") { + onChangeShareData(value as ShareOverviewWidgetType); + } }; return ( @@ -554,11 +583,15 @@ function SortableOverviewWidget({ function OverviewWidgetDragOverlay({ providerAccounts, + providerAccountRefreshing = false, + refreshProviderAccounts, usageRange, usageStats, widget }: { providerAccounts: ProviderAccountSnapshot[]; + providerAccountRefreshing?: boolean; + refreshProviderAccounts?: () => void | Promise; usageRange: UsageStatsRange; usageStats: UsageStatsSnapshot; widget: OverviewWidgetConfig; @@ -567,6 +600,8 @@ function OverviewWidgetDragOverlay({
      void | Promise; usageRange: UsageStatsRange; usageStats: UsageStatsSnapshot; widget: OverviewWidgetConfig; @@ -816,7 +855,7 @@ function OverviewWidgetRenderer({ if (widget.type === "system-status") { content = ; } else if (widget.type === "account-balance") { - content = ; + content = ; } else if (widget.type === "metric") { content = ; } else if (widget.type === "usage-trend") { @@ -829,6 +868,8 @@ function OverviewWidgetRenderer({ content = ; } else if (widget.type === "client-analysis") { content = ; + } else if (isShareOverviewWidgetType(widget.type)) { + content = ; } else { content = ; } @@ -952,19 +993,19 @@ function UsageTrendWidget({ } dataKey="requestCount" /> - + ) : null} {variant === "area" ? ( <> - + ) : null} {variant === "line" ? ( <> - + ) : null} {variant === "bar" ? ( @@ -1185,7 +1226,7 @@ function TokenMixOverviewWidget({ const tokenMix = [ { color: "#2563eb", name: t("Input"), value: totals.inputTokens }, { color: "#d97706", name: t("Output"), value: totals.outputTokens }, - { color: "#be123c", name: t("Cache"), value: totals.cacheTokens } + { color: overviewCacheColor, name: t("Cache"), value: totals.cacheTokens } ]; const total = tokenMix.reduce((sum, item) => sum + item.value, 0); const showLegend = dimensions.height >= 2 && dimensions.width >= 2; @@ -1446,11 +1487,19 @@ function overviewWidgetTemplates(): OverviewWidgetConfig[] { { enabled: true, id: "usage-trend", size: "3:2", type: "usage-trend", variant: "composed" }, { enabled: true, id: "token-activity", size: "4:2", type: "token-activity", variant: "heatmap" }, { enabled: true, id: "token-mix", size: "1:2", type: "token-mix", variant: "bars" }, - { enabled: true, id: "client-analysis", size: "2:2", type: "client-analysis", variant: "table" } + { enabled: true, id: "client-analysis", size: "2:2", type: "client-analysis", variant: "table" }, + { enabled: true, id: "share-usage-wrapped", size: "1:4", type: "share-usage-wrapped", variant: "card" }, + { enabled: true, id: "share-route-map", size: "1:4", type: "share-route-map", variant: "card" }, + { enabled: true, id: "share-model-leaderboard", size: "1:4", type: "share-model-leaderboard", variant: "card" }, + { enabled: true, id: "share-fuel-cockpit", size: "1:4", type: "share-fuel-cockpit", variant: "card" }, + { enabled: true, id: "share-token-calendar", size: "1:4", type: "share-token-calendar", variant: "card" }, + { enabled: true, id: "share-spend-receipt", size: "1:4", type: "share-spend-receipt", variant: "card" } ]; } -type OverviewWidgetCategory = "account-balance" | "activity" | "analysis" | "breakdown" | "metric" | "system-status" | "usage-trend"; +type ShareOverviewWidgetType = Extract; + +type OverviewWidgetCategory = "account-balance" | "activity" | "analysis" | "breakdown" | "metric" | "share-card" | "system-status" | "usage-trend"; function overviewWidgetCategoryOptions(): Array<{ label: string; value: OverviewWidgetCategory }> { return [ @@ -1460,7 +1509,8 @@ function overviewWidgetCategoryOptions(): Array<{ label: string; value: Overview "usage-trend", "activity", "breakdown", - "analysis" + "analysis", + "share-card" ].map((category) => ({ label: overviewWidgetCategoryLabel(category as OverviewWidgetCategory), value: category as OverviewWidgetCategory @@ -1481,6 +1531,17 @@ function overviewBreakdownDataOptions(): Array<{ label: string; value: "model-di ]; } +function overviewShareCardDataOptions(): Array<{ label: string; value: ShareOverviewWidgetType }> { + return [ + { label: "AI Usage Wrapped", value: "share-usage-wrapped" }, + { label: "CCR Route Map", value: "share-route-map" }, + { label: "Model Leaderboard", value: "share-model-leaderboard" }, + { label: "AI Fuel Cockpit", value: "share-fuel-cockpit" }, + { label: "Token Calendar Poster", value: "share-token-calendar" }, + { label: "Spend Receipt", value: "share-spend-receipt" } + ]; +} + function overviewWidgetDataOptions(widget: OverviewWidgetConfig, providerAccounts: ProviderAccountSnapshot[]): Array<{ label: string; value: string }> { const category = overviewWidgetCategory(widget.type); if (category === "metric") { @@ -1508,6 +1569,9 @@ function overviewWidgetDataOptions(widget: OverviewWidgetConfig, providerAccount if (category === "breakdown") { return overviewBreakdownDataOptions(); } + if (category === "share-card") { + return overviewShareCardDataOptions(); + } return [{ label: "Usage over time", value: "usage-trend" }]; } @@ -1528,6 +1592,9 @@ function overviewWidgetDataValue(widget: OverviewWidgetConfig): string { if (category === "activity") { return "token-activity"; } + if (category === "share-card") { + return widget.type; + } return category; } @@ -1541,6 +1608,9 @@ function overviewWidgetCategory(type: OverviewWidgetType): OverviewWidgetCategor if (type === "token-activity") { return "activity"; } + if (isShareOverviewWidgetType(type)) { + return "share-card"; + } return type; } @@ -1554,10 +1624,16 @@ function overviewWidgetTypeForCategory(category: OverviewWidgetCategory, current if (category === "activity") { return "token-activity"; } + if (category === "share-card") { + return isShareOverviewWidgetType(currentType) ? currentType : "share-usage-wrapped"; + } return category; } function overviewWidgetTemplateKey(widget: OverviewWidgetConfig): string { + if (isShareOverviewWidgetType(widget.type)) { + return widget.type; + } return overviewWidgetCategory(widget.type); } @@ -1566,6 +1642,7 @@ function overviewWidgetCategoryLabel(category: OverviewWidgetCategory): string { if (category === "analysis") return "Analysis component"; if (category === "activity") return "Activity component"; if (category === "metric") return "Metric component"; + if (category === "share-card") return "Share card"; if (category === "system-status") return "Status component"; if (category === "breakdown") return "Breakdown component"; return "Trend component"; @@ -1576,11 +1653,24 @@ function overviewWidgetCategoryDescription(category: OverviewWidgetCategory): st if (category === "analysis") return "Client or provider"; if (category === "activity") return "Token activity heatmap"; if (category === "metric") return "Requests, tokens, cost"; + if (category === "share-card") return "Social media PNG cards"; if (category === "system-status") return "Status timeline"; if (category === "breakdown") return "Token or model distribution"; return "Usage over time"; } +function overviewWidgetPaletteTitle(widget: OverviewWidgetConfig): string { + return isShareOverviewWidgetType(widget.type) + ? overviewWidgetTypeLabel(widget.type) + : overviewWidgetCategoryLabel(overviewWidgetCategory(widget.type)); +} + +function overviewWidgetPaletteDescription(widget: OverviewWidgetConfig): string { + return isShareOverviewWidgetType(widget.type) + ? overviewWidgetCategoryLabel("share-card") + : overviewWidgetCategoryDescription(overviewWidgetCategory(widget.type)); +} + function overviewWidgetTitle(widget: OverviewWidgetConfig, translate: (value: string) => string): string { if (widget.type === "metric") { return translate(overviewMetricLabel(widget.metric ?? "requests")); @@ -1594,6 +1684,12 @@ function overviewWidgetTypeLabel(type: OverviewWidgetType): string { if (type === "metric") return "Metric"; if (type === "model-distribution") return "Model Distribution"; if (type === "provider-analysis") return "Provider Analysis"; + if (type === "share-fuel-cockpit") return "AI Fuel Cockpit"; + if (type === "share-model-leaderboard") return "Model Leaderboard"; + if (type === "share-route-map") return "CCR Route Map"; + if (type === "share-spend-receipt") return "Spend Receipt"; + if (type === "share-token-calendar") return "Token Calendar Poster"; + if (type === "share-usage-wrapped") return "AI Usage Wrapped"; if (type === "system-status") return "System status"; if (type === "token-activity") return "Activity"; if (type === "token-mix") return "Token Mix"; @@ -1647,12 +1743,26 @@ function overviewWidgetVariantOptions(type: OverviewWidgetType): Array<{ label: { label: "Compact", value: "compact" } ]; } + if (isShareOverviewWidgetType(type)) { + return [ + { label: "Card", value: "card" } + ]; + } return [ { label: "Table", value: "table" }, { label: "Compact", value: "compact" } ]; } +function isShareOverviewWidgetType(type: OverviewWidgetType): type is ShareOverviewWidgetType { + return type === "share-fuel-cockpit" || + type === "share-model-leaderboard" || + type === "share-route-map" || + type === "share-spend-receipt" || + type === "share-token-calendar" || + type === "share-usage-wrapped"; +} + function overviewWidgetSizeClass(size: OverviewWidgetSize): string { const { height, width } = overviewWidgetDimensions(size); return cn(overviewWidgetWidthClass(width), overviewWidgetHeightClass(height)); @@ -1665,6 +1775,8 @@ function overviewWidgetOverlaySizeClass(size: OverviewWidgetSize): string { type OverviewWidgetDimensions = { height: 1 | 2 | 3 | 4; width: 1 | 2 | 3 | 4 }; +const overviewCacheColor = "#6366f1"; + function overviewWidgetDimensions(size: OverviewWidgetSize): OverviewWidgetDimensions { const [widthText, heightText] = size.split(":"); const width = overviewWidgetDimensionValue(widthText); @@ -1752,7 +1864,7 @@ function overviewMetricDatum(metric: OverviewMetricKind, totals: UsageTotals, tr return { label: translate("Output tokens"), ratio: totals.totalTokens > 0 ? totals.outputTokens / totals.totalTokens : 0, tone: "amber", value: formatCompactNumber(totals.outputTokens) }; } if (metric === "cache-tokens") { - return { label: translate("Cache tokens"), ratio: totals.totalTokens > 0 ? totals.cacheTokens / totals.totalTokens : 0, tone: "rose", value: formatCompactNumber(totals.cacheTokens) }; + return { label: translate("Cache tokens"), ratio: totals.totalTokens > 0 ? totals.cacheTokens / totals.totalTokens : 0, tone: "indigo", value: formatCompactNumber(totals.cacheTokens) }; } if (metric === "cache-ratio") { return { label: translate("Cache ratio"), ratio: totals.cacheRatio, tone: "indigo", value: formatPercent(totals.cacheRatio) }; @@ -1897,11 +2009,15 @@ function ProviderAccountsOverview({ accountProvider, accounts, dimensions, + onRefresh, + refreshing = false, variant = "cards" }: { accountProvider?: string; accounts: ProviderAccountSnapshot[]; dimensions: OverviewWidgetDimensions; + onRefresh?: () => void | Promise; + refreshing?: boolean; variant?: OverviewAccountVariant; }) { const t = useAppText(); @@ -1921,7 +2037,7 @@ function ProviderAccountsOverview({ {t("No account balance connectors configured")}
      ) : isSingleAccount ? ( - + ) : variant === "compact" ? (
      {visibleAccounts.map((account) => { @@ -1930,11 +2046,12 @@ function ProviderAccountsOverview({
      {providerAccountSnapshotLabel(account)}
      - {providerAccountShowSource(dimensions) ?
      {meter ? t(meter.label) : account.source}
      : null} + {providerAccountShowSource(dimensions) && meter ?
      {t(meter.label)}
      : null} + {providerAccountShowRefreshTime(dimensions) ?
      {formatProviderAccountRefreshTime(account, t)}
      : null}
      -
      - {providerAccountShowStatus(dimensions) ? {account.status} : null} - {meter ?
      {formatProviderAccountMeterValue(meter)}
      : null} +
      + {providerAccountShowRefresh(dimensions) ? : null} + {meter ?
      {formatProviderAccountMeterValue(meter)}
      : null}
      ); @@ -1950,9 +2067,13 @@ function ProviderAccountsOverview({
      {providerAccountSnapshotLabel(account)}
      - {providerAccountShowSource(dimensions) ?
      {meter ? t(meter.label) : account.source}
      : null} + {providerAccountShowSource(dimensions) && meter ?
      {t(meter.label)}
      : null} + {providerAccountShowRefreshTime(dimensions) ?
      {formatProviderAccountRefreshTime(account, t)}
      : null} +
      +
      + {meter ? {formatProviderAccountMeterValue(meter)} : null} + {providerAccountShowRefresh(dimensions) ? : null}
      -
      {meter ? formatProviderAccountMeterValue(meter) : account.status}
      {progress !== undefined ? (
      @@ -1966,7 +2087,7 @@ function ProviderAccountsOverview({ ) : (
      {visibleAccounts.map((account) => { - return ; + return ; })}
      )} @@ -1978,10 +2099,14 @@ function ProviderAccountsOverview({ function ProviderAccountSinglePanel({ account, dimensions, + onRefresh, + refreshing = false, variant }: { account: ProviderAccountSnapshot; dimensions: OverviewWidgetDimensions; + onRefresh?: () => void | Promise; + refreshing?: boolean; variant: OverviewAccountVariant; }) { const t = useAppText(); @@ -1995,9 +2120,9 @@ function ProviderAccountSinglePanel({
      {providerAccountSnapshotLabel(account)}
      - {providerAccountShowSource(dimensions) ?
      {account.source}
      : null} + {providerAccountShowRefreshTime(dimensions) ?
      {formatProviderAccountRefreshTime(account, t)}
      : null}
      - {providerAccountShowStatus(dimensions) ? {account.status} : null} + {providerAccountShowRefresh(dimensions) ? : null}
      {showQuotaVisual ? ( @@ -2006,7 +2131,7 @@ function ProviderAccountSinglePanel({ ) : meters.length > 0 ? (
      {meters.map((meter) => ( - + ))} {providerAccountShowExtraCount(dimensions) && account.meters.length > meters.length ? (
      +{account.meters.length - meters.length}
      @@ -2022,10 +2147,14 @@ function ProviderAccountSinglePanel({ function ProviderAccountSummaryCard({ account, dimensions, + onRefresh, + refreshing = false, variant }: { account: ProviderAccountSnapshot; dimensions: OverviewWidgetDimensions; + onRefresh?: () => void | Promise; + refreshing?: boolean; variant: OverviewAccountVariant; }) { const t = useAppText(); @@ -2039,9 +2168,9 @@ function ProviderAccountSummaryCard({
      {providerAccountSnapshotLabel(account)}
      - {providerAccountShowSource(dimensions) ?
      {account.source}
      : null} + {providerAccountShowRefreshTime(dimensions) ?
      {formatProviderAccountRefreshTime(account, t)}
      : null}
      - {providerAccountShowStatus(dimensions) ? {account.status} : null} + {providerAccountShowRefresh(dimensions) ? : null}
      {showQuotaVisual ? (
      @@ -2054,7 +2183,7 @@ function ProviderAccountSummaryCard({ ) : meters.length > 0 ? (
      {meters.map((meter) => ( - + ))} {providerAccountShowExtraCount(dimensions) && account.meters.length > meters.length ? (
      +{account.meters.length - meters.length}
      @@ -2067,35 +2196,666 @@ function ProviderAccountSummaryCard({ ); } +function ProviderAccountRefreshButton({ + account, + onRefresh, + refreshing = false +}: { + account: ProviderAccountSnapshot; + onRefresh?: () => void | Promise; + refreshing?: boolean; +}) { + const t = useAppText(); + const label = refreshing ? t("Refreshing account") : t("Refresh account"); + return ( + + ); +} + +function formatProviderAccountRefreshTime(account: ProviderAccountSnapshot, t: (value: string) => string): string { + return `${t("Last updated")}: ${formatProviderAccountUpdatedAt(account.updatedAt) || "-"}`; +} + +function formatProviderAccountUpdatedAt(value: string): string { + const date = new Date(value); + if (!Number.isFinite(date.getTime())) { + return ""; + } + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const seconds = String(date.getSeconds()).padStart(2, "0"); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} + function ProviderAccountMeterLine({ account, dimensions, meter, + onRefresh, single = false }: { account: ProviderAccountSnapshot; dimensions: OverviewWidgetDimensions; meter: ReturnType[number]; + onRefresh?: () => void | Promise; single?: boolean; }) { const t = useAppText(); const progress = isProviderAccountQuotaMeter(meter) ? providerAccountMeterProgress(meter) : undefined; + const canExpandDetails = dimensions.height >= 2 && isProviderAccountManualResetMeter(meter) && (meter.details?.length ?? 0) > 0; + const [detailsOpen, setDetailsOpen] = useState(false); + const [resetDialogDetail, setResetDialogDetail] = useState[number]>(); + const title = formatProviderAccountMeterTitle(meter, t); + const detailsId = `provider-account-meter-${providerAccountSnapshotKey(account)}-${meter.id}-details`.replace(/[^a-zA-Z0-9_-]/g, "-"); + const titleClassName = cn("min-w-0 truncate font-medium text-muted-foreground", single && dimensions.height >= 2 ? "text-[13px]" : "text-[12px]"); + const valueClassName = cn("shrink-0 font-semibold tracking-tight", single && dimensions.height >= 2 ? "text-[18px]" : "text-[15px]"); + const meterSummary = ( + <> +
      + {canExpandDetails ? ( + + {detailsOpen ? + ) : null} +
      {title}
      +
      +
      {formatProviderAccountMeterValue(meter)}
      + + ); return (
      -
      -
      = 2 ? "text-[13px]" : "text-[12px]")}>{formatProviderAccountMeterTitle(meter, t)}
      -
      = 2 ? "text-[18px]" : "text-[15px]")}>{formatProviderAccountMeterValue(meter)}
      -
      + {canExpandDetails ? ( + + ) : ( +
      + {meterSummary} +
      + )} {progress !== undefined && providerAccountShowProgress(dimensions) ? (
      ) : null} + + {canExpandDetails && detailsOpen ? ( + + + + ) : null} + + setResetDialogDetail(undefined)} + onResetComplete={onRefresh} + />
      ); } +function ProviderAccountMeterDetails({ + account, + detailsId, + meter, + onReset +}: { + account: ProviderAccountSnapshot; + detailsId: string; + meter: ProviderAccountMeter; + onReset: (detail: NonNullable[number]) => void; +}) { + const t = useAppText(); + const details = meter.details ?? []; + + return ( +
      + {details.map((detail, index) => { + const detailProgress = providerAccountMeterDetailValidityProgress(detail); + const label = providerAccountMeterDetailLabel(detail, index, t); + const status = providerAccountMeterDetailStatusLabel(detail.status, t); + return ( +
      +
      +
      +
      {label}
      +
      +
      + {status ?
      {status}
      : null} + {detail.redeemable ? ( + + ) : null} +
      +
      +
      +
      {t("Effective")}: {formatProviderAccountDetailDate(detail.effectiveAt)}
      +
      {t("Expires")}: {formatProviderAccountDetailDate(detail.expiresAt)}
      +
      + {detailProgress !== undefined ? ( +
      +
      +
      + ) : null} +
      + ); + })} +
      + ); +} + +function providerAccountMeterDetailLabel( + detail: NonNullable[number], + index: number, + t: (value: string) => string +): string { + return detail.label ? formatProviderAccountMeterDetailLabel(detail.label, t) : `#${index + 1}`; +} + +function formatProviderAccountMeterDetailLabel(value: string, t: (value: string) => string): string { + const trimmed = value.trim(); + const fullResetMatch = /^full reset(?:\s*\(([^)]*)\))?$/i.exec(trimmed); + if (fullResetMatch) { + const qualifier = fullResetMatch[1] ? formatCodexResetCreditQualifier(fullResetMatch[1], t) : ""; + if (!qualifier) { + return t("Full reset"); + } + return appTextLooksTranslated(t) + ? `${t("Full reset")}(${qualifier})` + : `${t("Full reset")} (${qualifier})`; + } + return t(trimmed); +} + +function formatCodexResetCreditQualifier(value: string, t: (value: string) => string): string { + return value + .replace(/\bweekly\b/gi, t("Weekly")) + .replace(/\bdaily\b/gi, t("Daily")) + .replace(/\bmonthly\b/gi, t("Monthly")) + .replace(/\byearly\b/gi, t("Yearly")) + .replace(/\bhours?\b/gi, t("hour")) + .replace(/\bhrs?\b/gi, t("hr")) + .replace(/\bminutes?\b/gi, t("minute")) + .replace(/\bmins?\b/gi, t("min")) + .replace(/\bdays?\b/gi, t("day")) + .replace(/\bweeks?\b/gi, t("week")) + .replace(/\bmonths?\b/gi, t("month")) + .replace(/\s*\+\s*/g, " + ") + .trim(); +} + +function appTextLooksTranslated(t: (value: string) => string): boolean { + return t("Manual reset credit") !== "Manual reset credit"; +} + +function providerAccountMeterDetailStatusLabel(status: string | undefined, t: (value: string) => string): string { + const normalized = status?.trim().toLowerCase(); + if (!normalized) { + return ""; + } + if (normalized === "available") return t("Available"); + if (normalized === "active") return t("Active"); + if (normalized === "expired") return t("Expired"); + if (normalized === "used") return t("Used"); + return t(status ?? ""); +} + +type CodexResetLaunchStatus = "idle" | "launching" | "launched"; + +function CodexResetCreditDialog({ + account, + detail, + onClose, + onResetComplete, + open +}: { + account: ProviderAccountSnapshot; + detail: NonNullable[number] | undefined; + onClose: () => void; + onResetComplete?: () => void | Promise; + open: boolean; +}) { + const t = useAppText(); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(""); + const detailLabel = detail ? providerAccountMeterDetailLabel(detail, 0, t) : ""; + const detailStatus = providerAccountMeterDetailStatusLabel(detail?.status, t); + + useEffect(() => { + if (!open) { + setStatus("idle"); + setError(""); + } + }, [open, detail?.id]); + + async function resetCredit() { + if (status !== "idle" || !detail?.id) { + return; + } + if (!window.ccr?.resetCodexRateLimitCredit) { + setError(t("Reset is unavailable.")); + return; + } + + setError(""); + setStatus("launching"); + const ignition = delay(2000); + try { + await window.ccr.resetCodexRateLimitCredit({ + credentialId: account.credentialId, + creditId: detail.id, + provider: account.provider + }); + await ignition; + setStatus("launched"); + await onResetComplete?.(); + window.setTimeout(() => { + onClose(); + setStatus("idle"); + }, 1800); + } catch (resetError) { + await ignition.catch(() => undefined); + setError(formatDialogError(resetError)); + setStatus("idle"); + } + } + + return ( + { if (!nextOpen && status !== "launching") onClose(); }}> + + + +
      +
      +
      +
      +
      + {codexResetSpaceDust.map((dust) => ( +
      + ))} + {codexResetLaunchStars.map((star) => ( +
      + ))} + {codexResetShootingStars.map((meteor) => ( +
      + ))} +
      + +
      +
      {t("Manual reset credit")}
      +
      {detailLabel || "-"}
      +
      + {t("Expires")}: {formatProviderAccountDetailDate(detail?.expiresAt)} + {detailStatus ? {detailStatus} : null} +
      +
      +
      +
      + void resetCredit()} + transition={status === "launching" ? { duration: 0.3, repeat: Infinity } : {}} + type="button" + whileHover={status === "idle" ? { scale: 1.05 } : {}} + whileTap={status === "idle" ? { scale: 0.95 } : {}} + > +
      + + {status === "idle" ? ( + + + {t("Reset")} + + ) : null} + {status === "launching" ? ( + + + + + {t("Resetting")} + + ) : null} + {status === "launched" ? ( + + {t("Reset complete")} + + ) : null} + +
      + + + {(status === "launching" || status === "launched") ? ( + + ) : null} + + + + {status === "launched" ? ( + +
      + + +
      +
      + ) : null} +
      +
      + + + {status !== "idle" ? ( + +
      + + + + + + + + + +
      +
      + ) : null} +
      +
      +
      + {error ?
      {error}
      : null} + + +
      + ); +} + +const codexResetStarfieldDense = [ + "radial-gradient(circle at 12% 18%, rgba(255,255,255,0.75) 0 1px, transparent 1.4px)", + "radial-gradient(circle at 42% 36%, rgba(186,230,253,0.65) 0 1px, transparent 1.6px)", + "radial-gradient(circle at 74% 22%, rgba(255,255,255,0.6) 0 1px, transparent 1.5px)", + "radial-gradient(circle at 18% 72%, rgba(226,232,240,0.55) 0 1px, transparent 1.4px)", + "radial-gradient(circle at 88% 78%, rgba(147,197,253,0.5) 0 1px, transparent 1.6px)" +].join(", "); + +const codexResetStarfieldWide = [ + "radial-gradient(circle at 18% 28%, rgba(255,255,255,0.5) 0 1.4px, transparent 2px)", + "radial-gradient(circle at 58% 16%, rgba(219,234,254,0.45) 0 1.2px, transparent 2px)", + "radial-gradient(circle at 78% 62%, rgba(255,255,255,0.42) 0 1.3px, transparent 2px)", + "radial-gradient(circle at 34% 82%, rgba(125,211,252,0.35) 0 1.2px, transparent 2px)" +].join(", "); + +const codexResetLaunchStars = Array.from({ length: 76 }, (_, index) => ({ + delay: (index % 9) * 0.18, + duration: 2 + (index % 7) * 0.35, + id: index, + left: (index * 37) % 100, + opacity: 0.45 + (index % 5) * 0.11, + size: `${1.2 + (index % 4) * 0.65}px`, + top: (index * 53) % 100 +})); + +const codexResetSpaceDust = Array.from({ length: 26 }, (_, index) => ({ + delay: (index % 8) * 0.55, + duration: 5.5 + (index % 6) * 0.75, + id: index, + left: (index * 41) % 100, + size: `${2 + (index % 3)}px`, + top: (index * 29) % 100 +})); + +const codexResetShootingStars = Array.from({ length: 5 }, (_, index) => ({ + delay: 1.4 + index * 2.8, + drop: 14 + (index % 3) * 5, + duration: 5.8 + index * 0.7, + id: index, + opacity: 0.55 + (index % 3) * 0.12, + repeatDelay: 8 + index * 1.4, + rotate: 14 + (index % 2) * 6, + top: 12 + index * 15 +})); + +const codexResetSpaceAnimationCss = ` + .ccr-codex-starfield-slow { + animation: ccr-codex-starfield-slow 9s linear infinite; + will-change: background-position; + } + + .ccr-codex-starfield-deep { + animation: ccr-codex-starfield-deep 15s linear infinite; + will-change: background-position; + } + + .ccr-codex-nebula { + background: + radial-gradient(circle at 24% 52%, rgba(239, 68, 68, 0.2), transparent 32%), + radial-gradient(circle at 70% 42%, rgba(59, 130, 246, 0.18), transparent 36%), + radial-gradient(circle at 54% 76%, rgba(20, 184, 166, 0.12), transparent 38%); + animation: ccr-codex-nebula 6s ease-in-out infinite alternate; + will-change: opacity, transform; + } + + .ccr-codex-twinkle-star { + animation-name: ccr-codex-twinkle-star; + animation-timing-function: ease-in-out; + animation-iteration-count: infinite; + opacity: 0.24; + will-change: opacity, transform; + } + + .ccr-codex-space-dust { + animation-name: ccr-codex-space-dust; + animation-timing-function: ease-in-out; + animation-iteration-count: infinite; + opacity: 0; + will-change: opacity, transform; + } + + .ccr-codex-meteor { + left: -32%; + opacity: 0; + transform: translate3d(-160px, -40px, 0) rotate(16deg); + animation-name: ccr-codex-meteor; + animation-timing-function: linear; + animation-iteration-count: infinite; + will-change: opacity, transform; + } + + @keyframes ccr-codex-starfield-slow { + from { background-position: 0 0; } + to { background-position: 180px 180px; } + } + + @keyframes ccr-codex-starfield-deep { + from { background-position: 0 0; } + to { background-position: -320px 320px; } + } + + @keyframes ccr-codex-nebula { + 0% { opacity: 0.42; transform: translate3d(-18px, 10px, 0) scale(1); } + 100% { opacity: 0.82; transform: translate3d(18px, -14px, 0) scale(1.08); } + } + + @keyframes ccr-codex-twinkle-star { + 0%, 100% { opacity: 0.18; transform: scale(0.65); } + 45% { opacity: 0.95; transform: scale(1.8); } + 70% { opacity: 0.36; transform: scale(1.1); } + } + + @keyframes ccr-codex-space-dust { + 0% { opacity: 0; transform: translate3d(-10px, 24px, 0) scale(0.7); } + 35% { opacity: 0.75; } + 100% { opacity: 0; transform: translate3d(34px, -44px, 0) scale(1.35); } + } + + @keyframes ccr-codex-meteor { + 0%, 48% { opacity: 0; transform: translate3d(-180px, -72px, 0) rotate(16deg) scaleX(0.75); } + 54% { opacity: 1; } + 72% { opacity: 0.85; } + 100% { opacity: 0; transform: translate3d(860px, 210px, 0) rotate(16deg) scaleX(1.25); } + } +`; + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); +} + +function formatDialogError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + function ProviderAccountBalanceMetric({ compact = false, dimensions, @@ -2259,7 +3019,15 @@ function primaryProviderAccountBalanceMeter(account: ProviderAccountSnapshot): P } function providerAccountMetersForDisplayOrdered(account: ProviderAccountSnapshot, maxCount: number): ProviderAccountMeter[] { - const ordered = [...providerAccountQuotaMeters(account), ...providerAccountBalanceMeters(account)]; + const quotaMeters = providerAccountQuotaMeters(account); + const manualResetMeters = account.meters.filter(isProviderAccountManualResetMeter); + const leadingQuotaCount = manualResetMeters.length > 0 ? Math.min(quotaMeters.length, maxCount <= 2 ? 1 : 2) : quotaMeters.length; + const ordered = [ + ...quotaMeters.slice(0, leadingQuotaCount), + ...manualResetMeters, + ...providerAccountBalanceMeters(account), + ...quotaMeters.slice(leadingQuotaCount) + ]; const seen = new Set(); const unique = ordered.filter((meter) => { const key = `${meter.id}:${meter.kind}:${meter.window ?? ""}`; @@ -2297,10 +3065,11 @@ function compareProviderAccountQuotaMeters(a: ProviderAccountMeter, b: ProviderA } function providerAccountMeterWindowRank(meter: ProviderAccountMeter): number { - if (meter.window === "5h" || meter.id.toLowerCase().includes("5h") || meter.label.toLowerCase().includes("5h")) { + const text = `${meter.window ?? ""} ${meter.id} ${meter.label}`.toLowerCase(); + if (meter.window === "5h" || text.includes("5h") || text.includes("primary")) { return 0; } - if (meter.window === "weekly" || meter.id.toLowerCase().includes("weekly") || meter.label.toLowerCase().includes("weekly")) { + if (meter.window === "weekly" || text.includes("weekly") || text.includes("secondary")) { return 1; } if (meter.window === "daily") return 2; @@ -2396,7 +3165,11 @@ function providerAccountShowSource(dimensions: OverviewWidgetDimensions): boolea return dimensions.height >= 2 && dimensions.width >= 2; } -function providerAccountShowStatus(dimensions: OverviewWidgetDimensions): boolean { +function providerAccountShowRefreshTime(dimensions: OverviewWidgetDimensions): boolean { + return dimensions.height >= 2 && dimensions.width >= 2; +} + +function providerAccountShowRefresh(dimensions: OverviewWidgetDimensions): boolean { return dimensions.width >= 2; } @@ -2932,7 +3705,7 @@ function ToolPayloadDialog({ : undefined; return ( - !open && onClose()} open> + !open && onClose()} open>
      diff --git a/src/renderer/pages/home/components/dialog-stack.tsx b/packages/ui/src/pages/home/components/dialog-stack.tsx similarity index 94% rename from src/renderer/pages/home/components/dialog-stack.tsx rename to packages/ui/src/pages/home/components/dialog-stack.tsx index 4424509..cc33a3f 100644 --- a/src/renderer/pages/home/components/dialog-stack.tsx +++ b/packages/ui/src/pages/home/components/dialog-stack.tsx @@ -1,11 +1,12 @@ import type { ComponentProps, ReactElement } from "react"; -import { AnimatePresence, DialogStackLayer } from "../shared"; +import { AnimatePresence, DialogStackLayer } from "../shared/index"; import { AddApiKeyDialog, ApiKeyCreatedDialog, EditApiKeyDialog } from "./api-keys"; import { ConfigureClaudeDesignDialog, DeleteExtensionDialog, PluginSettingsDialog } from "./extensions"; import { AddProfileDialog, ProfileOpenDialog } from "./profiles"; import { AddProviderDialog, DeleteProviderDialog, ProviderDeepLinkDialog } from "./providers"; import { AddRoutingRuleDialog, DeleteRoutingRuleDialog } from "./routing"; import { AppSettingsDialog } from "./settings"; +import { UpdateDialog } from "./update"; import { InstallExtensionDialog, VirtualModelDialog } from "./virtual-models"; export function AppDialogStack({ @@ -26,6 +27,7 @@ export function AppDialogStack({ routingDelete, routingUpsert, settings, + update, virtualModelUpsert }: { apiKeyAdd?: ComponentProps; @@ -45,6 +47,7 @@ export function AppDialogStack({ routingDelete?: ComponentProps; routingUpsert?: ComponentProps; settings?: ComponentProps; + update?: ComponentProps; virtualModelUpsert?: ComponentProps; }) { const dialogs = [ @@ -65,7 +68,8 @@ export function AppDialogStack({ extensionSettings ? { key: "extension-settings", node: } : null, claudeDesignConfig ? { key: "extension-config", node: } : null, cursorProxyConfig ? { key: "cursor-proxy-config", node: } : null, - settings ? { key: "settings", node: } : null + settings ? { key: "settings", node: } : null, + update ? { key: "update", node: } : null ].filter((dialog): dialog is { key: string; node: ReactElement } => Boolean(dialog)); return ( diff --git a/src/renderer/pages/home/components/extensions.tsx b/packages/ui/src/pages/home/components/extensions.tsx similarity index 96% rename from src/renderer/pages/home/components/extensions.tsx rename to packages/ui/src/pages/home/components/extensions.tsx index e07818f..05e8b00 100644 --- a/src/renderer/pages/home/components/extensions.tsx +++ b/packages/ui/src/pages/home/components/extensions.tsx @@ -7,7 +7,7 @@ import { Label, motion, normalizeClaudeDesignRuleTypeChange, PluginSettingsDraft, Plus, RouteTargetControl, Search, SelectControl, Settings, TextAreaControl, Toggle, translateOptions, Trash2, useAppText, useMemo, useState, X -} from "../shared"; +} from "../shared/index"; export function ExtensionsView({ configureExtension, config, @@ -303,13 +303,6 @@ export function ConfigureClaudeDesignDialog({ onChange({ enabled })} /> - - onChange({ defaultTarget })} - value={draft.defaultTarget} - /> -
      @@ -350,11 +343,6 @@ export function ConfigureClaudeDesignDialog({ onChangeRule(index, { pattern: event.target.value })} /> ) : null} - {rule.type === "long-context" ? ( - - onChangeRule(index, { threshold: event.target.value })} /> - - ) : null} {isClaudeDesignStaticRuleType(rule.type) ? (
      {t(claudeDesignRouteRuleTypeLabel(rule.type))}
      ) : null} diff --git a/src/renderer/pages/home/components/feedback.tsx b/packages/ui/src/pages/home/components/feedback.tsx similarity index 97% rename from src/renderer/pages/home/components/feedback.tsx rename to packages/ui/src/pages/home/components/feedback.tsx index 14782ec..182818b 100644 --- a/src/renderer/pages/home/components/feedback.tsx +++ b/packages/ui/src/pages/home/components/feedback.tsx @@ -1,7 +1,7 @@ import { AnimatePresence, AppToast, Check, motion, motionEase, reducedMotionTransition, useReducedMotion -} from "../shared"; +} from "../shared/index"; export function LightToast({ toast }: { toast?: AppToast }) { const shouldReduceMotion = useReducedMotion(); diff --git a/src/renderer/pages/home/components/index.ts b/packages/ui/src/pages/home/components/index.ts similarity index 96% rename from src/renderer/pages/home/components/index.ts rename to packages/ui/src/pages/home/components/index.ts index f7816f4..edd7faa 100644 --- a/src/renderer/pages/home/components/index.ts +++ b/packages/ui/src/pages/home/components/index.ts @@ -3,6 +3,7 @@ export { LightToast } from "./feedback"; export { AppDialogStack } from "./dialog-stack"; export { MainLayout, OnboardingLayout } from "./layout"; export { AppSettingsDialog } from "./settings"; +export { UpdateDialog } from "./update"; export { OverviewView, AgentAnalysisView } from "./dashboard"; export { ApiKeysView, AddApiKeyDialog, EditApiKeyDialog } from "./api-keys"; export { ServerView } from "./server"; diff --git a/src/renderer/pages/home/components/layout.tsx b/packages/ui/src/pages/home/components/layout.tsx similarity index 81% rename from src/renderer/pages/home/components/layout.tsx rename to packages/ui/src/pages/home/components/layout.tsx index 319777c..468afc2 100644 --- a/src/renderer/pages/home/components/layout.tsx +++ b/packages/ui/src/pages/home/components/layout.tsx @@ -1,11 +1,11 @@ import type { ComponentProps } from "react"; import { - AnimatedIconSwap, AnimatePresence, AppConfig, AppCopy, Button, cn, EndpointTitleBar, - GatewayStatus, listSpringTransition, LucideIcon, motion, motionEase, - NavigationId, PanelLeftClose, PanelLeftOpen, + AnimatedIconSwap, AnimatePresence, AppConfig, AppCopy, Button, Check, cn, EndpointTitleBar, + AppUpdateStatus, Download, GatewayStatus, listSpringTransition, LucideIcon, motion, motionEase, + LoaderCircle, NavigationId, PanelLeftClose, PanelLeftOpen, RefreshCw, reducedMotionTransition, ServiceControlButton, Settings, ViewId, ViewMotionShell, viewUsesInternalScroll -} from "../shared"; +} from "../shared/index"; import { ApiKeysView } from "./api-keys"; import { AgentAnalysisView, OverviewView } from "./dashboard"; import { ExtensionsView } from "./extensions"; @@ -63,12 +63,15 @@ export function MainLayout({ needsTrafficLightSafeArea, agentAnalysisEnabled, networkCaptureEnabled, + onOpenUpdate, onOpenSettings, onSelectNavigationItem, onToggleSidebar, shouldReduceMotion, sidebarOpen, toggleGatewayService, + updateActionBusy, + updateStatus, viewProps, requestLogsEnabled, visibleNavigation @@ -83,17 +86,37 @@ export function MainLayout({ isMac: boolean; needsTrafficLightSafeArea: boolean; networkCaptureEnabled: boolean; + onOpenUpdate: () => void; onOpenSettings: () => void; onSelectNavigationItem: (id: NavigationId) => void; onToggleSidebar: () => void; shouldReduceMotion: boolean | null; sidebarOpen: boolean; toggleGatewayService: () => void; + updateActionBusy: boolean; + updateStatus: AppUpdateStatus; viewProps: MainViewProps; requestLogsEnabled: boolean; visibleNavigation: MainNavigationItem[]; }) { - const windowControlSafeAreaWidth = isMac ? 152 : 88; + const showUpdateButton = updateStatus.supported; + const windowControlSafeAreaWidth = showUpdateButton + ? (isMac ? 188 : 124) + : (isMac ? 152 : 88); + const updateBusy = + updateActionBusy || + updateStatus.state === "checking" || + updateStatus.state === "downloading" || + updateStatus.state === "installing"; + const updateLabel = updateStatus.state === "downloaded" + ? copy.text["Update ready to install"] ?? "Update ready to install" + : updateStatus.state === "downloading" + ? copy.text["Downloading update"] ?? "Downloading update" + : updateStatus.state === "checking" + ? copy.text["Checking for updates"] ?? "Checking for updates" + : updateStatus.state === "available" + ? copy.text["Download update"] ?? "Download update" + : copy.text["Check for updates"] ?? "Check for updates"; return ( <> @@ -118,6 +141,27 @@ export function MainLayout({ onClick={toggleGatewayService} state={gatewayStatus.state} /> + {showUpdateButton ? ( + + ) : null}
      -
      +
      - {expanded ? : null} + {expanded ? : null}
      ); }); -function LogExpandedDetails({ entry }: { entry: RequestLogEntry }) { +function LogExpandedDetails({ + detailError, + detailLoading, + entry +}: { + detailError?: string; + detailLoading?: boolean; + entry: RequestLogEntry; +}) { const t = useAppText(); + const numberLocale = useAppNumberLocale(); const hasCredentialInfo = logHasCredentialInfo(entry); return ( @@ -520,15 +697,20 @@ function LogExpandedDetails({ entry }: { entry: RequestLogEntry }) { {entry.credentialChain.length ? ")} /> : null} {hasCredentialInfo ? : null} {entry.retryAttempts.length > 0 ? : null} - - - - - - + + + + + +
      {entry.retryAttempts.length > 0 ? : null} + {detailLoading || detailError ? ( +
      + {detailError || t("Loading full payload...")} +
      + ) : null}
      ("body"); const [preferTextBody, setPreferTextBody] = useState(false); + const [fullscreenOpen, setFullscreenOpen] = useState(false); const [query, setQuery] = useState(""); const bodyKey = logBodyCacheKey(body); const bodyView = useMemo(() => cachedFormatLogBodyView(bodyKey, body), [bodyKey]); @@ -670,6 +853,19 @@ function LogJsonPanel({ setPreferTextBody(false); }, [bodyKey]); + useEffect(() => { + if (!fullscreenOpen) { + return; + } + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setFullscreenOpen(false); + } + }; + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, [fullscreenOpen]); + function toggleJsonPath(path: string) { setExpandedJsonPaths((current) => { const next = new Set(current); @@ -706,36 +902,49 @@ function LogJsonPanel({
      {selectedTab === "body" ? ( <> -
      -
      - - setQuery(event.target.value)} - placeholder={t("筛选 JSON...")} - value={query} - /> -
      - {bodyView.json !== undefined && query.trim() === "" ? ( - - ) : null} - {body?.contentType ? {body.contentType} : null} - {body?.truncated ? {t("truncated")} : null} -
      - - {showJsonTree ? ( - - ) : ( -
      {visible}
      - )} + setPreferTextBody((current) => !current)} + preferTextBody={preferTextBody} + query={query} + title={title} + /> + setFullscreenOpen(true)} + > + + {fullscreenOpen ? ( + setFullscreenOpen(false)} + onQueryChange={setQuery} + onToggleJsonPath={toggleJsonPath} + onToggleTextBody={() => setPreferTextBody((current) => !current)} + preferTextBody={preferTextBody} + query={query} + showJsonTree={showJsonTree} + subtitle={subtitle} + title={title} + visible={visible} + value={bodyView.json} + /> + ) : null} ) : (
      @@ -747,6 +956,155 @@ function LogJsonPanel({ ); } +function LogJsonBodyToolbar({ + body, + bodyView, + onQueryChange, + onToggleTextBody, + preferTextBody, + query, + title +}: { + body?: RequestLogBody; + bodyView: ReturnType; + onQueryChange: (value: string) => void; + onToggleTextBody: () => void; + preferTextBody: boolean; + query: string; + title: string; +}) { + const t = useAppText(); + + return ( +
      +
      + + onQueryChange(event.target.value)} + placeholder={t("筛选 JSON...")} + value={query} + /> +
      + {bodyView.json !== undefined && query.trim() === "" ? ( + + ) : null} + {body?.contentType ? {body.contentType} : null} + {body?.truncated ? {t("truncated")} : null} +
      + ); +} + +function LogJsonBodyContent({ + expandedJsonPaths, + onToggleJsonPath, + showJsonTree, + value, + visible +}: { + expandedJsonPaths: Set; + onToggleJsonPath: (path: string) => void; + showJsonTree: boolean; + value: unknown; + visible: string; +}) { + return showJsonTree ? ( + + ) : ( +
      {visible}
      + ); +} + +function LogJsonFullscreenViewer({ + body, + bodyView, + copyLabel, + copyText, + expandedJsonPaths, + onClose, + onQueryChange, + onToggleJsonPath, + onToggleTextBody, + preferTextBody, + query, + showJsonTree, + subtitle, + title, + value, + visible +}: { + body?: RequestLogBody; + bodyView: ReturnType; + copyLabel: string; + copyText: string; + expandedJsonPaths: Set; + onClose: () => void; + onQueryChange: (value: string) => void; + onToggleJsonPath: (path: string) => void; + onToggleTextBody: () => void; + preferTextBody: boolean; + query: string; + showJsonTree: boolean; + subtitle?: string; + title: string; + value: unknown; + visible: string; +}) { + const t = useAppText(); + + return ( +
      +
      +
      + {title} + {subtitle ? {subtitle} : null} + +
      + +
      + + + +
      +
      +
      + ); +} + function logBodyCacheKey(body: RequestLogBody | undefined): string { if (!body) { return "missing"; @@ -850,11 +1208,15 @@ function jsonContainerHiddenSummary(value: Record | unknown[], function LogBodyViewer({ children, copyLabel, - copyText + copyText, + fullscreenLabel, + onFullscreen }: { children: ReactNode; copyLabel: string; copyText: string; + fullscreenLabel?: string; + onFullscreen?: () => void; }) { const t = useAppText(); const [copied, setCopied] = useState(false); @@ -874,20 +1236,33 @@ function LogBodyViewer({ return (
      - +
      + {onFullscreen ? ( + + ) : null} + +
      {children}
      ); @@ -903,7 +1278,7 @@ function LogJsonTree({ value: unknown; }) { return ( -
      +
      ); @@ -1040,10 +1415,27 @@ function JsonPrimitiveValue({ value }: { value: unknown }) { return {String(value)}; } -function NetworkHeaderCell({ label }: { label: string }) { +function NetworkHeaderCell({ + label, + onResizeStart, + resizeLabel +}: { + label: string; + onResizeStart?: (event: ReactPointerEvent) => void; + resizeLabel?: string; +}) { return ( -
      - {label} +
      + {label} + {onResizeStart ? ( +
      ); } diff --git a/src/renderer/pages/home/components/onboarding.tsx b/packages/ui/src/pages/home/components/onboarding.tsx similarity index 99% rename from src/renderer/pages/home/components/onboarding.tsx rename to packages/ui/src/pages/home/components/onboarding.tsx index d2ee1b6..9700048 100644 --- a/src/renderer/pages/home/components/onboarding.tsx +++ b/packages/ui/src/pages/home/components/onboarding.tsx @@ -5,7 +5,7 @@ import { LoaderCircle, onboardingMascotSpriteUrl, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder, ProviderConnectivityCheckReport, reducedMotionTransition, useAppText, useReducedMotion, useState, UserRound, X -} from "../shared"; +} from "../shared/index"; import { AddProviderForm } from "./providers"; import { AddProfileForm } from "./profiles"; diff --git a/src/renderer/pages/home/components/profiles.tsx b/packages/ui/src/pages/home/components/profiles.tsx similarity index 99% rename from src/renderer/pages/home/components/profiles.tsx rename to packages/ui/src/pages/home/components/profiles.tsx index e1bf2f0..f62f282 100644 --- a/src/renderer/pages/home/components/profiles.tsx +++ b/packages/ui/src/pages/home/components/profiles.tsx @@ -5,11 +5,11 @@ import { DialogTitle, Field, GatewayProviderConfig, Info, Input, KeyValueRowsControl, LoaderCircle, motion, normalizeProfileScope, normalizeProfileSurface, parseProfileModelValue, Pencil, Plus, PopoverContent, profileAgentLabel, profileAgentOptions, ProfileConfig, profileModelDisplayValue, profileModelMatchesQuery, profileModelProviderMatchesQuery, - profileModelProviderOptions, profileOpenSurfaces, profileScopeLabel, profileScopeOptions, profileSummaryItems, profileSurfaceLabel, profileSurfaceOptions, + profileModelOptionDisplayName, profileModelProviderOptions, profileOpenSurfaces, profileScopeLabel, profileScopeOptions, profileSummaryItems, profileSurfaceLabel, profileSurfaceOptions, Play, Power, RefreshCw, Search, Select, SelectControl, Terminal, Toggle, translateOptions, Trash2, useAppErrorText, useAppText, type ProfileOpenSurface, type ProfileRuntimeStatus, type ReactNode, type VirtualModelProfileConfig, copyTextToClipboard, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, X -} from "../shared"; +} from "../shared/index"; type ProfileActionBusy = { profileId: string; @@ -516,7 +516,7 @@ function ProfileModelSelector({ filteredProviders.find((provider) => provider.name === parsedValue.provider) ?? filteredProviders[0]; const filteredModels = activeProvider - ? activeProvider.models.filter((model) => profileModelMatchesQuery(activeProvider.name, model, query)) + ? activeProvider.models.filter((model) => profileModelMatchesQuery(activeProvider.name, model, query, profileModelOptionDisplayName(activeProvider, model))) : []; const displayValue = profileModelDisplayValue(value, parsedValue, providers, placeholder, virtualModelProfiles); @@ -729,6 +729,7 @@ function ProfileModelSelector({ ) : null} {activeProvider && filteredModels.map((model) => { const selected = parsedValue.provider === activeProvider.name && parsedValue.model === model; + const displayName = profileModelOptionDisplayName(activeProvider, model); return ( ); diff --git a/src/renderer/pages/home/components/providers.tsx b/packages/ui/src/pages/home/components/providers.tsx similarity index 73% rename from src/renderer/pages/home/components/providers.tsx rename to packages/ui/src/pages/home/components/providers.tsx index 34ac1a8..2b7765b 100644 --- a/src/renderer/pages/home/components/providers.tsx +++ b/packages/ui/src/pages/home/components/providers.tsx @@ -2,22 +2,23 @@ import { AddProviderDraft, AnimatedDisclosure, AnimatedIconSwap, AnimatedListItem, AnimatedPopover, AnimatePresence, AppConfig, Badge, Box, Braces, Button, Card, CardContent, CardHeader, CardTitle, Check, Checkbox, ChevronDown, ChevronRight, CircleAlert, cn, - compareProviderAccountSnapshots, Copy, copyTextToClipboard, createDefaultProviderAccountDraft, createModelCatalogItems, createProviderAccountDraftFromConfig, createProviderCredentialDraft, createProviderInstallLinkFromDraft, + compareProviderAccountSnapshots, copyTextToClipboard, createDefaultProviderAccountDraft, createModelCatalogItems, createProviderAccountDraftFromConfig, createProviderCredentialDraft, customProviderPresetId, defaultProviderAccountConfigForPreset, Dialog, DialogBody, DialogContent, DialogFooter, - DialogHeader, DialogTitle, Field, findProviderPreset, formatProviderAccountMeterValue, GatewayProviderConfig, - GatewayProviderProbeResult, getProviderPresets, Globe, inferProviderNameFromBaseUrl, Input, KeyValueRowsControl, Label, + DialogHeader, DialogTitle, ExternalLink, Field, findProviderPreset, formatProviderAccountMeterValue, GatewayProviderConfig, + GatewayProviderProbeResult, getProviderPresets, Globe, inferProviderNameFromBaseUrl, Info, Input, KeyValueRowsControl, Label, Layers3, LoaderCircle, localAgentProviderIconUrls, mergeProviderModelLists, modelCatalogItemMatchesQuery, motion, Pencil, Plus, PopoverContent, primaryProviderAccountMeter, primaryProviderPresetEndpoint, providerAccountConnectorApiKeySafetyIssue, providerAccountConnectorExample, ProviderAccountDraftMode, providerAccountModeOptions, ProviderAccountSnapshot, - providerAccountSnapshotCredentialLabel, providerAccountSnapshotLabel, ProviderAccountTestPath, - ProviderAccountTestResult, providerBaseUrl, providerCapabilitiesSummary, ProviderCredentialDraft, ProviderDeepLinkRequest, providerDraftSafetyIssue, providerCredentialDraftPatchFromJson, providerHttpJsonConnectorFromDraft, - ProviderConnectivityCheckReport, providerDeepLinkDisplayIcon, providerListItemKey, providerMatchesQuery, ProviderPreset, providerPresetIconUrls, providerProbeHasSupportedProtocol, - providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl, + providerAccountConnectorsTextWithNewApiUserBalanceTemplate, providerAccountSnapshotCredentialLabel, providerAccountSnapshotLabel, ProviderAccountTestPath, + ProviderAccountTestResult, providerBaseUrl, providerCapabilitiesSummary, ProviderCredentialDraft, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerDraftSafetyIssue, providerCredentialDraftPatchFromJson, providerHttpJsonConnectorFromDraft, + ProviderConnectivityCheckReport, providerCapabilityBaseUrlForProtocol, providerDeepLinkDisplayIcon, providerListItemKey, providerMatchesQuery, ProviderPreset, providerPresetIconUrls, providerProbeHasSupportedProtocol, + providerDisplayIcon, providerGlobalBaseUrlForProbe, providerModelDisplayName, providerModelDisplayTitle, providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl, resolveProviderDeepLinkPreset, ShieldCheck, splitLines, splitModelTagInput, Switch, Textarea, translatedProviderProtocolLabel, translateOptions, translateProbeProtocolMessage, Trash2, uniqueProviderName, uniqueProviderProtocols, useAppErrorText, useAppText, useEffect, useMemo, useRef, useState, X, isPlainRecord -} from "../shared"; -import type { LocalAgentProviderCandidate } from "../../../../shared/app"; +} from "../shared/index"; +import { providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; +import type { LocalAgentProviderCandidate } from "@ccr/core/contracts/app"; export function ProvidersView({ accountSnapshots, addProvider, editProvider, notify, providers, removeProvider }: { accountSnapshots: ProviderAccountSnapshot[]; addProvider: () => void; @@ -110,24 +111,25 @@ export function ProvidersView({ accountSnapshots, addProvider, editProvider, not
      - {visibleProviders.map(({ provider, index }) => { - const itemKey = providerListItemKey(provider, index); - const expanded = expandedProviders.has(itemKey); + {visibleProviders.map(({ provider, index }) => { + const itemKey = providerListItemKey(provider, index); + const expanded = expandedProviders.has(itemKey); const providerAccountSnapshots = accountSnapshotsByProvider.get(provider.name) ?? []; - return ( - -
      toggleProvider(provider, index)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - toggleProvider(provider, index); - } - }} - role="button" - tabIndex={0} - > + const providerIconUrl = providerDisplayIcon(provider); + return ( + +
      toggleProvider(provider, index)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggleProvider(provider, index); + } + }} + role="button" + tabIndex={0} + >
      +
      {provider.name || t("Unnamed")}
      @@ -215,16 +218,17 @@ export function ProvidersView({ accountSnapshots, addProvider, editProvider, not
      {provider.models.map((model) => { const modelKey = `${itemKey}:${model}`; + const displayName = providerModelDisplayName(provider, model); return ( ); })} @@ -248,8 +252,22 @@ export function ProvidersView({ accountSnapshots, addProvider, editProvider, not ); } -export function ModelsView({ config }: { config: AppConfig }) { +export function ModelsView({ + config, + updateModelDescription +}: { + config: AppConfig; + updateModelDescription: (providerIndex: number, model: string, description: string) => void; +}) { const t = useAppText(); + const [descriptionDraft, setDescriptionDraft] = useState(""); + const [descriptionTarget, setDescriptionTarget] = useState<{ + description: string; + displayName?: string; + model: string; + providerIndex: number; + providerName?: string; + }>(); const [query, setQuery] = useState(""); const rows = useMemo(() => createModelCatalogItems(config), [config]); const normalizedQuery = query.trim().toLowerCase(); @@ -258,6 +276,34 @@ export function ModelsView({ config }: { config: AppConfig }) { [normalizedQuery, rows] ); + function openDescriptionDialog(row: (typeof rows)[number]) { + if (row.providerIndex === undefined) { + return; + } + const description = row.description ?? ""; + setDescriptionDraft(description); + setDescriptionTarget({ + description, + displayName: row.displayName, + model: row.model, + providerIndex: row.providerIndex, + providerName: row.providerName + }); + } + + function closeDescriptionDialog() { + setDescriptionDraft(""); + setDescriptionTarget(undefined); + } + + function saveDescriptionDialog() { + if (!descriptionTarget) { + return; + } + updateModelDescription(descriptionTarget.providerIndex, descriptionTarget.model, descriptionDraft); + closeDescriptionDialog(); + } + return ( 0 ? (
      -
      -
      +
      +
      {t("Model")}
      +
      {t("Description")}
      {visibleRows.map((row) => (
      -
      - {row.model} +
      + {row.displayName ?? row.model}
      +
      + {row.providerName ? `${row.providerName}/${row.model}` : row.model} +
      +
      +
      + {row.providerIndex !== undefined ? ( +
      +
      +
      + {row.description || "-"} +
      +
      + +
      + ) : ( +
      + {row.description || "-"} +
      + )}
      ))} @@ -320,15 +395,80 @@ export function ModelsView({ config }: { config: AppConfig }) { ) : null} + ); } +function ModelCatalogDescriptionDialog({ + draft, + onChange, + onClose, + onSave, + target +}: { + draft: string; + onChange: (value: string) => void; + onClose: () => void; + onSave: () => void; + target?: { + description: string; + displayName?: string; + model: string; + providerName?: string; + }; +}) { + const t = useAppText(); + const open = Boolean(target); + const title = target?.displayName || target?.model || t("Model"); + const subtitle = target?.providerName ? `${target.providerName}/${target.model}` : target?.model; + + return ( + { if (!nextOpen) onClose(); }}> + + + {t("Edit description")} + + +
      +
      {title}
      + {subtitle ?
      {subtitle}
      : null} +
      + +