Date: Tue, 22 Sep 2026 22:01:16 -0400
Subject: [PATCH 3/5] feat(fusion): add Serply as a web search provider
Adds Serply to the hosted web search providers and the built-in Fusion
web_search MCP tool, alongside Brave, Bing, Google CSE, Serper, SerpAPI,
Tavily and Exa. Configured with SERPLY_API_KEY and an optional
SERPLY_SEARCH_ENDPOINT override, like the other providers.
---
.../en/configuration/fusion-web-search.md | 4 +-
.../zh/configuration/fusion-web-search.md | 4 +-
packages/core/src/contracts/app.ts | 1 +
.../features/hosted-web-search/discovery.ts | 24 +++++++
packages/core/src/mcp/fusion-config.ts | 3 +-
packages/core/src/mcp/fusion-vision-mcp.ts | 25 +++++++-
.../gateway/gateway-virtual-models.test.mjs | 62 +++++++++++++++++++
packages/ui/src/pages/home/shared/i18n.tsx | 4 +-
packages/ui/src/pages/home/shared/options.ts | 4 +-
9 files changed, 121 insertions(+), 10 deletions(-)
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 9684962d..023e2607 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,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
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 b1f21692..b8a50725 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,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 等搜索服务。
## 排查要点
diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts
index 15a65780..f8bd9fee 100644
--- a/packages/core/src/contracts/app.ts
+++ b/packages/core/src/contracts/app.ts
@@ -1080,6 +1080,7 @@ export type VirtualModelFusionWebSearchProvider =
| "google_cse"
| "serper"
| "serpapi"
+ | "serply"
| "tavily"
| "exa";
diff --git a/packages/core/src/gateway/features/hosted-web-search/discovery.ts b/packages/core/src/gateway/features/hosted-web-search/discovery.ts
index edab6761..c4fba695 100644
--- a/packages/core/src/gateway/features/hosted-web-search/discovery.ts
+++ b/packages/core/src/gateway/features/hosted-web-search/discovery.ts
@@ -529,6 +529,8 @@ async function runConfiguredWebSearch(input: WebSearchProviderInput): Promise webSearchResult(item, "title", "link", "snippet")).filter(isWebSearchProviderResult);
}
+async function searchSerply(input: WebSearchProviderInput): Promise {
+ 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 {
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":
diff --git a/packages/core/src/mcp/fusion-config.ts b/packages/core/src/mcp/fusion-config.ts
index ed7417ab..5880753c 100644
--- a/packages/core/src/mcp/fusion-config.ts
+++ b/packages/core/src/mcp/fusion-config.ts
@@ -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"
diff --git a/packages/core/src/mcp/fusion-vision-mcp.ts b/packages/core/src/mcp/fusion-vision-mcp.ts
index 7c812018..03211616 100644
--- a/packages/core/src/mcp/fusion-vision-mcp.ts
+++ b/packages/core/src/mcp/fusion-vision-mcp.ts
@@ -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;
@@ -681,6 +681,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);
}
@@ -767,6 +768,24 @@ async function searchSerpApi(input: SearchInput): Promise {
return items.map((item) => normalizeSearchResult(item, "title", "link", "snippet")).filter(isSearchResult);
}
+async function searchSerply(input: SearchInput): Promise {
+ 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 {
const apiKey = requireEnv("TAVILY_API_KEY", "Tavily API key");
const raw = await fetchJson(env("TAVILY_SEARCH_ENDPOINT") || "https://api.tavily.com/search", {
@@ -1076,7 +1095,7 @@ function resolveSearchProvider(): Exclude {
if (configured !== "auto") {
return configured;
}
- const candidates: Array> = ["brave", "bing", "google_cse", "serper", "serpapi", "tavily", "exa"];
+ const candidates: Array> = ["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.");
@@ -1092,6 +1111,7 @@ function parseSearchProvider(value: string | undefined): SearchProvider | undefi
value === "google_cse" ||
value === "serper" ||
value === "serpapi" ||
+ value === "serply" ||
value === "tavily" ||
value === "exa"
) {
@@ -1106,6 +1126,7 @@ function searchProviderIsConfigured(provider: Exclude):
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"));
}
diff --git a/packages/core/test/integration/gateway/gateway-virtual-models.test.mjs b/packages/core/test/integration/gateway/gateway-virtual-models.test.mjs
index 164be977..c8d6b1da 100644
--- a/packages/core/test/integration/gateway/gateway-virtual-models.test.mjs
+++ b/packages/core/test/integration/gateway/gateway-virtual-models.test.mjs
@@ -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 = [
{
diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx
index 71bf17e8..ac1b0ac2 100644
--- a/packages/ui/src/pages/home/shared/i18n.tsx
+++ b/packages/ui/src/pages/home/shared/i18n.tsx
@@ -708,7 +708,7 @@ export const appCopy: Record = {
"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 = {
"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.": "使用支持媒体协议的供应商模型生成和编辑图片。",
diff --git a/packages/ui/src/pages/home/shared/options.ts b/packages/ui/src/pages/home/shared/options.ts
index e8459ba3..64965df6 100644
--- a/packages/ui/src/pages/home/shared/options.ts
+++ b/packages/ui/src/pages/home/shared/options.ts
@@ -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
Date: Wed, 23 Sep 2026 14:38:20 +0800
Subject: [PATCH 4/5] fix(context-archive): keep trailing tool_result when
replacing Claude Code compact prompt
---
.../src/gateway/context-archive/protocol.ts | 7 +++++-
.../test/unit/agents/context-archive.test.mjs | 25 +++++++++++++++++++
2 files changed, 31 insertions(+), 1 deletion(-)
diff --git a/packages/core/src/gateway/context-archive/protocol.ts b/packages/core/src/gateway/context-archive/protocol.ts
index fd1f679f..2c4848db 100644
--- a/packages/core/src/gateway/context-archive/protocol.ts
+++ b/packages/core/src/gateway/context-archive/protocol.ts
@@ -236,7 +236,12 @@ function replaceClaudeCodeAutoCompactPrompt(
}
const nextMessage = cloneJsonObject(message);
nextMessage.content = protocol === "anthropic_messages"
- ? [{ text: claudeCodeCompactHandoffTask(task), type: "text" }]
+ ? [
+ ...(Array.isArray(message.content) ? message.content : []).filter(
+ (block) => isRecord(block) && block.type === "tool_result"
+ ),
+ { text: claudeCodeCompactHandoffTask(task), type: "text" }
+ ]
: claudeCodeCompactHandoffTask(task);
messages[index] = nextMessage;
body.messages = messages;
diff --git a/packages/core/test/unit/agents/context-archive.test.mjs b/packages/core/test/unit/agents/context-archive.test.mjs
index 9af71d59..9a4db550 100644
--- a/packages/core/test/unit/agents/context-archive.test.mjs
+++ b/packages/core/test/unit/agents/context-archive.test.mjs
@@ -1016,6 +1016,31 @@ test("only explicit or structural compact signals create archives", async () =>
assert.doesNotMatch(handoffText, /Your task is to create a detailed summary of the conversation so far/);
});
+test("Claude Code auto compact handoff keeps the trailing tool_result paired with its tool_use", async () => {
+ const toolResult = { content: "file contents", tool_use_id: "toolu_1", type: "tool_result" };
+ const auto = await prepareContextArchiveRequest({
+ body: Buffer.from(JSON.stringify({
+ messages: [
+ { content: "Read the file.", role: "user" },
+ { content: [{ id: "toolu_1", input: {}, name: "Read", type: "tool_use" }], role: "assistant" },
+ { content: [toolResult, { text: claudeAutoCompactPrompt(), type: "text" }], role: "user" }
+ ],
+ model: "claude-test"
+ })),
+ config: testConfig(),
+ headers: { "x-session-id": "claude-auto-tool-result" },
+ method: "POST",
+ path: "/v1/messages",
+ protocol: "anthropic_messages",
+ requestId: "claude-auto-tool-result"
+ });
+ assert.ok(auto);
+ const content = JSON.parse(auto.body.toString("utf8")).messages.at(-1).content;
+ assert.deepEqual(content[0], toolResult);
+ assert.equal(content.length, 2);
+ assert.match(content[1].text, /CCR compact handoff task/);
+});
+
test("compact refuses unresolved tool-call boundaries instead of trimming them", async () => {
const config = testConfig();
await assert.rejects(() => prepareContextArchiveRequest({
From 5488e6635e69207bef3cd67a415a890aa777b364 Mon Sep 17 00:00:00 2001
From: Diogo Macedo
Date: Fri, 25 Sep 2026 22:04:14 -0300
Subject: [PATCH 5/5] fix: make public OpenCode Zen free-tier imports pass the
client check
---
.../local-providers/opencode-freetier.ts | 238 +++++++++++++
.../src/agents/local-providers/opencode.ts | 9 +-
.../gateway/core-runtime/config-compiler.ts | 3 +-
.../local-agent-auth-provider-hook.ts | 29 +-
...-agent-provider-opencode-freetier.test.mjs | 326 ++++++++++++++++++
.../local-agent-provider-opencode.test.mjs | 24 +-
6 files changed, 623 insertions(+), 6 deletions(-)
create mode 100644 packages/core/src/agents/local-providers/opencode-freetier.ts
create mode 100644 packages/core/test/unit/agents/local-agent-provider-opencode-freetier.test.mjs
diff --git a/packages/core/src/agents/local-providers/opencode-freetier.ts b/packages/core/src/agents/local-providers/opencode-freetier.ts
new file mode 100644
index 00000000..330aed05
--- /dev/null
+++ b/packages/core/src/agents/local-providers/opencode-freetier.ts
@@ -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 {
+ 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 {
+ return {
+ "user-agent": openCodePublicFreeTierUserAgent(),
+ "x-opencode-client": OPENCODE_CLIENT,
+ "x-opencode-request": mintOpenCodeRequestId(),
+ "x-opencode-session": mintOpenCodeSessionId()
+ };
+}
+
+export function applyOpenCodePublicFreeTierHeaders(headers: Record | undefined): Record {
+ const next: Record = { ...(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 {
+ return {
+ description: OPENCODE_FREETIER_PLACEHOLDER_TOOL_DESCRIPTION,
+ name,
+ parameters: OPENCODE_FREETIER_PLACEHOLDER_TOOL_PARAMETERS,
+ type: "function"
+ };
+}
+
+function chatCompletionsPlaceholderTool(name: string): Record {
+ 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;
+ }
+}
diff --git a/packages/core/src/agents/local-providers/opencode.ts b/packages/core/src/agents/local-providers/opencode.ts
index 5cbdce9e..45453431 100644
--- a/packages/core/src/agents/local-providers/opencode.ts
+++ b/packages/core/src/agents/local-providers/opencode.ts
@@ -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;
diff --git a/packages/core/src/gateway/core-runtime/config-compiler.ts b/packages/core/src/gateway/core-runtime/config-compiler.ts
index 8815caca..5c0867d0 100644
--- a/packages/core/src/gateway/core-runtime/config-compiler.ts
+++ b/packages/core/src/gateway/core-runtime/config-compiler.ts
@@ -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 | undefined {
- if (!providerPlugins.some(isLocalAgentOauthProviderPlugin)) {
+ if (!providerPlugins.some((plugin) => isLocalAgentOauthProviderPlugin(plugin) || isOpenCodePublicFreeTierPlugin(plugin))) {
return undefined;
}
return {
diff --git a/packages/core/src/gateway/core-runtime/local-agent-auth-provider-hook.ts b/packages/core/src/gateway/core-runtime/local-agent-auth-provider-hook.ts
index e0c4d32d..fbf16154 100644
--- a/packages/core/src/gateway/core-runtime/local-agent-auth-provider-hook.ts
+++ b/packages/core/src/gateway/core-runtime/local-agent-auth-provider-hook.ts
@@ -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";
@@ -69,9 +70,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 +187,17 @@ async function resolveLiveKimiAccessToken(plugin: Record): 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,
diff --git a/packages/core/test/unit/agents/local-agent-provider-opencode-freetier.test.mjs b/packages/core/test/unit/agents/local-agent-provider-opencode-freetier.test.mjs
new file mode 100644
index 00000000..aea66926
--- /dev/null
+++ b/packages/core/test/unit/agents/local-agent-provider-opencode-freetier.test.mjs
@@ -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;
+ }
+}
diff --git a/packages/core/test/unit/agents/local-agent-provider-opencode.test.mjs b/packages/core/test/unit/agents/local-agent-provider-opencode.test.mjs
index fc858548..3cbb18b1 100644
--- a/packages/core/test/unit/agents/local-agent-provider-opencode.test.mjs
+++ b/packages/core/test/unit/agents/local-agent-provider-opencode.test.mjs
@@ -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",