mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-10-19 00:46:15 +00:00
chore(release): v3.1.2
This commit is contained in:
commit
858189daba
31 changed files with 888 additions and 72 deletions
|
|
@ -9,6 +9,7 @@ release
|
|||
release-local
|
||||
logs
|
||||
tmp
|
||||
.tmp
|
||||
test-results
|
||||
playwright-report
|
||||
blob-report
|
||||
|
|
|
|||
34
.github/actions/prepare-release-gateway/action.yml
vendored
Normal file
34
.github/actions/prepare-release-gateway/action.yml
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
name: Prepare release gateway
|
||||
description: Build the pinned gateway with CCR's regression fixes for release artifacts.
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Check out pinned gateway source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: The-NeXT-AI/ai-gateway
|
||||
ref: f061ad4371b6cf99d5e248746eac1e4ffdda8980
|
||||
path: .tmp/release-gateway
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Apply gateway regression fixes
|
||||
shell: bash
|
||||
working-directory: .tmp/release-gateway
|
||||
run: |
|
||||
git apply ../../patches/ai-gateway/0001-fix-tool-ids-and-fusion-traces.patch
|
||||
git apply ../../patches/ai-gateway/0002-complete-native-replay-and-response-status.patch
|
||||
|
||||
- name: Build and verify patched gateway
|
||||
shell: bash
|
||||
working-directory: .tmp/release-gateway
|
||||
run: |
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npx vitest run src/gateway/routes.test.ts src/adapters/builtins/anthropic-tool-id.test.ts
|
||||
npm run build
|
||||
mkdir -p ../../docker/local-ai-gateway
|
||||
npm pack --ignore-scripts --pack-destination ../../docker/local-ai-gateway
|
||||
3
.github/workflows/docker-release.yml
vendored
3
.github/workflows/docker-release.yml
vendored
|
|
@ -54,6 +54,9 @@ jobs:
|
|||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "major_minor=${version%.*}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Prepare patched gateway package
|
||||
uses: ./.github/actions/prepare-release-gateway
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
|
|
|
|||
56
.github/workflows/release.yml
vendored
56
.github/workflows/release.yml
vendored
|
|
@ -14,7 +14,7 @@ concurrency:
|
|||
|
||||
jobs:
|
||||
model-catalog:
|
||||
name: Update model catalog
|
||||
name: Prepare model catalog
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
|
|
@ -26,8 +26,8 @@ jobs:
|
|||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Update models.json
|
||||
run: npm run models:update
|
||||
- name: Validate checked-in model catalog
|
||||
run: node -e "JSON.parse(require('node:fs').readFileSync('packages/core/models.json', 'utf8'))"
|
||||
|
||||
- name: Upload model catalog
|
||||
uses: actions/upload-artifact@v4
|
||||
|
|
@ -37,9 +37,27 @@ jobs:
|
|||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
gateway:
|
||||
name: Build patched gateway
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare patched gateway package
|
||||
uses: ./.github/actions/prepare-release-gateway
|
||||
|
||||
- name: Upload patched gateway package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-patched-gateway
|
||||
path: docker/local-ai-gateway/the-next-ai-ai-gateway-*.tgz
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
macos:
|
||||
name: macOS (${{ matrix.display_name }})
|
||||
needs: model-catalog
|
||||
needs: [model-catalog, gateway]
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
|
@ -70,6 +88,16 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Download patched gateway package
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-patched-gateway
|
||||
path: .tmp/gateway-package
|
||||
|
||||
- name: Install patched gateway
|
||||
shell: bash
|
||||
run: npm install --no-save --package-lock=false .tmp/gateway-package/*.tgz
|
||||
|
||||
- name: Download updated model catalog
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
|
|
@ -186,6 +214,16 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Download patched gateway package
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-patched-gateway
|
||||
path: .tmp/gateway-package
|
||||
|
||||
- name: Install patched gateway
|
||||
shell: bash
|
||||
run: npm install --no-save --package-lock=false .tmp/gateway-package/*.tgz
|
||||
|
||||
- name: Download updated model catalog
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
|
|
@ -224,6 +262,16 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Download patched gateway package
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-patched-gateway
|
||||
path: .tmp/gateway-package
|
||||
|
||||
- name: Install patched gateway
|
||||
shell: bash
|
||||
run: npm install --no-save --package-lock=false .tmp/gateway-package/*.tgz
|
||||
|
||||
- name: Download updated model catalog
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -8,11 +8,9 @@
|
|||
</a>
|
||||
<br />
|
||||
<sub>
|
||||
<a href="https://www.kimi.com/code?aff=ccr"><strong>Kimi Code Subscription</strong></a>
|
||||
<strong>Kimi Code plan</strong> (<a href="https://www.kimi.com/code?aff=ccr">中文站</a> | <a href="https://www.kimi.ai/code?aff=ccr">Global</a>)
|
||||
·
|
||||
<a href="https://platform.kimi.ai?aff=ccr"><strong>API Global</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.com?aff=ccr">API China</a>
|
||||
<strong>API</strong> (<a href="https://platform.kimi.com?aff=ccr">中文站</a> | <a href="https://platform.kimi.ai?aff=ccr">Global</a>)
|
||||
</sub>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -22,7 +20,7 @@
|
|||
<strong>Thanks to Kimi for sponsoring this project!</strong> Kimi K3 is Moonshot AI's most capable model and the world's first open 3T-class model. With 2.8 trillion parameters, native vision, and a 1-million-token context window, K3 delivers frontier performance across long-horizon coding, knowledge work, and reasoning. Inside CCR, Kimi ships as a built-in provider preset: import the pay-as-you-go API or Kimi Code subscription in one click and route your coding agent's requests to Kimi. The subscription endpoint passes through natively without protocol conversion, API endpoints are adapted automatically, and account balance and subscription usage are visible in the CCR dashboard.
|
||||
</p>
|
||||
<p align="center">
|
||||
CCR already includes Kimi provider presets. Visit the Kimi Open Platform (<a href="https://platform.kimi.com?aff=ccr">中文站</a> | <a href="https://platform.kimi.ai?aff=ccr">Global</a>) to try the API, or explore the <a href="https://www.kimi.com/code?aff=ccr">Kimi Code subscription</a>.
|
||||
CCR already includes Kimi provider presets. Visit the Kimi Open Platform (<a href="https://platform.kimi.com?aff=ccr">中文站</a> | <a href="https://platform.kimi.ai?aff=ccr">Global</a>) to try the <strong>API</strong>, or explore the <strong>Kimi Code plan</strong> (<a href="https://www.kimi.com/code?aff=ccr">中文站</a> | <a href="https://www.kimi.ai/code?aff=ccr">Global</a>).
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -169,28 +167,28 @@ CCR supports OpenAI Chat / Responses, Anthropic Messages, Gemini Generate Conten
|
|||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center" width="330">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.1.0/Claude-Code-Router_3.1.0.exe">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.1.2/Claude-Code-Router_3.1.2.exe">
|
||||
<img src="/docs/public/platform-icons/windows.png" width="44" height="44" alt="Windows logo" />
|
||||
<br />
|
||||
<strong>Windows</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.1.0/Claude-Code-Router_3.1.0.AppImage">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.1.2/Claude-Code-Router_3.1.2.AppImage">
|
||||
<img src="/docs/public/platform-icons/linux.png" width="44" height="44" alt="Linux logo" />
|
||||
<br />
|
||||
<strong>Linux</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.1.0/Claude-Code-Router_3.1.0-mac-Apple-Silicon-arm64.dmg">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.1.2/Claude-Code-Router_3.1.2-mac-Apple-Silicon-arm64.dmg">
|
||||
<img src="/docs/public/platform-icons/macos.png" width="44" height="44" alt="macOS logo" />
|
||||
<br />
|
||||
<strong>macOS (Apple Silicon)</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.1.0/Claude-Code-Router_3.1.0-mac-Intel-x64.dmg">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.1.2/Claude-Code-Router_3.1.2-mac-Intel-x64.dmg">
|
||||
<img src="/docs/public/platform-icons/macos.png" width="44" height="44" alt="macOS logo" />
|
||||
<br />
|
||||
<strong>macOS (Intel)</strong>
|
||||
|
|
|
|||
16
README_zh.md
16
README_zh.md
|
|
@ -8,11 +8,9 @@
|
|||
</a>
|
||||
<br />
|
||||
<sub>
|
||||
<a href="https://www.kimi.com/code?aff=ccr"><strong>Kimi Code 订阅</strong></a>
|
||||
<strong>Kimi Code 订阅</strong>(<a href="https://www.kimi.com/code?aff=ccr">中文站</a>|<a href="https://www.kimi.ai/code?aff=ccr">Global</a>)
|
||||
·
|
||||
<a href="https://platform.kimi.com?aff=ccr"><strong>API 中文站</strong></a>
|
||||
·
|
||||
<a href="https://platform.kimi.ai?aff=ccr">API Global</a>
|
||||
<strong>API</strong>(<a href="https://platform.kimi.com?aff=ccr">中文站</a>|<a href="https://platform.kimi.ai?aff=ccr">Global</a>)
|
||||
</sub>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -22,7 +20,7 @@
|
|||
<strong>感谢 Kimi 赞助本项目!</strong>Kimi K3 是 Moonshot AI 迄今能力最强的模型,也是全球首个开源 3T 级模型。K3 拥有 2.8T 参数、原生视觉能力与 100 万 Token 上下文,在长周期编码、知识工作和推理任务中展现前沿性能。在 CCR 中,Kimi 已作为内置供应商预设开箱即用:无论按量付费 API 还是 Kimi Code 订阅,一键导入即可将编程 Agent 的请求路由到 Kimi;订阅端点原生直通、无需协议转换,API 端点自动适配,账户余额与订阅用量也能直接在 CCR 面板中查看。
|
||||
</p>
|
||||
<p align="center">
|
||||
CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(<a href="https://platform.kimi.com?aff=ccr">中文站</a>|<a href="https://platform.kimi.ai?aff=ccr">Global</a>)体验 API,或了解 <a href="https://www.kimi.com/code?aff=ccr">Kimi Code 订阅</a>。
|
||||
CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(<a href="https://platform.kimi.com?aff=ccr">中文站</a>|<a href="https://platform.kimi.ai?aff=ccr">Global</a>)体验 <strong>API</strong>,或了解 <strong>Kimi Code 订阅</strong>(<a href="https://www.kimi.com/code?aff=ccr">中文站</a>|<a href="https://www.kimi.ai/code?aff=ccr">Global</a>)。
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -169,28 +167,28 @@ CCR 支持 OpenAI Chat / Responses、Anthropic Messages、Gemini Generate Conten
|
|||
<table width="100%">
|
||||
<tr>
|
||||
<td align="center" width="330">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.22/Claude-Code-Router_3.0.22.exe">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.1.2/Claude-Code-Router_3.1.2.exe">
|
||||
<img src="/docs/public/platform-icons/windows.png" width="44" height="44" alt="Windows 图标" />
|
||||
<br />
|
||||
<strong>Windows</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.22/Claude-Code-Router_3.0.22.AppImage">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.1.2/Claude-Code-Router_3.1.2.AppImage">
|
||||
<img src="/docs/public/platform-icons/linux.png" width="44" height="44" alt="Linux 图标" />
|
||||
<br />
|
||||
<strong>Linux</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.22/Claude-Code-Router_3.0.22-mac-Apple-Silicon-arm64.dmg">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.1.2/Claude-Code-Router_3.1.2-mac-Apple-Silicon-arm64.dmg">
|
||||
<img src="/docs/public/platform-icons/macos.png" width="44" height="44" alt="macOS 图标" />
|
||||
<br />
|
||||
<strong>macOS (Apple Silicon)</strong>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="330">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.0.22/Claude-Code-Router_3.0.22-mac-Intel-x64.dmg">
|
||||
<a href="https://github.com/musistudio/claude-code-router/releases/download/v3.1.2/Claude-Code-Router_3.1.2-mac-Intel-x64.dmg">
|
||||
<img src="/docs/public/platform-icons/macos.png" width="44" height="44" alt="macOS 图标" />
|
||||
<br />
|
||||
<strong>macOS (Intel)</strong>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ Use `ccr-fusion-builtins / web_search`.
|
|||
|
||||
## Search Providers
|
||||
|
||||
Supported providers include In-app Browser, Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.
|
||||
Supported providers include In-app Browser, Brave, Bing, Google CSE, Serper, SerpAPI, Serply, Tavily, and Exa.
|
||||
|
||||
## In-app Browser
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ Configuration options include search engine, language, country or region, and sa
|
|||
- 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.
|
||||
> 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, Serply, Tavily, or Exa instead.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ lead: 为不支持联网的模型添加实时检索能力:选择 CCR 内置 we
|
|||
|
||||
## 搜索服务
|
||||
|
||||
支持 In-app Browser、Brave、Bing、Google CSE、Serper、SerpAPI、Tavily、Exa 等搜索服务。
|
||||
支持 In-app Browser、Brave、Bing、Google CSE、Serper、SerpAPI、Serply、Tavily、Exa 等搜索服务。
|
||||
|
||||
## In-app Browser
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ lead: 为不支持联网的模型添加实时检索能力:选择 CCR 内置 we
|
|||
- 地区:例如 `US`、`CN`。
|
||||
- 安全搜索:默认、中等、严格或关闭。
|
||||
|
||||
> 注意:`In-app Browser` 依赖 CCR Desktop 的 Electron 内置浏览器能力,只在桌面端可用。CLI、服务器部署或纯 Web 环境没有内置浏览器集成,请改用 Brave、Bing、Google CSE、Serper、SerpAPI、Tavily 或 Exa 等搜索服务。
|
||||
> 注意:`In-app Browser` 依赖 CCR Desktop 的 Electron 内置浏览器能力,只在桌面端可用。CLI、服务器部署或纯 Web 环境没有内置浏览器集成,请改用 Brave、Bing、Google CSE、Serper、SerpAPI、Serply、Tavily 或 Exa 等搜索服务。
|
||||
|
||||
## 排查要点
|
||||
|
||||
|
|
|
|||
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "claude-code-router-monorepo",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-code-router-monorepo",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.2",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
|
|
@ -8998,7 +8998,7 @@
|
|||
},
|
||||
"packages/cli": {
|
||||
"name": "@musistudio/claude-code-router",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@the-next-ai/ai-gateway": "^1.0.21",
|
||||
|
|
@ -9016,7 +9016,7 @@
|
|||
},
|
||||
"packages/core": {
|
||||
"name": "@claude-code-router/core",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.2",
|
||||
"dependencies": {
|
||||
"@the-next-ai/ai-gateway": "^1.0.21",
|
||||
"@the-next-ai/bot-gateway-sdk": "^0.1.0",
|
||||
|
|
@ -9034,7 +9034,7 @@
|
|||
},
|
||||
"packages/electron": {
|
||||
"name": "@claude-code-router/electron",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.2",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.11.1"
|
||||
},
|
||||
|
|
@ -9044,7 +9044,7 @@
|
|||
},
|
||||
"packages/ui": {
|
||||
"name": "@claude-code-router/ui",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.2",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "claude-code-router-monorepo",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.2",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"description": "Local Claude Code Router gateway with CLI and web management UI.",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@musistudio/claude-code-router",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.2",
|
||||
"license": "MIT",
|
||||
"description": "Local Claude Code Router gateway with CLI and web management UI.",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@claude-code-router/core",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.2",
|
||||
"private": true,
|
||||
"description": "Claude Code Router core gateway, routing, provider, and storage services.",
|
||||
"main": "dist/main/server.js",
|
||||
|
|
|
|||
238
packages/core/src/agents/local-providers/opencode-freetier.ts
Normal file
238
packages/core/src/agents/local-providers/opencode-freetier.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
isRecord,
|
||||
providerNamePlaceholder,
|
||||
providerNameSlugPlaceholder,
|
||||
readString
|
||||
} from "@ccr/core/agents/local-providers/shared";
|
||||
|
||||
/**
|
||||
* Free-tier client fingerprint for login-less ("public") OpenCode Zen imports.
|
||||
*
|
||||
* Zen serves zero-cost models to anonymous callers only when the request looks
|
||||
* like it comes from the official OpenCode client: the User-Agent must carry
|
||||
* the `opencode/` prefix, the `x-opencode-session` / `x-opencode-request`
|
||||
* headers must be well-formed client IDs with a fresh embedded timestamp, the
|
||||
* request must stream, and the declared tools must include OpenCode's own
|
||||
* `bash` and `read` (other tools may be declared alongside them). Anything
|
||||
* else is rejected with `403 FreeTierError: "OpenCode's free tier can only be
|
||||
* used from within OpenCode"`.
|
||||
*
|
||||
* The ID layout mirrors the open-source OpenCode ID generator
|
||||
* (`ses_` descending, `msg_` ascending: 12 hex chars of
|
||||
* `(timestampMs * 0x1000 + counter)` over 48 bits, plus 14 random base62
|
||||
* chars). There is no shared secret involved, so CCR can mint equally valid
|
||||
* IDs per request. If Zen ever hardens this gate (it already did once),
|
||||
* public imports stop working and the supported fallback is a logged-in Zen
|
||||
* API key.
|
||||
*
|
||||
* These headers never touch authentication: the provider keeps `apiKey:
|
||||
* "public"` and the hook below only sets client-identification headers.
|
||||
*/
|
||||
export const OPENCODE_PUBLIC_FREETIER_PLUGIN_SUFFIX = "opencode-public-freetier";
|
||||
|
||||
export const OPENCODE_PUBLIC_FREETIER_USER_AGENT_FALLBACK = "opencode/1.18.31";
|
||||
|
||||
const OPENCODE_CLIENT = "cli";
|
||||
const OPENCODE_ID_TIME_BYTES = 6;
|
||||
const OPENCODE_ID_TIME_HEX_LENGTH = OPENCODE_ID_TIME_BYTES * 2;
|
||||
const OPENCODE_ID_RANDOM_LENGTH = 14;
|
||||
const OPENCODE_ID_SUFFIX_LENGTH = OPENCODE_ID_TIME_HEX_LENGTH + OPENCODE_ID_RANDOM_LENGTH;
|
||||
const OPENCODE_ID_BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
const OPENCODE_FREETIER_REQUIRED_TOOL_NAMES = ["bash", "read"];
|
||||
const OPENCODE_FREETIER_PLACEHOLDER_TOOL_DESCRIPTION = "Unavailable in this client. Never call this tool; use the other tools instead.";
|
||||
const OPENCODE_FREETIER_PLACEHOLDER_TOOL_PARAMETERS = { properties: {}, type: "object" };
|
||||
|
||||
let freetierIdCounter = 0;
|
||||
let freetierIdLastTimestamp = 0;
|
||||
let cachedUserAgent: string | undefined;
|
||||
|
||||
export function isOpenCodePublicFreeTierPlugin(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const key = readString(value.key)?.toLowerCase();
|
||||
return Boolean(key?.startsWith("ccr-local-agent-") && key.includes(OPENCODE_PUBLIC_FREETIER_PLUGIN_SUFFIX));
|
||||
}
|
||||
|
||||
export function openCodePublicFreeTierPlugin(providerName = providerNamePlaceholder, keySuffix = ""): Record<string, unknown> {
|
||||
return {
|
||||
key: `ccr-local-agent-${providerNameSlugPlaceholder}-${OPENCODE_PUBLIC_FREETIER_PLUGIN_SUFFIX}${keySuffix}`,
|
||||
providerName,
|
||||
// The gateway drops provider plugins without an auth/request/response
|
||||
// section before handing the config to runtime hooks, so a bare marker
|
||||
// would never reach the free-tier hook.
|
||||
request: {
|
||||
headers: { "x-opencode-client": OPENCODE_CLIENT }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function mintOpenCodeSessionId(timestamp = Date.now()): string {
|
||||
return formatOpenCodeId("ses", "descending", timestamp, nextFreeTierCounter(timestamp), randomOpenCodeIdPart());
|
||||
}
|
||||
|
||||
export function mintOpenCodeRequestId(timestamp = Date.now()): string {
|
||||
return formatOpenCodeId("msg", "ascending", timestamp, nextFreeTierCounter(timestamp), randomOpenCodeIdPart());
|
||||
}
|
||||
|
||||
export function formatOpenCodeId(
|
||||
prefix: string,
|
||||
direction: "ascending" | "descending",
|
||||
timestampMs: number,
|
||||
counter: number,
|
||||
randomPart: string
|
||||
): string {
|
||||
const mask = (1n << BigInt(OPENCODE_ID_TIME_BYTES * 8)) - 1n;
|
||||
let now = BigInt(Math.trunc(timestampMs)) * 0x1000n + BigInt(Math.trunc(counter));
|
||||
now = direction === "descending" ? ~now & mask : now & mask;
|
||||
return `${prefix}_${now.toString(16).padStart(OPENCODE_ID_TIME_HEX_LENGTH, "0")}${randomPart}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the embedded millisecond timestamp from an ascending (`msg_`) or
|
||||
* descending (`ses_`) client ID. The 48-bit time field wraps roughly every 2.2
|
||||
* years, so callers must compare against the same wrap window.
|
||||
*/
|
||||
export function openCodeIdTimestampMs(id: string): number | undefined {
|
||||
const separator = id.indexOf("_");
|
||||
if (separator <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const suffix = id.slice(separator + 1);
|
||||
if (suffix.length !== OPENCODE_ID_SUFFIX_LENGTH || !/^[0-9A-Za-z]{26}$/.test(suffix)) {
|
||||
return undefined;
|
||||
}
|
||||
const hex = suffix.slice(0, OPENCODE_ID_TIME_HEX_LENGTH);
|
||||
if (!/^[0-9a-f]{12}$/.test(hex)) {
|
||||
return undefined;
|
||||
}
|
||||
const mask = (1n << BigInt(OPENCODE_ID_TIME_BYTES * 8)) - 1n;
|
||||
const encoded = BigInt(`0x${hex}`);
|
||||
const timestamp = id.slice(0, separator) === "ses" ? (~encoded & mask) / 0x1000n : encoded / 0x1000n;
|
||||
return Number(timestamp);
|
||||
}
|
||||
|
||||
export function openCodePublicFreeTierHeaders(): Record<string, string> {
|
||||
return {
|
||||
"user-agent": openCodePublicFreeTierUserAgent(),
|
||||
"x-opencode-client": OPENCODE_CLIENT,
|
||||
"x-opencode-request": mintOpenCodeRequestId(),
|
||||
"x-opencode-session": mintOpenCodeSessionId()
|
||||
};
|
||||
}
|
||||
|
||||
export function applyOpenCodePublicFreeTierHeaders(headers: Record<string, string> | undefined): Record<string, string> {
|
||||
const next: Record<string, string> = { ...(headers ?? {}) };
|
||||
for (const [name, value] of Object.entries(openCodePublicFreeTierHeaders())) {
|
||||
for (const key of Object.keys(next)) {
|
||||
if (key.toLowerCase() === name) {
|
||||
delete next[key];
|
||||
}
|
||||
}
|
||||
next[name] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function withOpenCodePublicFreeTierTools(body: unknown, upstreamUrl: string): unknown {
|
||||
if (!isRecord(body)) {
|
||||
return body;
|
||||
}
|
||||
const tools = Array.isArray(body.tools) ? body.tools : [];
|
||||
const declaredNames = new Set(tools.map(declaredToolName));
|
||||
const missingNames = OPENCODE_FREETIER_REQUIRED_TOOL_NAMES.filter((name) => !declaredNames.has(name));
|
||||
if (missingNames.length === 0) {
|
||||
return body;
|
||||
}
|
||||
const placeholderTool = isResponsesEndpoint(upstreamUrl) ? responsesPlaceholderTool : chatCompletionsPlaceholderTool;
|
||||
return { ...body, tools: [...tools, ...missingNames.map(placeholderTool)] };
|
||||
}
|
||||
|
||||
export function openCodePublicFreeTierUserAgent(): string {
|
||||
const override = process.env.CCR_OPENCODE_USER_AGENT?.trim();
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
if (cachedUserAgent) {
|
||||
return cachedUserAgent;
|
||||
}
|
||||
cachedUserAgent = readInstalledOpenCodeUserAgent() ?? OPENCODE_PUBLIC_FREETIER_USER_AGENT_FALLBACK;
|
||||
return cachedUserAgent;
|
||||
}
|
||||
|
||||
export function resetOpenCodePublicFreeTierState(): void {
|
||||
freetierIdCounter = 0;
|
||||
freetierIdLastTimestamp = 0;
|
||||
cachedUserAgent = undefined;
|
||||
}
|
||||
|
||||
function nextFreeTierCounter(timestamp: number): number {
|
||||
if (timestamp !== freetierIdLastTimestamp) {
|
||||
freetierIdLastTimestamp = timestamp;
|
||||
freetierIdCounter = 0;
|
||||
}
|
||||
freetierIdCounter += 1;
|
||||
return freetierIdCounter;
|
||||
}
|
||||
|
||||
function declaredToolName(tool: unknown): string | undefined {
|
||||
if (!isRecord(tool)) {
|
||||
return undefined;
|
||||
}
|
||||
return readString(tool.name) ?? (isRecord(tool.function) ? readString(tool.function.name) : undefined);
|
||||
}
|
||||
|
||||
function isResponsesEndpoint(upstreamUrl: string): boolean {
|
||||
try {
|
||||
return new URL(upstreamUrl).pathname.replace(/\/+$/, "").endsWith("/responses");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function responsesPlaceholderTool(name: string): Record<string, unknown> {
|
||||
return {
|
||||
description: OPENCODE_FREETIER_PLACEHOLDER_TOOL_DESCRIPTION,
|
||||
name,
|
||||
parameters: OPENCODE_FREETIER_PLACEHOLDER_TOOL_PARAMETERS,
|
||||
type: "function"
|
||||
};
|
||||
}
|
||||
|
||||
function chatCompletionsPlaceholderTool(name: string): Record<string, unknown> {
|
||||
return {
|
||||
function: {
|
||||
description: OPENCODE_FREETIER_PLACEHOLDER_TOOL_DESCRIPTION,
|
||||
name,
|
||||
parameters: OPENCODE_FREETIER_PLACEHOLDER_TOOL_PARAMETERS
|
||||
},
|
||||
type: "function"
|
||||
};
|
||||
}
|
||||
|
||||
function randomOpenCodeIdPart(): string {
|
||||
const bytes = randomBytes(OPENCODE_ID_RANDOM_LENGTH);
|
||||
let result = "";
|
||||
for (let index = 0; index < OPENCODE_ID_RANDOM_LENGTH; index += 1) {
|
||||
result += OPENCODE_ID_BASE62[bytes[index] % OPENCODE_ID_BASE62.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function readInstalledOpenCodeUserAgent(): string | undefined {
|
||||
try {
|
||||
const output = execFileSync("opencode", ["--version"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 3000
|
||||
}).trim();
|
||||
const version = output.match(/\d+\.\d+\.\d+/)?.[0];
|
||||
return version ? `opencode/${version}` : undefined;
|
||||
} catch {
|
||||
// The CCR host may not have the OpenCode CLI installed; callers fall back
|
||||
// to a pinned recent client version instead of failing the request.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
uniqueStrings
|
||||
} from "@ccr/core/agents/local-providers/shared";
|
||||
import { findProviderPresetByBaseUrl } from "@ccr/core/providers/presets/index";
|
||||
import { openCodePublicFreeTierPlugin } from "@ccr/core/agents/local-providers/opencode-freetier";
|
||||
|
||||
type OpenCodeCredential = {
|
||||
apiKey?: string;
|
||||
|
|
@ -191,7 +192,13 @@ export function importOpenCodeProvider(
|
|||
...provider,
|
||||
apiKey: "public"
|
||||
},
|
||||
providerPlugins: []
|
||||
// Marker plugins only: the gateway runtime hook mints a fresh OpenCode
|
||||
// free-tier client fingerprint per request. No auth section here on
|
||||
// purpose, so a later real api_key edit is never overridden.
|
||||
providerPlugins: [
|
||||
openCodePublicFreeTierPlugin(),
|
||||
openCodePublicFreeTierPlugin(providerInternalNamePlaceholder, "-internal")
|
||||
]
|
||||
};
|
||||
}
|
||||
const apiKey = credential?.apiKey;
|
||||
|
|
|
|||
|
|
@ -1080,6 +1080,7 @@ export type VirtualModelFusionWebSearchProvider =
|
|||
| "google_cse"
|
||||
| "serper"
|
||||
| "serpapi"
|
||||
| "serply"
|
||||
| "tavily"
|
||||
| "exa";
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { uniqueStrings } from "@ccr/core/gateway/internal/collections";
|
|||
import { isLocalClaudeCodeOauthProviderPlugin, mergeAnthropicBetaValues } from "@ccr/core/providers/oauth-plugin";
|
||||
import { isLocalAgentOauthProviderPlugin } from "@ccr/core/gateway/core-runtime/local-agent-auth-provider-hook";
|
||||
import { ccrRouterPluginKey } from "@ccr/core/gateway/core-runtime/router-plugin-contract";
|
||||
import { isOpenCodePublicFreeTierPlugin } from "@ccr/core/agents/local-providers/opencode-freetier";
|
||||
import { resolveConfiguredProviderModelSelector, resolveUniqueConfiguredProviderModelSelector } from "@ccr/core/routing/model-resolution";
|
||||
|
||||
const upstreamHeaderSanitizerPluginKey = "ccr-upstream-header-sanitizer";
|
||||
|
|
@ -219,7 +220,7 @@ function coreGatewayStaticAuthKeys(
|
|||
}
|
||||
|
||||
function localAgentAuthProviderHookPluginConfig(providerPlugins: unknown[]): Record<string, unknown> | undefined {
|
||||
if (!providerPlugins.some(isLocalAgentOauthProviderPlugin)) {
|
||||
if (!providerPlugins.some((plugin) => isLocalAgentOauthProviderPlugin(plugin) || isOpenCodePublicFreeTierPlugin(plugin))) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { readClaudeCodeOauth, readGrokAuth, readKimiAuth, resolveGrokAuth, resolveKimiAuth } from "@ccr/core/agents/local-providers/service";
|
||||
import { applyOpenCodePublicFreeTierHeaders, isOpenCodePublicFreeTierPlugin, withOpenCodePublicFreeTierTools } from "@ccr/core/agents/local-providers/opencode-freetier";
|
||||
import { grokAccessTokenExpired, grokClientVersion } from "@ccr/core/agents/local-providers/grok";
|
||||
import { kimiAccessTokenExpired, kimiIdentityHeaders } from "@ccr/core/agents/local-providers/kimi";
|
||||
import { transformCodexApplyPatchBridgeRequestBody } from "@ccr/core/gateway/features/codex-patch-bridge";
|
||||
|
|
@ -7,6 +8,7 @@ import { isRecord, stringValue } from "@ccr/core/gateway/internal/value";
|
|||
import { mergeAnthropicBetaValues } from "@ccr/core/providers/oauth-plugin";
|
||||
|
||||
const liveProviderPluginKeyPrefix = "module:ccr-live-auth:";
|
||||
const configProviderPluginKeyPrefix = "config:";
|
||||
const localAgentProviderPluginKeyPrefix = "ccr-local-agent-";
|
||||
|
||||
type HeaderRecord = Record<string, string>;
|
||||
|
|
@ -69,9 +71,22 @@ function localAgentOauthProviderHook(plugin: unknown): ProviderHook | undefined
|
|||
if (!isRecord(plugin)) {
|
||||
return undefined;
|
||||
}
|
||||
const kind = localAgentOauthKind(plugin);
|
||||
const key = stringValue(plugin.key);
|
||||
if (!kind || !key) {
|
||||
if (!key) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isOpenCodePublicFreeTierPlugin(plugin)) {
|
||||
return {
|
||||
key: `${configProviderPluginKeyPrefix}${key}`,
|
||||
provider: stringValue(plugin.provider),
|
||||
providerName: stringValue(plugin.providerName),
|
||||
transformRequest: transformOpenCodePublicFreeTierRequest
|
||||
};
|
||||
}
|
||||
|
||||
const kind = localAgentOauthKind(plugin);
|
||||
if (!kind) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
|
@ -173,6 +188,17 @@ async function resolveLiveKimiAccessToken(plugin: Record<string, unknown>): Prom
|
|||
return originalBearerToken(plugin);
|
||||
}
|
||||
|
||||
function transformOpenCodePublicFreeTierRequest(input: ProviderPluginInput): ProviderHookResult {
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
...input.upstreamRequest,
|
||||
body: withOpenCodePublicFreeTierTools(input.upstreamRequest.body, input.upstreamRequest.url),
|
||||
headers: applyOpenCodePublicFreeTierHeaders(input.upstreamRequest.headers)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function transformWithHeaders(input: ProviderPluginInput, headers: HeaderRecord): ProviderHookResult {
|
||||
return {
|
||||
ok: true,
|
||||
|
|
|
|||
|
|
@ -529,6 +529,8 @@ async function runConfiguredWebSearch(input: WebSearchProviderInput): Promise<We
|
|||
return searchSerper(input);
|
||||
case "serpapi":
|
||||
return searchSerpApi(input);
|
||||
case "serply":
|
||||
return searchSerply(input);
|
||||
case "tavily":
|
||||
return searchTavily(input);
|
||||
case "exa":
|
||||
|
|
@ -623,6 +625,26 @@ async function searchSerpApi(input: WebSearchProviderInput): Promise<WebSearchPr
|
|||
return items.map((item) => webSearchResult(item, "title", "link", "snippet")).filter(isWebSearchProviderResult);
|
||||
}
|
||||
|
||||
async function searchSerply(input: WebSearchProviderInput): Promise<WebSearchProviderResult[]> {
|
||||
const apiKey = searchEnv(input, "SERPLY_API_KEY");
|
||||
if (!apiKey) {
|
||||
console.warn("[gateway] Serply web search API key is not configured.");
|
||||
return [];
|
||||
}
|
||||
const url = new URL(searchEnv(input, "SERPLY_SEARCH_ENDPOINT") || "https://api.serply.io/v1/search");
|
||||
url.searchParams.set("q", input.query);
|
||||
url.searchParams.set("num", String(Math.min(input.count, 10)));
|
||||
const raw = await fetchJson(url.toString(), {
|
||||
headers: {
|
||||
"user-agent": "claude-code-router",
|
||||
"x-api-key": apiKey
|
||||
},
|
||||
signal: AbortSignal.timeout(input.timeoutMs)
|
||||
});
|
||||
const items = isRecord(raw) && Array.isArray(raw.results) ? raw.results.slice(0, input.count) : [];
|
||||
return items.map((item) => webSearchResult(item, "title", "link", "description")).filter(isWebSearchProviderResult);
|
||||
}
|
||||
|
||||
async function searchTavily(input: WebSearchProviderInput): Promise<WebSearchProviderResult[]> {
|
||||
const apiKey = searchEnv(input, "TAVILY_API_KEY");
|
||||
if (!apiKey) {
|
||||
|
|
@ -712,6 +734,8 @@ function searchProviderUrl(provider: VirtualModelFusionWebSearchProvider, query:
|
|||
return "https://serper.dev";
|
||||
case "serpapi":
|
||||
return "https://serpapi.com";
|
||||
case "serply":
|
||||
return "https://serply.io";
|
||||
case "tavily":
|
||||
return "https://tavily.com";
|
||||
case "exa":
|
||||
|
|
|
|||
|
|
@ -305,7 +305,7 @@ export 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."
|
||||
"Run the profile in CCR Desktop or switch the Fusion web search provider to Brave, Bing, Google CSE, Serper, SerpAPI, Serply, Tavily, or Exa."
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
|
|
@ -786,6 +786,7 @@ function parseFusionWebSearchProvider(value: unknown): VirtualModelFusionWebSear
|
|||
normalized === "google_cse" ||
|
||||
normalized === "serper" ||
|
||||
normalized === "serpapi" ||
|
||||
normalized === "serply" ||
|
||||
normalized === "tavily" ||
|
||||
normalized === "exa" ||
|
||||
normalized === "browser"
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ type ToolCallResult = {
|
|||
};
|
||||
|
||||
type FusionBuiltinToolKind = "vision" | "web_search";
|
||||
type SearchProvider = "auto" | "bing" | "brave" | "exa" | "google_cse" | "serpapi" | "serper" | "tavily";
|
||||
type SearchProvider = "auto" | "bing" | "brave" | "exa" | "google_cse" | "serpapi" | "serper" | "serply" | "tavily";
|
||||
type SearchInput = {
|
||||
count: number;
|
||||
country?: string;
|
||||
|
|
@ -682,6 +682,7 @@ async function searchWithProvider(
|
|||
if (provider === "google_cse") return searchGoogleCse(input);
|
||||
if (provider === "serper") return searchSerper(input);
|
||||
if (provider === "serpapi") return searchSerpApi(input);
|
||||
if (provider === "serply") return searchSerply(input);
|
||||
if (provider === "tavily") return searchTavily(input);
|
||||
return searchExa(input);
|
||||
}
|
||||
|
|
@ -768,6 +769,24 @@ async function searchSerpApi(input: SearchInput): Promise<SearchResult[]> {
|
|||
return items.map((item) => normalizeSearchResult(item, "title", "link", "snippet")).filter(isSearchResult);
|
||||
}
|
||||
|
||||
async function searchSerply(input: SearchInput): Promise<SearchResult[]> {
|
||||
const apiKey = requireEnv("SERPLY_API_KEY", "Serply API key");
|
||||
const url = new URL(env("SERPLY_SEARCH_ENDPOINT") || "https://api.serply.io/v1/search");
|
||||
url.searchParams.set("q", scopedSearchQuery(input));
|
||||
url.searchParams.set("num", String(Math.min(input.count, 10)));
|
||||
if (input.country) url.searchParams.set("gl", input.country);
|
||||
if (input.language) url.searchParams.set("hl", input.language);
|
||||
const raw = await fetchJson(url.toString(), {
|
||||
headers: {
|
||||
"user-agent": "claude-code-router",
|
||||
"x-api-key": apiKey
|
||||
},
|
||||
signal: AbortSignal.timeout(input.timeoutMs)
|
||||
});
|
||||
const items = isRecord(raw) && Array.isArray(raw.results) ? raw.results.slice(0, input.count) : [];
|
||||
return items.map((item) => normalizeSearchResult(item, "title", "link", "description")).filter(isSearchResult);
|
||||
}
|
||||
|
||||
async function searchTavily(input: SearchInput): Promise<SearchResult[]> {
|
||||
const apiKey = requireEnv("TAVILY_API_KEY", "Tavily API key");
|
||||
const raw = await fetchJson(env("TAVILY_SEARCH_ENDPOINT") || "https://api.tavily.com/search", {
|
||||
|
|
@ -1077,7 +1096,7 @@ function resolveSearchProvider(): Exclude<SearchProvider, "auto"> {
|
|||
if (configured !== "auto") {
|
||||
return configured;
|
||||
}
|
||||
const candidates: Array<Exclude<SearchProvider, "auto">> = ["brave", "bing", "google_cse", "serper", "serpapi", "tavily", "exa"];
|
||||
const candidates: Array<Exclude<SearchProvider, "auto">> = ["brave", "bing", "google_cse", "serper", "serpapi", "serply", "tavily", "exa"];
|
||||
const provider = candidates.find(searchProviderIsConfigured);
|
||||
if (!provider) {
|
||||
throw new Error("No search provider configured. Set SEARCH_PROVIDER and its API key.");
|
||||
|
|
@ -1093,6 +1112,7 @@ function parseSearchProvider(value: string | undefined): SearchProvider | undefi
|
|||
value === "google_cse" ||
|
||||
value === "serper" ||
|
||||
value === "serpapi" ||
|
||||
value === "serply" ||
|
||||
value === "tavily" ||
|
||||
value === "exa"
|
||||
) {
|
||||
|
|
@ -1107,6 +1127,7 @@ function searchProviderIsConfigured(provider: Exclude<SearchProvider, "auto">):
|
|||
if (provider === "google_cse") return Boolean(env("GOOGLE_SEARCH_API_KEY") && env("GOOGLE_SEARCH_CX"));
|
||||
if (provider === "serper") return Boolean(env("SERPER_API_KEY"));
|
||||
if (provider === "serpapi") return Boolean(env("SERPAPI_API_KEY"));
|
||||
if (provider === "serply") return Boolean(env("SERPLY_API_KEY"));
|
||||
if (provider === "tavily") return Boolean(env("TAVILY_API_KEY"));
|
||||
return Boolean(env("EXA_API_KEY"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -771,6 +771,68 @@ test("gateway prefetches non-browser Fusion web search records without browser i
|
|||
}
|
||||
});
|
||||
|
||||
test("gateway prefetches Serply Fusion web search records", async () => {
|
||||
const requests = [];
|
||||
const endpoint = "http://127.0.0.1/serply-search";
|
||||
const previousFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
requests.push({ headers: new Headers(init.headers), url: new URL(String(input)) });
|
||||
return new Response(JSON.stringify({
|
||||
results: [
|
||||
{ description: "First body", link: "https://example.test/one", title: "First" },
|
||||
{ description: "Second body", link: "https://example.test/two", title: "Second" },
|
||||
{ description: "Third body", link: "https://example.test/three", title: "Third" }
|
||||
]
|
||||
}), { headers: { "content-type": "application/json" }, status: 200 });
|
||||
};
|
||||
try {
|
||||
const config = {
|
||||
Providers: [],
|
||||
Router: { fallback: { mode: "off", models: [], retryCount: 0 } },
|
||||
gateway: {},
|
||||
virtualModelProfiles: [
|
||||
{
|
||||
displayName: "Research",
|
||||
enabled: true,
|
||||
id: "research",
|
||||
key: "research",
|
||||
match: { exactAliases: ["Fusion/research"], prefixes: [], suffixes: [] },
|
||||
metadata: {
|
||||
fusionWebSearch: {
|
||||
env: { SERPLY_API_KEY: "serply-key", SERPLY_SEARCH_ENDPOINT: endpoint },
|
||||
provider: "serply",
|
||||
resultCount: 2,
|
||||
toolName: "research_web_search"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const records = await selectHostedWebSearchProtocolRecords({
|
||||
protocol: "anthropic_messages",
|
||||
queryHint: "search query",
|
||||
requestId: "req-1",
|
||||
sinceMs: Date.now() - 1000,
|
||||
toolName: "research_web_search"
|
||||
}, undefined, config);
|
||||
|
||||
assert.equal(records.length, 1);
|
||||
assert.equal(records[0].engine, "serply");
|
||||
assert.equal(records[0].searchUrl, "https://serply.io");
|
||||
assert.deepEqual(records[0].results, [
|
||||
{ snippet: "First body", title: "First", url: "https://example.test/one" },
|
||||
{ snippet: "Second body", title: "Second", url: "https://example.test/two" }
|
||||
]);
|
||||
assert.equal(requests[0].url.origin + requests[0].url.pathname, endpoint);
|
||||
assert.equal(requests[0].url.searchParams.get("q"), "search query");
|
||||
assert.equal(requests[0].url.searchParams.get("num"), "2");
|
||||
assert.equal(requests[0].headers.get("x-api-key"), "serply-key");
|
||||
} finally {
|
||||
globalThis.fetch = previousFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("gateway config does not create fallback tools for MCP-backed Fusion tools", () => {
|
||||
const profiles = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1454,6 +1454,7 @@ test("profile service writes a multi-model Kimi CLI home that points inference t
|
|||
{
|
||||
api_base_url: "https://api.deepseek.com",
|
||||
api_key: "deepseek-key",
|
||||
modelMetadata: { "deepseek-v4-flash": { capabilities: { imageInput: false } } },
|
||||
models: ["deepseek-v4-flash"],
|
||||
name: "DeepSeek"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,326 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
|
||||
import { compileCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-compiler.ts";
|
||||
import { createGatewayPlugin } from "@ccr/core/gateway/core-runtime/local-agent-auth-provider-hook.ts";
|
||||
import {
|
||||
OPENCODE_PUBLIC_FREETIER_PLUGIN_SUFFIX,
|
||||
OPENCODE_PUBLIC_FREETIER_USER_AGENT_FALLBACK,
|
||||
applyOpenCodePublicFreeTierHeaders,
|
||||
formatOpenCodeId,
|
||||
isOpenCodePublicFreeTierPlugin,
|
||||
mintOpenCodeRequestId,
|
||||
mintOpenCodeSessionId,
|
||||
openCodeIdTimestampMs,
|
||||
openCodePublicFreeTierHeaders,
|
||||
openCodePublicFreeTierPlugin,
|
||||
openCodePublicFreeTierUserAgent,
|
||||
resetOpenCodePublicFreeTierState,
|
||||
withOpenCodePublicFreeTierTools
|
||||
} from "@ccr/core/agents/local-providers/opencode-freetier.ts";
|
||||
|
||||
test("OpenCode free-tier session IDs embed a fresh descending timestamp", () => {
|
||||
resetOpenCodePublicFreeTierState();
|
||||
const id = mintOpenCodeSessionId();
|
||||
assert.match(id, /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/);
|
||||
assert.equal(id.length, 30);
|
||||
const timestamp = openCodeIdTimestampMs(id);
|
||||
assert.ok(timestamp !== undefined);
|
||||
assert.ok(Math.abs(timestamp - (Date.now() % 2 ** 36)) < 60_000);
|
||||
});
|
||||
|
||||
test("OpenCode free-tier request IDs embed a fresh ascending timestamp", () => {
|
||||
resetOpenCodePublicFreeTierState();
|
||||
const id = mintOpenCodeRequestId();
|
||||
assert.match(id, /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/);
|
||||
assert.equal(id.length, 30);
|
||||
const timestamp = openCodeIdTimestampMs(id);
|
||||
assert.ok(timestamp !== undefined);
|
||||
assert.ok(Math.abs(timestamp - (Date.now() % 2 ** 36)) < 60_000);
|
||||
});
|
||||
|
||||
test("OpenCode free-tier IDs order by direction for identical timestamps", () => {
|
||||
const first = formatOpenCodeId("ses", "descending", 1789604300000, 1, "A".repeat(14));
|
||||
const second = formatOpenCodeId("ses", "descending", 1789604300000, 2, "A".repeat(14));
|
||||
assert.ok(first > second);
|
||||
|
||||
const firstRequest = formatOpenCodeId("msg", "ascending", 1789604300000, 1, "A".repeat(14));
|
||||
const secondRequest = formatOpenCodeId("msg", "ascending", 1789604300000, 2, "A".repeat(14));
|
||||
assert.ok(firstRequest < secondRequest);
|
||||
|
||||
assert.equal(openCodeIdTimestampMs(first), openCodeIdTimestampMs(firstRequest));
|
||||
});
|
||||
|
||||
test("OpenCode free-tier IDs are unique across requests", () => {
|
||||
resetOpenCodePublicFreeTierState();
|
||||
const ids = new Set();
|
||||
for (let index = 0; index < 200; index += 1) {
|
||||
ids.add(mintOpenCodeSessionId());
|
||||
ids.add(mintOpenCodeRequestId());
|
||||
}
|
||||
assert.equal(ids.size, 400);
|
||||
});
|
||||
|
||||
test("OpenCode free-tier timestamp decoder rejects malformed IDs", () => {
|
||||
assert.equal(openCodeIdTimestampMs("ses_short"), undefined);
|
||||
assert.equal(openCodeIdTimestampMs("ses_ZZZZZZZZZZZZAAAAAAAAAAAAAA"), undefined);
|
||||
assert.equal(openCodeIdTimestampMs("no-separator-at-all-0123456789abcdef"), undefined);
|
||||
assert.equal(openCodeIdTimestampMs(""), undefined);
|
||||
});
|
||||
|
||||
test("OpenCode free-tier user agent prefers an explicit override", () => {
|
||||
const previousOverride = process.env.CCR_OPENCODE_USER_AGENT;
|
||||
process.env.CCR_OPENCODE_USER_AGENT = "opencode/9.9.9-test";
|
||||
resetOpenCodePublicFreeTierState();
|
||||
try {
|
||||
assert.equal(openCodePublicFreeTierUserAgent(), "opencode/9.9.9-test");
|
||||
} finally {
|
||||
restoreEnv("CCR_OPENCODE_USER_AGENT", previousOverride);
|
||||
resetOpenCodePublicFreeTierState();
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenCode free-tier user agent reads the installed CLI version", () => {
|
||||
const binDir = mkdtempSync(path.join(os.tmpdir(), "ccr-opencode-freetier-bin-"));
|
||||
writeFileSync(path.join(binDir, "opencode"), "#!/bin/sh\necho 'opencode version 7.8.9'\n");
|
||||
chmodSync(path.join(binDir, "opencode"), 0o755);
|
||||
const previousPath = process.env.PATH;
|
||||
const previousOverride = process.env.CCR_OPENCODE_USER_AGENT;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ""}`;
|
||||
delete process.env.CCR_OPENCODE_USER_AGENT;
|
||||
resetOpenCodePublicFreeTierState();
|
||||
try {
|
||||
assert.equal(openCodePublicFreeTierUserAgent(), "opencode/7.8.9");
|
||||
} finally {
|
||||
restoreEnv("PATH", previousPath);
|
||||
restoreEnv("CCR_OPENCODE_USER_AGENT", previousOverride);
|
||||
resetOpenCodePublicFreeTierState();
|
||||
rmSync(binDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenCode free-tier user agent falls back without an installed CLI", () => {
|
||||
const binDir = mkdtempSync(path.join(os.tmpdir(), "ccr-opencode-freetier-empty-bin-"));
|
||||
const previousPath = process.env.PATH;
|
||||
const previousOverride = process.env.CCR_OPENCODE_USER_AGENT;
|
||||
process.env.PATH = binDir;
|
||||
delete process.env.CCR_OPENCODE_USER_AGENT;
|
||||
resetOpenCodePublicFreeTierState();
|
||||
try {
|
||||
assert.equal(openCodePublicFreeTierUserAgent(), OPENCODE_PUBLIC_FREETIER_USER_AGENT_FALLBACK);
|
||||
} finally {
|
||||
restoreEnv("PATH", previousPath);
|
||||
restoreEnv("CCR_OPENCODE_USER_AGENT", previousOverride);
|
||||
resetOpenCodePublicFreeTierState();
|
||||
rmSync(binDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenCode free-tier headers carry a fresh client fingerprint", () => {
|
||||
const previousOverride = process.env.CCR_OPENCODE_USER_AGENT;
|
||||
process.env.CCR_OPENCODE_USER_AGENT = "opencode/1.18.31";
|
||||
resetOpenCodePublicFreeTierState();
|
||||
try {
|
||||
const headers = openCodePublicFreeTierHeaders();
|
||||
assert.equal(headers["user-agent"], "opencode/1.18.31");
|
||||
assert.equal(headers["x-opencode-client"], "cli");
|
||||
assert.match(headers["x-opencode-session"], /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/);
|
||||
assert.match(headers["x-opencode-request"], /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/);
|
||||
} finally {
|
||||
restoreEnv("CCR_OPENCODE_USER_AGENT", previousOverride);
|
||||
resetOpenCodePublicFreeTierState();
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenCode free-tier header application overwrites the client user agent but keeps auth", () => {
|
||||
const previousOverride = process.env.CCR_OPENCODE_USER_AGENT;
|
||||
process.env.CCR_OPENCODE_USER_AGENT = "opencode/1.18.31";
|
||||
resetOpenCodePublicFreeTierState();
|
||||
try {
|
||||
const input = {
|
||||
authorization: "Bearer public",
|
||||
"User-Agent": "claude-code/1.0",
|
||||
"x-custom": "keep"
|
||||
};
|
||||
const headers = applyOpenCodePublicFreeTierHeaders(input);
|
||||
assert.equal(headers["user-agent"], "opencode/1.18.31");
|
||||
assert.equal(headers["User-Agent"], undefined);
|
||||
assert.equal(headers.authorization, "Bearer public");
|
||||
assert.equal(headers["x-custom"], "keep");
|
||||
assert.match(headers["x-opencode-session"], /^ses_/);
|
||||
assert.match(headers["x-opencode-request"], /^msg_/);
|
||||
assert.equal(input["User-Agent"], "claude-code/1.0");
|
||||
} finally {
|
||||
restoreEnv("CCR_OPENCODE_USER_AGENT", previousOverride);
|
||||
resetOpenCodePublicFreeTierState();
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenCode free-tier plugin detector only matches managed public imports", () => {
|
||||
assert.equal(isOpenCodePublicFreeTierPlugin(openCodePublicFreeTierPlugin()), true);
|
||||
assert.equal(isOpenCodePublicFreeTierPlugin({
|
||||
key: "ccr-local-agent-opencode-public-Responses-OPENCODE-PUBLIC-FREETIER".toLowerCase(),
|
||||
providerName: "OpenCode Public"
|
||||
}), true);
|
||||
assert.equal(isOpenCodePublicFreeTierPlugin({
|
||||
key: "ccr-local-agent-grok-cli-api-grok-cli-oauth"
|
||||
}), false);
|
||||
assert.equal(isOpenCodePublicFreeTierPlugin({ key: "external-opencode-public-freetier" }), false);
|
||||
assert.equal(isOpenCodePublicFreeTierPlugin({}), false);
|
||||
assert.equal(isOpenCodePublicFreeTierPlugin(undefined), false);
|
||||
});
|
||||
|
||||
test("OpenCode free-tier import plugin never overrides auth", () => {
|
||||
const plugin = openCodePublicFreeTierPlugin();
|
||||
assert.equal(
|
||||
plugin.key,
|
||||
`ccr-local-agent-__CCR_PROVIDER_NAME_SLUG__-${OPENCODE_PUBLIC_FREETIER_PLUGIN_SUFFIX}`
|
||||
);
|
||||
assert.equal(plugin.providerName, "__CCR_PROVIDER_NAME__");
|
||||
assert.equal(plugin.auth, undefined);
|
||||
});
|
||||
|
||||
test("OpenCode free-tier import plugin carries a request section so the gateway keeps it for runtime hooks", () => {
|
||||
assert.deepEqual(openCodePublicFreeTierPlugin().request, { headers: { "x-opencode-client": "cli" } });
|
||||
});
|
||||
|
||||
test("OpenCode free-tier internal import plugin gets a key distinct from the display one", () => {
|
||||
const internal = openCodePublicFreeTierPlugin("__CCR_PROVIDER_INTERNAL_NAME__", "-internal");
|
||||
|
||||
assert.equal(internal.providerName, "__CCR_PROVIDER_INTERNAL_NAME__");
|
||||
assert.notEqual(internal.key, openCodePublicFreeTierPlugin().key);
|
||||
assert.equal(isOpenCodePublicFreeTierPlugin(internal), true);
|
||||
});
|
||||
|
||||
test("OpenCode free-tier hook mints headers per request without touching auth", async () => {
|
||||
const previousOverride = process.env.CCR_OPENCODE_USER_AGENT;
|
||||
process.env.CCR_OPENCODE_USER_AGENT = "opencode/1.18.31";
|
||||
resetOpenCodePublicFreeTierState();
|
||||
try {
|
||||
const [hook] = createGatewayPlugin({
|
||||
config: {
|
||||
providerPlugins: [{
|
||||
key: "ccr-local-agent-opencode-public-chat-completions-opencode-public-freetier",
|
||||
providerName: "OpenCode Public (Chat Completions)"
|
||||
}]
|
||||
}
|
||||
}).providerHooks;
|
||||
assert.ok(hook);
|
||||
assert.equal(hook.authenticate, undefined);
|
||||
|
||||
const first = await hook.transformRequest({
|
||||
model: "big-pickle",
|
||||
upstreamRequest: {
|
||||
headers: { authorization: "Bearer public", "user-agent": "some-client/1.0" },
|
||||
method: "POST",
|
||||
url: "https://opencode.ai/zen/v1/chat/completions"
|
||||
}
|
||||
});
|
||||
assert.equal(first.ok, true);
|
||||
assert.equal(first.value.headers.authorization, "Bearer public");
|
||||
assert.equal(first.value.headers["user-agent"], "opencode/1.18.31");
|
||||
assert.match(first.value.headers["x-opencode-session"], /^ses_/);
|
||||
|
||||
const second = await hook.transformRequest({
|
||||
model: "big-pickle",
|
||||
upstreamRequest: { headers: {}, method: "POST", url: "https://opencode.ai/zen/v1/chat/completions" }
|
||||
});
|
||||
assert.equal(second.ok, true);
|
||||
assert.notEqual(second.value.headers["x-opencode-session"], first.value.headers["x-opencode-session"]);
|
||||
assert.notEqual(second.value.headers["x-opencode-request"], first.value.headers["x-opencode-request"]);
|
||||
} finally {
|
||||
restoreEnv("CCR_OPENCODE_USER_AGENT", previousOverride);
|
||||
resetOpenCodePublicFreeTierState();
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenCode free-tier tools append the required bash and read to chat completions requests", () => {
|
||||
const clientTool = { function: { name: "Bash", parameters: { type: "object" } }, type: "function" };
|
||||
const body = { model: "big-pickle", stream: true, tools: [clientTool] };
|
||||
|
||||
const next = withOpenCodePublicFreeTierTools(body, "https://opencode.ai/zen/v1/chat/completions");
|
||||
|
||||
assert.deepEqual(next.tools.map((tool) => tool.function.name), ["Bash", "bash", "read"]);
|
||||
assert.equal(next.tools[0], clientTool);
|
||||
assert.deepEqual(body.tools, [clientTool]);
|
||||
});
|
||||
|
||||
test("OpenCode free-tier tools use the flat tool shape on the responses endpoint", () => {
|
||||
const next = withOpenCodePublicFreeTierTools({ input: "hi" }, "https://opencode.ai/zen/v1/responses");
|
||||
|
||||
assert.deepEqual(next.tools.map((tool) => [tool.type, tool.name]), [["function", "bash"], ["function", "read"]]);
|
||||
});
|
||||
|
||||
test("OpenCode free-tier tools keep requests that already declare bash and read untouched", () => {
|
||||
const body = {
|
||||
tools: [
|
||||
{ function: { name: "read" }, type: "function" },
|
||||
{ function: { name: "bash" }, type: "function" }
|
||||
]
|
||||
};
|
||||
|
||||
assert.equal(withOpenCodePublicFreeTierTools(body, "https://opencode.ai/zen/v1/chat/completions"), body);
|
||||
});
|
||||
|
||||
test("OpenCode free-tier hook adds the required tools to the upstream body", async () => {
|
||||
const [hook] = createGatewayPlugin({
|
||||
config: {
|
||||
providerPlugins: [{
|
||||
key: "ccr-local-agent-opencode-public-chat-completions-opencode-public-freetier",
|
||||
providerName: "OpenCode Public (Chat Completions)"
|
||||
}]
|
||||
}
|
||||
}).providerHooks;
|
||||
|
||||
const result = await hook.transformRequest({
|
||||
model: "big-pickle",
|
||||
upstreamRequest: {
|
||||
body: { model: "big-pickle", stream: true },
|
||||
headers: {},
|
||||
method: "POST",
|
||||
url: "https://opencode.ai/zen/v1/chat/completions"
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(result.value.body.tools.map((tool) => tool.function.name), ["bash", "read"]);
|
||||
});
|
||||
|
||||
test("core gateway config installs the runtime hook for public free-tier plugins", async () => {
|
||||
const config = createDefaultAppConfig();
|
||||
config.providerPlugins = [{
|
||||
key: "ccr-local-agent-opencode-public-chat-completions-opencode-public-freetier",
|
||||
providerName: "OpenCode Public (Chat Completions)"
|
||||
}];
|
||||
config.Providers = [
|
||||
{
|
||||
api_base_url: "https://opencode.ai/zen/v1",
|
||||
api_key: "public",
|
||||
id: "opencode-public-chat-completions",
|
||||
models: ["big-pickle"],
|
||||
name: "OpenCode Public (Chat Completions)",
|
||||
type: "openai_chat_completions"
|
||||
}
|
||||
];
|
||||
|
||||
const compiled = await compileCoreGatewayConfig(
|
||||
config,
|
||||
"raw-trace-token",
|
||||
"billing-usage-token",
|
||||
"core-auth-token"
|
||||
);
|
||||
const plugins = Array.isArray(compiled.plugins) ? compiled.plugins : [];
|
||||
const freeTierHookPlugin = plugins.find((plugin) => plugin.key === "ccr-local-agent-auth-provider-hooks");
|
||||
assert.ok(freeTierHookPlugin);
|
||||
});
|
||||
|
||||
function restoreEnv(name, value) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
removeOpenCodeProviderAccountConfig
|
||||
} from "@ccr/core/agents/local-providers/opencode.ts";
|
||||
import { localAgentProviderApiKey } from "@ccr/core/agents/local-providers/shared.ts";
|
||||
import { OPENCODE_PUBLIC_FREETIER_PLUGIN_SUFFIX } from "@ccr/core/agents/local-providers/opencode-freetier.ts";
|
||||
|
||||
test("OpenCode local provider imports Zen models using each model's native protocol", async () => {
|
||||
await withOpenCodeHome(async (home) => {
|
||||
|
|
@ -381,13 +382,13 @@ test("OpenCode local provider imports public free models without a login", async
|
|||
assert.ok(available.every((candidate) => candidate.detail.includes("No login is required")));
|
||||
|
||||
const chatResult = importOpenCodeProvider(candidateForProtocol(candidates, "openai_chat_completions"), []);
|
||||
assert.deepEqual(chatResult.providerPlugins, []);
|
||||
assertPublicFreeTierPlugins(chatResult.providerPlugins);
|
||||
assert.equal(chatResult.provider.apiKey, "public");
|
||||
assert.deepEqual(chatResult.provider.models, ["chat-free"]);
|
||||
assert.equal(chatResult.provider.account, undefined);
|
||||
|
||||
const anthropicResult = importOpenCodeProvider(candidateForProtocol(candidates, "anthropic_messages"), []);
|
||||
assert.deepEqual(anthropicResult.providerPlugins, []);
|
||||
assertPublicFreeTierPlugins(anthropicResult.providerPlugins);
|
||||
assert.equal(anthropicResult.provider.apiKey, "public");
|
||||
});
|
||||
});
|
||||
|
|
@ -537,6 +538,25 @@ function candidateForId(candidates, id) {
|
|||
return candidate;
|
||||
}
|
||||
|
||||
function assertPublicFreeTierPlugins(providerPlugins) {
|
||||
assert.equal(providerPlugins.length, 2);
|
||||
for (const plugin of providerPlugins) {
|
||||
assert.ok(plugin.key.includes(OPENCODE_PUBLIC_FREETIER_PLUGIN_SUFFIX));
|
||||
assert.equal(plugin.auth, undefined);
|
||||
}
|
||||
assert.deepEqual(
|
||||
providerPlugins.map((plugin) => plugin.key),
|
||||
[
|
||||
"ccr-local-agent-__CCR_PROVIDER_NAME_SLUG__-opencode-public-freetier",
|
||||
"ccr-local-agent-__CCR_PROVIDER_NAME_SLUG__-opencode-public-freetier-internal"
|
||||
]
|
||||
);
|
||||
assert.deepEqual(
|
||||
providerPlugins.map((plugin) => plugin.providerName),
|
||||
["__CCR_PROVIDER_NAME__", "__CCR_PROVIDER_INTERNAL_NAME__"]
|
||||
);
|
||||
}
|
||||
|
||||
async function withOpenCodeHome(run) {
|
||||
const environmentNames = [
|
||||
"CCR_INTERNAL_HOME_DIR",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@claude-code-router/electron",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.2",
|
||||
"private": true,
|
||||
"description": "Claude Code Router Electron desktop shell.",
|
||||
"author": "musistudio",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@claude-code-router/ui",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.2",
|
||||
"private": true,
|
||||
"description": "Claude Code Router web management UI.",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1572,23 +1572,23 @@ function OverviewActivityGrid({
|
|||
const t = useAppText();
|
||||
const gridFrameRef = useRef<HTMLDivElement>(null);
|
||||
const gridFrameSize = useElementSize(gridFrameRef);
|
||||
const monthLabelsRef = useRef<HTMLDivElement>(null);
|
||||
const monthLabelsSize = useElementSize(monthLabelsRef);
|
||||
const monthLabelRef = useRef<HTMLDivElement>(null);
|
||||
const monthLabelSize = useElementSize(monthLabelRef);
|
||||
const showDayLabels = dimensions.width >= 2;
|
||||
const showMonthLabels = dimensions.height >= 2;
|
||||
const dayLabels = [t("M"), "", t("W"), "", t("F"), "", ""];
|
||||
const cellGap = dimensions.height <= 1 ? 2 : dimensions.width >= 3 ? 4 : 3;
|
||||
const labelColumnWidth = showDayLabels ? 20 : 0;
|
||||
const monthLabelHeight = showMonthLabels ? monthLabelsSize.height || 20 : 0;
|
||||
const monthLabelGap = showMonthLabels ? 4 : 0;
|
||||
// Reserve the month label row's measured height plus its `mb-1` margin: a hard-coded
|
||||
// height under-reserves, which pushes the last activity row past the frame and clips it.
|
||||
const monthLabelBlockHeight = showMonthLabels ? (monthLabelSize.height || 20) + MONTH_LABEL_BOTTOM_MARGIN : 0;
|
||||
const cellSize = activityGridCellSize({
|
||||
availableHeight: gridFrameSize.height,
|
||||
availableWidth: gridFrameSize.width,
|
||||
cellGap,
|
||||
fallbackCellSize: dimensions.height <= 1 ? 8 : dimensions.width >= 3 ? 10 : 9,
|
||||
labelColumnWidth,
|
||||
monthLabelGap,
|
||||
monthLabelHeight,
|
||||
monthLabelBlockHeight,
|
||||
weekCount: activity.weekCount
|
||||
});
|
||||
const activityColumns = `repeat(${activity.weekCount}, ${cellSize}px)`;
|
||||
|
|
@ -1603,7 +1603,7 @@ function OverviewActivityGrid({
|
|||
{showMonthLabels ? (
|
||||
<div
|
||||
className="mb-1 grid text-[10px] font-medium leading-none text-muted-foreground"
|
||||
ref={monthLabelsRef}
|
||||
ref={monthLabelRef}
|
||||
style={{
|
||||
columnGap: `${cellGap}px`,
|
||||
gridTemplateColumns: activityColumns,
|
||||
|
|
@ -1671,14 +1671,16 @@ function OverviewActivityGrid({
|
|||
);
|
||||
}
|
||||
|
||||
// Keeps the sizing math in sync with the month label row's `mb-1` class.
|
||||
const MONTH_LABEL_BOTTOM_MARGIN = 4;
|
||||
|
||||
type ActivityGridCellSizeInput = {
|
||||
availableHeight: number;
|
||||
availableWidth: number;
|
||||
cellGap: number;
|
||||
fallbackCellSize: number;
|
||||
labelColumnWidth: number;
|
||||
monthLabelGap: number;
|
||||
monthLabelHeight: number;
|
||||
monthLabelBlockHeight: number;
|
||||
weekCount: number;
|
||||
};
|
||||
|
||||
|
|
@ -1688,8 +1690,7 @@ function activityGridCellSize({
|
|||
cellGap,
|
||||
fallbackCellSize,
|
||||
labelColumnWidth,
|
||||
monthLabelGap,
|
||||
monthLabelHeight,
|
||||
monthLabelBlockHeight,
|
||||
weekCount
|
||||
}: ActivityGridCellSizeInput): number {
|
||||
if (weekCount <= 0) {
|
||||
|
|
@ -1700,7 +1701,7 @@ function activityGridCellSize({
|
|||
}
|
||||
|
||||
const widthForCells = availableWidth - labelColumnWidth - (labelColumnWidth ? cellGap : 0) - Math.max(0, weekCount - 1) * cellGap;
|
||||
const heightForCells = availableHeight - monthLabelHeight - monthLabelGap - 6 * cellGap;
|
||||
const heightForCells = availableHeight - monthLabelBlockHeight - 6 * cellGap;
|
||||
const maxByWidth = widthForCells / weekCount;
|
||||
const maxByHeight = heightForCells / 7;
|
||||
return Math.max(1, Math.floor(Math.min(maxByWidth, maxByHeight)));
|
||||
|
|
|
|||
|
|
@ -708,7 +708,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
"Image recognition and Web Search": "Image recognition and Web Search",
|
||||
"Generic image understanding tool for OCR, screenshot analysis, chart reading, UI comparison, error diagnosis, and other multi-image tasks.": "Generic image understanding tool for OCR, screenshot analysis, chart reading, UI comparison, error diagnosis, and other multi-image tasks.",
|
||||
"Generic web search tool supporting Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.": "Generic web search tool supporting Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.",
|
||||
"Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.": "Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.",
|
||||
"Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Serply, Tavily, and Exa.": "Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Serply, Tavily, and Exa.",
|
||||
"Image generation": "Image generation",
|
||||
"Video generation": "Video generation",
|
||||
"Generate and edit images with a media-capable provider model.": "Generate and edit images with a media-capable provider model.",
|
||||
|
|
@ -1985,7 +1985,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
"Image recognition and Web Search": "图片识别和 Web Search",
|
||||
"Generic image understanding tool for OCR, screenshot analysis, chart reading, UI comparison, error diagnosis, and other multi-image tasks.": "通用图片理解工具,支持 OCR、截图分析、图表解读、UI 对比、错误诊断等多图任务。",
|
||||
"Generic web search tool supporting Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.": "通用网络搜索工具,支持 Brave、Bing、Google CSE、Serper、SerpAPI、Tavily、Exa。",
|
||||
"Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.": "通用网络搜索工具,支持隐藏内置浏览器搜索,以及 Brave、Bing、Google CSE、Serper、SerpAPI、Tavily、Exa。",
|
||||
"Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Serply, Tavily, and Exa.": "通用网络搜索工具,支持隐藏内置浏览器搜索,以及 Brave、Bing、Google CSE、Serper、SerpAPI、Serply、Tavily、Exa。",
|
||||
"Image generation": "图片生成",
|
||||
"Video generation": "视频生成",
|
||||
"Generate and edit images with a media-capable provider model.": "使用支持媒体协议的供应商模型生成和编辑图片。",
|
||||
|
|
|
|||
|
|
@ -309,7 +309,7 @@ export const fusionToolOptions: Array<{ description: string; label: string; valu
|
|||
value: BUILTIN_FUSION_VISION_TOOL_NAME
|
||||
},
|
||||
{
|
||||
description: "Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Tavily, and Exa.",
|
||||
description: "Generic web search tool supporting hidden in-app browser search plus Brave, Bing, Google CSE, Serper, SerpAPI, Serply, Tavily, and Exa.",
|
||||
label: `${BUILTIN_FUSION_TOOL_SERVER_NAME} / ${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}`,
|
||||
value: BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME
|
||||
},
|
||||
|
|
@ -337,6 +337,7 @@ export const fusionWebSearchProviderOptions: Array<{ label: string; value: Virtu
|
|||
{ label: "Google CSE", value: "google_cse" },
|
||||
{ label: "Serper", value: "serper" },
|
||||
{ label: "SerpAPI", value: "serpapi" },
|
||||
{ label: "Serply", value: "serply" },
|
||||
{ label: "Tavily", value: "tavily" },
|
||||
{ label: "Exa", value: "exa" }
|
||||
];
|
||||
|
|
@ -349,6 +350,7 @@ export const fusionWebSearchEnvKeysByProvider: Record<VirtualModelFusionWebSearc
|
|||
google_cse: ["GOOGLE_SEARCH_API_KEY", "GOOGLE_SEARCH_CX", "GOOGLE_SEARCH_ENDPOINT"],
|
||||
serper: ["SERPER_API_KEY", "SERPER_SEARCH_ENDPOINT"],
|
||||
serpapi: ["SERPAPI_API_KEY", "SERPAPI_SEARCH_ENDPOINT"],
|
||||
serply: ["SERPLY_API_KEY", "SERPLY_SEARCH_ENDPOINT"],
|
||||
tavily: ["TAVILY_API_KEY", "TAVILY_SEARCH_ENDPOINT"]
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -216,19 +216,21 @@ function TokenActivityGrid({
|
|||
const t = useTrayText();
|
||||
const gridFrameRef = useRef<HTMLDivElement>(null);
|
||||
const gridFrameSize = useMeasuredElementSize(gridFrameRef);
|
||||
const monthLabelsRef = useRef<HTMLDivElement>(null);
|
||||
const monthLabelsSize = useMeasuredElementSize(monthLabelsRef);
|
||||
const monthLabelRef = useRef<HTMLDivElement>(null);
|
||||
const monthLabelSize = useMeasuredElementSize(monthLabelRef);
|
||||
const dayLabels = [t("M"), "", t("W"), "", t("F"), "", ""];
|
||||
const cellGap = 3;
|
||||
const labelColumnWidth = 14;
|
||||
// Reserve the month label row's measured height plus its `mb-1` margin: a hard-coded
|
||||
// height under-reserves, which pushes the last activity row past the frame and clips it.
|
||||
const monthLabelBlockHeight = (monthLabelSize.height || 20) + MONTH_LABEL_BOTTOM_MARGIN;
|
||||
const cellSize = activityGridCellSize({
|
||||
availableHeight: gridFrameSize.height,
|
||||
availableWidth: gridFrameSize.width,
|
||||
cellGap,
|
||||
fallbackCellSize: 8,
|
||||
labelColumnWidth,
|
||||
monthLabelGap: 4,
|
||||
monthLabelHeight: monthLabelsSize.height || 20,
|
||||
monthLabelBlockHeight,
|
||||
weekCount: activity.weekCount
|
||||
});
|
||||
const activityColumns = `repeat(${activity.weekCount}, ${cellSize}px)`;
|
||||
|
|
@ -241,7 +243,7 @@ function TokenActivityGrid({
|
|||
<div className="w-full max-w-full" style={{ width: `${gridWidth}px` }}>
|
||||
<div
|
||||
className="mb-1 grid text-[8px] font-medium leading-none text-slate-500"
|
||||
ref={monthLabelsRef}
|
||||
ref={monthLabelRef}
|
||||
style={{
|
||||
columnGap: `${cellGap}px`,
|
||||
gridTemplateColumns
|
||||
|
|
@ -313,19 +315,20 @@ type ActivityGridCellSizeInput = {
|
|||
cellGap: number;
|
||||
fallbackCellSize: number;
|
||||
labelColumnWidth: number;
|
||||
monthLabelGap: number;
|
||||
monthLabelHeight: number;
|
||||
monthLabelBlockHeight: number;
|
||||
weekCount: number;
|
||||
};
|
||||
|
||||
// Keeps the sizing math in sync with the month label row's `mb-1` class.
|
||||
const MONTH_LABEL_BOTTOM_MARGIN = 4;
|
||||
|
||||
function activityGridCellSize({
|
||||
availableHeight,
|
||||
availableWidth,
|
||||
cellGap,
|
||||
fallbackCellSize,
|
||||
labelColumnWidth,
|
||||
monthLabelGap,
|
||||
monthLabelHeight,
|
||||
monthLabelBlockHeight,
|
||||
weekCount
|
||||
}: ActivityGridCellSizeInput): number {
|
||||
if (weekCount <= 0) {
|
||||
|
|
@ -336,7 +339,7 @@ function activityGridCellSize({
|
|||
}
|
||||
|
||||
const widthForCells = availableWidth - labelColumnWidth - cellGap - Math.max(0, weekCount - 1) * cellGap;
|
||||
const heightForCells = availableHeight - monthLabelHeight - monthLabelGap - 6 * cellGap;
|
||||
const heightForCells = availableHeight - monthLabelBlockHeight - 6 * cellGap;
|
||||
const maxByWidth = widthForCells / weekCount;
|
||||
const maxByHeight = heightForCells / 7;
|
||||
return Math.max(1, Math.floor(Math.min(maxByWidth, maxByHeight)));
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ CCR_GATEWAY_SOURCE_DIR=/absolute/path/to/patched/gateway npm run build:assets
|
|||
|
||||
The second audit used `/tmp/ccr-audit-gateway` as an isolated source snapshot. Temporary directories are not deliverables; the two patches and the committed regression suites are the reproducible artifacts.
|
||||
|
||||
Do not assume a normal dependency install contains these fixes yet. The main dependency constraint remains `^1.0.21`. Release the gateway changes and update the dependency before distributing CCR without an explicit patched source directory.
|
||||
Do not assume a normal dependency install contains these fixes yet. The main dependency constraint remains `^1.0.21`. The desktop and Docker release workflows build the pinned source with both patches and verify its gateway regression tests. Desktop builds install that package on every platform; Docker builds consume it through `docker/local-ai-gateway`. Local CLI publishing must likewise build with an explicit patched `CCR_GATEWAY_SOURCE_DIR` until the gateway changes are released in the registry dependency.
|
||||
|
||||
## Verification
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue